Extend JamesDSP with convolution and spatial audio
This commit is contained in:
59
perf/dsp.mjs
59
perf/dsp.mjs
@@ -57,16 +57,17 @@ async function run(engine) {
|
||||
(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('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 => { 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);
|
||||
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');
|
||||
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(); });
|
||||
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'); }
|
||||
@@ -84,28 +85,66 @@ async function run(engine) {
|
||||
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();
|
||||
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 });
|
||||
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 });
|
||||
});
|
||||
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()) });
|
||||
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');
|
||||
result.checks.push({ name: 'offline reload with engine enabled', missing: await checkComplete() });
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user