From 6bb3271431272f09595613022601d291a8e04521 Mon Sep 17 00:00:00 2001 From: Jonathan Sykes Date: Fri, 9 Oct 2026 20:29:37 +0800 Subject: [PATCH] Add JamesDSP playback controls and verification harness --- frontend/app.js | 52 +++++++- frontend/assets.json | 2 +- frontend/index.html | 2 +- frontend/jamesdsp-app.test.js | 29 ++++ frontend/jamesdsp-assets.test.js | 8 +- frontend/jamesdsp-controller.test.js | 11 ++ frontend/styles.css | 6 + .../{jamesdsp.mjs => jamesdsp-77be0b2.mjs} | 0 frontend/vendor/jamesdsp/jamesdsp-feature.js | 29 +++- frontend/vendor/jamesdsp/jamesdsp-node.mjs | 11 +- frontend/vendor/jamesdsp/worklet.mjs | 6 +- frontend/views-core.js | 115 ++++++++++++++++ perf/README.md | 5 + perf/dsp.mjs | 126 ++++++++++++++++++ perf/settings-persist.mjs | 3 + 15 files changed, 376 insertions(+), 29 deletions(-) rename frontend/vendor/jamesdsp/dsp/{jamesdsp.mjs => jamesdsp-77be0b2.mjs} (100%) create mode 100644 perf/dsp.mjs diff --git a/frontend/app.js b/frontend/app.js index e86f3f2..808bd92 100755 --- a/frontend/app.js +++ b/frontend/app.js @@ -675,10 +675,14 @@ const DEFAULT_SETTINGS = { }; function normalizeJamesDspSettings(settings) { const saved = settings?.jamesDsp && typeof settings.jamesDsp === 'object' ? settings.jamesDsp : {}; + const params = saved.params && typeof saved.params === 'object' && !Array.isArray(saved.params) ? { ...saved.params } : {}; + // Impulse-response PCM is intentionally session-only; do not re-enable the + // convolver on a later boot without its uploaded audio buffer. + delete params['convolver.enabled']; 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 } : {}, + params, }; return settings; } @@ -3346,6 +3350,20 @@ function openMoreActions() { row.onclick = () => { closeModal(); setTimeout(() => src.click(), 0); }; list.appendChild(row); } + if (data.settings?.jamesDsp?.enabled && EQ.state().jamesDsp?.active) { + const row = document.createElement('button'); + row.type = 'button'; row.className = 'more-row'; + const bypassed = !!EQ.state().jamesDsp?.bypassed; + row.innerHTML = 'Temporarily compare with the unprocessed signal›'; + row.querySelector('b').textContent = bypassed ? 'Resume JamesDSP' : 'Bypass JamesDSP'; + row.setAttribute('aria-label', row.querySelector('b').textContent); + row.onclick = async () => { + closeModal(); + const next = await EQ.toggleDspBypass(); + toast(next ? 'JamesDSP bypassed' : 'JamesDSP active'); + }; + list.appendChild(row); + } if (!list.children.length) { toast('No more actions for this video'); return; } showModal('More actions', list, [{ label: 'Close', onClick: closeModal }]); } @@ -5521,7 +5539,7 @@ 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, levelDb: 0 }; - let dspController = null, dspControllerPromise = null; + let dspController = null, dspControllerPromise = null, dspRequest = 0; // 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) @@ -5554,6 +5572,8 @@ const EQ = (() => { function dspFailed(error) { if (!data.settings.jamesDsp?.enabled) return; data.settings.jamesDsp.enabled = false; + if ($('setSoundEngine')) $('setSoundEngine').value = 'standard'; + if ($('jamesDspControls')) $('jamesDspControls').hidden = true; persist(); toast(`JamesDSP unavailable; Standard sound is active. ${error?.message || ''}`.trim()); apply(); @@ -5562,15 +5582,19 @@ const EQ = (() => { async function getDspController() { if (dspController) return dspController; - if (!dspControllerPromise) dspControllerPromise = Lazy.load('feature:jamesdsp').then(() => { - dspController = JamesDSPFeature.createController({ graph: AudioGraph, prepareGraph: prepareDspGraph, onError: dspFailed }); + if (!dspControllerPromise) dspControllerPromise = Lazy.load('feature:jamesdsp').then(async () => { + dspController = await 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); + const request = ++dspRequest; + getDspController().then(controller => { + if (request !== dspRequest || !data.settings.jamesDsp?.enabled) return controller.disable(); + return controller.enable(data.settings.jamesDsp, st.levelDb); + }).catch(dspFailed); } function build() { @@ -5620,7 +5644,9 @@ const EQ = (() => { renderButton(); return; } + dspRequest++; if (dspController) dspController.disable().catch(() => {}); + else if (dspControllerPromise) dspControllerPromise.then(controller => controller.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; @@ -5733,8 +5759,22 @@ const EQ = (() => { return true; } + async function toggleDspBypass() { + if (!dspController?.active) return false; + try { return await dspController.toggleBypass(); } + catch (error) { dspFailed(error); return false; } + } + + async function loadDspIR(file) { + if (!data.settings.jamesDsp?.enabled || !dspController?.active) throw new Error('Turn on JamesDSP before importing an impulse response.'); + const context = st.ctx; + if (!context?.decodeAudioData) throw new Error('Audio decoding is unavailable on this device.'); + const audioBuffer = await context.decodeAudioData(await file.arrayBuffer()); + return dspController.loadIR(audioBuffer); + } + return { open, apply, renderButton, available, analyser, setLevel, gains, bands: EQ_BANDS, isIOS: () => isIOS, state: () => ({ wired: !!st.ctx, ctx: st.ctx && st.ctx.state, gains: st.filters ? st.filters.map((f) => f.gain.value) : null, - levelDb: st.level ? Math.round(200 * Math.log10(Math.max(1e-6, st.level.gain.value))) / 10 : null }) }; + levelDb: st.level ? Math.round(200 * Math.log10(Math.max(1e-6, st.level.gain.value))) / 10 : null, jamesDsp: dspController?.state() || null }), toggleDspBypass, loadDspIR }; })(); // ============================================================================ diff --git a/frontend/assets.json b/frontend/assets.json index 92512f9..9a9f2de 100644 --- a/frontend/assets.json +++ b/frontend/assets.json @@ -370,7 +370,7 @@ "/vendor/jamesdsp/jamesdsp-node.mjs", "/vendor/jamesdsp/params.mjs", "/vendor/jamesdsp/worklet.mjs", - "/vendor/jamesdsp/dsp/jamesdsp.mjs", + "/vendor/jamesdsp/dsp/jamesdsp-77be0b2.mjs", "/vendor/jamesdsp/dsp/jamesdsp.wasm" ], "dependencies": [ diff --git a/frontend/index.html b/frontend/index.html index eeb4f13..c37b12b 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 index d7ca822..ab42629 100644 --- a/frontend/jamesdsp-app.test.js +++ b/frontend/jamesdsp-app.test.js @@ -2,8 +2,18 @@ const { test } = require('node:test'); const assert = require('node:assert/strict'); const { readFileSync } = require('node:fs'); const { join } = require('node:path'); +const vm = require('node:vm'); const app = readFileSync(join(__dirname, 'app.js'), 'utf8'); +const views = readFileSync(join(__dirname, 'views-core.js'), 'utf8'); + +function parsePreset(text) { + const declaration = views.match(/function parseJamesDspPreset\(text\) \{[\s\S]*?\n\}/)?.[0]; + assert.ok(declaration, 'preset parser is present'); + const context = vm.createContext({}); + vm.runInContext(`${declaration}; globalThis.parse = parseJamesDspPreset;`, context); + return context.parse(text); +} test('the persisted engine is Standard by default and upgrades missing JamesDSP settings safely', () => { assert.match(app, /jamesDsp: \{ enabled: false, preset: 'flat', params: \{\} \}/); @@ -27,4 +37,23 @@ test('EQ creates no second context or element source and passes Standard fallbac 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\(\)/); + assert.match(app, /request !== dspRequest \|\| !data\.settings\.jamesDsp\?\.enabled/); +}); + +test('Playback settings expose persisted presets, bands, safe import/export, IR, and A/B bypass', () => { + assert.match(views, /sel\('setSoundEngine'/); + assert.match(views, /sel\('setDspPreset'/); + for (const id of ['setDspCompressor', 'dspPresetImportBtn', 'dspPresetExportBtn', 'dspResetBtn', 'dspIrFile']) assert.ok(views.includes(`id="${id}"`), id); + assert.match(views, /\[25, 40, 63, 100, 160, 250, 400, 630, 1000, 1600, 2500, 4000, 6300, 10000, 16000\]/); + assert.match(views, /imported\?\.texts/); + assert.doesNotMatch(views, /liveprog|ddc|arbitrary/); + assert.match(app, /Bypass JamesDSP/); + assert.match(app, /toggleDspBypass/); +}); + +test('preset import validates ranges and excludes text-effect parameters', () => { + assert.deepEqual(JSON.parse(JSON.stringify(parsePreset(JSON.stringify({ version: 1, preset: 'warm', params: { 'eq.band0': 3, 'bass.enabled': 1 } })))), { preset: 'warm', params: { 'eq.band0': 3, 'bass.enabled': 1 } }); + assert.throws(() => parsePreset({ version: 1, preset: 'flat', params: { 'liveprog.enabled': 1 } }), /text effects are disabled|Unsupported preset setting/); + assert.throws(() => parsePreset({ version: 1, params: { 'eq.band0': 50 } }), /Unsupported preset setting/); + assert.throws(() => parsePreset({ version: 1, texts: { effect: 'unsafe' }, params: {} }), /text effects are disabled/); }); diff --git a/frontend/jamesdsp-assets.test.js b/frontend/jamesdsp-assets.test.js index bc2dc36..edd3e49 100644 --- a/frontend/jamesdsp-assets.test.js +++ b/frontend/jamesdsp-assets.test.js @@ -18,7 +18,7 @@ test('JamesDSP runtime files live only in a lazy background group', () => { '/vendor/jamesdsp/jamesdsp-node.mjs', '/vendor/jamesdsp/params.mjs', '/vendor/jamesdsp/worklet.mjs', - '/vendor/jamesdsp/dsp/jamesdsp.mjs', + '/vendor/jamesdsp/dsp/jamesdsp-77be0b2.mjs', '/vendor/jamesdsp/dsp/jamesdsp.wasm', ]); for (const path of group.files) assert.equal(assets.groups.core.files.includes(path), false, `${path} is not core`); @@ -32,9 +32,9 @@ test('JamesDSP resolves every module dependency through the page build manifest' assert.match(node, /globalThis\.Lazy\?\.url\?\.\(path\)/); assert.match(node, /assetURL\('\/vendor\/jamesdsp\/dsp\/jamesdsp\.wasm'/); assert.match(node, /assetURL\('\/vendor\/jamesdsp\/params\.mjs'/); - 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\)/); + assert.match(node, /processorOptions:\{wasmBytes\}/); + assert.match(worklet, /import createJamesDSP from '\.\/dsp\/jamesdsp-77be0b2\.mjs'/); + assert.doesNotMatch(worklet, /import\(/); 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'\)/); diff --git a/frontend/jamesdsp-controller.test.js b/frontend/jamesdsp-controller.test.js index f5308b0..e0782c0 100644 --- a/frontend/jamesdsp-controller.test.js +++ b/frontend/jamesdsp-controller.test.js @@ -96,3 +96,14 @@ test('preset gain and Level gain combine within engine bounds without pausing pl assert.equal(f.controller.state().maxLiveHeaps, 1); assert.equal(f.controller.active, true); }); + +test('A/B bypass is retained when settings are reapplied', async () => { + const f = fixture(); + await f.controller.enable({ preset: 'warm' }, 0); + assert.equal(await f.controller.toggleBypass(), true); + await f.controller.enable({ preset: 'bass' }, 0); + assert.equal(f.controller.state().bypassed, true); + assert.ok(f.bypass.at(-1)); + assert.equal(await f.controller.toggleBypass(), false); + assert.equal(f.bypass.at(-1), false); +}); diff --git a/frontend/styles.css b/frontend/styles.css index 22f4de1..fa7092b 100755 --- a/frontend/styles.css +++ b/frontend/styles.css @@ -1159,6 +1159,12 @@ input[type="range"]::-webkit-slider-thumb:hover { transform: scale(1.25); } .set-row > span { display: flex; flex-direction: column; gap: 3px; color: var(--text); font-size: 13.5px; font-weight: 500; } .set-row small { color: var(--text-dim); font-size: 11.5px; font-weight: 400; line-height: 1.4; max-width: 280px; } .set-row input[type="range"] { width: 150px; } +.dsp-settings { margin-top: 8px; padding: 12px; border: 1px solid var(--line); border-radius: 12px; background: var(--bg-2); } +.dsp-settings .set-group-title { padding: 0 0 8px; } +.dsp-param-list { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 0 14px; } +.dsp-param-row input[type="range"] { width: min(46vw, 190px); } +.dsp-actions { display: flex; flex-wrap: wrap; gap: 8px; padding: 10px 0; } +.dsp-actions .btn { color: var(--text); } .set-row input[type="checkbox"] { width: 17px; height: 17px; accent-color: var(--accent); cursor: pointer; } .about-version { color: var(--text-dim); font-size: 12px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; user-select: text; } .set-select { diff --git a/frontend/vendor/jamesdsp/dsp/jamesdsp.mjs b/frontend/vendor/jamesdsp/dsp/jamesdsp-77be0b2.mjs similarity index 100% rename from frontend/vendor/jamesdsp/dsp/jamesdsp.mjs rename to frontend/vendor/jamesdsp/dsp/jamesdsp-77be0b2.mjs diff --git a/frontend/vendor/jamesdsp/jamesdsp-feature.js b/frontend/vendor/jamesdsp/jamesdsp-feature.js index 27e2d5e..b9d0df7 100644 --- a/frontend/vendor/jamesdsp/jamesdsp-feature.js +++ b/frontend/vendor/jamesdsp/jamesdsp-feature.js @@ -16,7 +16,7 @@ function createController(options) { const graph = options.graph; - let desired = false, active = false, nodeEntry = null, gainNode = null, context = null; + let desired = false, active = false, bypassed = 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; @@ -27,7 +27,7 @@ return result; }; - function snapshot() { return { active, desired, liveHeaps, maxLiveHeaps, nodesCreated, nodesDestroyed, sampleRate: context?.sampleRate || null }; } + function snapshot() { return { active, desired, bypassed, liveHeaps, maxLiveHeaps, nodesCreated, nodesDestroyed, sampleRate: context?.sampleRate || null }; } function routeStandard() { if (!elements.length) return; @@ -58,6 +58,7 @@ function fail(error) { desired = false; active = false; + bypassed = false; try { routeStandard(); } catch { /* graph route has already tried a direct audible fallback */ } releaseNode(); if (!notifying) { @@ -92,7 +93,7 @@ await instance.setBypass(true); await instance.applyPreset(params); await instance.setParam('gain', params.gain); - if (desired) await instance.setBypass(false); + if (desired) await instance.setBypass(bypassed); if (desired) rampGain(1); } @@ -101,7 +102,6 @@ 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'), }); @@ -144,6 +144,7 @@ function disable() { desired = false; + bypassed = false; if (active) { active = false; try { routeStandard(); } catch { /* routeStandard tried its direct fallback */ } @@ -159,7 +160,23 @@ function setBypass(value) { if (!nodeEntry) return Promise.resolve(false); - return nodeEntry.node.setBypass(Boolean(value)); + return serial(async () => { bypassed = Boolean(value); await nodeEntry.node.setBypass(bypassed); return bypassed; }).catch(error => { fail(error); throw error; }); + } + + function toggleBypass() { + if (!nodeEntry) return Promise.resolve(false); + return serial(async () => { bypassed = !bypassed; await nodeEntry.node.setBypass(bypassed); return bypassed; }).catch(error => { fail(error); throw error; }); + } + + function loadIR(buffer) { + if (!active || !nodeEntry) return Promise.reject(new Error('Enable JamesDSP before loading an impulse response')); + return serial(async () => { + if (!active || !nodeEntry) throw new Error('JamesDSP is no longer active'); + rampGain(0); + try { await nodeEntry.node.loadIR(buffer); await nodeEntry.node.setParam('convolver.enabled', 1); } + finally { if (active && desired) rampGain(1); } + return true; + }).catch(error => { fail(error); throw error; }); } function analyser() { @@ -175,7 +192,7 @@ return dspAnalyser; } - return { enable, disable, setLevelDb, setBypass, analyser, get active() { return active; }, get desired() { return desired; }, state: snapshot }; + return { enable, disable, setLevelDb, setBypass, toggleBypass, loadIR, analyser, get active() { return active; }, get desired() { return desired; }, state: snapshot }; } const api = { PRESETS, createController }; diff --git a/frontend/vendor/jamesdsp/jamesdsp-node.mjs b/frontend/vendor/jamesdsp/jamesdsp-node.mjs index b09bdd1..addbc3b 100644 --- a/frontend/vendor/jamesdsp/jamesdsp-node.mjs +++ b/frontend/vendor/jamesdsp/jamesdsp-node.mjs @@ -7,17 +7,12 @@ export class JamesDSPNode extends EventTarget { static async create(context, options={}) { if (![44100,48000].includes(context.sampleRate)) throw new RangeError('Use a 44.1 or 48 kHz AudioContext'); const wasmURL=options.wasmURL || assetURL('/vendor/jamesdsp/dsp/jamesdsp.wasm','./dsp/jamesdsp.wasm'); - const engineURL=options.engineURL || assetURL('/vendor/jamesdsp/dsp/jamesdsp.mjs','./dsp/jamesdsp.mjs'); const paramsURL=options.paramsURL || assetURL('/vendor/jamesdsp/params.mjs','./params.mjs'); const baseWorkletURL=options.workletURL || assetURL('/vendor/jamesdsp/worklet.mjs','./worklet.mjs'); const paramApi=await loadParams(paramsURL); - const workletURL=new URL(baseWorkletURL,import.meta.url); - // Fragments are not sent over HTTP, so SW CacheStorage can keep the exact - // manifest URL while import.meta.url still carries the build-local engine URL. - workletURL.hash=new URLSearchParams({engine:String(engineURL)}).toString(); if (!moduleLoads.has(context)) moduleLoads.set(context,new Map()); - const cache=moduleLoads.get(context), key=String(workletURL); - if (!cache.has(key)) cache.set(key,context.audioWorklet.addModule(workletURL).catch(e=>{cache.delete(key);throw e;})); + const cache=moduleLoads.get(context), key=String(baseWorkletURL); + if (!cache.has(key)) cache.set(key,context.audioWorklet.addModule(baseWorkletURL).catch(e=>{cache.delete(key);throw e;})); const response=await fetch(wasmURL); if (!response.ok) throw new Error(`WASM fetch failed: ${response.status}`); const wasmBytes=await response.arrayBuffer(); @@ -26,7 +21,7 @@ export class JamesDSPNode extends EventTarget { try { await wrapper.ready; return wrapper; } catch(e) { wrapper.destroy(); throw e; } } constructor(context,wasmBytes,paramApi) { - super(); this.context=context; this.defaultParams=paramApi.DEFAULT_PARAMS; this.validateParam=paramApi.validateParam; this.params={...this.defaultParams}; this.pending=new Map(); this.serial=0; this.destroyed=false; + super(); this.context=context; this.defaultParams=Object.fromEntries(Object.entries(paramApi.DEFAULT_PARAMS).filter(([name])=>!/^(?:liveprog|ddc|arbitrary)\./.test(name))); this.validateParam=paramApi.validateParam; this.params={...this.defaultParams}; this.pending=new Map(); this.serial=0; this.destroyed=false; this.node=new AudioWorkletNode(context,'jamesdsp',{numberOfInputs:1,numberOfOutputs:1,outputChannelCount:[2],channelCount:2,channelCountMode:'explicit',processorOptions:{wasmBytes}}); this.ready=new Promise((resolve,reject)=> { const timer=setTimeout(()=>reject(new Error('DSP initialization timed out')),15000); diff --git a/frontend/vendor/jamesdsp/worklet.mjs b/frontend/vendor/jamesdsp/worklet.mjs index b115aa5..18511e3 100644 --- a/frontend/vendor/jamesdsp/worklet.mjs +++ b/frontend/vendor/jamesdsp/worklet.mjs @@ -1,7 +1,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later -const engineURL=new URLSearchParams(new URL(import.meta.url).hash.slice(1)).get('engine'); -if(!engineURL) throw new Error('JamesDSP engine asset URL is missing'); -const {default:createJamesDSP}=await import(engineURL); +// AudioWorkletGlobalScope does not support dynamic import; this immutable +// vendor path is versioned with the upstream pin and remapped by the SW cache. +import createJamesDSP from './dsp/jamesdsp-77be0b2.mjs'; class JamesDSPProcessor extends AudioWorkletProcessor { constructor(options) { super(); this.closed=false;this.failed=false; diff --git a/frontend/views-core.js b/frontend/views-core.js index cf8cad5..753a67f 100644 --- a/frontend/views-core.js +++ b/frontend/views-core.js @@ -1,4 +1,18 @@ /* Eager UI definitions. Read app lexical state only when called after initialization. */ +function parseJamesDspPreset(text) { + const imported = typeof text === 'string' ? JSON.parse(text) : text; + const allowed = /^(?:eq\.band(?:[0-9]|1[0-4])|compressor\.band[0-6]|(?:eq|compressor|bass|stereo|reverb|crossfeed|tube|convolver)\.enabled|(?:gain|limiter\.threshold|limiter\.release|eq\.mode|eq\.interpolation|compressor\.time|compressor\.mode|compressor\.granularity|bass\.boost|stereo\.mix|reverb\.preset|crossfeed\.mode|tube\.drive))$/; + if (imported?.version !== 1 || imported?.texts || !imported?.params || typeof imported.params !== 'object' || Array.isArray(imported.params)) throw new Error('This file is not a supported preset (text effects are disabled).'); + const range = key => key.startsWith('eq.band') || key.startsWith('compressor.band') ? [-12, 12] : ({ gain: [-24, 12], 'limiter.threshold': [-24, 0], 'limiter.release': [10, 1000], 'eq.mode': [0, 5], 'eq.interpolation': [0, 1], 'compressor.time': [0.01, 2], 'compressor.mode': [0, 3], 'compressor.granularity': [0, 4], 'bass.boost': [0, 12], 'stereo.mix': [0, 1], 'reverb.preset': [0, 18], 'crossfeed.mode': [0, 5], 'tube.drive': [-12, 12] }[key] || (key.endsWith('.enabled') ? [0, 1] : null)); + for (const [key, value] of Object.entries(imported.params)) { + const bounds = range(key); + if (!allowed.test(key) || typeof value !== 'number' || !Number.isFinite(value) || !bounds || value < bounds[0] || value > bounds[1] || (key === 'convolver.enabled' && value !== 0)) throw new Error(`Unsupported preset setting: ${key}`); + if ((key.endsWith('.enabled') || /mode|preset|granularity|interpolation/.test(key)) && !Number.isInteger(value)) throw new Error(`Preset setting must be a whole number: ${key}`); + } + const presets = ['flat', 'bass', 'vocal', 'loudness', 'headphone', 'bright', 'warm']; + return { preset: presets.includes(imported.preset) ? imported.preset : 'flat', params: { ...imported.params } }; +} + async function renderLibrary() { els.listTitle.textContent = 'Library'; resetListHeaderExtras(); @@ -718,6 +732,12 @@ async function renderSettings() { const qOptions = qualities .map((q) => ``) .join(''); + const jamesDsp = data.settings.jamesDsp || { enabled: false, preset: 'flat', params: {} }; + const dspBands = [25, 40, 63, 100, 160, 250, 400, 630, 1000, 1600, 2500, 4000, 6300, 10000, 16000]; + const dspParamDefaults = { 'bass.boost': 6, 'stereo.mix': 0.5, 'limiter.threshold': -0.1 }; + const dspParam = key => Number.isFinite(Number(jamesDsp.params?.[key])) ? Number(jamesDsp.params[key]) : (dspParamDefaults[key] ?? 0); + const dspRange = (key, label, min, max, step = 1, unit = '') => ``; + const dspBandRows = dspBands.map((hz, i) => dspRange(`eq.band${i}`, `${hz >= 1000 ? (hz / 1000) + ' kHz' : hz + ' Hz'}`, -12, 12, 0.5, ' dB')).join(''); const sel = (id, val, opts) => ` ` : ''} +
+
Sound engine
+ +
+
Live processing${EQ.isIOS() ? 'On iPhone, enable Live sound processing above first. Audio may stop when the screen locks.' : 'Processing runs on this device. Large reverbs and convolution can use more CPU.'}
+ +
${dspBandRows}
+ + + + + + + + +
+ + +
+