Add JamesDSP playback controls and verification harness

This commit is contained in:
Jonathan Sykes
2026-10-09 20:29:37 +08:00
parent 27b32ff0c9
commit 6bb3271431
15 changed files with 376 additions and 29 deletions

View File

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

View File

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

View File

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