Add JamesDSP playback controls and verification harness
This commit is contained in:
@@ -675,10 +675,14 @@ const DEFAULT_SETTINGS = {
|
||||
};
|
||||
function normalizeJamesDspSettings(settings) {
|
||||
const saved = settings?.jamesDsp && typeof settings.jamesDsp === 'object' ? settings.jamesDsp : {};
|
||||
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
|
||||
// convolver on a later boot without its uploaded audio buffer.
|
||||
delete params['convolver.enabled'];
|
||||
settings.jamesDsp = {
|
||||
enabled: saved.enabled === true,
|
||||
preset: typeof saved.preset === 'string' ? saved.preset : 'flat',
|
||||
params: saved.params && typeof saved.params === 'object' && !Array.isArray(saved.params) ? { ...saved.params } : {},
|
||||
params,
|
||||
};
|
||||
return settings;
|
||||
}
|
||||
@@ -3346,6 +3350,20 @@ function openMoreActions() {
|
||||
row.onclick = () => { closeModal(); setTimeout(() => src.click(), 0); };
|
||||
list.appendChild(row);
|
||||
}
|
||||
if (data.settings?.jamesDsp?.enabled && EQ.state().jamesDsp?.active) {
|
||||
const row = document.createElement('button');
|
||||
row.type = 'button'; row.className = 'more-row';
|
||||
const bypassed = !!EQ.state().jamesDsp?.bypassed;
|
||||
row.innerHTML = '<span class="more-ico" aria-hidden="true">A/B</span><span class="more-txt"><b></b><small>Temporarily compare with the unprocessed signal</small></span><span class="more-go">›</span>';
|
||||
row.querySelector('b').textContent = bypassed ? 'Resume JamesDSP' : 'Bypass JamesDSP';
|
||||
row.setAttribute('aria-label', row.querySelector('b').textContent);
|
||||
row.onclick = async () => {
|
||||
closeModal();
|
||||
const next = await EQ.toggleDspBypass();
|
||||
toast(next ? 'JamesDSP bypassed' : 'JamesDSP active');
|
||||
};
|
||||
list.appendChild(row);
|
||||
}
|
||||
if (!list.children.length) { toast('No more actions for this video'); return; }
|
||||
showModal('More actions', list, [{ label: 'Close', onClick: closeModal }]);
|
||||
}
|
||||
@@ -5521,7 +5539,7 @@ const EQ = (() => {
|
||||
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
||||
const AC = window.AudioContext || window.webkitAudioContext;
|
||||
const st = { ctx: null, filters: null, levelDb: 0 };
|
||||
let dspController = null, dspControllerPromise = null;
|
||||
let dspController = null, dspControllerPromise = null, dspRequest = 0;
|
||||
// Web Audio is off on iOS (it silences playback when the screen locks)
|
||||
// unless the user opted in through "Level volume" (Settings → Playback).
|
||||
// Live Web Audio is off on iOS (it silences playback when the screen locks)
|
||||
@@ -5554,6 +5572,8 @@ const EQ = (() => {
|
||||
function dspFailed(error) {
|
||||
if (!data.settings.jamesDsp?.enabled) return;
|
||||
data.settings.jamesDsp.enabled = false;
|
||||
if ($('setSoundEngine')) $('setSoundEngine').value = 'standard';
|
||||
if ($('jamesDspControls')) $('jamesDspControls').hidden = true;
|
||||
persist();
|
||||
toast(`JamesDSP unavailable; Standard sound is active. ${error?.message || ''}`.trim());
|
||||
apply();
|
||||
@@ -5562,15 +5582,19 @@ const EQ = (() => {
|
||||
|
||||
async function getDspController() {
|
||||
if (dspController) return dspController;
|
||||
if (!dspControllerPromise) dspControllerPromise = Lazy.load('feature:jamesdsp').then(() => {
|
||||
dspController = JamesDSPFeature.createController({ graph: AudioGraph, prepareGraph: prepareDspGraph, onError: dspFailed });
|
||||
if (!dspControllerPromise) dspControllerPromise = Lazy.load('feature:jamesdsp').then(async () => {
|
||||
dspController = await JamesDSPFeature.createController({ graph: AudioGraph, prepareGraph: prepareDspGraph, onError: dspFailed });
|
||||
return dspController;
|
||||
}).catch(error => { dspControllerPromise = null; throw error; });
|
||||
return dspControllerPromise;
|
||||
}
|
||||
|
||||
function startDsp() {
|
||||
getDspController().then(controller => controller.enable(data.settings.jamesDsp, st.levelDb)).catch(dspFailed);
|
||||
const request = ++dspRequest;
|
||||
getDspController().then(controller => {
|
||||
if (request !== dspRequest || !data.settings.jamesDsp?.enabled) return controller.disable();
|
||||
return controller.enable(data.settings.jamesDsp, st.levelDb);
|
||||
}).catch(dspFailed);
|
||||
}
|
||||
|
||||
function build() {
|
||||
@@ -5620,7 +5644,9 @@ const EQ = (() => {
|
||||
renderButton();
|
||||
return;
|
||||
}
|
||||
dspRequest++;
|
||||
if (dspController) dspController.disable().catch(() => {});
|
||||
else if (dspControllerPromise) dspControllerPromise.then(controller => controller.disable()).catch(() => {});
|
||||
if (!available()) return;
|
||||
if (!st.ctx && isFlat() && EqParameters.amount(data.settings.vocalLevel ?? 100) === 100 && EqParameters.width(data.settings.stereoWidth) === 0) return;
|
||||
if (!build()) return;
|
||||
@@ -5733,8 +5759,22 @@ const EQ = (() => {
|
||||
return true;
|
||||
}
|
||||
|
||||
async function toggleDspBypass() {
|
||||
if (!dspController?.active) return false;
|
||||
try { return await dspController.toggleBypass(); }
|
||||
catch (error) { dspFailed(error); return false; }
|
||||
}
|
||||
|
||||
async function loadDspIR(file) {
|
||||
if (!data.settings.jamesDsp?.enabled || !dspController?.active) throw new Error('Turn on JamesDSP before importing an impulse response.');
|
||||
const context = st.ctx;
|
||||
if (!context?.decodeAudioData) throw new Error('Audio decoding is unavailable on this device.');
|
||||
const audioBuffer = await context.decodeAudioData(await file.arrayBuffer());
|
||||
return dspController.loadIR(audioBuffer);
|
||||
}
|
||||
|
||||
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 }) };
|
||||
levelDb: st.level ? Math.round(200 * Math.log10(Math.max(1e-6, st.level.gain.value))) / 10 : null, jamesDsp: dspController?.state() || null }), toggleDspBypass, loadDspIR };
|
||||
})();
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -370,7 +370,7 @@
|
||||
"/vendor/jamesdsp/jamesdsp-node.mjs",
|
||||
"/vendor/jamesdsp/params.mjs",
|
||||
"/vendor/jamesdsp/worklet.mjs",
|
||||
"/vendor/jamesdsp/dsp/jamesdsp.mjs",
|
||||
"/vendor/jamesdsp/dsp/jamesdsp-77be0b2.mjs",
|
||||
"/vendor/jamesdsp/dsp/jamesdsp.wasm"
|
||||
],
|
||||
"dependencies": [
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -2,8 +2,18 @@ const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { readFileSync } = require('node:fs');
|
||||
const { join } = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
|
||||
const app = readFileSync(join(__dirname, 'app.js'), 'utf8');
|
||||
const views = readFileSync(join(__dirname, 'views-core.js'), 'utf8');
|
||||
|
||||
function parsePreset(text) {
|
||||
const declaration = views.match(/function parseJamesDspPreset\(text\) \{[\s\S]*?\n\}/)?.[0];
|
||||
assert.ok(declaration, 'preset parser is present');
|
||||
const context = vm.createContext({});
|
||||
vm.runInContext(`${declaration}; globalThis.parse = parseJamesDspPreset;`, context);
|
||||
return context.parse(text);
|
||||
}
|
||||
|
||||
test('the persisted engine is Standard by default and upgrades missing JamesDSP settings safely', () => {
|
||||
assert.match(app, /jamesDsp: \{ enabled: false, preset: 'flat', params: \{\} \}/);
|
||||
@@ -27,4 +37,23 @@ test('EQ creates no second context or element source and passes Standard fallbac
|
||||
assert.match(app, /standardInput: st\.filters\[0\]/);
|
||||
assert.match(app, /dspController\.setLevelDb\(st\.levelDb\)/);
|
||||
assert.match(app, /if \(data\.settings\.jamesDsp\?\.enabled\) \{[\s\S]{0,450}startDsp\(\)/);
|
||||
assert.match(app, /request !== dspRequest \|\| !data\.settings\.jamesDsp\?\.enabled/);
|
||||
});
|
||||
|
||||
test('Playback settings expose persisted presets, bands, safe import/export, IR, and A/B bypass', () => {
|
||||
assert.match(views, /sel\('setSoundEngine'/);
|
||||
assert.match(views, /sel\('setDspPreset'/);
|
||||
for (const id of ['setDspCompressor', 'dspPresetImportBtn', 'dspPresetExportBtn', 'dspResetBtn', 'dspIrFile']) assert.ok(views.includes(`id="${id}"`), id);
|
||||
assert.match(views, /\[25, 40, 63, 100, 160, 250, 400, 630, 1000, 1600, 2500, 4000, 6300, 10000, 16000\]/);
|
||||
assert.match(views, /imported\?\.texts/);
|
||||
assert.doesNotMatch(views, /liveprog|ddc|arbitrary/);
|
||||
assert.match(app, /Bypass JamesDSP/);
|
||||
assert.match(app, /toggleDspBypass/);
|
||||
});
|
||||
|
||||
test('preset import validates ranges and excludes text-effect parameters', () => {
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(parsePreset(JSON.stringify({ version: 1, preset: 'warm', params: { 'eq.band0': 3, 'bass.enabled': 1 } })))), { preset: 'warm', params: { 'eq.band0': 3, 'bass.enabled': 1 } });
|
||||
assert.throws(() => parsePreset({ version: 1, preset: 'flat', params: { 'liveprog.enabled': 1 } }), /text effects are disabled|Unsupported preset setting/);
|
||||
assert.throws(() => parsePreset({ version: 1, params: { 'eq.band0': 50 } }), /Unsupported preset setting/);
|
||||
assert.throws(() => parsePreset({ version: 1, texts: { effect: 'unsafe' }, params: {} }), /text effects are disabled/);
|
||||
});
|
||||
|
||||
@@ -18,7 +18,7 @@ test('JamesDSP runtime files live only in a lazy background group', () => {
|
||||
'/vendor/jamesdsp/jamesdsp-node.mjs',
|
||||
'/vendor/jamesdsp/params.mjs',
|
||||
'/vendor/jamesdsp/worklet.mjs',
|
||||
'/vendor/jamesdsp/dsp/jamesdsp.mjs',
|
||||
'/vendor/jamesdsp/dsp/jamesdsp-77be0b2.mjs',
|
||||
'/vendor/jamesdsp/dsp/jamesdsp.wasm',
|
||||
]);
|
||||
for (const path of group.files) assert.equal(assets.groups.core.files.includes(path), false, `${path} is not core`);
|
||||
@@ -32,9 +32,9 @@ test('JamesDSP resolves every module dependency through the page build manifest'
|
||||
assert.match(node, /globalThis\.Lazy\?\.url\?\.\(path\)/);
|
||||
assert.match(node, /assetURL\('\/vendor\/jamesdsp\/dsp\/jamesdsp\.wasm'/);
|
||||
assert.match(node, /assetURL\('\/vendor\/jamesdsp\/params\.mjs'/);
|
||||
assert.match(node, /workletURL\.hash=new URLSearchParams/);
|
||||
assert.match(worklet, /new URLSearchParams\(new URL\(import\.meta\.url\)\.hash\.slice\(1\)\)/);
|
||||
assert.match(worklet, /await import\(engineURL\)/);
|
||||
assert.match(node, /processorOptions:\{wasmBytes\}/);
|
||||
assert.match(worklet, /import createJamesDSP from '\.\/dsp\/jamesdsp-77be0b2\.mjs'/);
|
||||
assert.doesNotMatch(worklet, /import\(/);
|
||||
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\/dsp\/jamesdsp\.wasm'\)/);
|
||||
|
||||
@@ -96,3 +96,14 @@ test('preset gain and Level gain combine within engine bounds without pausing pl
|
||||
assert.equal(f.controller.state().maxLiveHeaps, 1);
|
||||
assert.equal(f.controller.active, true);
|
||||
});
|
||||
|
||||
test('A/B bypass is retained when settings are reapplied', async () => {
|
||||
const f = fixture();
|
||||
await f.controller.enable({ preset: 'warm' }, 0);
|
||||
assert.equal(await f.controller.toggleBypass(), true);
|
||||
await f.controller.enable({ preset: 'bass' }, 0);
|
||||
assert.equal(f.controller.state().bypassed, true);
|
||||
assert.ok(f.bypass.at(-1));
|
||||
assert.equal(await f.controller.toggleBypass(), false);
|
||||
assert.equal(f.bypass.at(-1), false);
|
||||
});
|
||||
|
||||
@@ -1159,6 +1159,12 @@ input[type="range"]::-webkit-slider-thumb:hover { transform: scale(1.25); }
|
||||
.set-row > span { display: flex; flex-direction: column; gap: 3px; color: var(--text); font-size: 13.5px; font-weight: 500; }
|
||||
.set-row small { color: var(--text-dim); font-size: 11.5px; font-weight: 400; line-height: 1.4; max-width: 280px; }
|
||||
.set-row input[type="range"] { width: 150px; }
|
||||
.dsp-settings { margin-top: 8px; padding: 12px; border: 1px solid var(--line); border-radius: 12px; background: var(--bg-2); }
|
||||
.dsp-settings .set-group-title { padding: 0 0 8px; }
|
||||
.dsp-param-list { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 0 14px; }
|
||||
.dsp-param-row input[type="range"] { width: min(46vw, 190px); }
|
||||
.dsp-actions { display: flex; flex-wrap: wrap; gap: 8px; padding: 10px 0; }
|
||||
.dsp-actions .btn { color: var(--text); }
|
||||
.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; }
|
||||
.set-select {
|
||||
|
||||
29
frontend/vendor/jamesdsp/jamesdsp-feature.js
vendored
29
frontend/vendor/jamesdsp/jamesdsp-feature.js
vendored
@@ -16,7 +16,7 @@
|
||||
|
||||
function createController(options) {
|
||||
const graph = options.graph;
|
||||
let desired = false, active = 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 tail = Promise.resolve(), maxLiveHeaps = 0, liveHeaps = 0, nodesCreated = 0, nodesDestroyed = 0;
|
||||
let faultListener = null, notifying = false, dspAnalyser = null;
|
||||
@@ -27,7 +27,7 @@
|
||||
return result;
|
||||
};
|
||||
|
||||
function snapshot() { return { active, desired, liveHeaps, maxLiveHeaps, nodesCreated, nodesDestroyed, sampleRate: context?.sampleRate || null }; }
|
||||
function snapshot() { return { active, desired, bypassed, liveHeaps, maxLiveHeaps, nodesCreated, nodesDestroyed, sampleRate: context?.sampleRate || null }; }
|
||||
|
||||
function routeStandard() {
|
||||
if (!elements.length) return;
|
||||
@@ -58,6 +58,7 @@
|
||||
function fail(error) {
|
||||
desired = false;
|
||||
active = false;
|
||||
bypassed = false;
|
||||
try { routeStandard(); } catch { /* graph route has already tried a direct audible fallback */ }
|
||||
releaseNode();
|
||||
if (!notifying) {
|
||||
@@ -92,7 +93,7 @@
|
||||
await instance.setBypass(true);
|
||||
await instance.applyPreset(params);
|
||||
await instance.setParam('gain', params.gain);
|
||||
if (desired) await instance.setBypass(false);
|
||||
if (desired) await instance.setBypass(bypassed);
|
||||
if (desired) rampGain(1);
|
||||
}
|
||||
|
||||
@@ -101,7 +102,6 @@
|
||||
const { JamesDSPNode } = await import(moduleURL);
|
||||
return JamesDSPNode.create(audioContext, {
|
||||
wasmURL: root.Lazy.url('/vendor/jamesdsp/dsp/jamesdsp.wasm'),
|
||||
engineURL: root.Lazy.url('/vendor/jamesdsp/dsp/jamesdsp.mjs'),
|
||||
paramsURL: root.Lazy.url('/vendor/jamesdsp/params.mjs'),
|
||||
workletURL: root.Lazy.url('/vendor/jamesdsp/worklet.mjs'),
|
||||
});
|
||||
@@ -144,6 +144,7 @@
|
||||
|
||||
function disable() {
|
||||
desired = false;
|
||||
bypassed = false;
|
||||
if (active) {
|
||||
active = false;
|
||||
try { routeStandard(); } catch { /* routeStandard tried its direct fallback */ }
|
||||
@@ -159,7 +160,23 @@
|
||||
|
||||
function setBypass(value) {
|
||||
if (!nodeEntry) return Promise.resolve(false);
|
||||
return nodeEntry.node.setBypass(Boolean(value));
|
||||
return serial(async () => { bypassed = Boolean(value); await nodeEntry.node.setBypass(bypassed); return bypassed; }).catch(error => { fail(error); throw error; });
|
||||
}
|
||||
|
||||
function toggleBypass() {
|
||||
if (!nodeEntry) return Promise.resolve(false);
|
||||
return serial(async () => { bypassed = !bypassed; await nodeEntry.node.setBypass(bypassed); return bypassed; }).catch(error => { fail(error); throw error; });
|
||||
}
|
||||
|
||||
function loadIR(buffer) {
|
||||
if (!active || !nodeEntry) return Promise.reject(new Error('Enable JamesDSP before loading an impulse response'));
|
||||
return serial(async () => {
|
||||
if (!active || !nodeEntry) throw new Error('JamesDSP is no longer active');
|
||||
rampGain(0);
|
||||
try { await nodeEntry.node.loadIR(buffer); await nodeEntry.node.setParam('convolver.enabled', 1); }
|
||||
finally { if (active && desired) rampGain(1); }
|
||||
return true;
|
||||
}).catch(error => { fail(error); throw error; });
|
||||
}
|
||||
|
||||
function analyser() {
|
||||
@@ -175,7 +192,7 @@
|
||||
return dspAnalyser;
|
||||
}
|
||||
|
||||
return { enable, disable, setLevelDb, setBypass, 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 };
|
||||
|
||||
11
frontend/vendor/jamesdsp/jamesdsp-node.mjs
vendored
11
frontend/vendor/jamesdsp/jamesdsp-node.mjs
vendored
@@ -7,17 +7,12 @@ export class JamesDSPNode extends EventTarget {
|
||||
static async create(context, options={}) {
|
||||
if (![44100,48000].includes(context.sampleRate)) throw new RangeError('Use a 44.1 or 48 kHz AudioContext');
|
||||
const wasmURL=options.wasmURL || assetURL('/vendor/jamesdsp/dsp/jamesdsp.wasm','./dsp/jamesdsp.wasm');
|
||||
const engineURL=options.engineURL || assetURL('/vendor/jamesdsp/dsp/jamesdsp.mjs','./dsp/jamesdsp.mjs');
|
||||
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);
|
||||
const workletURL=new URL(baseWorkletURL,import.meta.url);
|
||||
// Fragments are not sent over HTTP, so SW CacheStorage can keep the exact
|
||||
// manifest URL while import.meta.url still carries the build-local engine URL.
|
||||
workletURL.hash=new URLSearchParams({engine:String(engineURL)}).toString();
|
||||
if (!moduleLoads.has(context)) moduleLoads.set(context,new Map());
|
||||
const cache=moduleLoads.get(context), key=String(workletURL);
|
||||
if (!cache.has(key)) cache.set(key,context.audioWorklet.addModule(workletURL).catch(e=>{cache.delete(key);throw e;}));
|
||||
const cache=moduleLoads.get(context), key=String(baseWorkletURL);
|
||||
if (!cache.has(key)) cache.set(key,context.audioWorklet.addModule(baseWorkletURL).catch(e=>{cache.delete(key);throw e;}));
|
||||
const response=await fetch(wasmURL);
|
||||
if (!response.ok) throw new Error(`WASM fetch failed: ${response.status}`);
|
||||
const wasmBytes=await response.arrayBuffer();
|
||||
@@ -26,7 +21,7 @@ export class JamesDSPNode extends EventTarget {
|
||||
try { await wrapper.ready; return wrapper; } catch(e) { wrapper.destroy(); throw e; }
|
||||
}
|
||||
constructor(context,wasmBytes,paramApi) {
|
||||
super(); this.context=context; this.defaultParams=paramApi.DEFAULT_PARAMS; 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(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;
|
||||
this.node=new AudioWorkletNode(context,'jamesdsp',{numberOfInputs:1,numberOfOutputs:1,outputChannelCount:[2],channelCount:2,channelCountMode:'explicit',processorOptions:{wasmBytes}});
|
||||
this.ready=new Promise((resolve,reject)=> {
|
||||
const timer=setTimeout(()=>reject(new Error('DSP initialization timed out')),15000);
|
||||
|
||||
6
frontend/vendor/jamesdsp/worklet.mjs
vendored
6
frontend/vendor/jamesdsp/worklet.mjs
vendored
@@ -1,7 +1,7 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
const engineURL=new URLSearchParams(new URL(import.meta.url).hash.slice(1)).get('engine');
|
||||
if(!engineURL) throw new Error('JamesDSP engine asset URL is missing');
|
||||
const {default:createJamesDSP}=await import(engineURL);
|
||||
// AudioWorkletGlobalScope does not support dynamic import; this immutable
|
||||
// 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 {
|
||||
constructor(options) {
|
||||
super(); this.closed=false;this.failed=false;
|
||||
|
||||
@@ -1,4 +1,18 @@
|
||||
/* Eager UI definitions. Read app lexical state only when called after initialization. */
|
||||
function parseJamesDspPreset(text) {
|
||||
const imported = typeof text === 'string' ? JSON.parse(text) : text;
|
||||
const allowed = /^(?:eq\.band(?:[0-9]|1[0-4])|compressor\.band[0-6]|(?:eq|compressor|bass|stereo|reverb|crossfeed|tube|convolver)\.enabled|(?:gain|limiter\.threshold|limiter\.release|eq\.mode|eq\.interpolation|compressor\.time|compressor\.mode|compressor\.granularity|bass\.boost|stereo\.mix|reverb\.preset|crossfeed\.mode|tube\.drive))$/;
|
||||
if (imported?.version !== 1 || imported?.texts || !imported?.params || typeof imported.params !== 'object' || Array.isArray(imported.params)) throw new Error('This file is not a supported preset (text effects are disabled).');
|
||||
const range = key => key.startsWith('eq.band') || key.startsWith('compressor.band') ? [-12, 12] : ({ gain: [-24, 12], 'limiter.threshold': [-24, 0], 'limiter.release': [10, 1000], 'eq.mode': [0, 5], 'eq.interpolation': [0, 1], 'compressor.time': [0.01, 2], 'compressor.mode': [0, 3], 'compressor.granularity': [0, 4], 'bass.boost': [0, 12], 'stereo.mix': [0, 1], 'reverb.preset': [0, 18], 'crossfeed.mode': [0, 5], 'tube.drive': [-12, 12] }[key] || (key.endsWith('.enabled') ? [0, 1] : null));
|
||||
for (const [key, value] of Object.entries(imported.params)) {
|
||||
const bounds = range(key);
|
||||
if (!allowed.test(key) || typeof value !== 'number' || !Number.isFinite(value) || !bounds || value < bounds[0] || value > bounds[1] || (key === 'convolver.enabled' && value !== 0)) throw new Error(`Unsupported preset setting: ${key}`);
|
||||
if ((key.endsWith('.enabled') || /mode|preset|granularity|interpolation/.test(key)) && !Number.isInteger(value)) throw new Error(`Preset setting must be a whole number: ${key}`);
|
||||
}
|
||||
const presets = ['flat', 'bass', 'vocal', 'loudness', 'headphone', 'bright', 'warm'];
|
||||
return { preset: presets.includes(imported.preset) ? imported.preset : 'flat', params: { ...imported.params } };
|
||||
}
|
||||
|
||||
async function renderLibrary() {
|
||||
els.listTitle.textContent = 'Library';
|
||||
resetListHeaderExtras();
|
||||
@@ -718,6 +732,12 @@ async function renderSettings() {
|
||||
const qOptions = qualities
|
||||
.map((q) => `<option value="${q}"${data.settings.quality === q ? ' selected' : ''}>${q === 'auto' ? 'Auto' : q}</option>`)
|
||||
.join('');
|
||||
const jamesDsp = data.settings.jamesDsp || { enabled: false, preset: 'flat', params: {} };
|
||||
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 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 dspBandRows = dspBands.map((hz, i) => dspRange(`eq.band${i}`, `${hz >= 1000 ? (hz / 1000) + ' kHz' : hz + ' Hz'}`, -12, 12, 0.5, ' dB')).join('');
|
||||
|
||||
const sel = (id, val, opts) => `<select id="${id}" class="set-select">` +
|
||||
opts.map(([v, label]) => `<option value="${v}"${val === v ? ' selected' : ''}>${label}</option>`).join('') +
|
||||
@@ -788,6 +808,28 @@ async function renderSettings() {
|
||||
</span>
|
||||
<input id="setLiveAudioIOS" type="checkbox" ${data.settings.liveAudioIOS ? 'checked' : ''} />
|
||||
</label>` : ''}
|
||||
<div class="dsp-settings" id="jamesDspSettings">
|
||||
<div class="set-group-title">Sound engine</div>
|
||||
<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']])}
|
||||
</label>
|
||||
<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>
|
||||
<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>
|
||||
<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>Stereo width<small>${Math.round(dspParam('stereo.mix') * 100)}%</small></span><input type="range" data-dsp-param="stereo.mix" min="0" max="1" step="0.05" value="${dspParam('stereo.mix')}" /></label>
|
||||
<label class="set-row"><span>Crossfeed mode</span>${sel('setDspCrossfeed', String(dspParam('crossfeed.mode')), [['0', 'Off'], ['1', 'Gentle'], ['2', 'Natural'], ['3', 'Wide'], ['4', 'Diffuse'], ['5', 'Strong']])}</label>
|
||||
<label class="set-row"><span>Reverb preset</span>${sel('setDspReverb', String(dspParam('reverb.preset')), Array.from({ length: 19 }, (_, i) => [String(i), i === 0 ? 'Off' : `Room ${i}`]))}</label>
|
||||
<label class="set-row"><span>Tube drive<small>${dspParam('tube.drive')} dB</small></span><input type="range" data-dsp-param="tube.drive" min="-12" max="12" step="0.5" value="${dspParam('tube.drive')}" /></label>
|
||||
<label class="set-row"><span>Limiter threshold<small>${dspParam('limiter.threshold')} dB</small></span><input type="range" data-dsp-param="limiter.threshold" min="-24" max="0" step="0.5" value="${dspParam('limiter.threshold')}" /></label>
|
||||
<label class="set-row"><span>Output gain<small>${dspParam('gain')} dB</small></span><input type="range" data-dsp-param="gain" min="-24" max="12" step="0.5" value="${dspParam('gain')}" /></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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<label class="set-row">
|
||||
<span>Default volume</span>
|
||||
<input id="setVolume" type="range" min="0" max="1" step="0.01" value="${data.settings.volume}" />
|
||||
@@ -1055,6 +1097,79 @@ async function renderSettings() {
|
||||
if (e.target.checked) toast('Live sound processing on — streamed songs go silent when the screen locks', { duration: 4500 });
|
||||
});
|
||||
|
||||
const dspControls = $('jamesDspControls');
|
||||
const dspEngine = $('setSoundEngine');
|
||||
if (dspEngine) dspEngine.addEventListener('change', e => {
|
||||
const enable = e.target.value === 'jamesdsp';
|
||||
if (enable && EQ.isIOS() && !data.settings.liveAudioIOS) {
|
||||
e.target.value = 'standard';
|
||||
toast('On iPhone, turn on Live sound processing first.');
|
||||
return;
|
||||
}
|
||||
data.settings.jamesDsp = data.settings.jamesDsp || { enabled: false, preset: 'flat', params: {} };
|
||||
data.settings.jamesDsp.enabled = enable;
|
||||
if (dspControls) dspControls.hidden = !enable;
|
||||
persist();
|
||||
EQ.apply();
|
||||
});
|
||||
const dspConfig = data.settings.jamesDsp || (data.settings.jamesDsp = { enabled: false, preset: 'flat', params: {} });
|
||||
let dspApplyTimer = null;
|
||||
const applyDspSettings = () => {
|
||||
clearTimeout(dspApplyTimer);
|
||||
persist();
|
||||
dspApplyTimer = setTimeout(() => { if (data.settings.jamesDsp?.enabled) EQ.apply(); }, 120);
|
||||
};
|
||||
if ($('setDspPreset')) $('setDspPreset').addEventListener('change', e => {
|
||||
dspConfig.preset = e.target.value;
|
||||
dspConfig.params = {};
|
||||
wrap.querySelectorAll('[data-dsp-param]').forEach(input => { input.value = input.dataset.dspParam === 'limiter.threshold' ? '-0.1' : '0'; });
|
||||
applyDspSettings();
|
||||
});
|
||||
wrap.querySelectorAll('[data-dsp-param]').forEach(input => input.addEventListener('input', () => {
|
||||
const value = Number(input.value);
|
||||
dspConfig.params[input.dataset.dspParam] = value;
|
||||
const small = input.closest('.set-row')?.querySelector('small');
|
||||
if (small) small.textContent = `${value}${input.dataset.dspParam === 'stereo.mix' ? '%' : input.dataset.dspParam === 'bass.boost' || input.dataset.dspParam === 'tube.drive' || input.dataset.dspParam === 'limiter.threshold' || input.dataset.dspParam === 'gain' || input.dataset.dspParam.startsWith('eq.band') ? ' dB' : ''}`;
|
||||
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 ($('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 ($('dspResetBtn')) $('dspResetBtn').addEventListener('click', () => {
|
||||
dspConfig.preset = 'flat'; dspConfig.params = {};
|
||||
if ($('setDspPreset')) $('setDspPreset').value = 'flat';
|
||||
wrap.querySelectorAll('[data-dsp-param]').forEach(input => { input.value = input.dataset.dspParam === 'limiter.threshold' ? '-0.1' : '0'; });
|
||||
if ($('setDspCrossfeed')) $('setDspCrossfeed').value = '0';
|
||||
if ($('setDspReverb')) $('setDspReverb').value = '0';
|
||||
if ($('setDspCompressor')) $('setDspCompressor').checked = false;
|
||||
applyDspSettings();
|
||||
toast('JamesDSP settings reset');
|
||||
});
|
||||
if ($('dspPresetExportBtn')) $('dspPresetExportBtn').addEventListener('click', () => {
|
||||
const params = { ...(dspConfig.params || {}) }; delete params['convolver.enabled'];
|
||||
const blob = new Blob([JSON.stringify({ version: 1, preset: dspConfig.preset || 'flat', params }, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob), a = document.createElement('a');
|
||||
a.href = url; a.download = 'ytplayer-jamesdsp-preset.json'; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
});
|
||||
if ($('dspPresetImportBtn')) $('dspPresetImportBtn').addEventListener('click', () => $('dspPresetFile')?.click());
|
||||
if ($('dspPresetFile')) $('dspPresetFile').addEventListener('change', async e => {
|
||||
const file = e.target.files?.[0]; if (!file) return;
|
||||
try {
|
||||
if (file.size > 65536) throw new Error('Preset files must be 64 KB or smaller.');
|
||||
const imported = parseJamesDspPreset(await file.text());
|
||||
dspConfig.preset = imported.preset; dspConfig.params = imported.params;
|
||||
if ($('setDspPreset')) $('setDspPreset').value = dspConfig.preset;
|
||||
applyDspSettings(); toast('JamesDSP preset imported');
|
||||
} catch (error) { toast(error?.message || 'Could not import preset'); }
|
||||
finally { e.target.value = ''; }
|
||||
});
|
||||
if ($('dspIrFile')) $('dspIrFile').addEventListener('change', async e => {
|
||||
const file = e.target.files?.[0]; if (!file) return;
|
||||
try { await EQ.loadDspIR(file); dspConfig.params['convolver.enabled'] = 1; applyDspSettings(); toast('Impulse response loaded for this session'); }
|
||||
catch (error) { toast(error?.message || 'Could not load impulse response'); }
|
||||
finally { e.target.value = ''; }
|
||||
});
|
||||
|
||||
|
||||
// ---- Saved searches manager ----
|
||||
{
|
||||
|
||||
@@ -74,6 +74,11 @@ Each scenario records the following metrics across **5 independent runs** (repor
|
||||
|
||||
## 4. How to Run
|
||||
|
||||
### Optional JamesDSP integration check
|
||||
`node perf/dsp.mjs --browser all` builds short local H.264/AAC test tones, exercises Standard and the lazy JamesDSP presets through the real app audio graph, compares the A/B-bypassed RMS to Standard, verifies one worklet heap and no extra media sources, tests the offline cached reload, and injects a failed WASM response to check Standard fallback. Its detailed measurements stay in ignored `perf/.tmp/dsp-results.json`.
|
||||
|
||||
`node perf/settings-persist.mjs --browser all` also round-trips a disabled, custom JamesDSP preset through local storage and confirms the Standard selector remains selected.
|
||||
|
||||
### Prerequisites
|
||||
Symlink dependencies and ensure server dependencies are installed:
|
||||
```bash
|
||||
|
||||
126
perf/dsp.mjs
Normal file
126
perf/dsp.mjs
Normal file
@@ -0,0 +1,126 @@
|
||||
#!/usr/bin/env node
|
||||
// End-to-end JamesDSP checks against the real local server, manifest cache and media fixture.
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawn, execFileSync } from 'node:child_process';
|
||||
import { createRequire } from 'node:module';
|
||||
import { createServer } from 'node:net';
|
||||
import { mkdtempSync, mkdirSync, cpSync, writeFileSync, statSync, rmSync, existsSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { chromium, webkit } from 'playwright';
|
||||
import { createThrottleProxy } from './proxy.mjs';
|
||||
|
||||
const repo = resolve(import.meta.dirname, '..');
|
||||
const requested = process.argv.includes('--browser') ? process.argv[process.argv.indexOf('--browser') + 1] : 'all';
|
||||
assert.ok(['all', 'chromium', 'webkit'].includes(requested));
|
||||
const scratch = mkdtempSync(join(repo, 'perf/.tmp/dsp-'));
|
||||
const results = { results: [], failures: [] };
|
||||
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
||||
async function poll(fn, timeout = 90000) { const end = Date.now() + timeout; while (Date.now() < end) { if (await fn()) return true; await sleep(100); } return false; }
|
||||
|
||||
async function run(engine) {
|
||||
const cwd = join(scratch, engine); mkdirSync(cwd); cpSync(join(repo, 'frontend'), join(cwd, 'public'), { recursive: true }); mkdirSync(join(cwd, 'media'));
|
||||
const portProbe = createServer(); await new Promise(resolve => portProbe.listen(0, '127.0.0.1', resolve)); const port = portProbe.address().port; await new Promise(resolve => portProbe.close(resolve));
|
||||
let child, browser, proxy, context, log = '';
|
||||
let origin = `http://127.0.0.1:${port}`;
|
||||
const result = { engine, errors: [], failures: [], rms: [], checks: [] }; results.results.push(result);
|
||||
async function stop() { if (!child) return; const proc = child; child = null; if (proc.exitCode === null) { proc.kill('SIGTERM'); await new Promise(resolve => proc.once('exit', resolve)); } }
|
||||
try {
|
||||
const clips = [
|
||||
{ id: 'dspfixture01', title: 'DSP test tone 110 Hz', channel: 'Local test', duration: 5, thumbnail: 'data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=' },
|
||||
{ id: 'dspfixture02', title: 'DSP test tone 660 Hz', channel: 'Local test', duration: 5, thumbnail: 'data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=' },
|
||||
];
|
||||
child = spawn('bun', [join(repo, 'server/server.js')], { cwd, env: { ...process.env, PORT: String(port), DB_PATH: join(cwd, 'db.sqlite'), MEDIA_DIR: join(cwd, 'media'), UPLOAD_DIR: join(cwd, 'uploads'), DATA_DIR: join(cwd, 'data'), ASSET_HASHING: '1', ASSET_SYNC: '1', BUILD_TAG: '' }, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
for (const stream of [child.stdout, child.stderr]) stream.on('data', chunk => { log = (log + chunk).slice(-8000); });
|
||||
if (!await poll(async () => { if (child.exitCode !== null) throw Error(log); try { return (await fetch(origin + '/api/version')).ok; } catch { return false; } })) throw Error('Server did not start: ' + log);
|
||||
for (const [index, clip] of clips.entries()) {
|
||||
const mp4 = join(cwd, 'media', clip.id + '.1.mp4');
|
||||
execFileSync('ffmpeg', ['-y', '-f', 'lavfi', '-i', 'color=c=blue:s=160x90:r=10:d=5', '-f', 'lavfi', '-i', `sine=frequency=${index ? 660 : 110}:duration=5`, '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-movflags', '+faststart', '-shortest', mp4], { stdio: 'ignore' });
|
||||
execFileSync('ffmpeg', ['-y', '-i', mp4, '-vn', '-c:a', 'copy', join(cwd, 'media', clip.id + '.1.m4a')], { stdio: 'ignore' });
|
||||
assert.ok(existsSync(mp4), `generated local clip ${mp4}`);
|
||||
}
|
||||
const require = createRequire(import.meta.url); const { createClient } = require(join(repo, 'server/node_modules/@libsql/client'));
|
||||
const db = createClient({ url: 'file:' + join(cwd, 'db.sqlite') });
|
||||
for (const clip of clips) { const file = join(cwd, 'media', clip.id + '.1.mp4'); await db.execute({ sql: 'INSERT OR REPLACE INTO media_cache (video_id,status,gen,size,height,vcodec,acodec,duration,meta,last_access) VALUES (?,?,?,?,?,?,?,?,?,?)', args: [clip.id, 'ready', 1, statSync(file).size, 90, 'h264', 'aac', 5, JSON.stringify(clip), Date.now()] }); }
|
||||
db.close();
|
||||
proxy = createThrottleProxy({ targetPort: port, profile: 'unthrottled' }); origin = `http://127.0.0.1:${await proxy.listen()}`;
|
||||
const type = engine === 'chromium' ? chromium : webkit; browser = await type.launch({ headless: true, ...(engine === 'chromium' ? { args: ['--autoplay-policy=no-user-gesture-required'] } : {}) });
|
||||
context = await browser.newContext({ viewport: { width: 414, height: 896 } });
|
||||
await context.addInitScript(() => { window.__dspToasts = []; new MutationObserver(() => { const text = document.querySelector('#toastContainer')?.textContent?.trim(); if (text && window.__dspToasts.at(-1) !== text) window.__dspToasts.push(text); }).observe(document, { childList: true, subtree: true, characterData: true }); });
|
||||
await context.route('**/*', route => route.request().url().startsWith(origin) ? route.continue() : route.fulfill({ status: 200, contentType: 'image/gif', body: Buffer.from('R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=', 'base64') }));
|
||||
const page = await context.newPage(); let offlinePhase = false, failedApiTransports = 0; page.on('pageerror', error => { if (offlinePhase && failedApiTransports && /TypeError: Load failed|\/api\//.test(error.message)) (result.expectedApiErrors ||= []).push(error.message); else result.errors.push(error.message); });
|
||||
page.on('requestfailed', request => { const path = new URL(request.url()).pathname; if (path.startsWith('/api/')) failedApiTransports++; else if (!(offlinePhase && path === '/sw.js')) result.errors.push(`request failed ${request.url()}: ${request.failure()?.errorText}`); });
|
||||
page.on('response', response => {
|
||||
const url = new URL(response.url()), path = url.pathname;
|
||||
// The local fixture IDs have no upstream YouTube artwork. These requests
|
||||
// are unrelated to the local media or DSP asset path under test.
|
||||
if (url.hostname === 'i.ytimg.com' && /\/vi\/dspfixture0[12]\//.test(path)) {
|
||||
(result.fixtureResponses ||= []).push({ url: response.url(), status: response.status() });
|
||||
} else if (!path.startsWith('/api/') && response.status() >= 400) result.errors.push(`asset response ${response.status()} ${response.url()}`);
|
||||
});
|
||||
page.on('response', response => { if (/jamesdsp\/(?:worklet|dsp\/jamesdsp)\.mjs/.test(response.url())) result.requests ||= [], result.requests.push({ url: response.url(), status: response.status(), type: response.headers()['content-type'] }); });
|
||||
page.on('requestfailed', request => { if (/jamesdsp\//.test(request.url())) result.requestsFailed ||= [], result.requestsFailed.push({ url: request.url(), error: request.failure()?.errorText }); });
|
||||
const boot = async () => { await page.goto(origin); await page.waitForFunction(() => typeof data !== 'undefined' && document.querySelector('#sidebarToggle')); };
|
||||
await boot();
|
||||
const manifest = await (await fetch(origin + '/api/manifest')).json();
|
||||
const checkComplete = () => page.evaluate(async files => { const cache = await caches.open('ytplayer-assets'); const missing = []; for (const [path, file] of Object.entries(files)) { const r = await cache.match(path + '?v=' + file.h); if (!r || r.headers.get('X-Asset-Hash') !== file.h) missing.push(path); } return missing; }, manifest.files);
|
||||
assert.equal((await page.evaluate(async () => { const shell = await caches.open('ytplayer-shell'); return (await shell.keys()).some(r => r.url.includes('jamesdsp')); })), false, 'DSP is never in the shell cache');
|
||||
assert.equal(await poll(async () => !(await checkComplete()).length, 120000), true, 'offline completion job fills manifest before offline test');
|
||||
result.checks.push({ name: 'all manifest files cached before offline', files: Object.keys(manifest.files).length });
|
||||
await page.evaluate(async () => { await Lazy.load('feature:eq-core'); await Lazy.load('feature:vocal-reducer'); data.settings.jamesDsp = { enabled: false, preset: 'flat', params: {} }; EQ.setLevel(1); EQ.setLevel(0); EQ.apply(); });
|
||||
await page.evaluate(clip => Player.loadVideo(clip), clips[0]);
|
||||
await page.locator('#playBtn').click({ force: true }).catch(() => {});
|
||||
if (!await poll(() => page.evaluate(() => Player.master?.currentTime > 0.4), 12000)) { await page.evaluate(() => Player.master?.play().catch(() => {})); assert.equal(await poll(() => page.evaluate(() => Player.master?.currentTime > 0.4), 12000), true, 'local test clip starts'); }
|
||||
await page.evaluate(() => { if (Player.master) Player.master.loop = true; });
|
||||
const rms = async () => page.evaluate(async () => {
|
||||
const analyser = EQ.analyser(); if (!analyser) throw new Error('EQ analyser unavailable');
|
||||
const values = new Float32Array(analyser.fftSize); let sum = 0, count = 0, end = performance.now() + 600;
|
||||
while (performance.now() < end) { analyser.getFloatTimeDomainData(values); for (const v of values) { sum += v * v; count++; } await new Promise(resolve => setTimeout(resolve, 18)); }
|
||||
return Math.sqrt(sum / Math.max(1, count));
|
||||
});
|
||||
await page.evaluate(() => { EQ.apply(); }); await sleep(250);
|
||||
const standardRms = await rms(); assert.ok(standardRms > 0, `Standard output has nonzero RMS (${standardRms})`);
|
||||
await page.locator('#bottomNav [data-view="settings"]').click(); await page.evaluate(() => document.querySelector('[data-section="playback"]').click());
|
||||
await page.selectOption('#setSoundEngine', 'jamesdsp');
|
||||
const active = await poll(() => page.evaluate(() => !!EQ.state().jamesDsp?.active), 12000);
|
||||
if (!active) result.diagnostics = await page.evaluate(() => ({ enabled: data.settings.jamesDsp?.enabled, state: EQ.state(), lazyLoaded: Lazy.loaded('feature:jamesdsp'), toast: document.querySelector('#toastContainer')?.textContent || '', toastHistory: window.__dspToasts }));
|
||||
assert.equal(active, true, `JamesDSP worklet activates ${JSON.stringify(result.diagnostics || {})}`);
|
||||
await sleep(200); const flat = await rms(); result.rms.push({ effect: 'flat', rms: flat });
|
||||
for (const preset of ['bass', 'vocal', 'loudness', 'headphone', 'bright', 'warm']) {
|
||||
await page.selectOption('#setDspPreset', preset); await sleep(900);
|
||||
const value = await rms(); assert.ok(Number.isFinite(value), `${preset} RMS is finite`); result.rms.push({ effect: preset, rms: value });
|
||||
}
|
||||
await page.evaluate(() => { data.settings.jamesDsp.params['bass.enabled'] = 1; data.settings.jamesDsp.params['eq.enabled'] = 1; EQ.apply(); }); await sleep(800);
|
||||
const activeRms = await rms();
|
||||
await page.evaluate(async () => { await EQ.toggleDspBypass(); }); await sleep(200); const bypassRms = await rms();
|
||||
assert.ok(Math.abs(bypassRms - standardRms) <= Math.max(standardRms * 0.15, 0.002), `bypass RMS returns near Standard (${bypassRms} vs ${standardRms})`);
|
||||
result.checks.push({ name: 'A/B bypass RMS', standardRms, activeRms, bypassRms });
|
||||
await page.evaluate(async () => { const state = EQ.state().jamesDsp, graph = AudioGraph.snapshot(); return { state, graph }; }).then(({ state, graph }) => {
|
||||
assert.equal(state.liveHeaps, 1); assert.equal(state.maxLiveHeaps, 1); assert.ok(graph.sourceCount <= 2, 'only existing player media sources'); result.checks.push({ name: 'one worklet heap / single graph', ...state, graph });
|
||||
});
|
||||
await page.evaluate(clip => { Player.pause(); Player.loadVideo(clip); }, clips[1]); await sleep(500); await page.evaluate(() => Player.master?.play().catch(() => {}));
|
||||
assert.equal(await poll(() => page.evaluate(() => Player.master?.currentTime > 0.2 && EQ.state().jamesDsp?.active), 12000), true, 'DSP survives media element/track swap');
|
||||
result.checks.push({ name: 'track switch retains DSP route', graph: await page.evaluate(() => AudioGraph.snapshot()) });
|
||||
|
||||
await page.evaluate(() => persist()); await sleep(500);
|
||||
offlinePhase = true; if (engine === 'chromium') await context.setOffline(true); else await proxy.setOffline(true);
|
||||
await page.reload(); await page.waitForFunction(() => typeof data !== 'undefined' && document.querySelector('#sidebarToggle'));
|
||||
assert.equal(await poll(() => page.evaluate(() => !!EQ.state().jamesDsp?.active), 30000), true, 'offline reload restores enabled DSP from cached modules');
|
||||
result.checks.push({ name: 'offline reload with engine enabled', missing: await checkComplete() });
|
||||
|
||||
// A separate fresh profile corrupts the optional wasm response and verifies that Standard stays audible.
|
||||
offlinePhase = false; failedApiTransports = 0; if (engine === 'chromium') await context.setOffline(false); else await proxy.setOffline(false);
|
||||
const brokenContext = await browser.newContext({ viewport: { width: 414, height: 896 } });
|
||||
await brokenContext.route('**/*', route => route.request().url().startsWith(origin) ? route.continue() : route.fulfill({ status: 200, contentType: 'image/gif', body: Buffer.from('R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=', 'base64') }));
|
||||
await brokenContext.addInitScript(() => { const realFetch = window.fetch.bind(window); window.fetch = (input, init) => String(input).includes('/vendor/jamesdsp/dsp/jamesdsp.wasm') ? Promise.resolve(new Response('corrupt', { status: 503 })) : realFetch(input, init); });
|
||||
const brokenPage = await brokenContext.newPage(); brokenPage.on('pageerror', e => result.errors.push('corrupt test: ' + e.message)); await brokenPage.goto(origin); await brokenPage.waitForFunction(() => typeof data !== 'undefined');
|
||||
await brokenPage.locator('#bottomNav [data-view="settings"]').click(); await brokenPage.evaluate(() => document.querySelector('[data-section="playback"]').click()); await brokenPage.selectOption('#setSoundEngine', 'jamesdsp');
|
||||
assert.equal(await poll(() => brokenPage.evaluate(() => data.settings.jamesDsp.enabled === false && EQ.state().wired), 30000), true, 'corrupt wasm falls back to Standard');
|
||||
result.checks.push({ name: 'corrupt wasm returns to Standard', enabled: await brokenPage.evaluate(() => data.settings.jamesDsp.enabled), graph: await brokenPage.evaluate(() => AudioGraph.snapshot()) });
|
||||
await brokenContext.close();
|
||||
} catch (error) { result.failures.push(error.stack || error.message); }
|
||||
finally { await context?.close(); await browser?.close(); await proxy?.close(); await stop(); }
|
||||
}
|
||||
|
||||
try { for (const engine of requested === 'all' ? ['chromium', 'webkit'] : [requested]) await run(engine); }
|
||||
finally { writeFileSync(join(repo, 'perf/.tmp/dsp-results.json'), JSON.stringify(results, null, 2) + '\n'); rmSync(scratch, { recursive: true, force: true }); }
|
||||
if (results.failures.length || results.results.some(result => result.failures.length || result.errors.length)) process.exitCode = 1;
|
||||
@@ -70,6 +70,9 @@ async function run(name){
|
||||
const {migrate}=require(join(scratch,'new/frontend/eq-parameters.js'));const expected=structuredClone(result.old);if(expected.eq?.g?.length===5)expected.eq.g=migrate(expected.eq.g);
|
||||
for(const key of new Set([...Object.keys(result.old),...Object.keys(result.upgraded)]))result.diff.push({key,before:result.old[key],after:result.upgraded[key],equal:JSON.stringify(result.old[key])===JSON.stringify(result.upgraded[key]),expectedMigration:key==='eq'&&result.old.eq?.g?.length===5});
|
||||
assert.deepEqual(result.upgraded,expected,'all persisted settings preserved except deliberate 5→10 EQ migration');await behaviour('upgraded');
|
||||
await page.evaluate(()=>{data.settings.jamesDsp={enabled:false,preset:'warm',params:{'eq.band0':3,'tube.drive':1.5}};persist();});await page.reload();await page.waitForFunction(()=>typeof data!=='undefined');await openSettings();await page.locator('[data-section="playback"]').click();
|
||||
const james=await page.evaluate(()=>data.settings.jamesDsp);assert.deepEqual(james,{enabled:false,preset:'warm',params:{'eq.band0':3,'tube.drive':1.5}});assert.equal(await page.locator('#setSoundEngine').inputValue(),'standard');assert.equal(await page.locator('#setDspPreset').inputValue(),'warm');result.jamesDspSettingsPersisted=true;
|
||||
await page.evaluate(()=>{data.settings.jamesDsp.enabled=false;persist();});
|
||||
const local=await settings(),remote={...local,saveBeforePlay:false,autoPreload:false,layout:'classic',quality:'auto'};
|
||||
async function profileCheck(updatedAt,expectedSave,stage){profileReply={ok:true,name:'probe',updatedAt,data:{settings:remote,playlists:[]}};await page.evaluate(s=>{data.settings=s;data.profile={name:'probe',syncedAt:200};localStorage.setItem('_ytpdata',JSON.stringify(data));},local);await boot();await page.waitForFunction(()=>_syncBusy===0);await openSettings();const s=await settings();assert.equal(s.saveBeforePlay,expectedSave);assert.equal(await page.locator('#setSaveBeforePlay').isChecked(),expectedSave);if(!expectedSave)assert.equal(s.layout,'classic');else assert.deepEqual(s,local);result.profileChecks.push({stage,serverUpdatedAt:updatedAt,localSyncedAt:200,saveBeforePlay:s.saveBeforePlay});}
|
||||
await profileCheck(100,true,'older timestamp ignored');await profileCheck(300,false,'older settings with newer timestamp adopted');
|
||||
|
||||
Reference in New Issue
Block a user