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

@@ -732,9 +732,11 @@ async function renderSettings() {
const qOptions = qualities
.map((q) => `<option value="${q}"${data.settings.quality === q ? ' selected' : ''}>${q === 'auto' ? 'Auto' : q}</option>`)
.join('');
const jamesDsp = data.settings.jamesDsp || { enabled: false, preset: 'flat', params: {} };
const jamesDsp = data.settings.jamesDsp || { enabled: false, downloadForOffline: false, preset: 'flat', params: {}, irRef: null };
const dspCores=Number(navigator.hardwareConcurrency), dspMemory=Number(navigator.deviceMemory);
const dspLowEnd=(dspCores>0&&dspCores<=4)||(dspMemory>0&&dspMemory<=4);
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 dspParamDefaults = { 'bass.boost': 6, 'stereo.mix': 0.5, 'limiter.threshold': -0.1, 'convolver.mix': 1, 'convolver.predelayMs': 0, 'spatial.mode': 6, 'spatial.spread': 60, 'spatial.distance': 1, 'spatial.mix': 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 = '') => `<label class="set-row dsp-param-row"><span>${label}<small>${dspParam(key)}${unit}</small></span><input type="range" data-dsp-param="${key}" min="${min}" max="${max}" step="${step}" value="${dspParam(key)}" /></label>`;
const dspBandRows = dspBands.map((hz, i) => dspRange(`eq.band${i}`, `${hz >= 1000 ? (hz / 1000) + ' kHz' : hz + ' Hz'}`, -12, 12, 0.5, ' dB')).join('');
@@ -742,6 +744,7 @@ async function renderSettings() {
const sel = (id, val, opts) => `<select id="${id}" class="set-select">` +
opts.map(([v, label]) => `<option value="${v}"${val === v ? ' selected' : ''}>${label}</option>`).join('') +
`</select>`;
const dspBuiltinIRSelect = `<select id="setDspBuiltinIR" class="set-select">${['Small room','Hall','Plate','Cathedral'].map(name=>`<option value="${name}"${dspLowEnd&&name!=='Small room'?' disabled':''}>${name}</option>`).join('')}</select>`;
wrap.innerHTML = `
<div class="set-group">
@@ -813,8 +816,10 @@ async function renderSettings() {
<label class="set-row"><span>Engine<small>Standard is the default. JamesDSP replaces the Standard EQ and vocal/stereo stages.</small></span>
${sel('setSoundEngine', jamesDsp.enabled ? 'jamesdsp' : 'standard', [['standard', 'Standard'], ['jamesdsp', 'JamesDSP']])}
</label>
<label class="set-row"><span>Download audio effects for offline<small>Includes the optional JamesDSP engine and its large WASM file in the offline cache.</small></span><input id="setDspOffline" type="checkbox" ${jamesDsp.downloadForOffline ? 'checked' : ''} /></label>
<div id="jamesDspControls" ${jamesDsp.enabled ? '' : 'hidden'}>
<div class="set-row"><span>Live processing<small>${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.'}</small></span></div>
<div class="set-row"><span>Live processing<small>${EQ.isIOS() ? 'On iPhone, enable Live sound processing above first. Audio may stop when the screen locks.' : 'Processing runs on this device.'}</small></span></div>
${dspLowEnd ? '<p class="dsp-risk" role="note">This device may have limited audio headroom. Long impulse responses are disabled and the short room response is capped at 0.5 seconds.</p>' : ''}
<label class="set-row"><span>Preset</span>${sel('setDspPreset', jamesDsp.preset || 'flat', [['flat', 'Flat'], ['bass', 'Bass boost'], ['vocal', 'Vocal'], ['loudness', 'Loudness'], ['headphone', 'Headphone crossfeed'], ['bright', 'Bright'], ['warm', 'Warm']])}</label>
<div class="dsp-param-list" aria-label="15-band equalizer">${dspBandRows}</div>
<label class="set-row"><span>Bass boost<small>${dspParam('bass.boost')} dB</small></span><input type="range" data-dsp-param="bass.boost" min="0" max="12" step="0.5" value="${dspParam('bass.boost')}" /></label>
@@ -827,7 +832,27 @@ async function renderSettings() {
<label class="set-row"><span>Compressor</span><input id="setDspCompressor" type="checkbox" ${dspParam('compressor.enabled') ? 'checked' : ''} /></label>
<div class="dsp-actions"><button id="dspPresetImportBtn" class="btn" type="button">Import preset JSON</button><button id="dspPresetExportBtn" class="btn" type="button">Export preset JSON</button><button id="dspResetBtn" class="btn" type="button">Reset DSP</button></div>
<input id="dspPresetFile" type="file" accept="application/json,.json" hidden />
<label class="set-row"><span>Impulse response<small>WAV or other browser-decodable audio; upload enables convolution for this session only.</small></span><input id="dspIrFile" type="file" accept="audio/*,.wav" /></label>
<section class="dsp-subsection" aria-labelledby="dspConvolverTitle">
<h4 id="dspConvolverTitle">Convolver</h4>
<label class="set-row"><span>Enable convolution</span><input id="setDspConvolver" type="checkbox" ${dspParam('convolver.enabled') ? 'checked' : ''} /></label>
<label class="set-row"><span>Dry / wet<small>${Math.round(dspParam('convolver.mix')*100)}%</small></span><input type="range" data-dsp-param="convolver.mix" min="0" max="1" step="0.01" value="${dspParam('convolver.mix')}" /></label>
<label class="set-row"><span>Pre-delay<small>${dspParam('convolver.predelayMs')} ms</small></span><input type="range" data-dsp-param="convolver.predelayMs" min="0" max="250" step="1" value="${dspParam('convolver.predelayMs')}" /></label>
<label class="set-row"><span>Built-in response</span>${dspBuiltinIRSelect}</label>
<button id="setDspLoadBuiltin" class="btn" type="button">Generate and load</button>
<label class="set-row"><span>Upload impulse response<small>WAV or other browser-decodable audio; up to 65,536 frames at the internal sample rate.</small></span><input id="dspIrFile" type="file" accept="audio/*,.wav" /></label>
<label class="set-row"><span>Stored responses</span><select id="setDspSavedIR" class="set-select"><option value="">Choose a saved response</option></select></label>
<button id="setDspLoadSavedIR" class="btn" type="button">Load saved response</button>
<p id="dspIrStatus" class="sp-note">${jamesDsp.irRef?.name ? `Loaded: ${jamesDsp.irRef.name}` : 'No impulse response loaded.'}</p>
</section>
<section class="dsp-subsection" aria-labelledby="dspSpatialTitle">
<h4 id="dspSpatialTitle">3D spatial stage</h4>
<label class="set-row"><span>Enable 3D spatial stage</span><input id="setDspSpatial" type="checkbox" ${dspParam('spatial.enabled') ? 'checked' : ''} /></label>
<label class="set-row"><span>HRTF / renderer mode</span>${sel('setDspSpatialMode',String(dspParam('spatial.mode')),[['2','HRTF crossfeed'],['3','HRTF surround 1'],['4','HRTF surround 2'],['5','HRTF surround 3'],['6','Virtual speakers']])}</label>
${dspRange('spatial.azimuth','Azimuth',-90,90,1,'°')}
${dspRange('spatial.spread','Speaker spread',0,120,1,'°')}
${dspRange('spatial.distance','Distance',0.5,5,0.1,' m')}
${dspRange('spatial.centerDb','Center boost',0,6,0.1,' dB')}
</section>
</div>
</div>
<label class="set-row">
@@ -1106,13 +1131,23 @@ async function renderSettings() {
toast('On iPhone, turn on Live sound processing first.');
return;
}
data.settings.jamesDsp = data.settings.jamesDsp || { enabled: false, preset: 'flat', params: {} };
data.settings.jamesDsp = data.settings.jamesDsp || { enabled: false, downloadForOffline: false, preset: 'flat', params: {}, irRef: null };
data.settings.jamesDsp.enabled = enable;
if (enable) data.settings.jamesDsp.downloadForOffline = true;
if (enable && $('setDspOffline')) $('setDspOffline').checked = true;
if (dspControls) dspControls.hidden = !enable;
persist();
reportDspOfflineOptIn();
EQ.apply();
});
const dspConfig = data.settings.jamesDsp || (data.settings.jamesDsp = { enabled: false, preset: 'flat', params: {} });
if ($('setDspOffline')) $('setDspOffline').addEventListener('change', e => {
data.settings.jamesDsp = data.settings.jamesDsp || { enabled: false, downloadForOffline: false, preset: 'flat', params: {} };
data.settings.jamesDsp.downloadForOffline = e.target.checked;
persist();
reportDspOfflineOptIn();
toast(e.target.checked ? 'Preparing audio effects for offline use' : 'Audio effects removed from the offline completion list');
});
const dspConfig = data.settings.jamesDsp || (data.settings.jamesDsp = { enabled: false, downloadForOffline: false, preset: 'flat', params: {}, irRef: null });
let dspApplyTimer = null;
const applyDspSettings = () => {
clearTimeout(dspApplyTimer);
@@ -1133,8 +1168,41 @@ async function renderSettings() {
applyDspSettings();
}));
if ($('setDspCrossfeed')) $('setDspCrossfeed').addEventListener('change', e => { dspConfig.params['crossfeed.mode'] = Number(e.target.value); dspConfig.params['crossfeed.enabled'] = Number(e.target.value) ? 1 : 0; applyDspSettings(); });
if ($('setDspConvolver')) $('setDspConvolver').addEventListener('change', e => {
const hasIR=dspConfig.irRef||EQ.state().jamesDsp?.irRef;
if(e.target.checked&&!hasIR){e.target.checked=false;toast('Load an impulse response first.');return;}
dspConfig.params['convolver.enabled']=e.target.checked?1:0;applyDspSettings();
});
if ($('setDspSpatial')) $('setDspSpatial').addEventListener('change', e => {
dspConfig.params['spatial.enabled']=e.target.checked?1:0;
if(e.target.checked){dspConfig.params['crossfeed.enabled']=0;if($('setDspCrossfeed'))$('setDspCrossfeed').value='0';}
applyDspSettings();
});
if ($('setDspSpatialMode')) $('setDspSpatialMode').addEventListener('change', e => {dspConfig.params['spatial.mode']=Number(e.target.value);applyDspSettings();});
if ($('setDspReverb')) $('setDspReverb').addEventListener('change', e => { dspConfig.params['reverb.preset'] = Number(e.target.value); dspConfig.params['reverb.enabled'] = Number(e.target.value) ? 1 : 0; applyDspSettings(); });
if ($('setDspCompressor')) $('setDspCompressor').addEventListener('change', e => { dspConfig.params['compressor.enabled'] = e.target.checked ? 1 : 0; applyDspSettings(); });
const refreshDspIrList=async()=>{
const select=$('setDspSavedIR');if(!select)return;
const selected=select.value;select.replaceChildren(new Option('Choose a saved response',''));
try{for(const item of await EQ.listDspIRs())select.add(new Option(`${item.name} · ${item.channels} ch · ${item.frames} frames`,item.name));if([...select.options].some(o=>o.value===selected))select.value=selected;}
catch(error){if($('dspIrStatus'))$('dspIrStatus').textContent=`Saved response list unavailable: ${error.message}`;}
};
if ($('setDspLoadBuiltin')) $('setDspLoadBuiltin').addEventListener('click',async()=>{
try{const name=$('setDspBuiltinIR').value;const ref=await EQ.loadDspBuiltinIR(name);if($('dspIrStatus'))$('dspIrStatus').textContent=`Loaded ${name}${ref?' and saved it for this device':' for this session only'}.`;$('setDspConvolver').checked=true;await refreshDspIrList();}
catch(error){toast(`Impulse response failed: ${error.message}`);}
});
if ($('setDspLoadSavedIR')) $('setDspLoadSavedIR').addEventListener('click',async()=>{
const name=$('setDspSavedIR').value;if(!name){await refreshDspIrList();return;}
try{const ref=await EQ.loadDspSavedIR(name);if($('dspIrStatus'))$('dspIrStatus').textContent=`Loaded saved response: ${name}`;$('setDspConvolver').checked=true;await refreshDspIrList();}
catch(error){toast(`Saved response failed: ${error.message}`);}
});
if ($('dspIrFile')) $('dspIrFile').addEventListener('change',async e=>{
const file=e.target.files?.[0];if(!file)return;
try{const ref=await EQ.loadDspIR(file);if($('dspIrStatus'))$('dspIrStatus').textContent=`Loaded ${ref?.name||file.name}${ref?' and saved it for this device':' for this session only'}.`;$('setDspConvolver').checked=true;await refreshDspIrList();}
catch(error){toast(`Impulse response failed: ${error.message}`);}
finally{e.target.value='';}
});
if(jamesDsp.enabled||jamesDsp.downloadForOffline)refreshDspIrList();
if ($('dspResetBtn')) $('dspResetBtn').addEventListener('click', () => {
dspConfig.preset = 'flat'; dspConfig.params = {};
if ($('setDspPreset')) $('setDspPreset').value = 'flat';