Extend JamesDSP with convolution and spatial audio
This commit is contained in:
70
frontend/vendor/jamesdsp/jamesdsp-node.mjs
vendored
70
frontend/vendor/jamesdsp/jamesdsp-node.mjs
vendored
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user