166 lines
19 KiB
JavaScript
166 lines
19 KiB
JavaScript
#!/usr/bin/env node
|
|
// End-to-end JamesDSP checks against the real local server, manifest cache and media fixture.
|
|
import assert from 'node:assert/strict';
|
|
import { spawn, execFileSync } from 'node:child_process';
|
|
import { createRequire } from 'node:module';
|
|
import { createServer } from 'node:net';
|
|
import { mkdtempSync, mkdirSync, cpSync, writeFileSync, statSync, rmSync, existsSync } from 'node:fs';
|
|
import { join, resolve } from 'node:path';
|
|
import { chromium, webkit } from 'playwright';
|
|
import { createThrottleProxy } from './proxy.mjs';
|
|
|
|
const repo = resolve(import.meta.dirname, '..');
|
|
const requested = process.argv.includes('--browser') ? process.argv[process.argv.indexOf('--browser') + 1] : 'all';
|
|
assert.ok(['all', 'chromium', 'webkit'].includes(requested));
|
|
const scratch = mkdtempSync(join(repo, 'perf/.tmp/dsp-'));
|
|
const results = { results: [], failures: [] };
|
|
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
async function poll(fn, timeout = 90000) { const end = Date.now() + timeout; while (Date.now() < end) { if (await fn()) return true; await sleep(100); } return false; }
|
|
|
|
async function run(engine) {
|
|
const cwd = join(scratch, engine); mkdirSync(cwd); cpSync(join(repo, 'frontend'), join(cwd, 'public'), { recursive: true }); mkdirSync(join(cwd, 'media'));
|
|
const portProbe = createServer(); await new Promise(resolve => portProbe.listen(0, '127.0.0.1', resolve)); const port = portProbe.address().port; await new Promise(resolve => portProbe.close(resolve));
|
|
let child, browser, proxy, context, log = '';
|
|
let origin = `http://127.0.0.1:${port}`;
|
|
const result = { engine, errors: [], failures: [], rms: [], checks: [] }; results.results.push(result);
|
|
async function stop() { if (!child) return; const proc = child; child = null; if (proc.exitCode === null) { proc.kill('SIGTERM'); await new Promise(resolve => proc.once('exit', resolve)); } }
|
|
try {
|
|
const clips = [
|
|
{ id: 'dspfixture01', title: 'DSP test tone 110 Hz', channel: 'Local test', duration: 5, thumbnail: 'data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=' },
|
|
{ id: 'dspfixture02', title: 'DSP test tone 660 Hz', channel: 'Local test', duration: 5, thumbnail: 'data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=' },
|
|
];
|
|
child = spawn('bun', [join(repo, 'server/server.js')], { cwd, env: { ...process.env, PORT: String(port), DB_PATH: join(cwd, 'db.sqlite'), MEDIA_DIR: join(cwd, 'media'), UPLOAD_DIR: join(cwd, 'uploads'), DATA_DIR: join(cwd, 'data'), ASSET_HASHING: '1', ASSET_SYNC: '1', BUILD_TAG: '' }, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
for (const stream of [child.stdout, child.stderr]) stream.on('data', chunk => { log = (log + chunk).slice(-8000); });
|
|
if (!await poll(async () => { if (child.exitCode !== null) throw Error(log); try { return (await fetch(origin + '/api/version')).ok; } catch { return false; } })) throw Error('Server did not start: ' + log);
|
|
for (const [index, clip] of clips.entries()) {
|
|
const mp4 = join(cwd, 'media', clip.id + '.1.mp4');
|
|
execFileSync('ffmpeg', ['-y', '-f', 'lavfi', '-i', 'color=c=blue:s=160x90:r=10:d=5', '-f', 'lavfi', '-i', `sine=frequency=${index ? 660 : 110}:duration=5`, '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-movflags', '+faststart', '-shortest', mp4], { stdio: 'ignore' });
|
|
execFileSync('ffmpeg', ['-y', '-i', mp4, '-vn', '-c:a', 'copy', join(cwd, 'media', clip.id + '.1.m4a')], { stdio: 'ignore' });
|
|
assert.ok(existsSync(mp4), `generated local clip ${mp4}`);
|
|
}
|
|
const require = createRequire(import.meta.url); const { createClient } = require(join(repo, 'server/node_modules/@libsql/client'));
|
|
const db = createClient({ url: 'file:' + join(cwd, 'db.sqlite') });
|
|
for (const clip of clips) { const file = join(cwd, 'media', clip.id + '.1.mp4'); await db.execute({ sql: 'INSERT OR REPLACE INTO media_cache (video_id,status,gen,size,height,vcodec,acodec,duration,meta,last_access) VALUES (?,?,?,?,?,?,?,?,?,?)', args: [clip.id, 'ready', 1, statSync(file).size, 90, 'h264', 'aac', 5, JSON.stringify(clip), Date.now()] }); }
|
|
db.close();
|
|
proxy = createThrottleProxy({ targetPort: port, profile: 'unthrottled' }); origin = `http://127.0.0.1:${await proxy.listen()}`;
|
|
const type = engine === 'chromium' ? chromium : webkit; browser = await type.launch({ headless: true, ...(engine === 'chromium' ? { args: ['--autoplay-policy=no-user-gesture-required'] } : {}) });
|
|
context = await browser.newContext({ viewport: { width: 414, height: 896 } });
|
|
await context.addInitScript(() => { window.__dspToasts = []; new MutationObserver(() => { const text = document.querySelector('#toastContainer')?.textContent?.trim(); if (text && window.__dspToasts.at(-1) !== text) window.__dspToasts.push(text); }).observe(document, { childList: true, subtree: true, characterData: true }); });
|
|
await context.route('**/*', route => route.request().url().startsWith(origin) ? route.continue() : route.fulfill({ status: 200, contentType: 'image/gif', body: Buffer.from('R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=', 'base64') }));
|
|
const page = await context.newPage(); let offlinePhase = false, failedApiTransports = 0; page.on('pageerror', error => { if (offlinePhase && failedApiTransports && /TypeError: Load failed|\/api\//.test(error.message)) (result.expectedApiErrors ||= []).push(error.message); else result.errors.push(error.message); });
|
|
page.on('requestfailed', request => { const path = new URL(request.url()).pathname; if (path.startsWith('/api/')) failedApiTransports++; else if (!(offlinePhase && path === '/sw.js')) result.errors.push(`request failed ${request.url()}: ${request.failure()?.errorText}`); });
|
|
page.on('response', response => {
|
|
const url = new URL(response.url()), path = url.pathname;
|
|
// The local fixture IDs have no upstream YouTube artwork. These requests
|
|
// are unrelated to the local media or DSP asset path under test.
|
|
if (url.hostname === 'i.ytimg.com' && /\/vi\/dspfixture0[12]\//.test(path)) {
|
|
(result.fixtureResponses ||= []).push({ url: response.url(), status: response.status() });
|
|
} else if (!path.startsWith('/api/') && response.status() >= 400) result.errors.push(`asset response ${response.status()} ${response.url()}`);
|
|
});
|
|
page.on('response', response => { if (/jamesdsp\/(?:worklet\.mjs|dsp\/jamesdsp[^/?]*\.mjs)/.test(response.url())) result.requests ||= [], result.requests.push({ url: response.url(), status: response.status(), type: response.headers()['content-type'] }); });
|
|
page.on('requestfailed', request => { if (/jamesdsp\//.test(request.url())) result.requestsFailed ||= [], result.requestsFailed.push({ url: request.url(), error: request.failure()?.errorText }); });
|
|
const boot = async () => { await page.goto(origin); await page.waitForFunction(() => typeof data !== 'undefined' && document.querySelector('#sidebarToggle')); };
|
|
await boot();
|
|
const manifest = await (await fetch(origin + '/api/manifest')).json();
|
|
const checkComplete = () => page.evaluate(async ({ files, groups }) => { const cache = await caches.open('ytplayer-assets'), opted = !!(data.settings.jamesDsp?.enabled || data.settings.jamesDsp?.downloadForOffline); const optional = new Set(Object.values(groups).filter(group => group.offlineOptIn && !(opted && group.offlineOptIn === 'jamesDsp')).flatMap(group => group.files)); const missing = []; for (const [path, file] of Object.entries(files)) { if (optional.has(path)) continue; const r = await cache.match(path + '?v=' + file.h); if (!r || r.headers.get('X-Asset-Hash') !== file.h) missing.push(path); } return missing; }, manifest);
|
|
assert.equal((await page.evaluate(async () => { const shell = await caches.open('ytplayer-shell'); return (await shell.keys()).some(r => r.url.includes('jamesdsp')); })), false, 'DSP is never in the shell cache');
|
|
assert.equal(await poll(async () => !(await checkComplete()).length, 120000), true, 'offline completion job fills manifest before offline test');
|
|
const optionalCount = Object.values(manifest.groups || {}).filter(group => group.offlineOptIn).reduce((n, group) => n + group.files.filter(path => manifest.files[path]).length, 0);
|
|
result.checks.push({ name: 'required files cached; optional DSP deferred', files: Object.keys(manifest.files).length - optionalCount, optionalDeferred: optionalCount, manifestFiles: Object.keys(manifest.files).length });
|
|
await page.evaluate(async () => { await Lazy.load('feature:eq-core'); await Lazy.load('feature:vocal-reducer'); data.settings.jamesDsp = { enabled: false, downloadForOffline: false, preset: 'flat', params: {}, irRef: null }; EQ.setLevel(1); EQ.setLevel(0); EQ.apply(); });
|
|
await page.evaluate(clip => Player.loadVideo(clip), clips[0]);
|
|
await page.locator('#playBtn').click({ force: true }).catch(() => {});
|
|
if (!await poll(() => page.evaluate(() => Player.master?.currentTime > 0.4), 12000)) { await page.evaluate(() => Player.master?.play().catch(() => {})); assert.equal(await poll(() => page.evaluate(() => Player.master?.currentTime > 0.4), 12000), true, 'local test clip starts'); }
|
|
await page.evaluate(() => { if (Player.master) Player.master.loop = true; });
|
|
const rms = async () => page.evaluate(async () => {
|
|
const analyser = EQ.analyser(); if (!analyser) throw new Error('EQ analyser unavailable');
|
|
const values = new Float32Array(analyser.fftSize); let sum = 0, count = 0, end = performance.now() + 600;
|
|
while (performance.now() < end) { analyser.getFloatTimeDomainData(values); for (const v of values) { sum += v * v; count++; } await new Promise(resolve => setTimeout(resolve, 18)); }
|
|
return Math.sqrt(sum / Math.max(1, count));
|
|
});
|
|
await page.evaluate(() => { EQ.apply(); }); await sleep(250);
|
|
const standardRms = await rms(); assert.ok(standardRms > 0, `Standard output has nonzero RMS (${standardRms})`);
|
|
await page.locator('#bottomNav [data-view="settings"]').click(); await page.evaluate(() => document.querySelector('[data-section="playback"]').click());
|
|
await page.selectOption('#setSoundEngine', 'jamesdsp');
|
|
const active = await poll(() => page.evaluate(() => !!EQ.state().jamesDsp?.active), 12000);
|
|
if (!active) result.diagnostics = await page.evaluate(() => ({ enabled: data.settings.jamesDsp?.enabled, state: EQ.state(), lazyLoaded: Lazy.loaded('feature:jamesdsp'), toast: document.querySelector('#toastContainer')?.textContent || '', toastHistory: window.__dspToasts }));
|
|
assert.equal(active, true, `JamesDSP worklet activates ${JSON.stringify(result.diagnostics || {})}`);
|
|
assert.equal(await poll(async () => !(await checkComplete()).length, 120000), true, 'engine opt-in completes optional WASM and module cache');
|
|
await sleep(200); const flat = await rms(); result.rms.push({ effect: 'flat', rms: flat });
|
|
for (const preset of ['bass', 'vocal', 'loudness', 'headphone', 'bright', 'warm']) {
|
|
await page.selectOption('#setDspPreset', preset); await sleep(900);
|
|
const value = await rms(); assert.ok(Number.isFinite(value), `${preset} RMS is finite`); result.rms.push({ effect: preset, rms: value });
|
|
}
|
|
const preConvolverRms = await rms();
|
|
result.irLoad = await page.evaluate(async () => { const reference = await EQ.loadDspBuiltinIR('Small room'); await new Promise(resolve => setTimeout(resolve, 300)); return { reference, settings: data.settings.jamesDsp, state: EQ.state().jamesDsp, toast: document.querySelector('#toastContainer')?.textContent || '' }; });
|
|
const convolverRms = await rms();
|
|
assert.equal(result.irLoad.state.irRef?.name, 'Small room', 'generated IR is loaded into the active worklet');
|
|
result.checks.push({ name: 'live generated IR loaded', beforeRms: preConvolverRms, afterRms: convolverRms, ir: result.irLoad.state.irRef });
|
|
await page.evaluate(clip => { Player.pause(); Player.loadVideo(clip); }, clips[1]); await sleep(500); await page.evaluate(() => Player.master?.play().catch(() => {}));
|
|
const trackSwapActive = await poll(() => page.evaluate(() => Player.master?.currentSrc.includes('dspfixture02') && Player.master?.readyState >= 2 && EQ.state().jamesDsp?.active), 12000);
|
|
if (!trackSwapActive) result.trackSwapDiagnostics = await page.evaluate(() => ({ time: Player.master?.currentTime, paused: Player.master?.paused, readyState: Player.master?.readyState, state: EQ.state().jamesDsp, graph: AudioGraph.snapshot(), source: Player.master?.currentSrc }));
|
|
assert.equal(trackSwapActive, true, 'DSP remains active on the replacement media source');
|
|
result.checks.push({ name: 'track switch retains DSP route', graph: await page.evaluate(() => AudioGraph.snapshot()) });
|
|
await page.evaluate(() => { data.settings.jamesDsp.params['bass.enabled'] = 1; data.settings.jamesDsp.params['eq.enabled'] = 1; EQ.apply(); }); await sleep(800);
|
|
const activeRms = await rms();
|
|
await page.evaluate(async () => { await EQ.toggleDspBypass(); }); await sleep(200); const bypassRms = await rms();
|
|
const bypassState = await page.evaluate(() => EQ.state().jamesDsp);
|
|
assert.equal(bypassState.route, 'standard', 'A/B bypass routes directly to the unchanged Standard graph');
|
|
result.checks.push({ name: 'A/B exact Standard route', standardRms, activeRms, bypassRms, ...bypassState });
|
|
await page.evaluate(async () => { const state = EQ.state().jamesDsp, graph = AudioGraph.snapshot(); return { state, graph }; }).then(({ state, graph }) => {
|
|
assert.equal(state.liveHeaps, 1); assert.equal(state.maxLiveHeaps, 1); assert.ok(graph.sourceCount <= 2, 'only existing player media sources'); result.checks.push({ name: 'one worklet heap / single graph', ...state, graph });
|
|
});
|
|
const dspRender = await page.evaluate(async () => {
|
|
const { JamesDSPNode } = await import(Lazy.url('/vendor/jamesdsp/jamesdsp-node.mjs'));
|
|
async function render(effect, rate = 32000) {
|
|
const frames = 32768, context = new OfflineAudioContext(2, frames, rate);
|
|
const engine = await JamesDSPNode.create(context, { wasmURL: Lazy.url('/vendor/jamesdsp/dsp/jamesdsp.wasm'), workletURL: Lazy.url('/vendor/jamesdsp/worklet.mjs') });
|
|
const buffer = context.createBuffer(2, 2048, rate);
|
|
for (let i = 0; i < buffer.length; i++) { const sample = Math.sin(2 * Math.PI * 440 * i / rate) * 0.25; buffer.getChannelData(0)[i] = sample; buffer.getChannelData(1)[i] = sample; }
|
|
if (effect === 'convolver') {
|
|
const ir = context.createBuffer(4, 4096, rate); ir.getChannelData(0)[0] = 0.5; ir.getChannelData(1)[97] = 0.25; ir.getChannelData(2)[211] = -0.2; ir.getChannelData(3)[0] = 0.5;
|
|
await engine.loadIR(ir); await engine.setParam('convolver.mix', 1); await engine.setParam('convolver.enabled', 1);
|
|
} else if (effect === 'spatial') {
|
|
await engine.setParam('spatial.enabled', 1); await engine.setParam('spatial.mode', 6); await engine.setParam('spatial.spread', 100); await engine.setParam('spatial.azimuth', 45);
|
|
}
|
|
await engine.setBypass(false);
|
|
const source = context.createBufferSource(); source.buffer = buffer; source.connect(engine.node); engine.node.connect(context.destination); source.start();
|
|
const rendered = await context.startRendering();
|
|
const left = rendered.getChannelData(0), right = rendered.getChannelData(1);
|
|
let l = 0, r = 0, diff = 0; for (let i = 0; i < frames; i++) { l += left[i] ** 2; r += right[i] ** 2; diff += (left[i] - right[i]) ** 2; }
|
|
const metrics = { leftRms: Math.sqrt(l / frames), rightRms: Math.sqrt(r / frames), interauralRms: Math.sqrt(diff / frames), rate: rendered.sampleRate };
|
|
engine.destroy(); return metrics;
|
|
}
|
|
const dry = await render('dry'), convolver = await render('convolver'), spatial = await render('spatial');
|
|
return { dry, convolver, spatial };
|
|
});
|
|
assert.equal(dspRender.dry.rate, 32000, 'ASRC accepts an arbitrary AudioContext rate');
|
|
assert.ok(dspRender.convolver.interauralRms > dspRender.dry.interauralRms + 0.00001, `four-channel IR changes rendered stereo output (${JSON.stringify(dspRender)})`);
|
|
assert.ok(dspRender.spatial.interauralRms > dspRender.dry.interauralRms + 0.00001, `3D stage changes interaural difference (${JSON.stringify(dspRender)})`);
|
|
result.checks.push({ name: 'offline four-channel convolver, spatial difference and 32 kHz ASRC', ...dspRender });
|
|
|
|
await page.evaluate(() => persist()); await sleep(500);
|
|
offlinePhase = true; if (engine === 'chromium') await context.setOffline(true); else await proxy.setOffline(true);
|
|
await page.reload(); await page.waitForFunction(() => typeof data !== 'undefined' && document.querySelector('#sidebarToggle'));
|
|
assert.equal(await poll(() => page.evaluate(() => !!EQ.state().jamesDsp?.active), 30000), true, 'offline reload restores enabled DSP from cached modules');
|
|
assert.equal(await page.evaluate(() => EQ.state().jamesDsp?.irRef?.name), 'Small room', 'stored generated IR is restored offline');
|
|
result.checks.push({ name: 'offline reload with engine and stored IR enabled', missing: await checkComplete(), ir: await page.evaluate(() => EQ.state().jamesDsp?.irRef) });
|
|
|
|
// A separate fresh profile corrupts the optional wasm response and verifies that Standard stays audible.
|
|
offlinePhase = false; failedApiTransports = 0; if (engine === 'chromium') await context.setOffline(false); else await proxy.setOffline(false);
|
|
const brokenContext = await browser.newContext({ viewport: { width: 414, height: 896 } });
|
|
await brokenContext.route('**/*', route => route.request().url().startsWith(origin) ? route.continue() : route.fulfill({ status: 200, contentType: 'image/gif', body: Buffer.from('R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=', 'base64') }));
|
|
await brokenContext.addInitScript(() => { const realFetch = window.fetch.bind(window); window.fetch = (input, init) => String(input).includes('/vendor/jamesdsp/dsp/jamesdsp.wasm') ? Promise.resolve(new Response('corrupt', { status: 503 })) : realFetch(input, init); });
|
|
const brokenPage = await brokenContext.newPage(); brokenPage.on('pageerror', e => result.errors.push('corrupt test: ' + e.message)); await brokenPage.goto(origin); await brokenPage.waitForFunction(() => typeof data !== 'undefined');
|
|
await brokenPage.locator('#bottomNav [data-view="settings"]').click(); await brokenPage.evaluate(() => document.querySelector('[data-section="playback"]').click()); await brokenPage.selectOption('#setSoundEngine', 'jamesdsp');
|
|
assert.equal(await poll(() => brokenPage.evaluate(() => data.settings.jamesDsp.enabled === false && EQ.state().wired), 30000), true, 'corrupt wasm falls back to Standard');
|
|
result.checks.push({ name: 'corrupt wasm returns to Standard', enabled: await brokenPage.evaluate(() => data.settings.jamesDsp.enabled), graph: await brokenPage.evaluate(() => AudioGraph.snapshot()) });
|
|
await brokenContext.close();
|
|
} catch (error) { result.failures.push(error.stack || error.message); }
|
|
finally { await context?.close(); await browser?.close(); await proxy?.close(); await stop(); }
|
|
}
|
|
|
|
try { for (const engine of requested === 'all' ? ['chromium', 'webkit'] : [requested]) await run(engine); }
|
|
finally { writeFileSync(join(repo, 'perf/.tmp/dsp-results.json'), JSON.stringify(results, null, 2) + '\n'); rmSync(scratch, { recursive: true, force: true }); }
|
|
if (results.failures.length || results.results.some(result => result.failures.length || result.errors.length)) process.exitCode = 1;
|