Files
ytplayer/frontend/vendor/jamesdsp/jamesdsp-feature.js
2026-10-09 20:00:49 +08:00

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