Extend JamesDSP with convolution and spatial audio

This commit is contained in:
Jonathan Sykes
2026-10-10 19:25:06 +08:00
parent b3f0525a8e
commit ef12468a99
28 changed files with 540 additions and 143 deletions

View File

@@ -663,7 +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: {} },
jamesDsp: { enabled: false, downloadForOffline: false, preset: 'flat', params: {}, irRef: null },
parallelSaves: 4, // offline saves running at once (Settings → Offline cache)
density: 'comfortable', // 'comfortable' | 'compact'
perfMode: false, // disable heavy visual effects for speed
@@ -678,11 +678,15 @@ function normalizeJamesDspSettings(settings) {
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'];
const irRef = saved.irRef && typeof saved.irRef.name === 'string' && typeof saved.irRef.hash === 'string'
? { name: saved.irRef.name, hash: saved.irRef.hash } : null;
if (!irRef) delete params['convolver.enabled'];
settings.jamesDsp = {
enabled: saved.enabled === true,
downloadForOffline: saved.downloadForOffline === true,
preset: typeof saved.preset === 'string' ? saved.preset : 'flat',
params,
irRef,
};
return settings;
}
@@ -5535,6 +5539,7 @@ const loadSoundFeatures=()=>Promise.all([Lazy.load('feature:eq-core'),Lazy.load(
const EQ_BANDS = EqParameters.bands;
const EQ_PRESETS = EqParameters.presets;
const JamesDSPFeature = Lazy.proxy('feature:jamesdsp', ['createController'], 'JamesDSPFeature');
const JamesDSPTools = Lazy.proxy('feature:jamesdsp', ['generateIR', 'decodeIR', 'prepareIR', 'saveIR', 'getIR', 'listIRs', 'deleteIR'], 'JamesDSPFeature');
const EQ = (() => {
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
const AC = window.AudioContext || window.webkitAudioContext;
@@ -5726,7 +5731,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 (dspController?.active && !dspController.state().bypassed) return dspController.analyser();
if (!available() || !build()) return null;
attach(els.video);
attach(els.audio);
@@ -5765,16 +5770,50 @@ const EQ = (() => {
catch (error) { dspFailed(error); return false; }
}
function dspLowEndDevice() {
const cores=Number(navigator.hardwareConcurrency), memory=Number(navigator.deviceMemory);
return (cores>0&&cores<=4)||(memory>0&&memory<=4);
}
async function loadDspIRBuffer(buffer, reference=null) {
if (!data.settings.jamesDsp?.enabled || !dspController?.active) throw new Error('Turn on JamesDSP before importing an impulse response.');
await dspController.loadIR(buffer,reference);
data.settings.jamesDsp.irRef=reference;
data.settings.jamesDsp.params['convolver.enabled']=1;
persist();
return reference;
}
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);
const context=st.ctx;if(!context?.decodeAudioData)throw new Error('Audio decoding is unavailable on this device.');
const buffer=await context.decodeAudioData(await file.arrayBuffer());
const prepared=await JamesDSPTools.prepareIR(context,buffer,dspLowEndDevice()?24000:65536);
const name=String(file.name||'Uploaded IR').replace(/\.[^.]+$/,'').slice(0,100)||'Uploaded IR';
let reference=null;try{reference=await JamesDSPTools.saveIR(name,prepared,{kind:'uploaded'});}catch(error){toast(`Impulse response stays on this device for this session: ${error.message}`);}
return loadDspIRBuffer(prepared,reference);
}
async function loadDspBuiltinIR(name) {
if(!data.settings.jamesDsp?.enabled||!dspController?.active)throw new Error('Turn on JamesDSP before loading an impulse response.');
const context=st.ctx;if(!context)throw new Error('Audio context is unavailable.');
const lowEnd=dspLowEndDevice();if(lowEnd&&name!=='Small room')throw new Error('Long impulse responses are disabled on this lower-power device.');
const buffer=await JamesDSPTools.generateIR(name,context.sampleRate,2,lowEnd?24000:65536);
let reference=null;try{reference=await JamesDSPTools.saveIR(name,buffer,{kind:'generated'});}catch(error){toast(`Generated response stays in this session: ${error.message}`);}
return loadDspIRBuffer(buffer,reference);
}
async function loadDspSavedIR(name) {
const stored=await JamesDSPTools.getIR(name);if(!stored)throw new Error(`Stored impulse response not found: ${name}`);
return loadDspIRBuffer(stored.buffer,{name:stored.name,hash:stored.hash});
}
async function listDspIRs() {
return JamesDSPTools.listIRs();
}
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, jamesDsp: dspController?.state() || null }), toggleDspBypass, loadDspIR };
levelDb: st.level ? Math.round(200 * Math.log10(Math.max(1e-6, st.level.gain.value))) / 10 : null, jamesDsp: dspController?.state() || null }), toggleDspBypass, loadDspIR, loadDspIRBuffer, loadDspBuiltinIR, loadDspSavedIR, listDspIRs };
})();
// ============================================================================
@@ -8857,6 +8896,8 @@ async function registerServiceWorker() {
const reg = await navigator.serviceWorker.register('/sw.js', { updateViaCache: 'none' });
_swReg = reg;
Lazy.reportLayout(reg);
reportDspOfflineOptIn(reg);
navigator.serviceWorker.ready.then(readyReg => reportDspOfflineOptIn(readyReg)).catch(() => {});
// A waiting worker only means "update pending" when this page is already
// controlled by a previous SW. On a FIRST install (fresh visit, or right
@@ -8892,6 +8933,14 @@ async function registerServiceWorker() {
}
}
function reportDspOfflineOptIn(reg = _swReg) {
if (!WEB || !navigator.serviceWorker) return;
const dsp = data.settings?.jamesDsp;
const values = dsp?.enabled || dsp?.downloadForOffline ? ['jamesDsp'] : [];
const workers = new Set([navigator.serviceWorker.controller, reg?.installing, reg?.active, reg?.waiting]);
for (const worker of workers) worker?.postMessage({ type: 'OFFLINE_OPT_INS', values });
}
// /?q=<query> — a shareable search link (same family as Share's /?v=&t=).
// Runs after wireUI() so the search form's submit handler exists; the param
// is stripped immediately so a reload doesn't re-run the search.