Extend JamesDSP with convolution and spatial audio
This commit is contained in:
28
frontend/vendor/jamesdsp/README.md
vendored
28
frontend/vendor/jamesdsp/README.md
vendored
@@ -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.
|
||||
|
||||
27
frontend/vendor/jamesdsp/VERSION.txt
vendored
27
frontend/vendor/jamesdsp/VERSION.txt
vendored
@@ -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.
|
||||
|
||||
File diff suppressed because one or more lines are too long
BIN
frontend/vendor/jamesdsp/dsp/jamesdsp.wasm
vendored
BIN
frontend/vendor/jamesdsp/dsp/jamesdsp.wasm
vendored
Binary file not shown.
22
frontend/vendor/jamesdsp/ir-builtins.mjs
vendored
Normal file
22
frontend/vendor/jamesdsp/ir-builtins.mjs
vendored
Normal 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
24
frontend/vendor/jamesdsp/ir-store.mjs
vendored
Normal 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
43
frontend/vendor/jamesdsp/ir-utils.mjs
vendored
Normal 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;
|
||||
}
|
||||
70
frontend/vendor/jamesdsp/jamesdsp-feature.js
vendored
70
frontend/vendor/jamesdsp/jamesdsp-feature.js
vendored
@@ -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 || {};
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
6
frontend/vendor/jamesdsp/params.mjs
vendored
6
frontend/vendor/jamesdsp/params.mjs
vendored
@@ -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},
|
||||
};
|
||||
|
||||
9
frontend/vendor/jamesdsp/worklet.mjs
vendored
9
frontend/vendor/jamesdsp/worklet.mjs
vendored
@@ -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});
|
||||
|
||||
Reference in New Issue
Block a user