diff --git a/frontend/app-seams.test.js b/frontend/app-seams.test.js
index 5e0311a..9655d9e 100644
--- a/frontend/app-seams.test.js
+++ b/frontend/app-seams.test.js
@@ -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");}
});
diff --git a/frontend/app.js b/frontend/app.js
index 087b1c0..e86f3f2 100755
--- a/frontend/app.js
+++ b/frontend/app.js
@@ -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,
};
}
diff --git a/frontend/assets.json b/frontend/assets.json
index 5f6f556..92512f9 100644
--- a/frontend/assets.json
+++ b/frontend/assets.json
@@ -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"
]
}
}
diff --git a/frontend/index.html b/frontend/index.html
index 9d42fe6..eeb4f13 100755
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -19,7 +19,7 @@
-
+
diff --git a/frontend/jamesdsp-app.test.js b/frontend/jamesdsp-app.test.js
new file mode 100644
index 0000000..d7ca822
--- /dev/null
+++ b/frontend/jamesdsp-app.test.js
@@ -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\(\)/);
+});
diff --git a/frontend/jamesdsp-assets.test.js b/frontend/jamesdsp-assets.test.js
index e89c873..bc2dc36 100644
--- a/frontend/jamesdsp-assets.test.js
+++ b/frontend/jamesdsp-assets.test.js
@@ -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', () => {
diff --git a/frontend/jamesdsp-controller.test.js b/frontend/jamesdsp-controller.test.js
new file mode 100644
index 0000000..f5308b0
--- /dev/null
+++ b/frontend/jamesdsp-controller.test.js
@@ -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);
+});
diff --git a/frontend/settings-persist.test.js b/frontend/settings-persist.test.js
index 8b862c1..9ed8f6f 100644
--- a/frontend/settings-persist.test.js
+++ b/frontend/settings-persist.test.js
@@ -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);
});
diff --git a/frontend/vendor/jamesdsp/jamesdsp-feature.js b/frontend/vendor/jamesdsp/jamesdsp-feature.js
new file mode 100644
index 0000000..27e2d5e
--- /dev/null
+++ b/frontend/vendor/jamesdsp/jamesdsp-feature.js
@@ -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);