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