Add JamesDSP playback controls and verification harness
This commit is contained in:
@@ -74,6 +74,11 @@ Each scenario records the following metrics across **5 independent runs** (repor
|
||||
|
||||
## 4. How to Run
|
||||
|
||||
### Optional JamesDSP integration check
|
||||
`node perf/dsp.mjs --browser all` builds short local H.264/AAC test tones, exercises Standard and the lazy JamesDSP presets through the real app audio graph, compares the A/B-bypassed RMS to Standard, verifies one worklet heap and no extra media sources, tests the offline cached reload, and injects a failed WASM response to check Standard fallback. Its detailed measurements stay in ignored `perf/.tmp/dsp-results.json`.
|
||||
|
||||
`node perf/settings-persist.mjs --browser all` also round-trips a disabled, custom JamesDSP preset through local storage and confirms the Standard selector remains selected.
|
||||
|
||||
### Prerequisites
|
||||
Symlink dependencies and ensure server dependencies are installed:
|
||||
```bash
|
||||
|
||||
126
perf/dsp.mjs
Normal file
126
perf/dsp.mjs
Normal file
@@ -0,0 +1,126 @@
|
||||
#!/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|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 => { const cache = await caches.open('ytplayer-assets'); const missing = []; for (const [path, file] of Object.entries(files)) { 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.files);
|
||||
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');
|
||||
result.checks.push({ name: 'all manifest files cached before offline', files: 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, preset: 'flat', params: {} }; 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 || {})}`);
|
||||
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 });
|
||||
}
|
||||
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();
|
||||
assert.ok(Math.abs(bypassRms - standardRms) <= Math.max(standardRms * 0.15, 0.002), `bypass RMS returns near Standard (${bypassRms} vs ${standardRms})`);
|
||||
result.checks.push({ name: 'A/B bypass RMS', standardRms, activeRms, bypassRms });
|
||||
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 });
|
||||
});
|
||||
await page.evaluate(clip => { Player.pause(); Player.loadVideo(clip); }, clips[1]); await sleep(500); await page.evaluate(() => Player.master?.play().catch(() => {}));
|
||||
assert.equal(await poll(() => page.evaluate(() => Player.master?.currentTime > 0.2 && EQ.state().jamesDsp?.active), 12000), true, 'DSP survives media element/track swap');
|
||||
result.checks.push({ name: 'track switch retains DSP route', graph: await page.evaluate(() => AudioGraph.snapshot()) });
|
||||
|
||||
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');
|
||||
result.checks.push({ name: 'offline reload with engine enabled', missing: await checkComplete() });
|
||||
|
||||
// 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;
|
||||
@@ -70,6 +70,9 @@ async function run(name){
|
||||
const {migrate}=require(join(scratch,'new/frontend/eq-parameters.js'));const expected=structuredClone(result.old);if(expected.eq?.g?.length===5)expected.eq.g=migrate(expected.eq.g);
|
||||
for(const key of new Set([...Object.keys(result.old),...Object.keys(result.upgraded)]))result.diff.push({key,before:result.old[key],after:result.upgraded[key],equal:JSON.stringify(result.old[key])===JSON.stringify(result.upgraded[key]),expectedMigration:key==='eq'&&result.old.eq?.g?.length===5});
|
||||
assert.deepEqual(result.upgraded,expected,'all persisted settings preserved except deliberate 5→10 EQ migration');await behaviour('upgraded');
|
||||
await page.evaluate(()=>{data.settings.jamesDsp={enabled:false,preset:'warm',params:{'eq.band0':3,'tube.drive':1.5}};persist();});await page.reload();await page.waitForFunction(()=>typeof data!=='undefined');await openSettings();await page.locator('[data-section="playback"]').click();
|
||||
const james=await page.evaluate(()=>data.settings.jamesDsp);assert.deepEqual(james,{enabled:false,preset:'warm',params:{'eq.band0':3,'tube.drive':1.5}});assert.equal(await page.locator('#setSoundEngine').inputValue(),'standard');assert.equal(await page.locator('#setDspPreset').inputValue(),'warm');result.jamesDspSettingsPersisted=true;
|
||||
await page.evaluate(()=>{data.settings.jamesDsp.enabled=false;persist();});
|
||||
const local=await settings(),remote={...local,saveBeforePlay:false,autoPreload:false,layout:'classic',quality:'auto'};
|
||||
async function profileCheck(updatedAt,expectedSave,stage){profileReply={ok:true,name:'probe',updatedAt,data:{settings:remote,playlists:[]}};await page.evaluate(s=>{data.settings=s;data.profile={name:'probe',syncedAt:200};localStorage.setItem('_ytpdata',JSON.stringify(data));},local);await boot();await page.waitForFunction(()=>_syncBusy===0);await openSettings();const s=await settings();assert.equal(s.saveBeforePlay,expectedSave);assert.equal(await page.locator('#setSaveBeforePlay').isChecked(),expectedSave);if(!expectedSave)assert.equal(s.layout,'classic');else assert.deepEqual(s,local);result.profileChecks.push({stage,serverUpdatedAt:updatedAt,localSyncedAt:200,saveBeforePlay:s.saveBeforePlay});}
|
||||
await profileCheck(100,true,'older timestamp ignored');await profileCheck(300,false,'older settings with newer timestamp adopted');
|
||||
|
||||
Reference in New Issue
Block a user