Add the guarded JamesDSP audio controller

This commit is contained in:
Jonathan Sykes
2026-10-09 20:00:49 +08:00
parent aca50c1690
commit 27b32ff0c9
9 changed files with 404 additions and 11 deletions

View File

@@ -21,7 +21,7 @@ test('Phase 4 preserves the exact playback, continuity, tracking and queue bodie
{const start=app.indexOf("function startSleepTimer(minutes, { endOfSong = false } = {}) {");assert.ok(start>=0,"startSleepTimer");const body=app.slice(start,start+487);assert.equal(createHash('sha256').update(body).digest('hex'),"f0a93401ab90196096074cb607b28d52c5903e11dbdfb8ddb95114e143af2409","startSleepTimer body unchanged");}
{const start=app.indexOf("function cancelSleepTimer() {");assert.ok(start>=0,"cancelSleepTimer");const body=app.slice(start,start+253);assert.equal(createHash('sha256').update(body).digest('hex'),"cf396ab758a70eede6af7444539a82ca15af801a9a57024dd5542067e9fa771a","cancelSleepTimer body unchanged");}
{const start=app.indexOf("function finishSleep() {");assert.ok(start>=0,"finishSleep");const body=app.slice(start,start+662);assert.equal(createHash('sha256').update(body).digest('hex'),"7ee93bf760bad5f39f4644339963918c5b369b2acc554b8e561c1a1f070ac6fb","finishSleep body unchanged");}
{const start=app.indexOf("const EqRender = (() => {");assert.ok(start>=0,"EqRender");const body=app.slice(start,start+5912);assert.equal(createHash('sha256').update(body).digest('hex'),"f014dd41dafebc33f09e87be866074afd7a2443c5bb452103a435585353a828f","EqRender body unchanged");}
{const start=app.indexOf("const EqRender = (() => {");assert.ok(start>=0,"EqRender");const body=app.slice(start,start+5960);assert.match(body,/EQ\.isIOS\(\) && WEB && !data\.settings\?\.jamesDsp\?\.enabled/);const standardBody=body.replace(' && !data.settings?.jamesDsp?.enabled','').slice(0,5912);assert.equal(createHash('sha256').update(standardBody).digest('hex'),"f014dd41dafebc33f09e87be866074afd7a2443c5bb452103a435585353a828f","Standard EqRender body unchanged");}
{const start=app.indexOf("const Level = (() => {");assert.ok(start>=0,"Level");const body=app.slice(start,start+3892);assert.equal(createHash('sha256').update(body).digest('hex'),"e48ad7797384ac31bb8b864662fc2a546b04470b981042f016874ba750957226","Level body unchanged");}
});

View File

