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

@@ -663,7 +663,7 @@ const DEFAULT_SETTINGS = {
fontScale: 'normal', // 'small' | 'normal' | 'large' | 'xl' fontScale: 'normal', // 'small' | 'normal' | 'large' | 'xl'
levelVolume: null, // loudness levelling; null = on (on iPhone: applied to saved songs' rendered copies) levelVolume: null, // loudness levelling; null = on (on iPhone: applied to saved songs' rendered copies)
liveAudioIOS: false, // iPhone: live Web Audio EQ/levelling for streamed songs (stops when the screen locks) liveAudioIOS: false, // iPhone: live Web Audio EQ/levelling for streamed songs (stops when the screen locks)
jamesDsp: { enabled: false, preset: 'flat', params: {} }, jamesDsp: { enabled: false, downloadForOffline: false, preset: 'flat', params: {}, irRef: null },
parallelSaves: 4, // offline saves running at once (Settings → Offline cache) parallelSaves: 4, // offline saves running at once (Settings → Offline cache)
density: 'comfortable', // 'comfortable' | 'compact' density: 'comfortable', // 'comfortable' | 'compact'
perfMode: false, // disable heavy visual effects for speed perfMode: false, // disable heavy visual effects for speed
@@ -678,11 +678,15 @@ function normalizeJamesDspSettings(settings) {
const params = saved.params && typeof saved.params === 'object' && !Array.isArray(saved.params) ? { ...saved.params } : {}; const params = saved.params && typeof saved.params === 'object' && !Array.isArray(saved.params) ? { ...saved.params } : {};
// Impulse-response PCM is intentionally session-only; do not re-enable the // Impulse-response PCM is intentionally session-only; do not re-enable the
// convolver on a later boot without its uploaded audio buffer. // convolver on a later boot without its uploaded audio buffer.
delete params['convolver.enabled']; const irRef = saved.irRef && typeof saved.irRef.name === 'string' && typeof saved.irRef.hash === 'string'
? { name: saved.irRef.name, hash: saved.irRef.hash } : null;
if (!irRef) delete params['convolver.enabled'];
settings.jamesDsp = { settings.jamesDsp = {
enabled: saved.enabled === true, enabled: saved.enabled === true,
downloadForOffline: saved.downloadForOffline === true,
preset: typeof saved.preset === 'string' ? saved.preset : 'flat', preset: typeof saved.preset === 'string' ? saved.preset : 'flat',
params, params,
irRef,
}; };
return settings; return settings;
} }
@@ -5535,6 +5539,7 @@ const loadSoundFeatures=()=>Promise.all([Lazy.load('feature:eq-core'),Lazy.load(
const EQ_BANDS = EqParameters.bands; const EQ_BANDS = EqParameters.bands;
const EQ_PRESETS = EqParameters.presets; const EQ_PRESETS = EqParameters.presets;
const JamesDSPFeature = Lazy.proxy('feature:jamesdsp', ['createController'], 'JamesDSPFeature'); const JamesDSPFeature = Lazy.proxy('feature:jamesdsp', ['createController'], 'JamesDSPFeature');
const JamesDSPTools = Lazy.proxy('feature:jamesdsp', ['generateIR', 'decodeIR', 'prepareIR', 'saveIR', 'getIR', 'listIRs', 'deleteIR'], 'JamesDSPFeature');
const EQ = (() => { const EQ = (() => {
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1); const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
const AC = window.AudioContext || window.webkitAudioContext; const AC = window.AudioContext || window.webkitAudioContext;
@@ -5726,7 +5731,7 @@ const EQ = (() => {
// one-way step the EQ takes, so it is only offered where the EQ is (never // one-way step the EQ takes, so it is only offered where the EQ is (never
// on iOS, where Web Audio would silence background playback). // on iOS, where Web Audio would silence background playback).
function analyser() { function analyser() {
if (dspController?.active) return dspController.analyser(); if (dspController?.active && !dspController.state().bypassed) return dspController.analyser();
if (!available() || !build()) return null; if (!available() || !build()) return null;
attach(els.video); attach(els.video);
attach(els.audio); attach(els.audio);
@@ -5765,16 +5770,50 @@ const EQ = (() => {
catch (error) { dspFailed(error); return false; } catch (error) { dspFailed(error); return false; }
} }
function dspLowEndDevice() {
const cores=Number(navigator.hardwareConcurrency), memory=Number(navigator.deviceMemory);
return (cores>0&&cores<=4)||(memory>0&&memory<=4);
}
async function loadDspIRBuffer(buffer, reference=null) {
if (!data.settings.jamesDsp?.enabled || !dspController?.active) throw new Error('Turn on JamesDSP before importing an impulse response.');
await dspController.loadIR(buffer,reference);
data.settings.jamesDsp.irRef=reference;
data.settings.jamesDsp.params['convolver.enabled']=1;
persist();
return reference;
}
async function loadDspIR(file) { async function loadDspIR(file) {
if (!data.settings.jamesDsp?.enabled || !dspController?.active) throw new Error('Turn on JamesDSP before importing an impulse response.'); if (!data.settings.jamesDsp?.enabled || !dspController?.active) throw new Error('Turn on JamesDSP before importing an impulse response.');
const context = st.ctx; const context=st.ctx;if(!context?.decodeAudioData)throw new Error('Audio decoding is unavailable on this device.');
if (!context?.decodeAudioData) throw new Error('Audio decoding is unavailable on this device.'); const buffer=await context.decodeAudioData(await file.arrayBuffer());
const audioBuffer = await context.decodeAudioData(await file.arrayBuffer()); const prepared=await JamesDSPTools.prepareIR(context,buffer,dspLowEndDevice()?24000:65536);
return dspController.loadIR(audioBuffer); const name=String(file.name||'Uploaded IR').replace(/\.[^.]+$/,'').slice(0,100)||'Uploaded IR';
let reference=null;try{reference=await JamesDSPTools.saveIR(name,prepared,{kind:'uploaded'});}catch(error){toast(`Impulse response stays on this device for this session: ${error.message}`);}
return loadDspIRBuffer(prepared,reference);
}
async function loadDspBuiltinIR(name) {
if(!data.settings.jamesDsp?.enabled||!dspController?.active)throw new Error('Turn on JamesDSP before loading an impulse response.');
const context=st.ctx;if(!context)throw new Error('Audio context is unavailable.');
const lowEnd=dspLowEndDevice();if(lowEnd&&name!=='Small room')throw new Error('Long impulse responses are disabled on this lower-power device.');
const buffer=await JamesDSPTools.generateIR(name,context.sampleRate,2,lowEnd?24000:65536);
let reference=null;try{reference=await JamesDSPTools.saveIR(name,buffer,{kind:'generated'});}catch(error){toast(`Generated response stays in this session: ${error.message}`);}
return loadDspIRBuffer(buffer,reference);
}
async function loadDspSavedIR(name) {
const stored=await JamesDSPTools.getIR(name);if(!stored)throw new Error(`Stored impulse response not found: ${name}`);
return loadDspIRBuffer(stored.buffer,{name:stored.name,hash:stored.hash});
}
async function listDspIRs() {
return JamesDSPTools.listIRs();
} }
return { open, apply, renderButton, available, analyser, setLevel, gains, bands: EQ_BANDS, isIOS: () => isIOS, state: () => ({ wired: !!st.ctx, ctx: st.ctx && st.ctx.state, gains: st.filters ? st.filters.map((f) => f.gain.value) : null, return { open, apply, renderButton, available, analyser, setLevel, gains, bands: EQ_BANDS, isIOS: () => isIOS, state: () => ({ wired: !!st.ctx, ctx: st.ctx && st.ctx.state, gains: st.filters ? st.filters.map((f) => f.gain.value) : null,
levelDb: st.level ? Math.round(200 * Math.log10(Math.max(1e-6, st.level.gain.value))) / 10 : null, jamesDsp: dspController?.state() || null }), toggleDspBypass, loadDspIR }; levelDb: st.level ? Math.round(200 * Math.log10(Math.max(1e-6, st.level.gain.value))) / 10 : null, jamesDsp: dspController?.state() || null }), toggleDspBypass, loadDspIR, loadDspIRBuffer, loadDspBuiltinIR, loadDspSavedIR, listDspIRs };
})(); })();
// ============================================================================ // ============================================================================
@@ -8857,6 +8896,8 @@ async function registerServiceWorker() {
const reg = await navigator.serviceWorker.register('/sw.js', { updateViaCache: 'none' }); const reg = await navigator.serviceWorker.register('/sw.js', { updateViaCache: 'none' });
_swReg = reg; _swReg = reg;
Lazy.reportLayout(reg); Lazy.reportLayout(reg);
reportDspOfflineOptIn(reg);
navigator.serviceWorker.ready.then(readyReg => reportDspOfflineOptIn(readyReg)).catch(() => {});
// A waiting worker only means "update pending" when this page is already // A waiting worker only means "update pending" when this page is already
// controlled by a previous SW. On a FIRST install (fresh visit, or right // controlled by a previous SW. On a FIRST install (fresh visit, or right
@@ -8892,6 +8933,14 @@ async function registerServiceWorker() {
} }
} }
function reportDspOfflineOptIn(reg = _swReg) {
if (!WEB || !navigator.serviceWorker) return;
const dsp = data.settings?.jamesDsp;
const values = dsp?.enabled || dsp?.downloadForOffline ? ['jamesDsp'] : [];
const workers = new Set([navigator.serviceWorker.controller, reg?.installing, reg?.active, reg?.waiting]);
for (const worker of workers) worker?.postMessage({ type: 'OFFLINE_OPT_INS', values });
}
// /?q=<query> — a shareable search link (same family as Share's /?v=&t=). // /?q=<query> — a shareable search link (same family as Share's /?v=&t=).
// Runs after wireUI() so the search form's submit handler exists; the param // Runs after wireUI() so the search form's submit handler exists; the param
// is stripped immediately so a reload doesn't re-run the search. // is stripped immediately so a reload doesn't re-run the search.

View File

@@ -3,12 +3,22 @@
'use strict'; 'use strict';
const CACHE = 'ytplayer-assets'; const CACHE = 'ytplayer-assets';
const STATE = '/__ytp_asset_state'; const STATE = '/__ytp_asset_state';
const OPT_INS = '/__ytp_offline_opt_ins';
const url = (path, file) => path + '?v=' + file.h; const url = (path, file) => path + '?v=' + file.h;
function paths(manifest) { function paths(manifest) {
return Object.keys(manifest.files).sort(); return Object.keys(manifest.files).sort();
} }
function offlinePaths(manifest, optIns = []) {
const allowed = new Set(optIns || []), optional = new Map();
for (const group of Object.values(manifest.groups || {})) {
if (!group.offlineOptIn || allowed.has(group.offlineOptIn)) continue;
for (const path of group.files || []) optional.set(path, true);
}
return paths(manifest).filter(path => !optional.has(path));
}
function blocking(manifest, previous, activeLayout) { function blocking(manifest, previous, activeLayout) {
const names = new Set(Object.entries(manifest.groups) const names = new Set(Object.entries(manifest.groups)
.filter(([name, group]) => name === 'core' || !group.background || .filter(([name, group]) => name === 'core' || !group.background ||
@@ -61,13 +71,17 @@
return valid; return valid;
} }
async function status(manifest, cache, { previous, activeLayout, offline = true } = {}) { async function readOptIns(cache) {
try { return (await (await cache.match(OPT_INS))?.json()) || []; } catch { return []; }
}
async function status(manifest, cache, { previous, activeLayout, offline = true, optIns } = {}) {
const found = await Promise.all(blocking(manifest, previous, activeLayout).map(async path => { const found = await Promise.all(blocking(manifest, previous, activeLayout).map(async path => {
const response = await cache.match(url(path, manifest.files[path])); const response = await cache.match(url(path, manifest.files[path]));
return verifiedProbe(response, manifest.files[path].h); return verifiedProbe(response, manifest.files[path].h);
})); }));
const missing = found.filter(value => !value).length; const missing = found.filter(value => !value).length;
return { ready: missing === 0, missing, version: manifest.buildTag, ...(offline ? await completeness(manifest, cache) : {}) }; return { ready: missing === 0, missing, version: manifest.buildTag, ...(offline ? await completeness(manifest, cache, { optIns }) : {}) };
} }
// All concurrent messages/layout jobs in this worker share six download slots. // All concurrent messages/layout jobs in this worker share six download slots.
@@ -89,10 +103,10 @@
finally { pendingDownloads.delete(key); } finally { pendingDownloads.delete(key); }
} }
async function syncAssets(manifest, { cache, fetchFn, concurrency = 6, attempts = 3, activeLayout, groups, all = false }) { async function syncAssets(manifest, { cache, fetchFn, concurrency = 6, attempts = 3, activeLayout, groups, all = false, optIns } = {}) {
const previous = (await state(cache))?.current; const previous = (await state(cache))?.current;
const missing = []; const missing = [];
const selected = all ? paths(manifest) : groups ? [...new Set(groups.flatMap(name => manifest.groups[name]?.files || []))].sort() const selected = all ? offlinePaths(manifest, optIns || await readOptIns(cache)) : groups ? [...new Set(groups.flatMap(name => manifest.groups[name]?.files || []))].sort()
: blocking(manifest, previous, activeLayout); : blocking(manifest, previous, activeLayout);
for (const path of selected) { for (const path of selected) {
const key = url(path, manifest.files[path]); const key = url(path, manifest.files[path]);
@@ -135,8 +149,8 @@
// Verified cache entries are the durable progress journal. Counters are rebuilt // Verified cache entries are the durable progress journal. Counters are rebuilt
// after worker termination or browser eviction; never trust a stored ready bit. // after worker termination or browser eviction; never trust a stored ready bit.
async function completeness(manifest, cache) { async function completeness(manifest, cache, { optIns } = {}) {
const files = paths(manifest); const files = offlinePaths(manifest, optIns || await readOptIns(cache));
const missingFiles = (await Promise.all(files.map(async path => { const missingFiles = (await Promise.all(files.map(async path => {
const response = await cache.match(url(path, manifest.files[path])); const response = await cache.match(url(path, manifest.files[path]));
return verifiedProbe(response, manifest.files[path].h) ? null : path; return verifiedProbe(response, manifest.files[path].h) ? null : path;
@@ -146,12 +160,13 @@
} }
const completionKey = manifest => '/__ytp_completion/' + manifest.buildTag; const completionKey = manifest => '/__ytp_completion/' + manifest.buildTag;
async function complete(manifest, { cache, fetchFn, cycles = 4, now = Date.now, async function complete(manifest, { cache, fetchFn, cycles = 4, now = Date.now, optIns,
sleep = ms => new Promise(resolve => setTimeout(resolve, ms)), baseDelay = 1000, sleep = ms => new Promise(resolve => setTimeout(resolve, ms)), baseDelay = 1000,
maxDelay = 30000, notify = () => {}, force = false } = {}) { maxDelay = 30000, notify = () => {}, force = false } = {}) {
const key = completionKey(manifest), saved = await cache.match(key); const key = completionKey(manifest), saved = await cache.match(key);
let job = saved ? await saved.json() : { failures: 0, nextRetryAt: 0 }; let job = saved ? await saved.json() : { failures: 0, nextRetryAt: 0 };
let snapshot = await completeness(manifest, cache); const selectedOptIns = optIns || await readOptIns(cache);
let snapshot = await completeness(manifest, cache, { optIns: selectedOptIns });
const publish = async running => { const publish = async running => {
job = { ...job, ...snapshot, running, version: manifest.buildTag }; job = { ...job, ...snapshot, running, version: manifest.buildTag };
await cache.put(key, Response.json(job)); await cache.put(key, Response.json(job));
@@ -162,9 +177,9 @@
for (let cycle = 0; cycle < cycles; cycle++) { for (let cycle = 0; cycle < cycles; cycle++) {
await publish(true); await publish(true);
let error; let error;
try { await syncAssets(manifest, { cache, fetchFn, all: true }); } try { await syncAssets(manifest, { cache, fetchFn, all: true, optIns: selectedOptIns }); }
catch (failure) { error = failure; } catch (failure) { error = failure; }
snapshot = await completeness(manifest, cache); snapshot = await completeness(manifest, cache, { optIns: selectedOptIns });
if (snapshot.offlineReady) { job.failures = 0; job.nextRetryAt = 0; job.error = null; await publish(false); return job; } if (snapshot.offlineReady) { job.failures = 0; job.nextRetryAt = 0; job.error = null; await publish(false); return job; }
job.failures++; job.failures++;
const delay = Math.min(maxDelay, baseDelay * 2 ** Math.min(job.failures - 1, 10)); const delay = Math.min(maxDelay, baseDelay * 2 ** Math.min(job.failures - 1, 10));
@@ -198,7 +213,7 @@
return next; return next;
} }
const api = { CACHE, STATE, url, paths, blocking, plan, retained, fallback, status, completeness, completionKey, complete, syncAssets, state, commit }; const api = { CACHE, STATE, OPT_INS, url, paths, offlinePaths, blocking, plan, retained, fallback, status, completeness, completionKey, complete, syncAssets, readOptIns, state, commit };
if (typeof module !== 'undefined' && module.exports) module.exports = api; if (typeof module !== 'undefined' && module.exports) module.exports = api;
else root.AssetSyncCore = api; else root.AssetSyncCore = api;
})(typeof globalThis !== 'undefined' ? globalThis : this); })(typeof globalThis !== 'undefined' ? globalThis : this);

View File

@@ -56,6 +56,20 @@ test('completion includes ungrouped runtime files and exposes verified missing p
assert.equal((await core.status(m,c)).offlineReady,true); assert.equal((await core.status(m,c)).offlineReady,true);
}); });
test('offline completion skips opt-in DSP assets until the user enables offline audio effects', async () => {
const c=cache(),m=manifest();m.files['/vendor/jamesdsp/dsp/jamesdsp.wasm']={h:'wasm'};
m.groups.jamesdsp={contract:1,eager:false,background:true,offlineOptIn:'jamesDsp',files:['/vendor/jamesdsp/dsp/jamesdsp.wasm']};
const calls=[];
const defaultJob=await core.complete(m,{cache:c,fetchFn:async u=>{calls.push(u);return response(u.split('=')[1])}});
assert.deepEqual(calls.sort(),['/a.js?v=a','/index.html?v=b']);
assert.equal(defaultJob.total,2);assert.equal(defaultJob.offlineReady,true);
calls.length=0;
await c.put(core.OPT_INS,Response.json(['jamesDsp']));
const optedJob=await core.complete(m,{cache:c,fetchFn:async u=>{calls.push(u);return response(u.split('=')[1])}});
assert.deepEqual(calls,['/vendor/jamesdsp/dsp/jamesdsp.wasm?v=wasm']);
assert.equal(optedJob.total,3);assert.equal(optedJob.offlineReady,true);
});
test('completion persists backoff and resumes only missing files after a worker restart',async()=>{ test('completion persists backoff and resumes only missing files after a worker restart',async()=>{
const c=cache(),m=manifest();let clock=1000;const calls=[]; const c=cache(),m=manifest();let clock=1000;const calls=[];
await core.complete(m,{cache:c,cycles:1,now:()=>clock,baseDelay:10,fetchFn:async u=>{if(u.includes('index'))throw Error('offline');return response('a');}}); await core.complete(m,{cache:c,cycles:1,now:()=>clock,baseDelay:10,fetchFn:async u=>{if(u.includes('index'))throw Error('offline');return response('a');}});

View File

@@ -365,12 +365,16 @@
"contract": 1, "contract": 1,
"eager": false, "eager": false,
"background": true, "background": true,
"offlineOptIn": "jamesDsp",
"files": [ "files": [
"/vendor/jamesdsp/jamesdsp-feature.js", "/vendor/jamesdsp/jamesdsp-feature.js",
"/vendor/jamesdsp/jamesdsp-node.mjs", "/vendor/jamesdsp/jamesdsp-node.mjs",
"/vendor/jamesdsp/params.mjs", "/vendor/jamesdsp/params.mjs",
"/vendor/jamesdsp/ir-utils.mjs",
"/vendor/jamesdsp/ir-builtins.mjs",
"/vendor/jamesdsp/ir-store.mjs",
"/vendor/jamesdsp/worklet.mjs", "/vendor/jamesdsp/worklet.mjs",
"/vendor/jamesdsp/dsp/jamesdsp-77be0b2.mjs", "/vendor/jamesdsp/dsp/jamesdsp-a982b53.mjs",
"/vendor/jamesdsp/dsp/jamesdsp.wasm" "/vendor/jamesdsp/dsp/jamesdsp.wasm"
], ],
"dependencies": [ "dependencies": [

File diff suppressed because one or more lines are too long

View File

@@ -16,7 +16,8 @@ function parsePreset(text) {
} }
test('the persisted engine is Standard by default and upgrades missing JamesDSP settings safely', () => { test('the persisted engine is Standard by default and upgrades missing JamesDSP settings safely', () => {
assert.match(app, /jamesDsp: \{ enabled: false, preset: 'flat', params: \{\} \}/); assert.match(app, /jamesDsp: \{ enabled: false, downloadForOffline: false, preset: 'flat', params: \{\}, irRef: null \}/);
assert.match(app, /downloadForOffline: saved\.downloadForOffline === true/);
assert.match(app, /enabled: saved\.enabled === true/); assert.match(app, /enabled: saved\.enabled === true/);
assert.match(app, /preset: typeof saved\.preset === 'string' \? saved\.preset : 'flat'/); assert.match(app, /preset: typeof saved\.preset === 'string' \? saved\.preset : 'flat'/);
assert.match(app, /settings: normalizeJamesDspSettings\(\{ \.\.\.DEFAULT_SETTINGS, \.\.\.\(loaded\.settings \|\| \{\}\) \}\)/); assert.match(app, /settings: normalizeJamesDspSettings\(\{ \.\.\.DEFAULT_SETTINGS, \.\.\.\(loaded\.settings \|\| \{\}\) \}\)/);
@@ -47,6 +48,12 @@ test('Playback settings expose persisted presets, bands, safe import/export, IR,
assert.match(views, /\[25, 40, 63, 100, 160, 250, 400, 630, 1000, 1600, 2500, 4000, 6300, 10000, 16000\]/); assert.match(views, /\[25, 40, 63, 100, 160, 250, 400, 630, 1000, 1600, 2500, 4000, 6300, 10000, 16000\]/);
assert.match(views, /imported\?\.texts/); assert.match(views, /imported\?\.texts/);
assert.doesNotMatch(views, /liveprog|ddc|arbitrary/); assert.doesNotMatch(views, /liveprog|ddc|arbitrary/);
assert.match(views, /navigator\.hardwareConcurrency/);
assert.match(views, /Long impulse responses are disabled/);
assert.doesNotMatch(views, /44\.1 or 48 kHz/);
assert.match(views, /setDspSpatialMode/);
assert.match(views, /data-dsp-param="convolver\.predelayMs"/);
assert.match(views, /data-dsp-param="convolver\.mix"/);
assert.match(app, /Bypass JamesDSP/); assert.match(app, /Bypass JamesDSP/);
assert.match(app, /toggleDspBypass/); assert.match(app, /toggleDspBypass/);
}); });

View File

@@ -12,13 +12,17 @@ test('JamesDSP runtime files live only in a lazy background group', () => {
assert.equal(group.contract, 1); assert.equal(group.contract, 1);
assert.equal(group.eager, false); assert.equal(group.eager, false);
assert.equal(group.background, true); assert.equal(group.background, true);
assert.equal(group.offlineOptIn, 'jamesDsp');
assert.deepEqual(group.dependencies, ['feature:eq-core', 'feature:vocal-reducer']); assert.deepEqual(group.dependencies, ['feature:eq-core', 'feature:vocal-reducer']);
assert.deepEqual(group.files, [ assert.deepEqual(group.files, [
'/vendor/jamesdsp/jamesdsp-feature.js', '/vendor/jamesdsp/jamesdsp-feature.js',
'/vendor/jamesdsp/jamesdsp-node.mjs', '/vendor/jamesdsp/jamesdsp-node.mjs',
'/vendor/jamesdsp/params.mjs', '/vendor/jamesdsp/params.mjs',
'/vendor/jamesdsp/ir-utils.mjs',
'/vendor/jamesdsp/ir-builtins.mjs',
'/vendor/jamesdsp/ir-store.mjs',
'/vendor/jamesdsp/worklet.mjs', '/vendor/jamesdsp/worklet.mjs',
'/vendor/jamesdsp/dsp/jamesdsp-77be0b2.mjs', '/vendor/jamesdsp/dsp/jamesdsp-a982b53.mjs',
'/vendor/jamesdsp/dsp/jamesdsp.wasm', '/vendor/jamesdsp/dsp/jamesdsp.wasm',
]); ]);
for (const path of group.files) assert.equal(assets.groups.core.files.includes(path), false, `${path} is not core`); for (const path of group.files) assert.equal(assets.groups.core.files.includes(path), false, `${path} is not core`);
@@ -33,24 +37,31 @@ test('JamesDSP resolves every module dependency through the page build manifest'
assert.match(node, /assetURL\('\/vendor\/jamesdsp\/dsp\/jamesdsp\.wasm'/); assert.match(node, /assetURL\('\/vendor\/jamesdsp\/dsp\/jamesdsp\.wasm'/);
assert.match(node, /assetURL\('\/vendor\/jamesdsp\/params\.mjs'/); assert.match(node, /assetURL\('\/vendor\/jamesdsp\/params\.mjs'/);
assert.match(node, /processorOptions:\{wasmBytes\}/); assert.match(node, /processorOptions:\{wasmBytes\}/);
assert.match(worklet, /import createJamesDSP from '\.\/dsp\/jamesdsp-77be0b2\.mjs'/); assert.match(worklet, /import createJamesDSP from '\.\/dsp\/jamesdsp-a982b53\.mjs'/);
assert.doesNotMatch(worklet, /import\(/); assert.doesNotMatch(worklet, /import\(/);
assert.match(node, /context\.sampleRate<8000\|\|context\.sampleRate>96000/);
assert.match(node, /!\[1,2,4\]\.includes\(buffer\.numberOfChannels\) \|\| buffer\.length>65536/);
const feature = readFileSync(join(root, 'vendor/jamesdsp/jamesdsp-feature.js'), 'utf8'); const feature = readFileSync(join(root, 'vendor/jamesdsp/jamesdsp-feature.js'), 'utf8');
assert.match(feature, /Lazy\.url\('\/vendor\/jamesdsp\/jamesdsp-node\.mjs'\)/); assert.match(feature, /Lazy\.url\('\/vendor\/jamesdsp\/jamesdsp-node\.mjs'\)/);
assert.match(feature, /Lazy\.url\('\/vendor\/jamesdsp\/dsp\/jamesdsp\.wasm'\)/); assert.match(feature, /Lazy\.url\('\/vendor\/jamesdsp\/dsp\/jamesdsp\.wasm'\)/);
}); });
test('v1 wrapper rejects text effects and omits them from portable presets', () => { test('player wrapper rejects text effects while portable presets retain IR references', () => {
const node = readFileSync(join(root, 'vendor/jamesdsp/jamesdsp-node.mjs'), 'utf8'); const node = readFileSync(join(root, 'vendor/jamesdsp/jamesdsp-node.mjs'), 'utf8');
const worklet = readFileSync(join(root, 'vendor/jamesdsp/worklet.mjs'), 'utf8'); const worklet = readFileSync(join(root, 'vendor/jamesdsp/worklet.mjs'), 'utf8');
assert.match(node, /Text effects are disabled in this player version/); assert.match(node, /Text effects are disabled in this player version/);
assert.match(node, /toJSON\(\) \{return \{version:1,params:/); assert.match(node, /toJSON\(\) \{return \{version:2,params:/);
assert.match(node, /irCapFrames:view\.getUint32\(32,true\)/);
assert.match(node, /getIR\(preset\.irRef\.name\)/);
const feature = readFileSync(join(root, 'vendor/jamesdsp/jamesdsp-feature.js'), 'utf8');
assert.match(feature, /async function prepareIR\(context, buffer, maxFrames = 65536\)/);
assert.match(feature, /prepared\.length>Math\.min\(65536,maxFrames\)/);
assert.doesNotMatch(worklet, /data\.type==='text'/); assert.doesNotMatch(worklet, /data\.type==='text'/);
}); });
test('vendor provenance pins the source and retains both supplied license texts', () => { test('vendor provenance pins the source and retains both supplied license texts', () => {
const version = readFileSync(join(root, 'vendor/jamesdsp/VERSION.txt'), 'utf8'); const version = readFileSync(join(root, 'vendor/jamesdsp/VERSION.txt'), 'utf8');
assert.match(version, /77be0b2e825a20ba98feff33d16dd0a43232ef3b/); assert.match(version, /a982b53c4ab347d1d9c50f4ce7ee3c2735eaa817/);
assert.match(version, /GPL-2\.0 text/); assert.match(version, /GPL-2\.0 text/);
assert.ok(readFileSync(join(root, 'vendor/jamesdsp/LICENSE.txt'), 'utf8').includes('Version 3')); assert.ok(readFileSync(join(root, 'vendor/jamesdsp/LICENSE.txt'), 'utf8').includes('Version 3'));
assert.ok(readFileSync(join(root, 'vendor/jamesdsp/UPSTREAM-MAIN-LICENSE.txt'), 'utf8').includes('Version 2')); assert.ok(readFileSync(join(root, 'vendor/jamesdsp/UPSTREAM-MAIN-LICENSE.txt'), 'utf8').includes('Version 2'));

View File

@@ -52,11 +52,11 @@ test('a rejected or unsupported engine leaves the Standard route active', async
assert.equal(f.controller.state().liveHeaps, 0); assert.equal(f.controller.state().liveHeaps, 0);
assert.equal(f.errors.length, 1); assert.equal(f.errors.length, 1);
const wrongRate = fixture(); const supportedRate = fixture();
wrongRate.context.sampleRate = 32000; supportedRate.context.sampleRate = 32000;
await assert.rejects(wrongRate.controller.enable({ preset: 'flat' }), /44\.1 or 48 kHz/); await supportedRate.controller.enable({ preset: 'flat' });
assert.equal(wrongRate.created.length, 0); assert.equal(supportedRate.controller.state().active, true);
assert.equal(wrongRate.controller.state().liveHeaps, 0); assert.equal(supportedRate.controller.state().sampleRate, 32000);
}); });
test('a runtime worklet failure switches sources to Standard before destroying the heap', async () => { test('a runtime worklet failure switches sources to Standard before destroying the heap', async () => {
@@ -101,9 +101,14 @@ test('A/B bypass is retained when settings are reapplied', async () => {
const f = fixture(); const f = fixture();
await f.controller.enable({ preset: 'warm' }, 0); await f.controller.enable({ preset: 'warm' }, 0);
assert.equal(await f.controller.toggleBypass(), true); assert.equal(await f.controller.toggleBypass(), true);
assert.equal(f.controller.state().route, 'standard');
assert.deepEqual(f.routed.at(-1), [['video', 'audio'], f.standardInput], 'bypass follows the exact Standard route');
await f.controller.enable({ preset: 'bass' }, 0); await f.controller.enable({ preset: 'bass' }, 0);
assert.equal(f.controller.state().bypassed, true); assert.equal(f.controller.state().bypassed, true);
assert.equal(f.controller.state().route, 'standard');
assert.deepEqual(f.routed.at(-1), [['video', 'audio'], f.standardInput], 'settings changes cannot replace the Standard route while bypassed');
assert.ok(f.bypass.at(-1)); assert.ok(f.bypass.at(-1));
assert.equal(await f.controller.toggleBypass(), false); assert.equal(await f.controller.toggleBypass(), false);
assert.equal(f.controller.state().route, 'jamesdsp');
assert.equal(f.bypass.at(-1), false); assert.equal(f.bypass.at(-1), false);
}); });

View File

@@ -0,0 +1,23 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
test('generated room responses support four true-stereo paths and respect the low-memory cap', async () => {
const { generateIR, BUILTIN_IRS } = await import('./vendor/jamesdsp/ir-builtins.mjs');
const { resampleIR } = await import('./vendor/jamesdsp/ir-utils.mjs');
const ir = generateIR('Hall', 48000, 4, { maxFrames: 24000 });
assert.equal(ir.numberOfChannels, 4);
assert.equal(ir.length, 24000);
assert.equal(ir.truncated, true);
assert.ok(ir.getChannelData(0).some((sample, i) => sample !== ir.getChannelData(1)[i]), 'each matrix path has distinct taps');
const converted = resampleIR(ir, 32000);
assert.equal(converted.sampleRate, 32000);
assert.equal(converted.numberOfChannels, 4);
assert.equal(converted.length, 16000);
assert.deepEqual(Object.keys(BUILTIN_IRS), ['Small room', 'Hall', 'Plate', 'Cathedral']);
});
test('the IndexedDB store rejects impulse responses beyond its measured internal-frame cap', async () => {
const { saveIR } = await import('./vendor/jamesdsp/ir-store.mjs');
const oversized = { sampleRate: 48000, numberOfChannels: 4, length: 65537, getChannelData: () => new Float32Array(65537) };
await assert.rejects(saveIR('oversized', oversized), /65,536-frame internal limit/);
});

View File

@@ -6,8 +6,8 @@ const source = fs.readFileSync(__dirname + '/app.js', 'utf8');
// Exercise the production persistence/profile functions without starting a DOM player. // Exercise the production persistence/profile functions without starting a DOM player.
const declaration = name => source.match(new RegExp('(?:async )?function ' + name + '\\([^]*?\\n}'))[0]; const declaration = name => source.match(new RegExp('(?:async )?function ' + name + '\\([^]*?\\n}'))[0];
function fixture(updatedAt) { function fixture(updatedAt) {
const settings = { saveBeforePlay: true, autoPreload: true, layout: 'f7-swipe', eq: { preset: 'custom', g: [1,2,3,4,5] }, jamesDsp: { enabled: true, preset: 'warm', params: { 'eq.band0': 3 } } }; const settings = { saveBeforePlay: true, autoPreload: true, layout: 'f7-swipe', eq: { preset: 'custom', g: [1,2,3,4,5] }, jamesDsp: { enabled: true, downloadForOffline: true, preset: 'warm', params: { 'eq.band0': 3 } } };
const defaults = { saveBeforePlay: false, autoPreload: true, layout: 'classic', jamesDsp: { enabled: false, preset: 'flat', params: {} } }; const defaults = { saveBeforePlay: false, autoPreload: true, layout: 'classic', jamesDsp: { enabled: false, downloadForOffline: false, preset: 'flat', params: {} } };
const context = vm.createContext({ WEB: true, data: { settings, profile: { name: 'test', syncedAt: 200 } }, DEFAULT_SETTINGS: defaults, API: { saveData: async () => {} }, profileHeaders: () => ({}), scheduleProfilePush: () => context.pushes++, pushes: 0, fetch: async () => ({ status: 200, json: async () => ({ ok: true, name: 'test', updatedAt, data: { settings: { saveBeforePlay: false } } }) }), localStorage: { getItem: () => JSON.stringify({ settings }) } }); const context = vm.createContext({ WEB: true, data: { settings, profile: { name: 'test', syncedAt: 200 } }, DEFAULT_SETTINGS: defaults, API: { saveData: async () => {} }, profileHeaders: () => ({}), scheduleProfilePush: () => context.pushes++, pushes: 0, fetch: async () => ({ status: 200, json: async () => ({ ok: true, name: 'test', updatedAt, data: { settings: { saveBeforePlay: false } } }) }), localStorage: { getItem: () => JSON.stringify({ settings }) } });
vm.runInContext([declaration('loadDataFromStorage'), declaration('normalizeJamesDspSettings'), declaration('applyProfileData'), declaration('pullProfileIfNewer')].join('\n'), context); vm.runInContext([declaration('loadDataFromStorage'), declaration('normalizeJamesDspSettings'), declaration('applyProfileData'), declaration('pullProfileIfNewer')].join('\n'), context);
return context; return context;
@@ -18,7 +18,7 @@ test('stored settings load verbatim instead of reverting to defaults', () => {
assert.equal(loaded.settings.saveBeforePlay, true); assert.equal(loaded.settings.saveBeforePlay, true);
assert.equal(loaded.settings.layout, 'f7-swipe'); assert.equal(loaded.settings.layout, 'f7-swipe');
assert.deepEqual(JSON.parse(JSON.stringify(loaded.settings.eq.g)), [1,2,3,4,5]); assert.deepEqual(JSON.parse(JSON.stringify(loaded.settings.eq.g)), [1,2,3,4,5]);
assert.deepEqual(JSON.parse(JSON.stringify(loaded.settings.jamesDsp)), { enabled: true, preset: 'warm', params: { 'eq.band0': 3 } }); assert.deepEqual(JSON.parse(JSON.stringify(loaded.settings.jamesDsp)), { enabled: true, downloadForOffline: true, preset: 'warm', params: { 'eq.band0': 3 } });
}); });
for (const updatedAt of [100,200]) test(`profile timestamp ${updatedAt} cannot overwrite local settings synced at 200`, async () => { for (const updatedAt of [100,200]) test(`profile timestamp ${updatedAt} cannot overwrite local settings synced at 200`, async () => {
const c = fixture(updatedAt), previous = c.data.settings; const c = fixture(updatedAt), previous = c.data.settings;
@@ -32,6 +32,6 @@ test('newer profile intentionally replaces settings and restores missing default
assert.equal(c.data.settings.saveBeforePlay, false); assert.equal(c.data.settings.saveBeforePlay, false);
assert.equal(c.data.settings.autoPreload, true); assert.equal(c.data.settings.autoPreload, true);
assert.equal(c.data.settings.layout, 'classic'); assert.equal(c.data.settings.layout, 'classic');
assert.deepEqual(JSON.parse(JSON.stringify(c.data.settings.jamesDsp)), { enabled: false, preset: 'flat', params: {} }); assert.deepEqual(JSON.parse(JSON.stringify(c.data.settings.jamesDsp)), { enabled: false, downloadForOffline: false, preset: 'flat', params: {}, irRef: null });
assert.equal(c.data.profile.syncedAt, 300); assert.equal(c.data.profile.syncedAt, 300);
}); });

View File

@@ -1165,6 +1165,9 @@ input[type="range"]::-webkit-slider-thumb:hover { transform: scale(1.25); }
.dsp-param-row input[type="range"] { width: min(46vw, 190px); } .dsp-param-row input[type="range"] { width: min(46vw, 190px); }
.dsp-actions { display: flex; flex-wrap: wrap; gap: 8px; padding: 10px 0; } .dsp-actions { display: flex; flex-wrap: wrap; gap: 8px; padding: 10px 0; }
.dsp-actions .btn { color: var(--text); } .dsp-actions .btn { color: var(--text); }
.dsp-subsection { margin: 14px 0; padding: 12px; border: 1px solid var(--line); border-radius: 10px; background: var(--bg-1); }
.dsp-subsection h4 { margin: 0 0 8px; color: var(--text); }
.dsp-risk { padding: 9px 11px; border-radius: 8px; color: var(--text); background: color-mix(in srgb, var(--accent) 13%, var(--bg-2)); font-size: .9em; }
.set-row input[type="checkbox"] { width: 17px; height: 17px; accent-color: var(--accent); cursor: pointer; } .set-row input[type="checkbox"] { width: 17px; height: 17px; accent-color: var(--accent); cursor: pointer; }
.about-version { color: var(--text-dim); font-size: 12px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; user-select: text; } .about-version { color: var(--text-dim); font-size: 12px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; user-select: text; }
.set-select { .set-select {

View File

@@ -1,19 +1,17 @@
# JamesDSP vendor files # JamesDSP vendor files
The WebAssembly engine, parameter table and browser adapter originate from The browser port's worklet, 8–96 kHz streaming sample-rate adapter, matrix
`jamesdsp-web` revision `77be0b2e825a20ba98feff33d16dd0a43232ef3b`. Source is convolver, HRTF/virtual-speaker spatial stage, generated IRs and IndexedDB IR
available in that repository and the underlying JamesDSPManager source is store are pinned to the source revision in VERSION.txt. Runtime files stay in
referenced by its `upstream/` tree. The runtime files remain in the lazy the lazy `feature:jamesdsp` group. A user must enable the engine or choose
`feature:jamesdsp` asset group; license and provenance documents are not runtime “Download audio effects for offline” before the offline-completion job caches
assets. them, including the WASM binary.
`jamesdsp-node.js` and `worklet.js` carry the integration changes described in The adapted wrapper resolves dependencies through `Lazy.url()` and routes the
`VERSION.txt`. The generated C engine and `params.js` are unchanged from the worklet through the existing AudioGraph. It rejects Liveprog, DDC and arbitrary
pinned revision. The Node wrapper intentionally rejects text-effect parameters text effects. Impulse responses are limited to 65,536 internal frames; lower-
and does not serialize text, preventing Liveprog/DDC/arbitrary text payloads power devices use a 24,000-frame cap and warn before larger responses are used.
from entering the v1 surface.
The upstream `upstream/Main/LICENSE` text says GPL-2.0 while the port repository The port is GPL-3.0-or-later (`LICENSE.txt`). The upstream license snapshot says
ships a GPL-3.0-or-later license. Both texts are retained as supplied. Confirm GPL-2.0; both supplied notices remain in this directory pending upstream
the upstream provenance and redistribution position before a public release; clarification. See VERSION.txt for the adaptation record.
this note is not a legal conclusion.

View File

@@ -1,15 +1,18 @@
JamesDSP browser port source revision: 77be0b2e825a20ba98feff33d16dd0a43232ef3b JamesDSP browser port source revision: a982b53c4ab347d1d9c50f4ce7ee3c2735eaa817
Repository: /home/josh/development/personal/jamesdsp-web (main) Repository: /home/josh/development/personal/jamesdsp-web (main)
Vendored from web/jamesdsp-node.js, web/params.js, web/worklet.js, Vendored source files: web/jamesdsp-node.js, params.js, ir-utils.js,
web/dsp/jamesdsp.js and web/dsp/jamesdsp.wasm. ir-builtins.js, ir-store.js, worklet.js, dsp/jamesdsp.js and dsp/jamesdsp.wasm.
Integration adaptations: manifest-hashed module URLs are selected through Lazy; Integration adaptations: wrapper dependencies and WASM URLs use the page's
the worklet carries its engine URL in a fragment so its HTTP/CacheStorage key manifest-hashed Lazy.url map; the worklet statically imports the pinned
remains the exact manifest URL; the wrapper and worklet do not expose text jamesdsp-a982b53.mjs module because AudioWorkletGlobalScope rejects dynamic
effects (Liveprog, DDC, arbitrary EQ) in this v1 integration. Upstream source is import. The wrapper keeps the shared AudioGraph route and does not create a
retained unchanged for the generated engine and parameter module. MediaElementSource. Liveprog, DDC and arbitrary text effects are rejected and
not serialized in ytplayer. All runtime code remains in lazy feature:jamesdsp;
its offline completion membership requires the explicit jamesDsp opt-in.
License note: LICENSE.txt is the port's GPL-3.0-or-later license. The upstream The generated engine, parameter tables, IR utilities/store and built-in IR set
snapshot at upstream/Main/LICENSE contains GPL-2.0 text; the discrepancy is are copied from the pinned revision. The uploaded/generated IR cap is 65,536
preserved and recorded for release review. See README.md and JamesDSP's source frames at the internal 44.1/48 kHz rate (low-power devices use 24,000 frames).
repository for corresponding source information. License provenance: LICENSE.txt is the port's GPL-3.0-or-later license. The
upstream snapshot at upstream/Main/LICENSE contains GPL-2.0 text; see README.md.

Binary file not shown.

View File

@@ -0,0 +1,22 @@
// SPDX-License-Identifier: GPL-3.0-or-later
const definitions={
'Small room':{seconds:.5,rt60:.25,seed:0x51a11},
Hall:{seconds:2,rt60:1.4,seed:0x4a11},
Plate:{seconds:1.5,rt60:1,seed:0x91a7e},
Cathedral:{seconds:5,rt60:4,seed:0xca7ed},
};
export const BUILTIN_IRS=Object.freeze(Object.fromEntries(Object.entries(definitions).map(([name,d])=>[name,Object.freeze({...d,version:1})])));
export function generateIR(name,sampleRate,channels=2,{maxFrames=65536}={}) {
const spec=BUILTIN_IRS[name];if(!spec||!Number.isInteger(sampleRate)||sampleRate<8000||sampleRate>96000||![1,2,4].includes(channels)) throw new RangeError('Unsupported generated IR');
const frames=Math.max(1,Math.min(Math.round(spec.seconds*sampleRate),maxFrames));let seed=spec.seed>>>0;
const paths=Array.from({length:channels},()=>new Float32Array(frames));
const next=()=>{seed=(Math.imul(seed,1664525)+1013904223)>>>0;return seed/0x100000000*2-1;};
for(let path=0;path<channels;path++) {
const x=paths[path];x[0]=1;
for(let tap=1;tap<Math.min(24,frames);tap++) {const at=Math.min(frames-1,Math.round(sampleRate*(.004+tap*.009+next()*.001)));x[at]+=(next()*.22)/(1+tap*.25);}
for(let i=1;i<frames;i++) {const t=i/sampleRate, fade=Math.min(1,t/.012,(frames-i)/Math.max(1,sampleRate*.02));x[i]+=next()*.045*Math.exp(-6.91*t/spec.rt60)*Math.max(0,fade);}
}
let peak=0;for(const path of paths) for(const value of path) peak=Math.max(peak,Math.abs(value));
for(const path of paths) for(let i=0;i<path.length;i++) path[i]*=.5/peak;
return {name,sampleRate,numberOfChannels:channels,length:frames,seed:spec.seed,version:1,truncated:frames<spec.seconds*sampleRate,getChannelData:i=>paths[i]};
}

24
frontend/vendor/jamesdsp/ir-store.mjs vendored Normal file
View File

@@ -0,0 +1,24 @@
// SPDX-License-Identifier: GPL-3.0-or-later
const DB='jamesdsp-ir', STORE='irs', VERSION=1;
function openDB() {
if(!globalThis.indexedDB) return Promise.reject(new Error('IndexedDB is unavailable'));
return new Promise((resolve,reject)=>{const request=indexedDB.open(DB,VERSION);request.onupgradeneeded=()=>request.result.createObjectStore(STORE,{keyPath:'name'});request.onsuccess=()=>resolve(request.result);request.onerror=()=>reject(request.error);});
}
async function transaction(mode,action) {
const db=await openDB();return new Promise((resolve,reject)=>{const tx=db.transaction(STORE,mode),store=tx.objectStore(STORE);let result;try {result=action(store);} catch(error){db.close();reject(error);return;}tx.oncomplete=()=>{db.close();resolve(result);};tx.onerror=()=>{db.close();reject(tx.error);};tx.onabort=()=>{db.close();reject(tx.error||new Error('IR storage transaction aborted'));};});
}
async function digest(data) {const hash=await crypto.subtle.digest('SHA-256',data);return [...new Uint8Array(hash)].map(x=>x.toString(16).padStart(2,'0')).join('');}
export async function saveIR(name,buffer,metadata={}) {
if(typeof name!=='string'||!name.trim()||name.length>100||![1,2,4].includes(buffer?.numberOfChannels)||!Number.isFinite(buffer.sampleRate)||buffer.sampleRate<8000||buffer.sampleRate>96000||!buffer.length) throw new TypeError('Invalid named impulse response');
const internalRate=buffer.sampleRate===44100?44100:48000;
if(Math.round(buffer.length*internalRate/buffer.sampleRate)>65536) throw new RangeError('Stored impulse response exceeds the 65,536-frame internal limit');
const channels=Array.from({length:buffer.numberOfChannels},(_,i)=>new Float32Array(buffer.getChannelData(i)));
const pcm=new Float32Array(channels.length*buffer.length);for(let i=0;i<buffer.length;i++)for(let c=0;c<channels.length;c++)pcm[i*channels.length+c]=channels[c][i];
const record={name:name.trim(),sampleRate:buffer.sampleRate,channels:channels.length,frames:buffer.length,pcm,metadata:{...metadata},hash:await digest(pcm.buffer),version:1};
await transaction('readwrite',s=>s.put(record));return {name:record.name,hash:record.hash};
}
export async function getIR(name) {
const db=await openDB();return new Promise((resolve,reject)=>{const r=db.transaction(STORE,'readonly').objectStore(STORE).get(name);r.onsuccess=async()=>{try {const x=r.result;if(!x){resolve(null);return;}if(await digest(x.pcm.buffer)!==x.hash)throw new Error('Stored IR hash mismatch');const channels=Array.from({length:x.channels},()=>new Float32Array(x.frames));for(let i=0;i<x.frames;i++)for(let c=0;c<x.channels;c++)channels[c][i]=x.pcm[i*x.channels+c];resolve({...x,buffer:{sampleRate:x.sampleRate,numberOfChannels:x.channels,length:x.frames,getChannelData:i=>channels[i]}});}catch(e){reject(e);}finally{db.close();}};r.onerror=()=>{db.close();reject(r.error);};});
}
export async function listIRs() {const db=await openDB();return new Promise((resolve,reject)=>{const r=db.transaction(STORE,'readonly').objectStore(STORE).getAll();r.onsuccess=()=>{db.close();resolve(r.result.map(({name,hash,sampleRate,channels,frames,metadata})=>({name,hash,sampleRate,channels,frames,metadata})));};r.onerror=()=>{db.close();reject(r.error);};});}
export async function deleteIR(name) {await transaction('readwrite',s=>s.delete(name));}

43
frontend/vendor/jamesdsp/ir-utils.mjs vendored Normal file
View File

@@ -0,0 +1,43 @@
// SPDX-License-Identifier: GPL-3.0-or-later
const copyBuffer=(buffer,rate=buffer.sampleRate)=>{
const channels=Array.from({length:buffer.numberOfChannels},(_,i)=>new Float32Array(buffer.getChannelData(i)));
return {sampleRate:rate,numberOfChannels:channels.length,length:channels[0]?.length||0,getChannelData:i=>channels[i]};
};
export async function decodeIR(context,bytes) {
const decoded=await context.decodeAudioData(bytes.slice(0));
return resampleIR(decoded,context.sampleRate);
}
export function resampleIR(buffer,targetRate) {
if(!Number.isFinite(targetRate)||targetRate<8000||targetRate>96000||!buffer?.numberOfChannels||buffer.numberOfChannels>4||!buffer.length) throw new RangeError('Invalid IR or target sample rate');
if(buffer.sampleRate===targetRate) return copyBuffer(buffer,targetRate);
const length=Math.max(1,Math.round(buffer.length*targetRate/buffer.sampleRate));
const channels=[];
for(let ch=0;ch<buffer.numberOfChannels;ch++) {
const input=buffer.getChannelData(ch), output=new Float32Array(length), ratio=buffer.sampleRate/targetRate;
for(let i=0;i<length;i++) {const x=i*ratio, a=Math.min(Math.floor(x),input.length-1), b=Math.min(a+1,input.length-1), f=x-a;output[i]=input[a]*(1-f)+input[b]*f;}
channels.push(output);
}
return {sampleRate:targetRate,numberOfChannels:channels.length,length,getChannelData:i=>channels[i]};
}
export function trimIR(buffer,{thresholdDb=-80}={}) {
if(!Number.isFinite(thresholdDb)||thresholdDb>0||thresholdDb<-160) throw new RangeError('Invalid trim threshold');
const channels=Array.from({length:buffer.numberOfChannels},(_,i)=>buffer.getChannelData(i));
const threshold=10**(thresholdDb/20);let first=buffer.length,last=-1;
for(let i=0;i<buffer.length;i++) {let peak=0;for(const channel of channels) peak=Math.max(peak,Math.abs(channel[i]));if(peak>=threshold){first=Math.min(first,i);last=i;}}
if(last<0) first=last=0;
const result=channels.map(channel=>channel.slice(first,last+1));
return {buffer:{sampleRate:buffer.sampleRate,numberOfChannels:result.length,length:result[0]?.length||0,getChannelData:i=>result[i]},startFrame:first,endFrame:last+1};
}
export function normalizeIR(buffer,{peak=0.5}={}) {
if(!Number.isFinite(peak)||peak<=0||peak>1) throw new RangeError('Peak must be in (0, 1]');
const channels=Array.from({length:buffer.numberOfChannels},(_,i)=>new Float32Array(buffer.getChannelData(i)));
let max=0;for(const channel of channels) for(const value of channel) max=Math.max(max,Math.abs(value));
const gain=max===0?1:peak/max;for(const channel of channels) for(let i=0;i<channel.length;i++) channel[i]*=gain;
return {sampleRate:buffer.sampleRate,numberOfChannels:channels.length,length:channels[0]?.length||0,getChannelData:i=>channels[i]};
}
export function prepareIR(context,buffer,{trim=false,normalize=true,peak=0.5,thresholdDb=-80}={}) {
let result=resampleIR(buffer,context.sampleRate);
if(trim) result=trimIR(result,{thresholdDb}).buffer;
if(normalize) result=normalizeIR(result,{peak});
return result;
}

View File

@@ -2,6 +2,13 @@
(function (root) { (function (root) {
'use strict'; 'use strict';
let irToolsPromise;
const irTools = () => irToolsPromise ||= Promise.all([
import(root.Lazy.url('/vendor/jamesdsp/ir-utils.mjs')),
import(root.Lazy.url('/vendor/jamesdsp/ir-builtins.mjs')),
import(root.Lazy.url('/vendor/jamesdsp/ir-store.mjs')),
]).then(([utils,builtins,store])=>({utils,builtins,store}));
const PRESETS = Object.freeze({ const PRESETS = Object.freeze({
flat: { name: 'Flat', params: {} }, flat: { name: 'Flat', params: {} },
bass: { name: 'Bass', params: { 'eq.enabled': 1, 'eq.band0': 4, 'eq.band1': 3, 'bass.enabled': 1, 'bass.boost': 5, gain: -3 } }, bass: { name: 'Bass', params: { 'eq.enabled': 1, 'eq.band0': 4, 'eq.band1': 3, 'bass.enabled': 1, 'bass.boost': 5, gain: -3 } },
@@ -18,7 +25,7 @@
const graph = options.graph; const graph = options.graph;
let desired = false, active = false, bypassed = false, nodeEntry = null, gainNode = null, context = null; let desired = false, active = false, bypassed = false, nodeEntry = null, gainNode = null, context = null;
let elements = [], standardInput = null, levelDb = 0, settings = null; let elements = [], standardInput = null, levelDb = 0, settings = null;
let tail = Promise.resolve(), maxLiveHeaps = 0, liveHeaps = 0, nodesCreated = 0, nodesDestroyed = 0; let tail = Promise.resolve(), maxLiveHeaps = 0, liveHeaps = 0, nodesCreated = 0, nodesDestroyed = 0, loadedIRRef = null;
let faultListener = null, notifying = false, dspAnalyser = null; let faultListener = null, notifying = false, dspAnalyser = null;
const serial = fn => { const serial = fn => {
@@ -27,7 +34,7 @@
return result; return result;
}; };
function snapshot() { return { active, desired, bypassed, liveHeaps, maxLiveHeaps, nodesCreated, nodesDestroyed, sampleRate: context?.sampleRate || null }; } function snapshot() { return { active, desired, bypassed, route: active && !bypassed ? 'jamesdsp' : 'standard', liveHeaps, maxLiveHeaps, nodesCreated, nodesDestroyed, sampleRate: context?.sampleRate || null, irRef: loadedIRRef }; }
function routeStandard() { function routeStandard() {
if (!elements.length) return; if (!elements.length) return;
@@ -51,6 +58,7 @@
dspAnalyser = null; dspAnalyser = null;
if (entry) { if (entry) {
try { entry.node.destroy(); } catch { /* continue freeing references */ } try { entry.node.destroy(); } catch { /* continue freeing references */ }
loadedIRRef = null;
if (entry.live) { entry.live = false; liveHeaps = Math.max(0, liveHeaps - 1); nodesDestroyed++; } if (entry.live) { entry.live = false; liveHeaps = Math.max(0, liveHeaps - 1); nodesDestroyed++; }
} }
} }
@@ -84,6 +92,7 @@
async function configure(instance, nextSettings, nextLevelDb) { async function configure(instance, nextSettings, nextLevelDb) {
const preset = PRESETS[nextSettings?.preset] || PRESETS.flat; const preset = PRESETS[nextSettings?.preset] || PRESETS.flat;
const params = { ...preset.params, ...(nextSettings?.params || {}) }; const params = { ...preset.params, ...(nextSettings?.params || {}) };
if (Number(params['spatial.enabled']) && Number(params['crossfeed.enabled'])) params['crossfeed.enabled'] = 0;
params.gain = clampGain((Number(params.gain) || 0) + (Number(nextLevelDb) || 0)); params.gain = clampGain((Number(params.gain) || 0) + (Number(nextLevelDb) || 0));
params.bypass = 1; params.bypass = 1;
if (active && context?.state === 'running') { if (active && context?.state === 'running') {
@@ -91,7 +100,10 @@
await new Promise(resolve => setTimeout(resolve, 20)); await new Promise(resolve => setTimeout(resolve, 20));
} }
await instance.setBypass(true); await instance.setBypass(true);
await instance.applyPreset(params); const ref = nextSettings?.irRef;
const needsIR = ref && (loadedIRRef?.name !== ref.name || loadedIRRef?.hash !== ref.hash);
await instance.applyPreset(needsIR ? { params, irRef: ref } : params);
if (needsIR) loadedIRRef = { name: ref.name, hash: ref.hash };
await instance.setParam('gain', params.gain); await instance.setParam('gain', params.gain);
if (desired) await instance.setBypass(bypassed); if (desired) await instance.setBypass(bypassed);
if (desired) rampGain(1); if (desired) rampGain(1);
@@ -117,7 +129,7 @@
context = prepared.context; context = prepared.context;
elements = prepared.elements; elements = prepared.elements;
standardInput = prepared.standardInput; standardInput = prepared.standardInput;
if (![44100, 48000].includes(context?.sampleRate)) throw new RangeError(`JamesDSP supports 44.1 or 48 kHz; this device uses ${context?.sampleRate || 'unknown'} Hz`); if (!Number.isFinite(context?.sampleRate) || context.sampleRate < 8000 || context.sampleRate > 96000) throw new RangeError(`JamesDSP supports 8–96 kHz; this device uses ${context?.sampleRate || 'unknown'} Hz`);
if (!desired) return false; if (!desired) return false;
let instance = nodeEntry?.node; let instance = nodeEntry?.node;
if (!instance) { if (!instance) {
@@ -136,7 +148,7 @@
await configure(instance, settings, levelDb); await configure(instance, settings, levelDb);
if (!desired) { releaseNode(); return false; } if (!desired) { releaseNode(); return false; }
if (context.state === 'suspended') await context.resume().catch(() => {}); if (context.state === 'suspended') await context.resume().catch(() => {});
graph.routeAll(elements, instance.node); graph.routeAll(elements, bypassed ? standardInput : instance.node);
active = true; active = true;
return true; return true;
}).catch(error => { fail(error); throw error; }); }).catch(error => { fail(error); throw error; });
@@ -158,22 +170,35 @@
return enable(settings, levelDb); return enable(settings, levelDb);
} }
function setBypass(value) { function changeBypass(value) {
if (!nodeEntry) return Promise.resolve(false); if (!nodeEntry) return Promise.resolve(false);
return serial(async () => { bypassed = Boolean(value); await nodeEntry.node.setBypass(bypassed); return bypassed; }).catch(error => { fail(error); throw error; }); return serial(async () => {
const next = Boolean(value);
if (next === bypassed) return bypassed;
// A/B bypass is an exact route around the worklet. This preserves the
// Standard graph sample-for-sample instead of relying on DSP bypass math.
if (!next) await nodeEntry.node.setBypass(false);
if (active) {
rampGain(0);
await new Promise(resolve => setTimeout(resolve, 20));
graph.routeAll(elements, next ? standardInput : nodeEntry.node);
rampGain(next ? 0 : 1);
}
if (next) await nodeEntry.node.setBypass(true);
bypassed = next;
return bypassed;
}).catch(error => { fail(error); throw error; });
} }
function toggleBypass() { function setBypass(value) { return changeBypass(value); }
if (!nodeEntry) return Promise.resolve(false); function toggleBypass() { return changeBypass(!bypassed); }
return serial(async () => { bypassed = !bypassed; await nodeEntry.node.setBypass(bypassed); return bypassed; }).catch(error => { fail(error); throw error; });
}
function loadIR(buffer) { function loadIR(buffer, reference = null) {
if (!active || !nodeEntry) return Promise.reject(new Error('Enable JamesDSP before loading an impulse response')); if (!active || !nodeEntry) return Promise.reject(new Error('Enable JamesDSP before loading an impulse response'));
return serial(async () => { return serial(async () => {
if (!active || !nodeEntry) throw new Error('JamesDSP is no longer active'); if (!active || !nodeEntry) throw new Error('JamesDSP is no longer active');
rampGain(0); rampGain(0);
try { await nodeEntry.node.loadIR(buffer); await nodeEntry.node.setParam('convolver.enabled', 1); } try { await nodeEntry.node.loadIR(buffer, reference); loadedIRRef = reference ? { name: reference.name, hash: reference.hash } : null; await nodeEntry.node.setParam('convolver.enabled', 1); }
finally { if (active && desired) rampGain(1); } finally { if (active && desired) rampGain(1); }
return true; return true;
}).catch(error => { fail(error); throw error; }); }).catch(error => { fail(error); throw error; });
@@ -195,7 +220,24 @@
return { enable, disable, setLevelDb, setBypass, toggleBypass, loadIR, analyser, get active() { return active; }, get desired() { return desired; }, state: snapshot }; return { enable, disable, setLevelDb, setBypass, toggleBypass, loadIR, analyser, get active() { return active; }, get desired() { return desired; }, state: snapshot };
} }
const api = { PRESETS, createController }; async function generateIR(name, sampleRate, channels = 2, maxFrames = 65536) {
const {builtins}=await irTools();
return builtins.generateIR(name,sampleRate,channels,{maxFrames});
}
async function decodeIR(context, bytes) { const {utils}=await irTools();return utils.decodeIR(context,bytes); }
async function prepareIR(context, buffer, maxFrames = 65536) {
const {utils}=await irTools();
const rate=context.sampleRate===44100?44100:48000;
const prepared=utils.resampleIR(utils.prepareIR(context,buffer,{normalize:false}),rate);
if (![1,2,4].includes(prepared.numberOfChannels) || prepared.length<1 || prepared.length>Math.min(65536,maxFrames)) throw new RangeError(`Impulse response exceeds the ${Math.min(65536,maxFrames)}-frame internal limit`);
return prepared;
}
async function saveIR(name, buffer, metadata) { const {store}=await irTools();return store.saveIR(name,buffer,metadata); }
async function getIR(name) { const {store}=await irTools();return store.getIR(name); }
async function listIRs() { const {store}=await irTools();return store.listIRs(); }
async function deleteIR(name) { const {store}=await irTools();return store.deleteIR(name); }
const api = { PRESETS, createController, generateIR, decodeIR, prepareIR, saveIR, getIR, listIRs, deleteIR };
if (typeof module !== 'undefined' && module.exports) module.exports = api; if (typeof module !== 'undefined' && module.exports) module.exports = api;
else { else {
root.YT = root.YT || {}; root.YT = root.YT || {};

View File

@@ -1,27 +1,29 @@
// SPDX-License-Identifier: GPL-3.0-or-later // SPDX-License-Identifier: GPL-3.0-or-later
function assetURL(path, fallback) { return globalThis.Lazy?.url?.(path) || new URL(fallback, import.meta.url); }
const [parameterAPI, irUtils, irStore] = await Promise.all([
import(assetURL('/vendor/jamesdsp/params.mjs', './params.mjs')),
import(assetURL('/vendor/jamesdsp/ir-utils.mjs', './ir-utils.mjs')),
import(assetURL('/vendor/jamesdsp/ir-store.mjs', './ir-store.mjs')),
]);
const {DEFAULT_PARAMS, validateParam} = parameterAPI;
const {prepareIR,resampleIR} = irUtils;
const {getIR} = irStore;
const moduleLoads = new WeakMap(); const moduleLoads = new WeakMap();
let paramsLoad;
function assetURL(path, relative) { return globalThis.Lazy?.url?.(path) || new URL(relative, import.meta.url); }
function loadParams(paramsURL) { if (!paramsLoad) paramsLoad = import(paramsURL); return paramsLoad; }
export class JamesDSPNode extends EventTarget { export class JamesDSPNode extends EventTarget {
static async create(context, options={}) { static async create(context, {wasmURL=assetURL('/vendor/jamesdsp/dsp/jamesdsp.wasm','./dsp/jamesdsp.wasm'), workletURL=assetURL('/vendor/jamesdsp/worklet.mjs','./worklet.mjs')}={}) {
if (![44100,48000].includes(context.sampleRate)) throw new RangeError('Use a 44.1 or 48 kHz AudioContext'); if (!Number.isFinite(context.sampleRate)||context.sampleRate<8000||context.sampleRate>96000) throw new RangeError('AudioContext rate must be 8–96 kHz');
const wasmURL=options.wasmURL || assetURL('/vendor/jamesdsp/dsp/jamesdsp.wasm','./dsp/jamesdsp.wasm');
const paramsURL=options.paramsURL || assetURL('/vendor/jamesdsp/params.mjs','./params.mjs');
const baseWorkletURL=options.workletURL || assetURL('/vendor/jamesdsp/worklet.mjs','./worklet.mjs');
const paramApi=await loadParams(paramsURL);
if (!moduleLoads.has(context)) moduleLoads.set(context,new Map()); if (!moduleLoads.has(context)) moduleLoads.set(context,new Map());
const cache=moduleLoads.get(context), key=String(baseWorkletURL); const cache=moduleLoads.get(context), key=String(workletURL);
if (!cache.has(key)) cache.set(key,context.audioWorklet.addModule(baseWorkletURL).catch(e=>{cache.delete(key);throw e;})); if (!cache.has(key)) cache.set(key,context.audioWorklet.addModule(workletURL).catch(e=>{cache.delete(key);throw e;}));
const response=await fetch(wasmURL); const response=await fetch(wasmURL);
if (!response.ok) throw new Error(`WASM fetch failed: ${response.status}`); if (!response.ok) throw new Error(`WASM fetch failed: ${response.status}`);
const wasmBytes=await response.arrayBuffer(); const wasmBytes=await response.arrayBuffer();
await cache.get(key); await cache.get(key);
const wrapper=new JamesDSPNode(context,wasmBytes,paramApi); const wrapper=new JamesDSPNode(context,wasmBytes);
try { await wrapper.ready; return wrapper; } catch(e) { wrapper.destroy(); throw e; } try { await wrapper.ready; return wrapper; } catch(e) { wrapper.destroy(); throw e; }
} }
constructor(context,wasmBytes,paramApi) { constructor(context,wasmBytes) {
super(); this.context=context; this.defaultParams=Object.fromEntries(Object.entries(paramApi.DEFAULT_PARAMS).filter(([name])=>!/^(?:liveprog|ddc|arbitrary)\./.test(name))); this.validateParam=paramApi.validateParam; this.params={...this.defaultParams}; this.pending=new Map(); this.serial=0; this.destroyed=false; super(); this.context=context; this.defaultParams=Object.fromEntries(Object.entries(DEFAULT_PARAMS).filter(([name])=>!/^(?:liveprog|ddc|arbitrary)\./.test(name))); this.params={...this.defaultParams}; this.pending=new Map(); this.serial=0; this.destroyed=false; this.source=null; this.irRef=null;
this.node=new AudioWorkletNode(context,'jamesdsp',{numberOfInputs:1,numberOfOutputs:1,outputChannelCount:[2],channelCount:2,channelCountMode:'explicit',processorOptions:{wasmBytes}}); this.node=new AudioWorkletNode(context,'jamesdsp',{numberOfInputs:1,numberOfOutputs:1,outputChannelCount:[2],channelCount:2,channelCountMode:'explicit',processorOptions:{wasmBytes}});
this.ready=new Promise((resolve,reject)=> { this.ready=new Promise((resolve,reject)=> {
const timer=setTimeout(()=>reject(new Error('DSP initialization timed out')),15000); const timer=setTimeout(()=>reject(new Error('DSP initialization timed out')),15000);
@@ -35,44 +37,48 @@ export class JamesDSPNode extends EventTarget {
}); });
} }
fail(error) { for(const p of this.pending.values()) {clearTimeout(p.timer);p.reject(error);} this.pending.clear();this.dispatchEvent(new CustomEvent('error',{detail:error})); } fail(error) { for(const p of this.pending.values()) {clearTimeout(p.timer);p.reject(error);} this.pending.clear();this.dispatchEvent(new CustomEvent('error',{detail:error})); }
request(type,data={}) { request(type,data={},transfer=[]) {
if(this.destroyed) return Promise.reject(new Error('DSP has been destroyed')); if(this.destroyed) return Promise.reject(new Error('DSP has been destroyed'));
const id=++this.serial; const id=++this.serial;
return new Promise((resolve,reject)=> { return new Promise((resolve,reject)=> {
const timer=setTimeout(()=>{this.pending.delete(id);reject(new Error(`DSP ${type} timed out`));},15000); const timer=setTimeout(()=>{this.pending.delete(id);reject(new Error(`DSP ${type} timed out`));},15000);
this.pending.set(id,{resolve,reject,timer});this.node.port.postMessage({type,id,...data}); this.pending.set(id,{resolve,reject,timer});this.node.port.postMessage({type,id,...data},transfer);
}); });
} }
// Routing is owned by AudioGraph. Expose the worklet node without creating a source. // One source → this DSP → destination. For arbitrary graphs use .node directly.
async setParam(name,value) { connect(source,destination=this.context.destination) {
if (/^(?:liveprog|ddc|arbitrary)\./.test(name)) throw new Error('Text effects are disabled in this player version'); if(this.destroyed) throw new Error('DSP has been destroyed');
this.validateParam(name,value);await this.request('param',{name,value});this.params[name]=value; if(this.source) this.source.disconnect(this.node);
this.node.disconnect();this.source=source;source.connect(this.node);this.node.connect(destination);return destination;
} }
async setParam(name,value) {if(/^(?:liveprog|ddc|arbitrary)\./.test(name))throw new Error('Text effects are disabled in this player version');validateParam(name,value);await this.request('param',{name,value});this.params[name]=value;}
setBypass(enabled) {return this.setParam('bypass',Number(Boolean(enabled)));} setBypass(enabled) {return this.setParam('bypass',Number(Boolean(enabled)));}
async setText() { async setText(effect,text) {
throw new Error('Text effects are disabled in this player version'); throw new Error('Text effects are disabled in this player version');
} }
async loadIR(buffer) { async loadIR(buffer,reference=null) {
if(buffer.sampleRate!==this.context.sampleRate || ![1,2].includes(buffer.numberOfChannels) || buffer.length>65536 || buffer.length<1) throw new RangeError('IR must be mono/stereo, at context rate, at most 65536 frames'); buffer=prepareIR(this.context,buffer,{normalize:false});
const internalRate=this.context.sampleRate===44100?44100:48000;
buffer=resampleIR(buffer,internalRate);
if(![1,2,4].includes(buffer.numberOfChannels) || buffer.length>65536 || buffer.length<1) throw new RangeError('IR must have 1, 2, or 4 channels and at most 65536 frames');
const pcm=new Float32Array(buffer.length*buffer.numberOfChannels); const pcm=new Float32Array(buffer.length*buffer.numberOfChannels);
for(let ch=0;ch<buffer.numberOfChannels;ch++) {const data=buffer.getChannelData(ch);for(let i=0;i<buffer.length;i++) pcm[i*buffer.numberOfChannels+ch]=data[i];} for(let ch=0;ch<buffer.numberOfChannels;ch++) {const data=buffer.getChannelData(ch);for(let i=0;i<buffer.length;i++) pcm[i*buffer.numberOfChannels+ch]=data[i];}
await this.request('ir',{pcm,frames:buffer.length,channels:buffer.numberOfChannels});this.params['convolver.enabled']=0; await this.request('ir',{pcm,frames:buffer.length,channels:buffer.numberOfChannels,sourceRate:internalRate},[pcm.buffer]);this.params['convolver.enabled']=0;this.irRef=reference?{name:reference.name,hash:reference.hash}:null;
} }
async getInfo() {const {bytes,heapBytes}=await this.request('info');const view=new DataView(bytes);return {abiVersion:view.getUint32(0,true),structBytes:view.getUint32(4,true),contextRate:view.getFloat32(8,true),internalRate:view.getFloat32(12,true),srcBackend:view.getUint32(16,true),latencyFrames:view.getUint32(20,true),irChannels:view.getUint32(24,true),irFrames:view.getUint32(28,true),irCapFrames:view.getUint32(32,true),headFrames:view.getUint32(36,true),tailFrames:view.getUint32(40,true),liveBytes:view.getUint32(44,true),peakBytes:view.getUint32(48,true),faults:view.getUint32(52,true),underruns:view.getUint32(56,true),overflows:view.getUint32(60,true),heapBytes};}
async applyPreset(preset) { async applyPreset(preset) {
if (preset?.texts && Object.keys(preset.texts).length) throw new Error('Text effects are disabled in this player version');
// All keys validated before mutation; applying a preset resets unspecified params. // All keys validated before mutation; applying a preset resets unspecified params.
if(preset?.texts && Object.keys(preset.texts).length) throw new Error('Text effects are disabled in this player version');
const params={...this.defaultParams,...(preset.params||preset)}; const params={...this.defaultParams,...(preset.params||preset)};
for(const [name,value] of Object.entries(params)) { for(const [name,value] of Object.entries(params)) {if(/^(?:liveprog|ddc|arbitrary)\./.test(name))throw new Error('Text effects are disabled in this player version');validateParam(name,value);}
if (/^(?:liveprog|ddc|arbitrary)\./.test(name)) throw new Error('Text effects are disabled in this player version'); if(preset.irRef) {const saved=await getIR(preset.irRef.name);if(!saved||saved.hash!==preset.irRef.hash)throw new Error(`Preset IR is missing or changed: ${preset.irRef.name}`);await this.loadIR(saved.buffer,preset.irRef);}
this.validateParam(name,value); // Configure before enabling; the referenced IR is loaded first.
}
// Configure before enabling; convolution still requires a separately loaded IR.
for(const [name,value] of Object.entries(params).sort(([a],[b])=>Number(a.endsWith('.enabled'))-Number(b.endsWith('.enabled')))) await this.setParam(name,value); for(const [name,value] of Object.entries(params).sort(([a],[b])=>Number(a.endsWith('.enabled'))-Number(b.endsWith('.enabled')))) await this.setParam(name,value);
} }
toJSON() {return {version:1,params:{...this.params}};} toJSON() {return {version:2,params:{...this.params},...(this.irRef?{irRef:{...this.irRef}}:{})};}
destroy() { destroy() {
if(this.destroyed) return; if(this.destroyed) return;
this.node.disconnect(); if(this.source) {try {this.source.disconnect(this.node);} catch {}} this.node.disconnect();
this.node.port.postMessage({type:'destroy'});this.destroyed=true;this.fail(new Error('DSP destroyed'));this.node.port.close(); this.node.port.postMessage({type:'destroy'});this.destroyed=true;this.fail(new Error('DSP destroyed'));this.node.port.close();
} }
} }

View File

@@ -1,14 +1,17 @@
// SPDX-License-Identifier: GPL-3.0-or-later // SPDX-License-Identifier: GPL-3.0-or-later
export const EQ_FREQUENCIES = [25,40,63,100,160,250,400,630,1000,1600,2500,4000,6300,10000,16000]; export const EQ_FREQUENCIES = [25,40,63,100,160,250,400,630,1000,1600,2500,4000,6300,10000,16000];
export const COMP_FREQUENCIES = [95,200,400,800,1600,3400,7500]; export const COMP_FREQUENCIES = [95,200,400,800,1600,3400,7500];
export const EFFECTS = ['eq','compressor','bass','stereo','reverb','crossfeed','tube','convolver','liveprog','ddc','arbitrary']; export const EFFECTS = ['eq','compressor','bass','stereo','reverb','crossfeed','spatial','tube','convolver','liveprog','ddc','arbitrary'];
export const PARAMS = { export const PARAMS = {
bypass:[0,1,0], gain:[-24,12,0], bypass:[0,1,0], gain:[-24,12,0],
'limiter.threshold':[-24,0,-0.1], 'limiter.release':[10,1000,100], 'limiter.threshold':[-24,0,-0.1], 'limiter.release':[10,1000,100],
'eq.mode':[0,5,1], 'eq.interpolation':[0,1,0], 'eq.mode':[0,5,1], 'eq.interpolation':[0,1,0],
'compressor.time':[0.01,2,0.1], 'compressor.mode':[0,3,3], 'compressor.granularity':[0,4,0], 'compressor.time':[0.01,2,0.1], 'compressor.mode':[0,3,3], 'compressor.granularity':[0,4,0],
'bass.boost':[0,12,6], 'stereo.mix':[0,1,0.5], 'reverb.preset':[0,18,0], 'bass.boost':[0,12,6], 'stereo.mix':[0,1,0.5], 'reverb.preset':[0,18,0],
'convolver.mix':[0,1,1], 'convolver.predelayMs':[0,250,0],
'crossfeed.mode':[0,5,0], 'tube.drive':[-12,12,0], 'crossfeed.mode':[0,5,0], 'tube.drive':[-12,12,0],
'spatial.mode':[2,6,2], 'spatial.azimuth':[-90,90,0], 'spatial.spread':[0,120,60],
'spatial.distance':[0.5,5,1], 'spatial.room':[0,1,0], 'spatial.centerDb':[0,6,0], 'spatial.mix':[0,1,1],
}; };
for (const effect of EFFECTS) PARAMS[`${effect}.enabled`] = [0,1,0]; for (const effect of EFFECTS) PARAMS[`${effect}.enabled`] = [0,1,0];
EQ_FREQUENCIES.forEach((_, i) => PARAMS[`eq.band${i}`] = [-12,12,0]); EQ_FREQUENCIES.forEach((_, i) => PARAMS[`eq.band${i}`] = [-12,12,0]);
@@ -24,4 +27,5 @@ export const PRESETS = {
'Warm lows': {'eq.band0':4,'eq.band1':4,'eq.band2':3,'eq.band3':2,'eq.enabled':1,'gain':-3}, 'Warm lows': {'eq.band0':4,'eq.band1':4,'eq.band2':3,'eq.band3':2,'eq.enabled':1,'gain':-3},
'Headphones': {'crossfeed.enabled':1,'crossfeed.mode':1,'eq.band8':-2,'eq.enabled':1}, 'Headphones': {'crossfeed.enabled':1,'crossfeed.mode':1,'eq.band8':-2,'eq.enabled':1},
'Small room': {'reverb.preset':7,'reverb.enabled':1,'gain':-3}, 'Small room': {'reverb.preset':7,'reverb.enabled':1,'gain':-3},
'Virtual speakers': {'spatial.enabled':1,'spatial.mode':6,'spatial.spread':90},
}; };

View File

@@ -1,7 +1,5 @@
// SPDX-License-Identifier: GPL-3.0-or-later // SPDX-License-Identifier: GPL-3.0-or-later
// AudioWorkletGlobalScope does not support dynamic import; this immutable import createJamesDSP from './dsp/jamesdsp-a982b53.mjs';
// vendor path is versioned with the upstream pin and remapped by the SW cache.
import createJamesDSP from './dsp/jamesdsp-77be0b2.mjs';
class JamesDSPProcessor extends AudioWorkletProcessor { class JamesDSPProcessor extends AudioWorkletProcessor {
constructor(options) { constructor(options) {
super(); this.closed=false;this.failed=false; super(); this.closed=false;this.failed=false;
@@ -31,7 +29,10 @@ class JamesDSPProcessor extends AudioWorkletProcessor {
if(data.type==='param') result=this.string(data.name,p=>this.wasm._jdsp_set_param(this.handle,p,data.value)); if(data.type==='param') result=this.string(data.name,p=>this.wasm._jdsp_set_param(this.handle,p,data.value));
else if(data.type==='ir') { else if(data.type==='ir') {
const p=this.wasm._malloc(data.pcm.byteLength);if(!p) throw new Error('IR allocation failed'); const p=this.wasm._malloc(data.pcm.byteLength);if(!p) throw new Error('IR allocation failed');
try {this.wasm.HEAPF32.set(data.pcm,p/4);result=this.wasm._jdsp_load_ir(this.handle,p,data.frames,data.channels);} finally {this.wasm._free(p);} try {this.wasm.HEAPF32.set(data.pcm,p/4);result=this.wasm._jdsp_load_ir_ex(this.handle,p,data.frames,data.channels,data.sourceRate);} finally {this.wasm._free(p);}
} else if(data.type==='info') {
const p=this.wasm._malloc(64);if(!p)throw new Error('DSP info allocation failed');
try {result=this.wasm._jdsp_get_info(this.handle,p);if(result===0){const bytes=this.wasm.HEAPU8.slice(p,p+64);this.port.postMessage({type:'reply',id:data.id,result:{bytes:bytes.buffer,heapBytes:this.wasm.HEAPU8.buffer.byteLength}},[bytes.buffer]);return;}} finally {this.wasm._free(p);}
} else throw new Error('Unknown DSP message'); } else throw new Error('Unknown DSP message');
if(result!==0) throw new Error(`DSP rejected ${data.name||data.effect||data.type}`); if(result!==0) throw new Error(`DSP rejected ${data.name||data.effect||data.type}`);
this.port.postMessage({type:'reply',id:data.id,result}); this.port.postMessage({type:'reply',id:data.id,result});

View File

@@ -732,9 +732,11 @@ async function renderSettings() {
const qOptions = qualities const qOptions = qualities
.map((q) => `<option value="${q}"${data.settings.quality === q ? ' selected' : ''}>${q === 'auto' ? 'Auto' : q}</option>`) .map((q) => `<option value="${q}"${data.settings.quality === q ? ' selected' : ''}>${q === 'auto' ? 'Auto' : q}</option>`)
.join(''); .join('');
const jamesDsp = data.settings.jamesDsp || { enabled: false, preset: 'flat', params: {} }; const jamesDsp = data.settings.jamesDsp || { enabled: false, downloadForOffline: false, preset: 'flat', params: {}, irRef: null };
const dspCores=Number(navigator.hardwareConcurrency), dspMemory=Number(navigator.deviceMemory);
const dspLowEnd=(dspCores>0&&dspCores<=4)||(dspMemory>0&&dspMemory<=4);
const dspBands = [25, 40, 63, 100, 160, 250, 400, 630, 1000, 1600, 2500, 4000, 6300, 10000, 16000]; const dspBands = [25, 40, 63, 100, 160, 250, 400, 630, 1000, 1600, 2500, 4000, 6300, 10000, 16000];
const dspParamDefaults = { 'bass.boost': 6, 'stereo.mix': 0.5, 'limiter.threshold': -0.1 }; const dspParamDefaults = { 'bass.boost': 6, 'stereo.mix': 0.5, 'limiter.threshold': -0.1, 'convolver.mix': 1, 'convolver.predelayMs': 0, 'spatial.mode': 6, 'spatial.spread': 60, 'spatial.distance': 1, 'spatial.mix': 1 };
const dspParam = key => Number.isFinite(Number(jamesDsp.params?.[key])) ? Number(jamesDsp.params[key]) : (dspParamDefaults[key] ?? 0); const dspParam = key => Number.isFinite(Number(jamesDsp.params?.[key])) ? Number(jamesDsp.params[key]) : (dspParamDefaults[key] ?? 0);
const dspRange = (key, label, min, max, step = 1, unit = '') => `<label class="set-row dsp-param-row"><span>${label}<small>${dspParam(key)}${unit}</small></span><input type="range" data-dsp-param="${key}" min="${min}" max="${max}" step="${step}" value="${dspParam(key)}" /></label>`; const dspRange = (key, label, min, max, step = 1, unit = '') => `<label class="set-row dsp-param-row"><span>${label}<small>${dspParam(key)}${unit}</small></span><input type="range" data-dsp-param="${key}" min="${min}" max="${max}" step="${step}" value="${dspParam(key)}" /></label>`;
const dspBandRows = dspBands.map((hz, i) => dspRange(`eq.band${i}`, `${hz >= 1000 ? (hz / 1000) + ' kHz' : hz + ' Hz'}`, -12, 12, 0.5, ' dB')).join(''); const dspBandRows = dspBands.map((hz, i) => dspRange(`eq.band${i}`, `${hz >= 1000 ? (hz / 1000) + ' kHz' : hz + ' Hz'}`, -12, 12, 0.5, ' dB')).join('');
@@ -742,6 +744,7 @@ async function renderSettings() {
const sel = (id, val, opts) => `<select id="${id}" class="set-select">` + const sel = (id, val, opts) => `<select id="${id}" class="set-select">` +
opts.map(([v, label]) => `<option value="${v}"${val === v ? ' selected' : ''}>${label}</option>`).join('') + opts.map(([v, label]) => `<option value="${v}"${val === v ? ' selected' : ''}>${label}</option>`).join('') +
`</select>`; `</select>`;
const dspBuiltinIRSelect = `<select id="setDspBuiltinIR" class="set-select">${['Small room','Hall','Plate','Cathedral'].map(name=>`<option value="${name}"${dspLowEnd&&name!=='Small room'?' disabled':''}>${name}</option>`).join('')}</select>`;
wrap.innerHTML = ` wrap.innerHTML = `
<div class="set-group"> <div class="set-group">
@@ -813,8 +816,10 @@ async function renderSettings() {
<label class="set-row"><span>Engine<small>Standard is the default. JamesDSP replaces the Standard EQ and vocal/stereo stages.</small></span> <label class="set-row"><span>Engine<small>Standard is the default. JamesDSP replaces the Standard EQ and vocal/stereo stages.</small></span>
${sel('setSoundEngine', jamesDsp.enabled ? 'jamesdsp' : 'standard', [['standard', 'Standard'], ['jamesdsp', 'JamesDSP']])} ${sel('setSoundEngine', jamesDsp.enabled ? 'jamesdsp' : 'standard', [['standard', 'Standard'], ['jamesdsp', 'JamesDSP']])}
</label> </label>
<label class="set-row"><span>Download audio effects for offline<small>Includes the optional JamesDSP engine and its large WASM file in the offline cache.</small></span><input id="setDspOffline" type="checkbox" ${jamesDsp.downloadForOffline ? 'checked' : ''} /></label>
<div id="jamesDspControls" ${jamesDsp.enabled ? '' : 'hidden'}> <div id="jamesDspControls" ${jamesDsp.enabled ? '' : 'hidden'}>
<div class="set-row"><span>Live processing<small>${EQ.isIOS() ? 'On iPhone, enable Live sound processing above first. Audio may stop when the screen locks.' : 'Processing runs on this device. Large reverbs and convolution can use more CPU.'}</small></span></div> <div class="set-row"><span>Live processing<small>${EQ.isIOS() ? 'On iPhone, enable Live sound processing above first. Audio may stop when the screen locks.' : 'Processing runs on this device.'}</small></span></div>
${dspLowEnd ? '<p class="dsp-risk" role="note">This device may have limited audio headroom. Long impulse responses are disabled and the short room response is capped at 0.5 seconds.</p>' : ''}
<label class="set-row"><span>Preset</span>${sel('setDspPreset', jamesDsp.preset || 'flat', [['flat', 'Flat'], ['bass', 'Bass boost'], ['vocal', 'Vocal'], ['loudness', 'Loudness'], ['headphone', 'Headphone crossfeed'], ['bright', 'Bright'], ['warm', 'Warm']])}</label> <label class="set-row"><span>Preset</span>${sel('setDspPreset', jamesDsp.preset || 'flat', [['flat', 'Flat'], ['bass', 'Bass boost'], ['vocal', 'Vocal'], ['loudness', 'Loudness'], ['headphone', 'Headphone crossfeed'], ['bright', 'Bright'], ['warm', 'Warm']])}</label>
<div class="dsp-param-list" aria-label="15-band equalizer">${dspBandRows}</div> <div class="dsp-param-list" aria-label="15-band equalizer">${dspBandRows}</div>
<label class="set-row"><span>Bass boost<small>${dspParam('bass.boost')} dB</small></span><input type="range" data-dsp-param="bass.boost" min="0" max="12" step="0.5" value="${dspParam('bass.boost')}" /></label> <label class="set-row"><span>Bass boost<small>${dspParam('bass.boost')} dB</small></span><input type="range" data-dsp-param="bass.boost" min="0" max="12" step="0.5" value="${dspParam('bass.boost')}" /></label>
@@ -827,7 +832,27 @@ async function renderSettings() {
<label class="set-row"><span>Compressor</span><input id="setDspCompressor" type="checkbox" ${dspParam('compressor.enabled') ? 'checked' : ''} /></label> <label class="set-row"><span>Compressor</span><input id="setDspCompressor" type="checkbox" ${dspParam('compressor.enabled') ? 'checked' : ''} /></label>
<div class="dsp-actions"><button id="dspPresetImportBtn" class="btn" type="button">Import preset JSON</button><button id="dspPresetExportBtn" class="btn" type="button">Export preset JSON</button><button id="dspResetBtn" class="btn" type="button">Reset DSP</button></div> <div class="dsp-actions"><button id="dspPresetImportBtn" class="btn" type="button">Import preset JSON</button><button id="dspPresetExportBtn" class="btn" type="button">Export preset JSON</button><button id="dspResetBtn" class="btn" type="button">Reset DSP</button></div>
<input id="dspPresetFile" type="file" accept="application/json,.json" hidden /> <input id="dspPresetFile" type="file" accept="application/json,.json" hidden />
<label class="set-row"><span>Impulse response<small>WAV or other browser-decodable audio; upload enables convolution for this session only.</small></span><input id="dspIrFile" type="file" accept="audio/*,.wav" /></label> <section class="dsp-subsection" aria-labelledby="dspConvolverTitle">
<h4 id="dspConvolverTitle">Convolver</h4>
<label class="set-row"><span>Enable convolution</span><input id="setDspConvolver" type="checkbox" ${dspParam('convolver.enabled') ? 'checked' : ''} /></label>
<label class="set-row"><span>Dry / wet<small>${Math.round(dspParam('convolver.mix')*100)}%</small></span><input type="range" data-dsp-param="convolver.mix" min="0" max="1" step="0.01" value="${dspParam('convolver.mix')}" /></label>
<label class="set-row"><span>Pre-delay<small>${dspParam('convolver.predelayMs')} ms</small></span><input type="range" data-dsp-param="convolver.predelayMs" min="0" max="250" step="1" value="${dspParam('convolver.predelayMs')}" /></label>
<label class="set-row"><span>Built-in response</span>${dspBuiltinIRSelect}</label>
<button id="setDspLoadBuiltin" class="btn" type="button">Generate and load</button>
<label class="set-row"><span>Upload impulse response<small>WAV or other browser-decodable audio; up to 65,536 frames at the internal sample rate.</small></span><input id="dspIrFile" type="file" accept="audio/*,.wav" /></label>
<label class="set-row"><span>Stored responses</span><select id="setDspSavedIR" class="set-select"><option value="">Choose a saved response</option></select></label>
<button id="setDspLoadSavedIR" class="btn" type="button">Load saved response</button>
<p id="dspIrStatus" class="sp-note">${jamesDsp.irRef?.name ? `Loaded: ${jamesDsp.irRef.name}` : 'No impulse response loaded.'}</p>
</section>
<section class="dsp-subsection" aria-labelledby="dspSpatialTitle">
<h4 id="dspSpatialTitle">3D spatial stage</h4>
<label class="set-row"><span>Enable 3D spatial stage</span><input id="setDspSpatial" type="checkbox" ${dspParam('spatial.enabled') ? 'checked' : ''} /></label>
<label class="set-row"><span>HRTF / renderer mode</span>${sel('setDspSpatialMode',String(dspParam('spatial.mode')),[['2','HRTF crossfeed'],['3','HRTF surround 1'],['4','HRTF surround 2'],['5','HRTF surround 3'],['6','Virtual speakers']])}</label>
${dspRange('spatial.azimuth','Azimuth',-90,90,1,'°')}
${dspRange('spatial.spread','Speaker spread',0,120,1,'°')}
${dspRange('spatial.distance','Distance',0.5,5,0.1,' m')}
${dspRange('spatial.centerDb','Center boost',0,6,0.1,' dB')}
</section>
</div> </div>
</div> </div>
<label class="set-row"> <label class="set-row">
@@ -1106,13 +1131,23 @@ async function renderSettings() {
toast('On iPhone, turn on Live sound processing first.'); toast('On iPhone, turn on Live sound processing first.');
return; return;
} }
data.settings.jamesDsp = data.settings.jamesDsp || { enabled: false, preset: 'flat', params: {} }; data.settings.jamesDsp = data.settings.jamesDsp || { enabled: false, downloadForOffline: false, preset: 'flat', params: {}, irRef: null };
data.settings.jamesDsp.enabled = enable; data.settings.jamesDsp.enabled = enable;
if (enable) data.settings.jamesDsp.downloadForOffline = true;
if (enable && $('setDspOffline')) $('setDspOffline').checked = true;
if (dspControls) dspControls.hidden = !enable; if (dspControls) dspControls.hidden = !enable;
persist(); persist();
reportDspOfflineOptIn();
EQ.apply(); EQ.apply();
}); });
const dspConfig = data.settings.jamesDsp || (data.settings.jamesDsp = { enabled: false, preset: 'flat', params: {} }); if ($('setDspOffline')) $('setDspOffline').addEventListener('change', e => {
data.settings.jamesDsp = data.settings.jamesDsp || { enabled: false, downloadForOffline: false, preset: 'flat', params: {} };
data.settings.jamesDsp.downloadForOffline = e.target.checked;
persist();
reportDspOfflineOptIn();
toast(e.target.checked ? 'Preparing audio effects for offline use' : 'Audio effects removed from the offline completion list');
});
const dspConfig = data.settings.jamesDsp || (data.settings.jamesDsp = { enabled: false, downloadForOffline: false, preset: 'flat', params: {}, irRef: null });
let dspApplyTimer = null; let dspApplyTimer = null;
const applyDspSettings = () => { const applyDspSettings = () => {
clearTimeout(dspApplyTimer); clearTimeout(dspApplyTimer);
@@ -1133,8 +1168,41 @@ async function renderSettings() {
applyDspSettings(); applyDspSettings();
})); }));
if ($('setDspCrossfeed')) $('setDspCrossfeed').addEventListener('change', e => { dspConfig.params['crossfeed.mode'] = Number(e.target.value); dspConfig.params['crossfeed.enabled'] = Number(e.target.value) ? 1 : 0; applyDspSettings(); }); if ($('setDspCrossfeed')) $('setDspCrossfeed').addEventListener('change', e => { dspConfig.params['crossfeed.mode'] = Number(e.target.value); dspConfig.params['crossfeed.enabled'] = Number(e.target.value) ? 1 : 0; applyDspSettings(); });
if ($('setDspConvolver')) $('setDspConvolver').addEventListener('change', e => {
const hasIR=dspConfig.irRef||EQ.state().jamesDsp?.irRef;
if(e.target.checked&&!hasIR){e.target.checked=false;toast('Load an impulse response first.');return;}
dspConfig.params['convolver.enabled']=e.target.checked?1:0;applyDspSettings();
});
if ($('setDspSpatial')) $('setDspSpatial').addEventListener('change', e => {
dspConfig.params['spatial.enabled']=e.target.checked?1:0;
if(e.target.checked){dspConfig.params['crossfeed.enabled']=0;if($('setDspCrossfeed'))$('setDspCrossfeed').value='0';}
applyDspSettings();
});
if ($('setDspSpatialMode')) $('setDspSpatialMode').addEventListener('change', e => {dspConfig.params['spatial.mode']=Number(e.target.value);applyDspSettings();});
if ($('setDspReverb')) $('setDspReverb').addEventListener('change', e => { dspConfig.params['reverb.preset'] = Number(e.target.value); dspConfig.params['reverb.enabled'] = Number(e.target.value) ? 1 : 0; applyDspSettings(); }); if ($('setDspReverb')) $('setDspReverb').addEventListener('change', e => { dspConfig.params['reverb.preset'] = Number(e.target.value); dspConfig.params['reverb.enabled'] = Number(e.target.value) ? 1 : 0; applyDspSettings(); });
if ($('setDspCompressor')) $('setDspCompressor').addEventListener('change', e => { dspConfig.params['compressor.enabled'] = e.target.checked ? 1 : 0; applyDspSettings(); }); if ($('setDspCompressor')) $('setDspCompressor').addEventListener('change', e => { dspConfig.params['compressor.enabled'] = e.target.checked ? 1 : 0; applyDspSettings(); });
const refreshDspIrList=async()=>{
const select=$('setDspSavedIR');if(!select)return;
const selected=select.value;select.replaceChildren(new Option('Choose a saved response',''));
try{for(const item of await EQ.listDspIRs())select.add(new Option(`${item.name} · ${item.channels} ch · ${item.frames} frames`,item.name));if([...select.options].some(o=>o.value===selected))select.value=selected;}
catch(error){if($('dspIrStatus'))$('dspIrStatus').textContent=`Saved response list unavailable: ${error.message}`;}
};
if ($('setDspLoadBuiltin')) $('setDspLoadBuiltin').addEventListener('click',async()=>{
try{const name=$('setDspBuiltinIR').value;const ref=await EQ.loadDspBuiltinIR(name);if($('dspIrStatus'))$('dspIrStatus').textContent=`Loaded ${name}${ref?' and saved it for this device':' for this session only'}.`;$('setDspConvolver').checked=true;await refreshDspIrList();}
catch(error){toast(`Impulse response failed: ${error.message}`);}
});
if ($('setDspLoadSavedIR')) $('setDspLoadSavedIR').addEventListener('click',async()=>{
const name=$('setDspSavedIR').value;if(!name){await refreshDspIrList();return;}
try{const ref=await EQ.loadDspSavedIR(name);if($('dspIrStatus'))$('dspIrStatus').textContent=`Loaded saved response: ${name}`;$('setDspConvolver').checked=true;await refreshDspIrList();}
catch(error){toast(`Saved response failed: ${error.message}`);}
});
if ($('dspIrFile')) $('dspIrFile').addEventListener('change',async e=>{
const file=e.target.files?.[0];if(!file)return;
try{const ref=await EQ.loadDspIR(file);if($('dspIrStatus'))$('dspIrStatus').textContent=`Loaded ${ref?.name||file.name}${ref?' and saved it for this device':' for this session only'}.`;$('setDspConvolver').checked=true;await refreshDspIrList();}
catch(error){toast(`Impulse response failed: ${error.message}`);}
finally{e.target.value='';}
});
if(jamesDsp.enabled||jamesDsp.downloadForOffline)refreshDspIrList();
if ($('dspResetBtn')) $('dspResetBtn').addEventListener('click', () => { if ($('dspResetBtn')) $('dspResetBtn').addEventListener('click', () => {
dspConfig.preset = 'flat'; dspConfig.params = {}; dspConfig.preset = 'flat'; dspConfig.params = {};
if ($('setDspPreset')) $('setDspPreset').value = 'flat'; if ($('setDspPreset')) $('setDspPreset').value = 'flat';

View File

@@ -75,9 +75,9 @@ Each scenario records the following metrics across **5 independent runs** (repor
## 4. How to Run ## 4. How to Run
### Optional JamesDSP integration check ### 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 ### Prerequisites
Symlink dependencies and ensure server dependencies are installed: 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() }); (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()}`); } 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 }); }); 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')); }; const boot = async () => { await page.goto(origin); await page.waitForFunction(() => typeof data !== 'undefined' && document.querySelector('#sidebarToggle')); };
await boot(); await boot();
const manifest = await (await fetch(origin + '/api/manifest')).json(); 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 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'); 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 }); const optionalCount = Object.values(manifest.groups || {}).filter(group => group.offlineOptIn).reduce((n, group) => n + group.files.filter(path => manifest.files[path]).length, 0);
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(); }); 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.evaluate(clip => Player.loadVideo(clip), clips[0]);
await page.locator('#playBtn').click({ force: true }).catch(() => {}); 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'); } 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); 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 })); 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(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 }); await sleep(200); const flat = await rms(); result.rms.push({ effect: 'flat', rms: flat });
for (const preset of ['bass', 'vocal', 'loudness', 'headphone', 'bright', 'warm']) { for (const preset of ['bass', 'vocal', 'loudness', 'headphone', 'bright', 'warm']) {
await page.selectOption('#setDspPreset', preset); await sleep(900); 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 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); 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(); const activeRms = await rms();
await page.evaluate(async () => { await EQ.toggleDspBypass(); }); await sleep(200); const bypassRms = 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})`); const bypassState = await page.evaluate(() => EQ.state().jamesDsp);
result.checks.push({ name: 'A/B bypass RMS', standardRms, activeRms, bypassRms }); 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 }) => { 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 }); 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(() => {})); const dspRender = await page.evaluate(async () => {
assert.equal(await poll(() => page.evaluate(() => Player.master?.currentTime > 0.2 && EQ.state().jamesDsp?.active), 12000), true, 'DSP survives media element/track swap'); const { JamesDSPNode } = await import(Lazy.url('/vendor/jamesdsp/jamesdsp-node.mjs'));
result.checks.push({ name: 'track switch retains DSP route', graph: await page.evaluate(() => AudioGraph.snapshot()) }); 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); await page.evaluate(() => persist()); await sleep(500);
offlinePhase = true; if (engine === 'chromium') await context.setOffline(true); else await proxy.setOffline(true); 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')); 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 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. // 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); 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')); 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 wait(page,async()=>!!(await navigator.serviceWorker.getRegistration())?.active);
await page.evaluate(()=>Lazy.warm()); 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); 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'); 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); await checkCssOrder(page);

View File

@@ -37,13 +37,22 @@ async function run(engine){
}; };
let page=await context.newPage();attach(page); 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 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 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);} const optional=new Set(Object.values(manifest.groups||{}).filter(group=>group.offlineOptIn).flatMap(group=>group.files||[]));
return{total:Object.keys(manifest.files).length,missing}; const expected=Object.entries(manifest.files).filter(([path])=>legacy||includeOptional||!optional.has(path));
},{manifest,legacy}); 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()); 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'))); 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); 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}); if(result.inventory.missing.length)result.failures.push({kind:'incomplete',paths:result.inventory.missing});
// Offline navigation always starts from a controlled document. // 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 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 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); 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}); 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'}); if(!withoutPage)result.failures.push({kind:'completion-without-page'});
await cdp.detach();await inspector.close(); await cdp.detach();await inspector.close();
}else await other.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));} }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});} 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()); 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 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 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 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])); 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); 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.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(); 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'); 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}); 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'); 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}));}); 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); 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()); 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();}); await page.evaluate(()=>{data.settings.jamesDsp.enabled=false;persist();});
const local=await settings(),remote={...local,saveBeforePlay:false,autoPreload:false,layout:'classic',quality:'auto'}; 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});} 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});}