247 lines
11 KiB
JavaScript
247 lines
11 KiB
JavaScript
/* Lazy classic-script bridge for the optional JamesDSP ES module and worklet. */
|
||
(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 } },
|
||
vocal: { name: 'Vocal', params: { 'eq.enabled': 1, 'eq.band5': 2, 'eq.band7': 3, 'eq.band9': 2, 'compressor.enabled': 1 } },
|
||
loudness: { name: 'Loudness', params: { 'limiter.threshold': -3, gain: 2 } },
|
||
headphone: { name: 'Headphone', params: { 'crossfeed.enabled': 1, 'crossfeed.mode': 1 } },
|
||
bright: { name: 'Bright', params: { 'eq.enabled': 1, 'eq.band9': 2, 'eq.band10': 3, 'eq.band11': 3, 'eq.band12': 2 } },
|
||
warm: { name: 'Warm', params: { 'eq.enabled': 1, 'eq.band0': 3, 'eq.band1': 2, 'eq.band7': -1, gain: -2 } },
|
||
});
|
||
|
||
const clampGain = value => Math.max(-24, Math.min(12, Number(value) || 0));
|
||
|
||
function createController(options) {
|
||
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, loadedIRRef = null;
|
||
let faultListener = null, notifying = false, dspAnalyser = null;
|
||
|
||
const serial = fn => {
|
||
const result = tail.then(fn, fn);
|
||
tail = result.catch(() => {});
|
||
return result;
|
||
};
|
||
|
||
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;
|
||
try {
|
||
graph.routeAll(elements, standardInput || context?.destination);
|
||
} catch (error) {
|
||
try { if (context?.destination) graph.routeAll(elements, context.destination); } catch { /* direct graph route is the last audible fallback */ }
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
function releaseNode() {
|
||
const entry = nodeEntry;
|
||
nodeEntry = null;
|
||
if (faultListener && entry) entry.node.removeEventListener('error', faultListener);
|
||
faultListener = null;
|
||
try { entry?.node.node.disconnect(); } catch { /* already disconnected */ }
|
||
try { gainNode?.disconnect(); } catch { /* already disconnected */ }
|
||
gainNode = null;
|
||
try { dspAnalyser?.disconnect(); } catch { /* already disconnected */ }
|
||
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++; }
|
||
}
|
||
}
|
||
|
||
function fail(error) {
|
||
desired = false;
|
||
active = false;
|
||
bypassed = false;
|
||
try { routeStandard(); } catch { /* graph route has already tried a direct audible fallback */ }
|
||
releaseNode();
|
||
if (!notifying) {
|
||
notifying = true;
|
||
try { options.onError?.(error instanceof Error ? error : new Error(String(error || 'JamesDSP failed'))); }
|
||
finally { notifying = false; }
|
||
}
|
||
}
|
||
|
||
function rampGain(value) {
|
||
if (!gainNode || !context) return;
|
||
const param = gainNode.gain;
|
||
const now = context.currentTime || 0;
|
||
if (context.state !== 'running' || typeof param.linearRampToValueAtTime !== 'function') {
|
||
param.value = value;
|
||
return;
|
||
}
|
||
param.cancelScheduledValues?.(now);
|
||
param.setValueAtTime?.(param.value, now);
|
||
param.linearRampToValueAtTime(value, now + 0.018);
|
||
}
|
||
|
||
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') {
|
||
rampGain(0);
|
||
await new Promise(resolve => setTimeout(resolve, 20));
|
||
}
|
||
await instance.setBypass(true);
|
||
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);
|
||
}
|
||
|
||
async function loadNode(audioContext) {
|
||
const moduleURL = root.Lazy.url('/vendor/jamesdsp/jamesdsp-node.mjs');
|
||
const { JamesDSPNode } = await import(moduleURL);
|
||
return JamesDSPNode.create(audioContext, {
|
||
wasmURL: root.Lazy.url('/vendor/jamesdsp/dsp/jamesdsp.wasm'),
|
||
paramsURL: root.Lazy.url('/vendor/jamesdsp/params.mjs'),
|
||
workletURL: root.Lazy.url('/vendor/jamesdsp/worklet.mjs'),
|
||
});
|
||
}
|
||
|
||
async function enable(nextSettings, nextLevelDb = levelDb) {
|
||
desired = true;
|
||
settings = nextSettings || {};
|
||
levelDb = Number(nextLevelDb) || 0;
|
||
return serial(async () => {
|
||
if (!desired) return false;
|
||
const prepared = await options.prepareGraph();
|
||
context = prepared.context;
|
||
elements = prepared.elements;
|
||
standardInput = prepared.standardInput;
|
||
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) {
|
||
instance = await (options.loadNode || loadNode)(context);
|
||
if (!desired) { try { instance.destroy(); } catch {} return false; }
|
||
nodeEntry = { node: instance, live: true };
|
||
liveHeaps++; nodesCreated++; maxLiveHeaps = Math.max(maxLiveHeaps, liveHeaps);
|
||
faultListener = event => fail(event.detail || new Error('JamesDSP processing failed'));
|
||
instance.addEventListener('error', faultListener);
|
||
gainNode = context.createGain();
|
||
gainNode.gain.value = 1;
|
||
instance.node.connect(gainNode);
|
||
gainNode.connect(context.destination);
|
||
}
|
||
if (!desired) { releaseNode(); return false; }
|
||
await configure(instance, settings, levelDb);
|
||
if (!desired) { releaseNode(); return false; }
|
||
if (context.state === 'suspended') await context.resume().catch(() => {});
|
||
graph.routeAll(elements, bypassed ? standardInput : instance.node);
|
||
active = true;
|
||
return true;
|
||
}).catch(error => { fail(error); throw error; });
|
||
}
|
||
|
||
function disable() {
|
||
desired = false;
|
||
bypassed = false;
|
||
if (active) {
|
||
active = false;
|
||
try { routeStandard(); } catch { /* routeStandard tried its direct fallback */ }
|
||
}
|
||
return serial(async () => { releaseNode(); return true; });
|
||
}
|
||
|
||
function setLevelDb(value) {
|
||
levelDb = Number(value) || 0;
|
||
if (!desired) return Promise.resolve(false);
|
||
return enable(settings, levelDb);
|
||
}
|
||
|
||
function changeBypass(value) {
|
||
if (!nodeEntry) return Promise.resolve(false);
|
||
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 setBypass(value) { return changeBypass(value); }
|
||
function toggleBypass() { return changeBypass(!bypassed); }
|
||
|
||
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, 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; });
|
||
}
|
||
|
||
function analyser() {
|
||
if (!active || !gainNode || !context) return null;
|
||
if (!dspAnalyser) {
|
||
dspAnalyser = context.createAnalyser();
|
||
dspAnalyser.fftSize = 128;
|
||
dspAnalyser.smoothingTimeConstant = 0.78;
|
||
gainNode.disconnect();
|
||
gainNode.connect(dspAnalyser);
|
||
dspAnalyser.connect(context.destination);
|
||
}
|
||
return dspAnalyser;
|
||
}
|
||
|
||
return { enable, disable, setLevelDb, setBypass, toggleBypass, loadIR, analyser, get active() { return active; }, get desired() { return desired; }, state: snapshot };
|
||
}
|
||
|
||
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 || {};
|
||
root.YT.JamesDSPFeature = api;
|
||
}
|
||
})(typeof window !== 'undefined' ? window : globalThis);
|