Extend JamesDSP with convolution and spatial audio

This commit is contained in:
Jonathan Sykes
2026-10-10 19:25:06 +08:00
parent b3f0525a8e
commit ef12468a99
28 changed files with 540 additions and 143 deletions

View File

@@ -75,9 +75,9 @@ 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/dsp.mjs --browser all` builds short local H.264/AAC test tones, exercises Standard and lazy JamesDSP presets through the real app graph, verifies A/B bypass routes directly to the unchanged Standard chain, measures generated-IR effects and the four-channel/spatial stages in an offline render, checks an arbitrary 32 kHz context, and tests stored-IR offline reload, one worklet heap, no new media sources, and corrupt-WASM fallback. The default install must omit the optional WASM; the harness then opts in through Settings and confirms completion. 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.
`node perf/settings-persist.mjs --browser all` also round-trips a disabled, custom JamesDSP preset and the offline-audio opt-in through local storage and confirms the Standard selector remains selected.
### Prerequisites
Symlink dependencies and ensure server dependencies are installed:

View File

@@ -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);

View File

@@ -42,7 +42,8 @@ async function run(name){
console.log(name,'initial boot');await page.goto(origin);await page.waitForFunction(()=>typeof data!=='undefined'&&document.querySelector('#carModeBtn'));
await wait(page,async()=>!!(await navigator.serviceWorker.getRegistration())?.active);
await page.evaluate(()=>Lazy.warm());
await wait(page,async()=>{const c=await caches.open('ytplayer-assets'),r=await c.match('/__ytp_asset_state');if(!r)return false;const s=await r.json();return !(await Promise.all(Object.values(s.current.groups).flatMap(g=>g.files).map(async p=>!!await c.match(p+'?v='+s.current.files[p].h)))).includes(false);});
await wait(page,async()=>{const c=await caches.open('ytplayer-assets'),r=await c.match('/__ytp_asset_state');if(!r)return false;const s=await r.json(),files=Object.entries(s.current.groups).filter(([,g])=>!g.offlineOptIn).flatMap(([,g])=>g.files);return !(await Promise.all(files.map(async p=>!!await c.match(p+'?v='+s.current.files[p].h)))).includes(false);});
assert.equal(await page.evaluate(async()=>{const m=Lazy.manifest,path='/vendor/jamesdsp/dsp/jamesdsp.wasm',cache=await caches.open('ytplayer-assets');return !await cache.match(path+'?v='+m.files[path].h);}),true,'optional DSP WASM stays uncached on a default install');
console.log(name,'warm complete');assert.equal(await page.evaluate(()=>Lazy.loaded('feature:car-mode')),false);
assert.equal(await page.evaluate(()=>[...document.querySelectorAll('.stg-overlay,.deck-chips,.vol-pct,.mode-name,.stg-bot,.ch-badge,.upnext-clear,.list-sub,.p2p-strip,.sleep-status .sl-ic,.sleep-status .sl-sub,.mc-tune')].every(n=>getComputedStyle(n).display==='none')),true,'classic retains shared visibility defaults without loading Glass CSS');
await checkCssOrder(page);

View File

@@ -37,13 +37,22 @@ async function run(engine){
};
let page=await context.newPage();attach(page);
const boot=async()=>{await page.goto(origin);await page.waitForFunction(()=>typeof data!=='undefined'&&document.querySelector('[aria-label="Car mode"]'));};
const inventory=()=>page.evaluate(async({manifest,legacy})=>{
const inventory=(includeOptional=false)=>page.evaluate(async({manifest,legacy,includeOptional})=>{
const names=await caches.keys(),cache=await caches.open(legacy?names.find(n=>n.startsWith('ytplayer-')&&!['ytplayer-assets','ytplayer-fonts','ytplayer-thumbs'].includes(n)):'ytplayer-assets');
const missing=[];for(const [path,file]of Object.entries(manifest.files)){const r=await cache.match(legacy?path:path+'?v='+file.h);if(!r||(!legacy&&r.headers.get('X-Asset-Hash')!==file.h))missing.push(path);}
return{total:Object.keys(manifest.files).length,missing};
},{manifest,legacy});
const optional=new Set(Object.values(manifest.groups||{}).filter(group=>group.offlineOptIn).flatMap(group=>group.files||[]));
const expected=Object.entries(manifest.files).filter(([path])=>legacy||includeOptional||!optional.has(path));
const missing=[];for(const [path,file]of expected){const r=await cache.match(legacy?path:path+'?v='+file.h);if(!r||(!legacy&&r.headers.get('X-Asset-Hash')!==file.h))missing.push(path);}
return{total:expected.length,manifestTotal:Object.keys(manifest.files).length,optionalExcluded:Object.keys(manifest.files).length-expected.length,missing};
},{manifest,legacy,includeOptional});
const started=Date.now();console.log(engine,'install boot');await boot();console.log(engine,'waiting for worker activation');if(!await poll(()=>page.evaluate(async()=>!!(await navigator.serviceWorker.getRegistration())?.active),30000))throw Error('Worker did not activate: '+JSON.stringify(await page.evaluate(async()=>{const r=await navigator.serviceWorker.getRegistration();return {active:r?.active?.state,waiting:r?.waiting?.state,installing:r?.installing?.state,caches:await caches.keys(),keys:(await(await caches.open('ytplayer-assets')).keys()).map(r=>new URL(r.url).pathname)};})));await page.evaluate(()=>navigator.serviceWorker.ready);console.log(engine,'worker active');await page.evaluate(()=>window.Lazy?.warm());
await poll(async()=>!(await inventory()).missing.length,Number(option('--warm-timeout','15000')));
if(!legacy){const wasmPath='/vendor/jamesdsp/dsp/jamesdsp.wasm',wasm=manifest.files[wasmPath];if(wasm){const cached=await page.evaluate(async key=>!!await(await caches.open('ytplayer-assets')).match(key),wasmPath+'?v='+wasm.h);if(cached)result.failures.push({kind:'opt-in-wasm-cached-by-default',path:wasmPath});}}
if(!legacy){
await page.evaluate(()=>{data.settings.jamesDsp={...data.settings.jamesDsp,downloadForOffline:true};persist();reportDspOfflineOptIn();});
const dspReady=await poll(async()=>{const inventoryNow=await inventory(true);return !inventoryNow.missing.length;},120000);
result.conditions.push({name:'explicit audio-effects offline opt-in completes optional assets',complete:dspReady});
if(!dspReady)result.failures.push({kind:'optional-dsp-completion',paths:(await inventory(true)).missing});
}
console.log(engine,'inventory complete');result.installMs=Date.now()-started;result.inventory=await inventory();result.installWire=proxy.getStats();result.saveData=await page.evaluate(()=>navigator.connection?.saveData);
if(result.inventory.missing.length)result.failures.push({kind:'incomplete',paths:result.inventory.missing});
// Offline navigation always starts from a controlled document.
@@ -108,13 +117,17 @@ async function run(engine){
await page.evaluate(async manifest=>{const c=await caches.open('ytplayer-assets');for(const path of Object.keys(manifest.files).filter(p=>manifest.files[p].g!=='core'))await c.delete(path+'?v='+manifest.files[path].h);navigator.serviceWorker.controller.postMessage({type:'COMPLETE_ASSETS'});},manifest);
await poll(()=>page.evaluate(async()=>{const c=await caches.open('ytplayer-assets'),s=await(await c.match('/__ytp_asset_state')).json(),r=await c.match('/__ytp_completion/'+s.current.buildTag);return r&&(await r.json()).running;}),5000);
await page.close();await other.close();await proxy.setOffline(false);
const withoutPage=await poll(async()=>{const {cacheDataEntries:entries}=await cdp.send('CacheStorage.requestEntries',{cacheId,skipCount:0,pageSize:1000});return Object.entries(manifest.files).every(([path,file])=>entries.some(e=>e.requestURL===origin+path+'?v='+file.h&&e.responseHeaders.some(h=>h.name.toLowerCase()==='x-asset-hash'&&h.value===file.h)));});
const optionalPaths=new Set(Object.values(manifest.groups||{}).filter(group=>group.offlineOptIn==='jamesDsp').flatMap(group=>group.files||[]));
const includeOptional=await page.evaluate(async()=>{const cache=await caches.open('ytplayer-assets'),response=await cache.match('/__ytp_offline_opt_ins');return (await response?.json()||[]).includes('jamesDsp');});
const requiredFiles=Object.entries(manifest.files).filter(([path])=>includeOptional||!optionalPaths.has(path));
const withoutPage=await poll(async()=>{const {cacheDataEntries:entries}=await cdp.send('CacheStorage.requestEntries',{cacheId,skipCount:0,pageSize:1000});return requiredFiles.every(([path,file])=>entries.some(e=>e.requestURL===origin+path+'?v='+file.h&&e.responseHeaders.some(h=>h.name.toLowerCase()==='x-asset-hash'&&h.value===file.h)));});
result.conditions.push({name:'worker completes with every application page closed; passive same-origin inspector only',complete:withoutPage});
if(!withoutPage)result.failures.push({kind:'completion-without-page'});
await cdp.detach();await inspector.close();
}else await other.close();
}
console.log(engine,JSON.stringify({missing:result.inventory.missing,failures:result.failures,conditions:result.conditions}));
result.inventory=await inventory(!legacy);
console.log(engine,JSON.stringify({missing:result.inventory.missing,inventory:result.inventory.total,manifestFiles:result.inventory.manifestTotal,failures:result.failures,conditions:result.conditions}));
}catch(e){result.failures.push({kind:'harness',error:e.stack});console.log(engine,e.message);}finally{await context?.close();await browser?.close();await proxy?.close();child.kill('SIGTERM');if(child.exitCode===null)await new Promise(r=>child.once('exit',r));}
}
try{for(const engine of option('--browser','all')==='all'?['chromium','webkit']:[option('--browser')])await run(engine);}finally{writeFileSync(resolve(option('--out',join(repo,'perf/results/offline-complete.json'))),JSON.stringify({results},null,2)+'\n');rmSync(scratch,{recursive:true,force:true});}

View File

@@ -49,7 +49,7 @@ async function run(name){
await context.route('**/*',route=>route.request().url().startsWith(origin)?route.continue():route.abort());
const page=await context.newPage();page.on('pageerror',e=>result.errors.push(e.message));
const boot=async()=>{await page.goto(origin);await page.waitForFunction(()=>typeof data!=='undefined'&&document.querySelector('#sidebarToggle'));await page.waitForTimeout(800);};
const settings=()=>page.evaluate(()=>JSON.parse(localStorage.getItem('_ytpdata')).settings);
const settings=()=>page.evaluate(()=>JSON.parse(JSON.stringify(data.settings)));
const openSettings=async()=>{await page.locator('[data-view="settings"]').first().evaluate(e=>e.click());await page.waitForSelector('#setSaveBeforePlay',{state:'attached'});};
const readUI=()=>page.evaluate(ids=>Object.fromEntries(ids.map(id=>{const e=document.getElementById(id);return[id,e.type==='checkbox'?e.checked:e.value];})),Object.values(ui).map(x=>x[0]));
await boot();await page.waitForFunction(()=>!!navigator.serviceWorker.controller);
@@ -67,13 +67,15 @@ async function run(name){
await page.waitForFunction(()=>!document.getElementById('modal').classList.contains('hidden')&&/Update available/.test(document.getElementById('modalTitle').textContent));
await page.locator('#modal').getByRole('button',{name:'Refresh UI',exact:true}).evaluate(e=>e.click());await page.waitForFunction(tag=>document.querySelector('meta[name="ytp-build"]')?.content===tag,result.newTag,{timeout:90000});await sleep(1000);await openSettings();
console.log(name,'real Refresh UI completed');result.upgraded=await settings();result.newRaw=await page.evaluate(()=>localStorage.getItem('_ytpdata'));result.newUI=await readUI();assert.deepEqual(result.newUI,result.oldUI,'checkbox/select values preserved');
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);
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);expected.jamesDsp={enabled:false,downloadForOffline:false,preset:'flat',params:{},irRef:null,...(expected.jamesDsp||{}),params:expected.jamesDsp?.params||{}};
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 openSettings();await page.locator('[data-section="playback"]').evaluate(e=>e.click());await page.locator('#setDspPreset').evaluate(e=>{e.value='warm';e.dispatchEvent(new Event('change',{bubbles:true}));});
for(const [key,value] of [['eq.band0','3'],['tube.drive','1.5']])await page.locator(`[data-dsp-param="${key}"]`).evaluate((e,v)=>{e.value=v;e.dispatchEvent(new Event('input',{bubbles:true}));},value);
await sleep(600);await page.reload();await page.waitForFunction(()=>typeof data!=='undefined');await openSettings();await page.locator('[data-section="playback"]').evaluate(e=>e.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;
const james=await page.evaluate(()=>data.settings.jamesDsp);assert.deepEqual(james,{enabled:false,downloadForOffline:false,preset:'warm',params:{'eq.band0':3,'tube.drive':1.5},irRef:null});assert.equal(await page.locator('#setSoundEngine').inputValue(),'standard');assert.equal(await page.locator('#setDspPreset').inputValue(),'warm');
await page.locator('#setDspOffline').check();const wasmReady=await poll(()=>page.evaluate(async()=>{const m=JSON.parse(document.getElementById('ytp-assets').textContent),c=await caches.open('ytplayer-assets');return !!await c.match('/vendor/jamesdsp/dsp/jamesdsp.wasm?v='+m.files['/vendor/jamesdsp/dsp/jamesdsp.wasm'].h);}));assert.equal(wasmReady,true,'offline opt-in eventually caches the optional WASM');
await page.reload();await page.waitForFunction(()=>typeof data!=='undefined');await openSettings();await page.locator('[data-section="playback"]').evaluate(e=>e.click());assert.equal(await page.locator('#setDspOffline').isChecked(),true);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});}