Extend JamesDSP with convolution and spatial audio
This commit is contained in:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user