Add optional JamesDSP audio effects
This commit is contained in:
1
frontend/vendor/jamesdsp/LICENSE.txt
vendored
Normal file
1
frontend/vendor/jamesdsp/LICENSE.txt
vendored
Normal file
@@ -0,0 +1 @@
|
||||
JamesDSP Web port files in this directory are licensed under GPL-3.0-or-later; see the upstream project LICENSE and upstream notices for provenance. The upstream README notes that its bundled original JamesDSP license text says GPL-2.0, so recipients should retain the upstream notices when redistributing.
|
||||
56
frontend/vendor/jamesdsp/controller.js
vendored
Normal file
56
frontend/vendor/jamesdsp/controller.js
vendored
Normal file
@@ -0,0 +1,56 @@
|
||||
import { JamesDSPNode } from './jamesdsp-node.js';
|
||||
|
||||
const presets = {
|
||||
Flat: {},
|
||||
'Bass boost': { 'bass.enabled': 1, 'bass.boost': 6, 'eq.enabled': 1, 'eq.band0': 3, 'eq.band1': 2, 'gain': -3 },
|
||||
Vocal: { 'eq.enabled': 1, 'eq.band3': -2, 'eq.band5': 2, 'eq.band7': 3, 'eq.band9': 2, 'eq.band11': -1 },
|
||||
'Headphone crossfeed': { 'crossfeed.enabled': 1, 'crossfeed.mode': 1 },
|
||||
Loudness: { 'limiter.threshold': -1, 'gain': 2 },
|
||||
};
|
||||
|
||||
export class JamesDSPController {
|
||||
constructor() { this.context = null; this.node = null; this.source = null; this.element = null; this.sources = new WeakMap(); }
|
||||
async enable(element, settings) {
|
||||
const AC = window.AudioContext || window.webkitAudioContext;
|
||||
if (!AC) throw new Error('Web Audio is not supported in this browser');
|
||||
if (!this.context) this.context = new AC();
|
||||
if (![44100, 48000].includes(this.context.sampleRate)) throw new Error(`JamesDSP supports 44.1 or 48 kHz; this device uses ${this.context.sampleRate} Hz`);
|
||||
if (!this.node) {
|
||||
this.node = await JamesDSPNode.create(this.context);
|
||||
this.node.addEventListener('error', (event) => this.fault(event.detail));
|
||||
}
|
||||
if (this.element !== element) {
|
||||
if (this.source) { this.source.disconnect(this.node); this.source.connect(this.context.destination); }
|
||||
this.source = this.sources.get(element) || this.context.createMediaElementSource(element);
|
||||
this.sources.set(element, this.source);
|
||||
this.node.connect(this.source);
|
||||
this.element = element;
|
||||
}
|
||||
await this.node.setBypass(false);
|
||||
await this.apply(settings);
|
||||
if (this.context.state === 'suspended') await this.context.resume();
|
||||
}
|
||||
async apply(settings) {
|
||||
if (!this.node) return;
|
||||
const preset = presets[settings.preset] || presets.Flat;
|
||||
const params = { ...preset, ...(settings.params || {}) };
|
||||
await this.node.setBypass(true);
|
||||
await this.node.applyPreset(params);
|
||||
await this.node.setBypass(false);
|
||||
}
|
||||
async update(name, value, settings) {
|
||||
settings.params ||= {};
|
||||
settings.params[name] = value;
|
||||
await this.apply(settings);
|
||||
}
|
||||
async disable() {
|
||||
if (this.node) await this.node.setBypass(true).catch(() => {});
|
||||
}
|
||||
fault(error) {
|
||||
console.warn('[JamesDSP] Processing failed; routing media dry:', error);
|
||||
try { this.source?.disconnect(this.node); this.source?.connect(this.context.destination); } catch {}
|
||||
this.node?.destroy(); this.node = null; this.source = null; this.element = null;
|
||||
this.onError?.(error || new Error('Audio processor failed'));
|
||||
}
|
||||
}
|
||||
export const JAMESDSP_PRESETS = Object.keys(presets);
|
||||
2
frontend/vendor/jamesdsp/dsp/jamesdsp.js
vendored
Normal file
2
frontend/vendor/jamesdsp/dsp/jamesdsp.js
vendored
Normal file
File diff suppressed because one or more lines are too long
BIN
frontend/vendor/jamesdsp/dsp/jamesdsp.wasm
vendored
Executable file
BIN
frontend/vendor/jamesdsp/dsp/jamesdsp.wasm
vendored
Executable file
Binary file not shown.
72
frontend/vendor/jamesdsp/jamesdsp-node.js
vendored
Normal file
72
frontend/vendor/jamesdsp/jamesdsp-node.js
vendored
Normal file
@@ -0,0 +1,72 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
import {DEFAULT_PARAMS, validateParam} from './params.js';
|
||||
const moduleLoads = new WeakMap();
|
||||
export class JamesDSPNode extends EventTarget {
|
||||
static async create(context, {wasmURL=new URL('./dsp/jamesdsp.wasm',import.meta.url), workletURL=new URL('./worklet.js',import.meta.url)}={}) {
|
||||
if (![44100,48000].includes(context.sampleRate)) throw new RangeError('Use a 44.1 or 48 kHz AudioContext');
|
||||
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 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);
|
||||
try { await wrapper.ready; return wrapper; } catch(e) { wrapper.destroy(); throw e; }
|
||||
}
|
||||
constructor(context,wasmBytes) {
|
||||
super(); this.context=context; this.params={...DEFAULT_PARAMS}; this.texts={}; this.pending=new Map(); this.serial=0; this.destroyed=false; this.source=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);
|
||||
this.node.onprocessorerror=()=>{const error=new Error('DSP processor stopped');clearTimeout(timer);reject(error);this.fail(error);};
|
||||
this.node.port.onmessage=({data})=> {
|
||||
if(data.type==='ready') {clearTimeout(timer);resolve();}
|
||||
else if(data.type==='reply') { const p=this.pending.get(data.id); if(p) {this.pending.delete(data.id);clearTimeout(p.timer);data.error?p.reject(new Error(data.error)):p.resolve(data.result);} }
|
||||
else if(data.type==='error') {const error=new Error(data.error); clearTimeout(timer); reject(error); this.fail(error);}
|
||||
else this.dispatchEvent(new CustomEvent(data.type,{detail:data}));
|
||||
};
|
||||
});
|
||||
}
|
||||
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={}) {
|
||||
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});
|
||||
});
|
||||
}
|
||||
// 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) {validateParam(name,value);await this.request('param',{name,value});this.params[name]=value;}
|
||||
setBypass(enabled) {return this.setParam('bypass',Number(Boolean(enabled)));}
|
||||
async setText(effect,text) {
|
||||
if(!['liveprog','ddc','arbitrary'].includes(effect) || typeof text!=='string' || text.length>65536) throw new RangeError('Invalid text effect');
|
||||
await this.request('text',{effect,text});this.texts[effect]=text;this.params[`${effect}.enabled`]=0;
|
||||
}
|
||||
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');
|
||||
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;
|
||||
}
|
||||
async applyPreset(preset) {
|
||||
// All keys validated before mutation; applying a preset resets unspecified params.
|
||||
const params={...DEFAULT_PARAMS,...(preset.params||preset)};
|
||||
for(const [name,value] of Object.entries(params)) validateParam(name,value);
|
||||
for(const [effect,text] of Object.entries(preset.texts||{})) await this.setText(effect,text);
|
||||
// Configure before enabling; text and IR resources must have been loaded.
|
||||
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},texts:{...this.texts}};}
|
||||
destroy() {
|
||||
if(this.destroyed) return;
|
||||
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();
|
||||
}
|
||||
}
|
||||
27
frontend/vendor/jamesdsp/params.js
vendored
Normal file
27
frontend/vendor/jamesdsp/params.js
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
// 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 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],
|
||||
'crossfeed.mode':[0,5,0], 'tube.drive':[-12,12,0],
|
||||
};
|
||||
for (const effect of EFFECTS) PARAMS[`${effect}.enabled`] = [0,1,0];
|
||||
EQ_FREQUENCIES.forEach((_, i) => PARAMS[`eq.band${i}`] = [-12,12,0]);
|
||||
COMP_FREQUENCIES.forEach((_, i) => PARAMS[`compressor.band${i}`] = [-12,12,0]);
|
||||
export const DEFAULT_PARAMS = Object.fromEntries(Object.entries(PARAMS).map(([k,v])=>[k,v[2]]));
|
||||
export function validateParam(name, value) {
|
||||
const spec=PARAMS[name];
|
||||
if (!spec || typeof value!=='number' || !Number.isFinite(value) || value<spec[0] || value>spec[1]) throw new RangeError(`Invalid parameter ${name}: ${value}`);
|
||||
if ((name.endsWith('.enabled') || /mode|preset|granularity|interpolation|bypass/.test(name)) && !Number.isInteger(value)) throw new RangeError(`${name} requires an integer`);
|
||||
}
|
||||
export const PRESETS = {
|
||||
Flat: {},
|
||||
'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},
|
||||
};
|
||||
55
frontend/vendor/jamesdsp/worklet.js
vendored
Normal file
55
frontend/vendor/jamesdsp/worklet.js
vendored
Normal file
@@ -0,0 +1,55 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
import createJamesDSP from './dsp/jamesdsp.js';
|
||||
class JamesDSPProcessor extends AudioWorkletProcessor {
|
||||
constructor(options) {
|
||||
super(); this.closed=false;this.failed=false;
|
||||
this.initialize(options);
|
||||
}
|
||||
async initialize(options) {
|
||||
try {
|
||||
this.wasm=await createJamesDSP({wasmBinary:options.processorOptions.wasmBytes,locateFile:()=>'jamesdsp.wasm',print:()=>{},printErr:()=>{}});
|
||||
this.handle=this.wasm._jdsp_create(sampleRate);
|
||||
this.pointer=this.wasm._malloc(128*2*4);
|
||||
if(!this.handle || !this.pointer) throw new Error('DSP allocation failed or sample rate unsupported');
|
||||
this.audio=this.wasm.HEAPF32.subarray(this.pointer/4,this.pointer/4+256);
|
||||
this.port.onmessage=({data})=>this.message(data);
|
||||
this.port.postMessage({type:'ready'});
|
||||
} catch(error) {this.failed=true;this.port.postMessage({type:'error',error:String(error)});}
|
||||
}
|
||||
string(value,fn) {
|
||||
const utf8=encodeURIComponent(value+'\0').replace(/%([0-9A-F]{2})/g,(_,hex)=>String.fromCharCode(parseInt(hex,16)));
|
||||
const bytes=Uint8Array.from(utf8,c=>c.charCodeAt(0)), p=this.wasm._malloc(bytes.length);
|
||||
if(!p) throw new Error('DSP text allocation failed');
|
||||
try {this.wasm.HEAPU8.set(bytes,p);return fn(p);} finally {this.wasm._free(p);}
|
||||
}
|
||||
message(data) {
|
||||
if(data.type==='destroy') {if(this.handle) this.wasm._jdsp_destroy(this.handle);if(this.pointer) this.wasm._free(this.pointer);this.closed=true;return;}
|
||||
try {
|
||||
let result;
|
||||
if(data.type==='param') result=this.string(data.name,p=>this.wasm._jdsp_set_param(this.handle,p,data.value));
|
||||
else if(data.type==='text') result=this.string(data.effect,e=>this.string(data.text,t=>this.wasm._jdsp_set_text(this.handle,e,t)));
|
||||
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);}
|
||||
} 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});
|
||||
} catch(error) {this.port.postMessage({type:'reply',id:data.id,error:String(error)});}
|
||||
}
|
||||
process(inputs,outputs) {
|
||||
if(this.closed) return false;
|
||||
const out=outputs[0], input=inputs[0];
|
||||
if(!out?.length || this.failed || !this.handle) return true;
|
||||
// Mono duplicates to stereo; missing input becomes silence, preserving tails.
|
||||
for(let offset=0;offset<out[0].length;offset+=128) {
|
||||
const n=Math.min(128,out[0].length-offset);
|
||||
for(let i=0;i<n;i++) {this.audio[i*2]=input[0]?.[offset+i]||0;this.audio[i*2+1]=(input[1]||input[0])?.[offset+i]||0;}
|
||||
try {
|
||||
if(this.wasm._jdsp_process(this.handle,this.pointer,n)!==0) throw new Error('DSP produced nonfinite output');
|
||||
for(let i=0;i<n;i++) {out[0][offset+i]=this.audio[i*2];if(out[1]) out[1][offset+i]=this.audio[i*2+1];}
|
||||
} catch(error) {out.forEach(channel=>channel.fill(0));this.failed=true;this.port.postMessage({type:'error',error:String(error)});break;}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
registerProcessor('jamesdsp',JamesDSPProcessor);
|
||||
Reference in New Issue
Block a user