Files
ytplayer/frontend/vendor/jamesdsp/jamesdsp-node.mjs
2026-10-09 20:29:37 +08:00

79 lines
5.5 KiB
JavaScript

// SPDX-License-Identifier: GPL-3.0-or-later
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);
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 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);
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;
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});
});
}
// 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;
}
setBypass(enabled) {return this.setParam('bypass',Number(Boolean(enabled)));}
async setText() {
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');
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) {
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.
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).sort(([a],[b])=>Number(a.endsWith('.enabled'))-Number(b.endsWith('.enabled')))) await this.setParam(name,value);
}
toJSON() {return {version:1,params:{...this.params}};}
destroy() {
if(this.destroyed) return;
this.node.disconnect();
this.node.port.postMessage({type:'destroy'});this.destroyed=true;this.fail(new Error('DSP destroyed'));this.node.port.close();
}
}