Add JamesDSP playback controls and verification harness

This commit is contained in:
Jonathan Sykes
2026-10-09 20:29:37 +08:00
parent 27b32ff0c9
commit 6bb3271431
15 changed files with 376 additions and 29 deletions

View File

@@ -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 = '<span class="more-ico" aria-hidden="true">A/B</span><span class="more-txt"><b></b><small>Temporarily compare with the unprocessed signal</small></span><span class="more-go">›</span>';
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 };
})();
// ============================================================================