Add optional JamesDSP audio effects

This commit is contained in:
Jonathan Sykes
2026-10-07 19:23:35 +08:00
parent 93d3b05263
commit dce186c187
12 changed files with 320 additions and 2 deletions

View File

@@ -644,6 +644,7 @@ function toAssetUrl(path) {
// ---------- State ---------- // ---------- State ----------
const DEFAULT_SETTINGS = { const DEFAULT_SETTINGS = {
quality: 'auto', volume: 1, audioOnly: false, autoPreload: true, quality: 'auto', volume: 1, audioOnly: false, autoPreload: true,
jamesDsp: { enabled: false, preset: 'Flat', params: {} },
p2pShare: true, // P2P (docs/p2p-architecture.md): share my saved videos with other devices — ON by default p2pShare: true, // P2P (docs/p2p-architecture.md): share my saved videos with other devices — ON by default
p2pReceive: true, // fetch from other devices when YouTube and the server can't — ON by default p2pReceive: true, // fetch from other devices when YouTube and the server can't — ON by default
saveBeforePlay: false, // download (server-muxed single file) before playing instead of streaming saveBeforePlay: false, // download (server-muxed single file) before playing instead of streaming
@@ -2658,6 +2659,7 @@ const Player = {
play() { play() {
this._wantsPlaying = true; this._wantsPlaying = true;
if (data.settings.jamesDsp?.enabled) JamesDSP.activate(this.soundEl).catch((e) => JamesDSP.fail(e));
audioContinuity.play(this.master); audioContinuity.play(this.master);
audioContinuity.resume(this.secondary, this.master); audioContinuity.resume(this.secondary, this.master);
}, },
@@ -6817,6 +6819,36 @@ const PiP = (() => {
return { toggle, render }; return { toggle, render };
})(); })();
// Optional JamesDSP stage. The module and WASM are fetched only after opt-in.
const JamesDSP = (() => {
const state = { controller: null, importing: null };
async function activate(element = Player.soundEl) {
if (!data.settings.jamesDsp?.enabled || !element) return;
try {
if (!state.importing) state.importing = import('./vendor/jamesdsp/controller.js');
const mod = await state.importing;
if (!state.controller) { state.controller = new mod.JamesDSPController(); state.controller.onError = fail; }
await state.controller.enable(element, data.settings.jamesDsp);
} catch (error) { fail(error); throw error; }
}
function fail(error) {
console.warn('[JamesDSP] Disabled:', error);
data.settings.jamesDsp.enabled = false;
state.controller?.disable();
persist();
const checkbox = $('setJamesDsp'); if (checkbox) checkbox.checked = false;
const note = $('jamesDspStatus'); if (note) note.textContent = `Audio effects unavailable: ${error?.message || error}`;
toast(`Audio effects disabled: ${error?.message || error}`);
}
async function toggle(on) {
data.settings.jamesDsp.enabled = !!on; persist();
if (!on) { await state.controller?.disable(); return; }
await activate(Player.soundEl);
}
async function apply() { if (data.settings.jamesDsp?.enabled) await activate(Player.soundEl); }
return { activate, fail, toggle, apply, get controller() { return state.controller; } };
})();
// ============================================================================ // ============================================================================
// Equalizer — 10-band graphic EQ (Web Audio) // Equalizer — 10-band graphic EQ (Web Audio)
// ============================================================================ // ============================================================================
@@ -9643,6 +9675,17 @@ async function renderSettings() {
</label> </label>
</div> </div>
<div class="set-group" id="jamesDspSettings">
<div class="set-group-title">Audio effects (JamesDSP)</div>
<label class="set-row"><span>Enable JamesDSP<small>Loads the audio processor only when enabled. iOS background playback may be interrupted.</small></span><input id="setJamesDsp" type="checkbox" ${data.settings.jamesDsp?.enabled ? 'checked' : ''} /></label>
<label class="set-row"><span>Preset</span>${sel('setJamesDspPreset', data.settings.jamesDsp?.preset || 'Flat', [['Flat','Flat'],['Bass boost','Bass boost'],['Vocal','Vocal'],['Headphone crossfeed','Headphone crossfeed'],['Loudness','Loudness']])}</label>
<label class="set-row"><span>EQ low band<small>25 Hz · −12 to +12 dB</small></span><input type="range" min="-12" max="12" step="1" value="${data.settings.jamesDsp?.params?.['eq.band0'] ?? 0}" id="setDspEq" /></label>
<label class="set-row"><span>Bass boost<small>0 to 12 dB</small></span><input type="range" min="0" max="12" step="1" value="${data.settings.jamesDsp?.params?.['bass.boost'] ?? 6}" id="setDspBass" /></label>
<label class="set-row"><span>Headphone crossfeed</span><input id="setDspCrossfeed" type="checkbox" ${data.settings.jamesDsp?.params?.['crossfeed.enabled'] ? 'checked' : ''} /></label>
<label class="set-row"><span>Limiter threshold<small>−24 to 0 dB</small></span><input type="range" min="-24" max="0" step="1" value="${data.settings.jamesDsp?.params?.['limiter.threshold'] ?? -0.1}" id="setDspLimiter" /></label>
<small id="jamesDspStatus" class="sp-note">Supported sample rates: 44.1 and 48 kHz.</small>
</div>
<div class="set-group"> <div class="set-group">
<div class="set-group-title">Sharing (peer-to-peer)</div> <div class="set-group-title">Sharing (peer-to-peer)</div>
<label class="set-row"> <label class="set-row">
@@ -9836,6 +9879,7 @@ async function renderSettings() {
const definitions = [ const definitions = [
['appearance', 'Appearance', 'Listening', () => `${data.settings.theme || 'dark'} · ${data.settings.fontScale || 'normal'} text`, '<circle cx="12" cy="12" r="8"/><path d="M12 4v16"/>'], ['appearance', 'Appearance', 'Listening', () => `${data.settings.theme || 'dark'} · ${data.settings.fontScale || 'normal'} text`, '<circle cx="12" cy="12" r="8"/><path d="M12 4v16"/>'],
['playback', 'Playback & sound', 'Listening', () => `${data.settings.quality || 'auto'} quality · ${Level.enabled() ? 'Levelled volume' : 'Original volume'}`, '<path d="m9 5 10 7-10 7zM4 5v14"/>'], ['playback', 'Playback & sound', 'Listening', () => `${data.settings.quality || 'auto'} quality · ${Level.enabled() ? 'Levelled volume' : 'Original volume'}`, '<path d="m9 5 10 7-10 7zM4 5v14"/>'],
['jamesdsp', 'Audio effects (JamesDSP)', 'Listening', () => data.settings.jamesDsp?.enabled ? `On · ${data.settings.jamesDsp.preset || 'Flat'}` : 'Off', '<path d="M4 12h3l2-7 4 14 2-7h5"/>'],
['sharing', 'Sharing', 'Library & storage', () => data.settings.p2pShare !== false ? 'Sharing saved videos' : 'Sharing off', '<circle cx="6" cy="12" r="2"/><circle cx="18" cy="5" r="2"/><circle cx="18" cy="19" r="2"/><path d="m8 11 8-5M8 13l8 5"/>'], ['sharing', 'Sharing', 'Library & storage', () => data.settings.p2pShare !== false ? 'Sharing saved videos' : 'Sharing off', '<circle cx="6" cy="12" r="2"/><circle cx="18" cy="5" r="2"/><circle cx="18" cy="19" r="2"/><path d="m8 11 8-5M8 13l8 5"/>'],
['offline', 'Offline storage', 'Library & storage', () => `${SaveSlots.limit ? SaveSlots.limit() : Number(data.settings.parallelSaves) || 4} at once · ${$('cacheTotal')?.textContent || 'Checking storage'}`, '<path d="M12 3v12m-4-4 4 4 4-4M4 16v5h16v-5"/>'], ['offline', 'Offline storage', 'Library & storage', () => `${SaveSlots.limit ? SaveSlots.limit() : Number(data.settings.parallelSaves) || 4} at once · ${$('cacheTotal')?.textContent || 'Checking storage'}`, '<path d="M12 3v12m-4-4 4 4 4-4M4 16v5h16v-5"/>'],
['searches', 'Saved searches', 'Library & storage', () => `${SearchLibrary.list().length} saved searches`, '<circle cx="10" cy="10" r="6"/><path d="m15 15 6 6"/>'], ['searches', 'Saved searches', 'Library & storage', () => `${SearchLibrary.list().length} saved searches`, '<circle cx="10" cy="10" r="6"/><path d="m15 15 6 6"/>'],
@@ -9914,6 +9958,12 @@ async function renderSettings() {
refresh(); refresh();
} }
$('setJamesDsp').addEventListener('change', (e) => { JamesDSP.toggle(e.target.checked).catch(() => {}); });
$('setJamesDspPreset').addEventListener('change', async (e) => { data.settings.jamesDsp ||= { enabled: false, params: {} }; data.settings.jamesDsp.preset = e.target.value; persist(); try { await JamesDSP.apply(); } catch {} });
const dspParam = (id, name, parse = Number) => $(id).addEventListener('input', async (e) => { data.settings.jamesDsp ||= { enabled: false, preset: 'Flat', params: {} }; const value = parse(e.target.value); data.settings.jamesDsp.params ||= {}; data.settings.jamesDsp.params[name] = value; persist(); try { if (JamesDSP.controller) await JamesDSP.controller.update(name, value, data.settings.jamesDsp); } catch (error) { JamesDSP.fail(error); } });
dspParam('setDspEq', 'eq.band0'); dspParam('setDspBass', 'bass.boost'); dspParam('setDspLimiter', 'limiter.threshold');
$('setDspCrossfeed').addEventListener('change', async (e) => { const s = data.settings.jamesDsp ||= { enabled: false, preset: 'Flat', params: {} }; s.params ||= {}; s.params['crossfeed.enabled'] = Number(e.target.checked); persist(); try { if (JamesDSP.controller) await JamesDSP.controller.update('crossfeed.enabled', Number(e.target.checked), s); } catch (error) { JamesDSP.fail(error); } });
// ---- Wire playback controls ---- // ---- Wire playback controls ----
$('setQuality').addEventListener('change', (e) => { $('setQuality').addEventListener('change', (e) => {
data.settings.quality = e.target.value; data.settings.quality = e.target.value;

View File

@@ -129,8 +129,8 @@
<!-- Player pane --> <!-- Player pane -->
<section id="playerPane" class="player-pane empty"> <section id="playerPane" class="player-pane empty">
<div class="player-stage"> <div class="player-stage">
<video id="video" playsinline></video> <video crossorigin="anonymous" id="video" playsinline></video>
<audio id="audio"></audio> <audio id="audio" crossorigin="anonymous"></audio>
<!-- Lyrics shown in place of the video (watch party / sing-along) --> <!-- Lyrics shown in place of the video (watch party / sing-along) -->
<div id="stageLyrics" class="stage-lyrics hidden"> <div id="stageLyrics" class="stage-lyrics hidden">
<div id="stageLyricsList" class="sl-list"></div> <div id="stageLyricsList" class="sl-list"></div>

View File

@@ -96,6 +96,12 @@ const SHELL = [
'/p2p-transfer.js', '/p2p-transfer.js',
'/p2p-recv-worker.js', '/p2p-recv-worker.js',
'/settings-sections.js', '/settings-sections.js',
'/vendor/jamesdsp/controller.js',
'/vendor/jamesdsp/jamesdsp-node.js',
'/vendor/jamesdsp/worklet.js',
'/vendor/jamesdsp/params.js',
'/vendor/jamesdsp/dsp/jamesdsp.js',
'/vendor/jamesdsp/dsp/jamesdsp.wasm',
'/related-videos.js', '/related-videos.js',
'/related-videos.css', '/related-videos.css',
'/audio-continuity.js', '/audio-continuity.js',

1
frontend/vendor/jamesdsp/LICENSE.txt vendored Normal file
View 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
View 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);

File diff suppressed because one or more lines are too long

BIN
frontend/vendor/jamesdsp/dsp/jamesdsp.wasm vendored Executable file

Binary file not shown.

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

View File

@@ -0,0 +1,6 @@
const { defineConfig } = require('@playwright/test');
module.exports = defineConfig({
testDir: './tests', testMatch: /jamesdsp\.spec\.js/, timeout: 60000, workers: 1,
use: { baseURL: 'http://localhost:8088', browserName: 'chromium', serviceWorkers: 'block', viewport: { width: 1280, height: 900 } },
webServer: { command: 'npx serve frontend -l 8088 --no-clipboard', port: 8088, reuseExistingServer: !process.env.CI },
});

43
tests/jamesdsp.spec.js Normal file
View File

@@ -0,0 +1,43 @@
const { test, expect } = require('@playwright/test');
const { openClassic } = require('./helpers/classic-fixture');
function wavTone() {
const rate = 48000, frames = rate;
const b = Buffer.alloc(44 + frames * 2);
b.write('RIFF', 0); b.writeUInt32LE(b.length - 8, 4); b.write('WAVEfmt ', 8);
b.writeUInt32LE(16, 16); b.writeUInt16LE(1, 20); b.writeUInt16LE(1, 22);
b.writeUInt32LE(rate, 24); b.writeUInt32LE(rate * 2, 28); b.writeUInt16LE(2, 32); b.writeUInt16LE(16, 34);
b.write('data', 36); b.writeUInt32LE(frames * 2, 40);
for (let i = 0; i < frames; i++) b.writeInt16LE(Math.round(Math.sin(2 * Math.PI * 440 * i / rate) * 9000), 44 + i * 2);
return b;
}
test('JamesDSP stays lazy when off, plays a fixture tone when enabled, and persists settings', async ({ page }) => {
await page.route('**/tone.wav', route => route.fulfill({ contentType: 'audio/wav', body: wavTone() }));
await openClassic(page);
await page.evaluate(() => {
window.__audioContexts = 0;
const AC = window.AudioContext;
window.AudioContext = class extends AC { constructor(...args) { super(...args); window.__audioContexts++; } };
});
expect(await page.evaluate(() => window.__audioContexts)).toBe(0);
await page.locator('.nav-item[data-view="settings"]').click();
await page.locator('[data-section="jamesdsp"]').click();
await page.locator('#setJamesDsp').check();
await expect.poll(() => page.evaluate(() => window.__audioContexts)).toBe(1);
await page.evaluate(async () => {
const audio = document.getElementById('audio');
Player.mode = 'audio'; Player.master = audio; Player.secondary = null;
await JamesDSP.activate(audio);
audio.src = '/tone.wav'; audio.load(); await audio.play();
});
await expect.poll(() => page.locator('#audio').evaluate(a => a.currentTime)).toBeGreaterThan(0.1);
await page.waitForTimeout(600);
const reloaded = await page.context().newPage();
await reloaded.goto('/');
await reloaded.locator('.nav-item[data-view="settings"]').click();
await expect(reloaded.locator('[data-section="jamesdsp"]')).toBeVisible();
await reloaded.locator('[data-section="jamesdsp"]').click();
await expect(reloaded.locator('#setJamesDsp')).toBeChecked();
await expect(reloaded.locator('#setJamesDspPreset')).toHaveValue('Flat');
});