188 lines
7.7 KiB
JavaScript
188 lines
7.7 KiB
JavaScript
/* Lazy classic-script bridge for the optional JamesDSP ES module and worklet. */
|
|
(function (root) {
|
|
'use strict';
|
|
|
|
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, 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 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, liveHeaps, maxLiveHeaps, nodesCreated, nodesDestroyed, sampleRate: context?.sampleRate || null }; }
|
|
|
|
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 */ }
|
|
if (entry.live) { entry.live = false; liveHeaps = Math.max(0, liveHeaps - 1); nodesDestroyed++; }
|
|
}
|
|
}
|
|
|
|
function fail(error) {
|
|
desired = false;
|
|
active = 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 || {}) };
|
|
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);
|
|
await instance.applyPreset(params);
|
|
await instance.setParam('gain', params.gain);
|
|
if (desired) await instance.setBypass(false);
|
|
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'),
|
|
engineURL: root.Lazy.url('/vendor/jamesdsp/dsp/jamesdsp.mjs'),
|
|
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 (![44100, 48000].includes(context?.sampleRate)) throw new RangeError(`JamesDSP supports 44.1 or 48 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, instance.node);
|
|
active = true;
|
|
return true;
|
|
}).catch(error => { fail(error); throw error; });
|
|
}
|
|
|
|
function disable() {
|
|
desired = 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 setBypass(value) {
|
|
if (!nodeEntry) return Promise.resolve(false);
|
|
return nodeEntry.node.setBypass(Boolean(value));
|
|
}
|
|
|
|
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, analyser, get active() { return active; }, get desired() { return desired; }, state: snapshot };
|
|
}
|
|
|
|
const api = { PRESETS, createController };
|
|
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
|
else {
|
|
root.YT = root.YT || {};
|
|
root.YT.JamesDSPFeature = api;
|
|
}
|
|
})(typeof window !== 'undefined' ? window : globalThis);
|