Add the guarded JamesDSP audio controller
This commit is contained in:
187
frontend/vendor/jamesdsp/jamesdsp-feature.js
vendored
Normal file
187
frontend/vendor/jamesdsp/jamesdsp-feature.js
vendored
Normal file
@@ -0,0 +1,187 @@
|
||||
/* 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);
|
||||
Reference in New Issue
Block a user