@@ -663,6 +663,7 @@ const DEFAULT_SETTINGS = {
fontScale: 'normal', // 'small' | 'normal' | 'large' | 'xl'
levelVolume: null, // loudness levelling; null = on (on iPhone: applied to saved songs' rendered copies)
liveAudioIOS: false, // iPhone: live Web Audio EQ/levelling for streamed songs (stops when the screen locks)
jamesDsp: { enabled: false, preset: 'flat', params: {} },
parallelSaves: 4, // offline saves running at once (Settings → Offline cache)
density: 'comfortable', // 'comfortable' | 'compact'
perfMode: false, // disable heavy visual effects for speed
@@ -672,7 +673,16 @@ const DEFAULT_SETTINGS = {
autoBackupIntervalDays: 7,
serviceVideo: false, // show the video panel inside service mode (lyric videos)
};
let data = { playlists: [], history: [], queue: [], customVideos: [], pinnedPlaylists: [], settings: { ...DEFAULT_SETTINGS }, resumePositions: {}, rememberPos: {}, playCount: {}, abMarkers: {}, bookmarks: {}, lyricOffsets: {}, stats: null, lastAutoBackup: 0, profile: null };
function normalizeJamesDspSettings(settings) {
const saved = settings?.jamesDsp && typeof settings.jamesDsp === 'object' ? settings.jamesDsp : {};
settings.jamesDsp = {
enabled: saved.enabled === true,
preset: typeof saved.preset === 'string' ? saved.preset : 'flat',
params: saved.params && typeof saved.params === 'object' && !Array.isArray(saved.params) ? { ...saved.params } : {},
};
return settings;
}
let data = { playlists: [], history: [], queue: [], customVideos: [], pinnedPlaylists: [], settings: normalizeJamesDspSettings({ ...DEFAULT_SETTINGS }), resumePositions: {}, rememberPos: {}, playCount: {}, abMarkers: {}, bookmarks: {}, lyricOffsets: {}, stats: null, lastAutoBackup: 0, profile: null };
let view = { type: 'search' }; // 'search'|'history'|'playlist'|'settings'|'queue'|'saved'|'downloads'|'channel'
let searchResults = [];
// Distinguishes "never searched" (hide the list pane, show the landing) from
@@ -695,6 +705,8 @@ const IS_IOS = /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platfo
let _currentEqUrl = null;
async function useEqRender(id) {
if (_currentEqUrl) { URL.revokeObjectURL(_currentEqUrl); _currentEqUrl = null; }
// JamesDSP is a live insert: never layer it over the Standard renderer copy.
if (data.settings?.jamesDsp?.enabled) return null;
if (typeof EqRender === 'undefined' || !WEB) return null;
try { _currentEqUrl = await EqRender.urlFor(id); } catch { _currentEqUrl = null; }
return _currentEqUrl;
@@ -905,7 +917,7 @@ function applyProfileData(name, payload, updatedAt) {
if (payload.bookmarks && typeof payload.bookmarks === 'object') data.bookmarks = payload.bookmarks;
if (payload.lyricOffsets && typeof payload.lyricOffsets === 'object') data.lyricOffsets = payload.lyricOffsets;
if (payload.stats && typeof payload.stats === 'object') data.stats = payload.stats;
if (payload.settings && typeof payload.settings === 'object') data.settings = { ...DEFAULT_SETTINGS, ...payload.settings };
if (payload.settings && typeof payload.settings === 'object') data.settings = normalizeJamesDspSettings({ ...DEFAULT_SETTINGS, ...payload.settings });
data.profile = { name, syncedAt: updatedAt || 0, protected: !!payload.__protected, kind: payload.__kind || null };
API.saveData(data).catch(() => {});
}
@@ -5334,7 +5346,7 @@ const Level = (() => {
// so it keeps going when locked. One render at a time, in the background.
const EqRender = (() => {
const DIR = 'eq-renders', KEY = 'ytpEqRenders', RATE = 32000, MAX_SECONDS = 12 * 60;
const active = () => EQ.isIOS() && WEB && !!window.OPFS && window.OPFS.isSupported() && typeof OfflineAudioContext !== 'undefined';
const active = () => EQ.isIOS() && WEB && !data.settings?.jamesDsp?.enabled && !!window.OPFS && window.OPFS.isSupported() && typeof OfflineAudioContext !== 'undefined';
const readMap = () => { try { return JSON.parse(localStorage.getItem(KEY) || '{}'); } catch { return {}; } };
const writeMap = (m) => { try { localStorage.setItem(KEY, JSON.stringify(m)); } catch { /* blocked */ } };
const queue = new Set();
@@ -5504,10 +5516,12 @@ const PiP = (() => {
const loadSoundFeatures=()=>Promise.all([Lazy.load('feature:eq-core'),Lazy.load('feature:vocal-reducer')]);
const EQ_BANDS = EqParameters.bands;
const EQ_PRESETS = EqParameters.presets;
const JamesDSPFeature = Lazy.proxy('feature:jamesdsp', ['createController'], 'JamesDSPFeature');
const EQ = (() => {
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
const AC = window.AudioContext || window.webkitAudioContext;
const st = { ctx: null, filters: null };
const st = { ctx: null, filters: null, levelDb: 0 };
let dspController = null, dspControllerPromise = null;
// Web Audio is off on iOS (it silences playback when the screen locks)
// unless the user opted in through "Level volume" (Settings → Playback).
// Live Web Audio is off on iOS (it silences playback when the screen locks)
@@ -5522,6 +5536,43 @@ const EQ = (() => {
};
const isFlat = () => gains().every((v) => v === 0);
async function prepareDspGraph() {
if (!available()) throw new Error(isIOS ? 'Turn on Live sound processing in Settings → Playback first.' : 'Web Audio is unavailable.');
if (rendered()) throw new Error('This song is already playing a Standard processed copy. JamesDSP will start with the next original track.');
if (!st.ctx && !AudioGraph.snapshot().contextCreated) {
const context = AudioGraph.context({ sampleRate: 48000 });
if (context.state === 'suspended') context.resume().catch(() => {});
}
await loadSoundFeatures();
if (!st.ctx && !build()) throw new Error('The Standard fallback audio chain is not ready.');
if (!st.ctx || !st.filters?.[0]) throw new Error('The Standard fallback audio chain could not be built.');
attach(els.video);
attach(els.audio);
return { context: st.ctx, elements: [els.video, els.audio].filter(Boolean), standardInput: st.filters[0] };
}
function dspFailed(error) {
if (!data.settings.jamesDsp?.enabled) return;
data.settings.jamesDsp.enabled = false;
persist();
toast(`JamesDSP unavailable; Standard sound is active. ${error?.message || ''}`.trim());
apply();
Level.apply();
}
async function getDspController() {
if (dspController) return dspController;
if (!dspControllerPromise) dspControllerPromise = Lazy.load('feature:jamesdsp').then(() => {
dspController = JamesDSPFeature.createController({ graph: AudioGraph, prepareGraph: prepareDspGraph, onError: dspFailed });
return dspController;
}).catch(error => { dspControllerPromise = null; throw error; });
return dspControllerPromise;
}
function startDsp() {
getDspController().then(controller => controller.enable(data.settings.jamesDsp, st.levelDb)).catch(dspFailed);
}
function build() {
if (st.ctx) return true;
if (!Lazy.loaded('feature:eq-core') || !Lazy.loaded('feature:vocal-reducer')) {
@@ -5562,6 +5613,14 @@ const EQ = (() => {
// Apply the stored curve; wires the graph up the first time it's non-flat.
function apply() {
if (data.settings.jamesDsp?.enabled) {
if (!available()) { dspFailed(new Error(isIOS ? 'Live sound processing is off on this iPhone.' : 'Web Audio is unavailable.')); return; }
if (rendered()) { dspFailed(new Error('The current song already uses a Standard processed copy.')); return; }
startDsp();
renderButton();
return;
}
if (dspController) dspController.disable().catch(() => {});
if (!available()) return;
if (!st.ctx && isFlat() && EqParameters.amount(data.settings.vocalLevel ?? 100) === 100 && EqParameters.width(data.settings.stereoWidth) === 0) return;
if (!build()) return;
@@ -5641,6 +5700,7 @@ const EQ = (() => {
// one-way step the EQ takes, so it is only offered where the EQ is (never
// on iOS, where Web Audio would silence background playback).
function analyser() {
if (dspController?.active) return dspController.analyser();
if (!available() || !build()) return null;
attach(els.video);
attach(els.audio);
@@ -5656,8 +5716,13 @@ const EQ = (() => {
// Set the levelling gain (dB). 0 dB never wires the graph on its own.
function setLevel(db) {
if (!available()) return false;
if (rendered()) db = 0;
st.levelDb = Number(db) || 0;
if (data.settings.jamesDsp?.enabled) {
if (dspController?.desired) dspController.setLevelDb(st.levelDb).catch(dspFailed);
return true;
}
if (!available()) return false;
if (!st.ctx && Math.abs(db) < 0.3) return true;
if (!build()) return false;
attach(els.video);
@@ -8966,7 +9031,7 @@ async function boot() {
lyricOffsets: loaded.lyricOffsets || {},
stats: loaded.stats || null,
lastAutoBackup: loaded.lastAutoBackup || 0,
settings: { ...DEFAULT_SETTINGS, ...(loaded.settings || {}) },
settings: normalizeJamesDspSettings({ ...DEFAULT_SETTINGS, ...(loaded.settings || {}) }),
profile: loaded.profile || null,
};
}

View File

@@ -366,11 +366,16 @@
"eager": false,
"background": true,
"files": [
"/vendor/jamesdsp/jamesdsp-feature.js",
"/vendor/jamesdsp/jamesdsp-node.mjs",
"/vendor/jamesdsp/params.mjs",
"/vendor/jamesdsp/worklet.mjs",
"/vendor/jamesdsp/dsp/jamesdsp.mjs",
"/vendor/jamesdsp/dsp/jamesdsp.wasm"
],
"dependencies": [
"feature:eq-core",
"feature:vocal-reducer"
]
}
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,30 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { readFileSync } = require('node:fs');
const { join } = require('node:path');
const app = readFileSync(join(__dirname, 'app.js'), 'utf8');
test('the persisted engine is Standard by default and upgrades missing JamesDSP settings safely', () => {
assert.match(app, /jamesDsp: \{ enabled: false, preset: 'flat', params: \{\} \}/);
assert.match(app, /enabled: saved\.enabled === true/);
assert.match(app, /preset: typeof saved\.preset === 'string' \? saved\.preset : 'flat'/);
assert.match(app, /settings: normalizeJamesDspSettings\(\{ \.\.\.DEFAULT_SETTINGS, \.\.\.\(loaded\.settings \|\| \{\}\) \}\)/);
assert.match(app, /data\.settings = normalizeJamesDspSettings\(\{ \.\.\.DEFAULT_SETTINGS, \.\.\.payload\.settings \}\)/);
});
test('live JamesDSP shares the existing iOS gate and never stacks over EqRender copies', () => {
assert.match(app, /const available = \(\) => !!AC && \(!isIOS \|\| data\.settings\.liveAudioIOS === true\)/);
assert.match(app, /if \(data\.settings\?\.jamesDsp\?\.enabled\) return null/);
assert.match(app, /const active = \(\) => EQ\.isIOS\(\) && WEB && !data\.settings\?\.jamesDsp\?\.enabled/);
assert.match(app, /if \(rendered\(\)\) throw new Error\('This song is already playing a Standard processed copy\./);
});
test('EQ creates no second context or element source and passes Standard fallback routing to the controller', () => {
assert.equal((app.match(/\.createMediaElementSource\(/g) || []).length, 0);
assert.match(app, /st\.ctx = AudioGraph\.context\(\)/);
assert.match(app, /AudioGraph\.connect\(el, st\.filters\[0\]\)/);
assert.match(app, /standardInput: st\.filters\[0\]/);
assert.match(app, /dspController\.setLevelDb\(st\.levelDb\)/);
assert.match(app, /if \(data\.settings\.jamesDsp\?\.enabled\) \{[\s\S]{0,450}startDsp\(\)/);
});

View File

@@ -12,7 +12,9 @@ test('JamesDSP runtime files live only in a lazy background group', () => {
assert.equal(group.contract, 1);
assert.equal(group.eager, false);
assert.equal(group.background, true);
assert.deepEqual(group.dependencies, ['feature:eq-core', 'feature:vocal-reducer']);
assert.deepEqual(group.files, [
'/vendor/jamesdsp/jamesdsp-feature.js',
'/vendor/jamesdsp/jamesdsp-node.mjs',
'/vendor/jamesdsp/params.mjs',
'/vendor/jamesdsp/worklet.mjs',
@@ -33,6 +35,9 @@ test('JamesDSP resolves every module dependency through the page build manifest'
assert.match(node, /workletURL\.hash=new URLSearchParams/);
assert.match(worklet, /new URLSearchParams\(new URL\(import\.meta\.url\)\.hash\.slice\(1\)\)/);
assert.match(worklet, /await import\(engineURL\)/);
const feature = readFileSync(join(root, 'vendor/jamesdsp/jamesdsp-feature.js'), 'utf8');
assert.match(feature, /Lazy\.url\('\/vendor\/jamesdsp\/jamesdsp-node\.mjs'\)/);
assert.match(feature, /Lazy\.url\('\/vendor\/jamesdsp\/dsp\/jamesdsp\.wasm'\)/);
});
test('v1 wrapper rejects text effects and omits them from portable presets', () => {

View File

@@ -0,0 +1,98 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { createController } = require('./vendor/jamesdsp/jamesdsp-feature.js');
function fixture({ loadNode } = {}) {
const routed = [], errors = [], created = [], configured = [], bypass = [];
const graph = { routeAll: (elements, destination) => routed.push([elements.map(x => x.id), destination]) };
const gainParam = { value: 1, cancelScheduledValues() {}, setValueAtTime(v) { this.value = v; }, linearRampToValueAtTime(v) { this.value = v; } };
const context = { sampleRate: 48000, state: 'running', currentTime: 0, destination: 'destination', createGain: () => ({ gain: gainParam, connect() {}, disconnect() {} }), resume: async () => {} };
const standardInput = 'standard-input', elements = [{ id: 'video' }, { id: 'audio' }];
const prepareGraph = async () => ({ context, standardInput, elements });
const defaultLoad = async () => {
const listeners = new Map();
const instance = {
node: { connect() {}, disconnect() {} }, destroyed: false,
addEventListener(type, fn) { listeners.set(type, fn); },
removeEventListener(type, fn) { if (listeners.get(type) === fn) listeners.delete(type); },
async setBypass(v) { bypass.push(v); },
async applyPreset(params) { configured.push({ ...params }); },
async setParam(name, value) { configured.push({ [name]: value }); },
destroy() { this.destroyed = true; },
fail(error) { listeners.get('error')?.({ detail: error }); },
};
created.push(instance); return instance;
};
const controller = createController({ graph, prepareGraph, loadNode: loadNode || defaultLoad, onError: error => errors.push(error) });
return { controller, routed, errors, created, configured, bypass, context, elements, standardInput };
}
test('enable routes one initialized JamesDSP node and disable restores Standard before destroying it', async () => {
const f = fixture();
assert.equal(await f.controller.enable({ preset: 'bass', params: { gain: -2 } }, 3), true);
assert.equal(f.controller.active, true);
assert.deepEqual(f.routed, [[['video', 'audio'], f.created[0].node]]);
assert.equal(f.created.length, 1);
assert.equal(f.controller.state().liveHeaps, 1);
assert.equal(f.controller.state().maxLiveHeaps, 1);
const standard = f.controller.disable();
assert.deepEqual(f.routed.at(-1), [['video', 'audio'], f.standardInput]);
await standard;
assert.equal(f.created[0].destroyed, true);
assert.equal(f.controller.state().liveHeaps, 0);
assert.equal(f.controller.state().nodesDestroyed, 1);
assert.equal(f.errors.length, 0);
});
test('a rejected or unsupported engine leaves the Standard route active', async () => {
const f = fixture({ loadNode: async () => { throw new Error('corrupt wasm'); } });
await assert.rejects(f.controller.enable({ preset: 'flat' }), /corrupt wasm/);
assert.deepEqual(f.routed, [[['video', 'audio'], f.standardInput]]);
assert.equal(f.controller.active, false);
assert.equal(f.controller.state().liveHeaps, 0);
assert.equal(f.errors.length, 1);
const wrongRate = fixture();
wrongRate.context.sampleRate = 32000;
await assert.rejects(wrongRate.controller.enable({ preset: 'flat' }), /44\.1 or 48 kHz/);
assert.equal(wrongRate.created.length, 0);
assert.equal(wrongRate.controller.state().liveHeaps, 0);
});
test('a runtime worklet failure switches sources to Standard before destroying the heap', async () => {
const f = fixture();
await f.controller.enable({ preset: 'flat' });
const instance = f.created[0];
instance.fail(new Error('processor fault'));
assert.deepEqual(f.routed.at(-1), [['video', 'audio'], f.standardInput]);
assert.equal(instance.destroyed, true);
assert.equal(f.controller.active, false);
assert.equal(f.controller.state().liveHeaps, 0);
assert.match(f.errors[0].message, /processor fault/);
});
test('disable during async node loading prevents a late DSP route', async () => {
let finish;
const f = fixture({ loadNode: () => new Promise(resolve => { finish = resolve; }) });
const enabling = f.controller.enable({ preset: 'flat' });
await new Promise(resolve => setImmediate(resolve));
const disabling = f.controller.disable();
const neverRouted = {
node: { connect() {}, disconnect() {} },
addEventListener() {}, removeEventListener() {}, setBypass: async () => {}, applyPreset: async () => {}, setParam: async () => {}, destroy() { this.destroyed = true; },
};
finish(neverRouted);
await enabling; await disabling;
assert.equal(f.routed.some(([, destination]) => destination === neverRouted.node), false);
assert.equal(neverRouted.destroyed, true);
assert.equal(f.controller.state().maxLiveHeaps, 0);
});
test('preset gain and Level gain combine within engine bounds without pausing playback', async () => {
const f = fixture();
await f.controller.enable({ preset: 'loudness', params: { gain: 8 } }, 9);
assert.ok(f.configured.some(entry => entry.gain === 12));
assert.equal(f.created.length, 1);
assert.equal(f.controller.state().maxLiveHeaps, 1);
assert.equal(f.controller.active, true);
});

View File

@@ -6,9 +6,10 @@ const source = fs.readFileSync(__dirname + '/app.js', 'utf8');
// Exercise the production persistence/profile functions without starting a DOM player.
const declaration = name => source.match(new RegExp('(?:async )?function ' + name + '\\([^]*?\\n}'))[0];
function fixture(updatedAt) {
const settings = { saveBeforePlay: true, autoPreload: true, layout: 'f7-swipe', eq: { preset: 'custom', g: [1,2,3,4,5] } };
const context = vm.createContext({ WEB: true, data: { settings, profile: { name: 'test', syncedAt: 200 } }, DEFAULT_SETTINGS: { saveBeforePlay: false, autoPreload: true, layout: 'classic' }, API: { saveData: async () => {} }, profileHeaders: () => ({}), scheduleProfilePush: () => context.pushes++, pushes: 0, fetch: async () => ({ status: 200, json: async () => ({ ok: true, name: 'test', updatedAt, data: { settings: { saveBeforePlay: false } } }) }), localStorage: { getItem: () => JSON.stringify({ settings }) } });
vm.runInContext([declaration('loadDataFromStorage'), declaration('applyProfileData'), declaration('pullProfileIfNewer')].join('\n'), context);
const settings = { saveBeforePlay: true, autoPreload: true, layout: 'f7-swipe', eq: { preset: 'custom', g: [1,2,3,4,5] }, jamesDsp: { enabled: true, preset: 'warm', params: { 'eq.band0': 3 } } };
const defaults = { saveBeforePlay: false, autoPreload: true, layout: 'classic', jamesDsp: { enabled: false, preset: 'flat', params: {} } };
const context = vm.createContext({ WEB: true, data: { settings, profile: { name: 'test', syncedAt: 200 } }, DEFAULT_SETTINGS: defaults, API: { saveData: async () => {} }, profileHeaders: () => ({}), scheduleProfilePush: () => context.pushes++, pushes: 0, fetch: async () => ({ status: 200, json: async () => ({ ok: true, name: 'test', updatedAt, data: { settings: { saveBeforePlay: false } } }) }), localStorage: { getItem: () => JSON.stringify({ settings }) } });
vm.runInContext([declaration('loadDataFromStorage'), declaration('normalizeJamesDspSettings'), declaration('applyProfileData'), declaration('pullProfileIfNewer')].join('\n'), context);
return context;
}
test('stored settings load verbatim instead of reverting to defaults', () => {
@@ -17,6 +18,7 @@ test('stored settings load verbatim instead of reverting to defaults', () => {
assert.equal(loaded.settings.saveBeforePlay, true);
assert.equal(loaded.settings.layout, 'f7-swipe');
assert.deepEqual(JSON.parse(JSON.stringify(loaded.settings.eq.g)), [1,2,3,4,5]);
assert.deepEqual(JSON.parse(JSON.stringify(loaded.settings.jamesDsp)), { enabled: true, preset: 'warm', params: { 'eq.band0': 3 } });
});
for (const updatedAt of [100,200]) test(`profile timestamp ${updatedAt} cannot overwrite local settings synced at 200`, async () => {
const c = fixture(updatedAt), previous = c.data.settings;
@@ -30,5 +32,6 @@ test('newer profile intentionally replaces settings and restores missing default
assert.equal(c.data.settings.saveBeforePlay, false);
assert.equal(c.data.settings.autoPreload, true);
assert.equal(c.data.settings.layout, 'classic');
assert.deepEqual(JSON.parse(JSON.stringify(c.data.settings.jamesDsp)), { enabled: false, preset: 'flat', params: {} });
assert.equal(c.data.profile.syncedAt, 300);
});

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