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

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

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)
Vendored from web/jamesdsp-node.js, web/params.js, web/worklet.js,
web/dsp/jamesdsp.js and web/dsp/jamesdsp.wasm.
Vendored source files: web/jamesdsp-node.js, params.js, ir-utils.js,
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;
the worklet carries its engine URL in a fragment so its HTTP/CacheStorage key
remains the exact manifest URL; the wrapper and worklet do not expose text
effects (Liveprog, DDC, arbitrary EQ) in this v1 integration. Upstream source is
retained unchanged for the generated engine and parameter module.
Integration adaptations: wrapper dependencies and WASM URLs use the page's
manifest-hashed Lazy.url map; the worklet statically imports the pinned
jamesdsp-a982b53.mjs module because AudioWorkletGlobalScope rejects dynamic
import. The wrapper keeps the shared AudioGraph route and does not create a
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
snapshot at upstream/Main/LICENSE contains GPL-2.0 text; the discrepancy is
preserved and recorded for release review. See README.md and JamesDSP's source
repository for corresponding source information.
The generated engine, parameter tables, IR utilities/store and built-in IR set
are copied from the pinned revision. The uploaded/generated IR cap is 65,536
frames at the internal 44.1/48 kHz rate (low-power devices use 24,000 frames).
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) {
'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({
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 } },
@@ -18,7 +25,7 @@
const graph = options.graph;
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 tail = Promise.resolve(), maxLiveHeaps = 0, liveHeaps = 0, nodesCreated = 0, nodesDestroyed = 0, loadedIRRef = null;
let faultListener = null, notifying = false, dspAnalyser = null;
const serial = fn => {
@@ -27,7 +34,7 @@
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() {
if (!elements.length) return;
@@ -51,6 +58,7 @@
dspAnalyser = null;
if (entry) {
try { entry.node.destroy(); } catch { /* continue freeing references */ }
loadedIRRef = null;
if (entry.live) { entry.live = false; liveHeaps = Math.max(0, liveHeaps - 1); nodesDestroyed++; }
}
}
@@ -84,6 +92,7 @@
async function configure(instance, nextSettings, nextLevelDb) {
const preset = PRESETS[nextSettings?.preset] || PRESETS.flat;
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.bypass = 1;
if (active && context?.state === 'running') {
@@ -91,7 +100,10 @@
await new Promise(resolve => setTimeout(resolve, 20));
}
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);
if (desired) await instance.setBypass(bypassed);
if (desired) rampGain(1);
@@ -117,7 +129,7 @@
context = prepared.context;
elements = prepared.elements;
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;
let instance = nodeEntry?.node;
if (!instance) {
@@ -136,7 +148,7 @@
await configure(instance, settings, levelDb);
if (!desired) { releaseNode(); return false; }
if (context.state === 'suspended') await context.resume().catch(() => {});
graph.routeAll(elements, instance.node);
graph.routeAll(elements, bypassed ? standardInput : instance.node);
active = true;
return true;
}).catch(error => { fail(error); throw error; });
@@ -158,22 +170,35 @@
return enable(settings, levelDb);
}
function setBypass(value) {
function changeBypass(value) {
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() {
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 setBypass(value) { return changeBypass(value); }
function toggleBypass() { return changeBypass(!bypassed); }
function loadIR(buffer) {
function loadIR(buffer, reference = null) {
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); }
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); }
return true;
}).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 };
}
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;
else {
root.YT = root.YT || {};

View File

@@ -1,27 +1,29 @@
// 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();
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 {
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 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);
static async create(context, {wasmURL=assetURL('/vendor/jamesdsp/dsp/jamesdsp.wasm','./dsp/jamesdsp.wasm'), workletURL=assetURL('/vendor/jamesdsp/worklet.mjs','./worklet.mjs')}={}) {
if (!Number.isFinite(context.sampleRate)||context.sampleRate<8000||context.sampleRate>96000) throw new RangeError('AudioContext rate must be 8–96 kHz');
if (!moduleLoads.has(context)) moduleLoads.set(context,new Map());
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 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 response=await fetch(wasmURL);
if (!response.ok) throw new Error(`WASM fetch failed: ${response.status}`);
const wasmBytes=await response.arrayBuffer();
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; }
}
constructor(context,wasmBytes,paramApi) {
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;
constructor(context,wasmBytes) {
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.ready=new Promise((resolve,reject)=> {
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})); }
request(type,data={}) {
request(type,data={},transfer=[]) {
if(this.destroyed) return Promise.reject(new Error('DSP has been destroyed'));
const id=++this.serial;
return new Promise((resolve,reject)=> {
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.
async setParam(name,value) {
if (/^(?:liveprog|ddc|arbitrary)\./.test(name)) throw new Error('Text effects are disabled in this player version');
this.validateParam(name,value);await this.request('param',{name,value});this.params[name]=value;
// One source → this DSP → destination. For arbitrary graphs use .node directly.
connect(source,destination=this.context.destination) {
if(this.destroyed) throw new Error('DSP has been destroyed');
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)));}
async setText() {
async setText(effect,text) {
throw new Error('Text effects are disabled in this player version');
}
async loadIR(buffer) {
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');
async loadIR(buffer,reference=null) {
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);
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) {
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.
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)};
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');
this.validateParam(name,value);
}
// Configure before enabling; convolution still requires a separately loaded IR.
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(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);}
// Configure before enabling; the referenced IR is loaded first.
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() {
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();
}
}

View File

@@ -1,14 +1,17 @@
// 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 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 = {
bypass:[0,1,0], gain:[-24,12,0],
'limiter.threshold':[-24,0,-0.1], 'limiter.release':[10,1000,100],
'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],
'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],
'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];
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},
'Headphones': {'crossfeed.enabled':1,'crossfeed.mode':1,'eq.band8':-2,'eq.enabled':1},
'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
// 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';
import createJamesDSP from './dsp/jamesdsp-a982b53.mjs';
class JamesDSPProcessor extends AudioWorkletProcessor {
constructor(options) {
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));
else if(data.type==='ir') {
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');
if(result!==0) throw new Error(`DSP rejected ${data.name||data.effect||data.type}`);
this.port.postMessage({type:'reply',id:data.id,result});