Add JamesDSP playback controls and verification harness
This commit is contained in:
@@ -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) => `<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 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 = '') => `<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('');
|
||||
|
||||
const sel = (id, val, opts) => `<select id="${id}" class="set-select">` +
|
||||
opts.map(([v, label]) => `<option value="${v}"${val === v ? ' selected' : ''}>${label}</option>`).join('') +
|
||||
@@ -788,6 +808,28 @@ async function renderSettings() {
|
||||
</span>
|
||||
<input id="setLiveAudioIOS" type="checkbox" ${data.settings.liveAudioIOS ? 'checked' : ''} />
|
||||
</label>` : ''}
|
||||
<div class="dsp-settings" id="jamesDspSettings">
|
||||
<div class="set-group-title">Sound engine</div>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<label class="set-row"><span>Stereo width<small>${Math.round(dspParam('stereo.mix') * 100)}%</small></span><input type="range" data-dsp-param="stereo.mix" min="0" max="1" step="0.05" value="${dspParam('stereo.mix')}" /></label>
|
||||
<label class="set-row"><span>Crossfeed mode</span>${sel('setDspCrossfeed', String(dspParam('crossfeed.mode')), [['0', 'Off'], ['1', 'Gentle'], ['2', 'Natural'], ['3', 'Wide'], ['4', 'Diffuse'], ['5', 'Strong']])}</label>
|
||||
<label class="set-row"><span>Reverb preset</span>${sel('setDspReverb', String(dspParam('reverb.preset')), Array.from({ length: 19 }, (_, i) => [String(i), i === 0 ? 'Off' : `Room ${i}`]))}</label>
|
||||
<label class="set-row"><span>Tube drive<small>${dspParam('tube.drive')} dB</small></span><input type="range" data-dsp-param="tube.drive" min="-12" max="12" step="0.5" value="${dspParam('tube.drive')}" /></label>
|
||||
<label class="set-row"><span>Limiter threshold<small>${dspParam('limiter.threshold')} dB</small></span><input type="range" data-dsp-param="limiter.threshold" min="-24" max="0" step="0.5" value="${dspParam('limiter.threshold')}" /></label>
|
||||
<label class="set-row"><span>Output gain<small>${dspParam('gain')} dB</small></span><input type="range" data-dsp-param="gain" min="-24" max="12" step="0.5" value="${dspParam('gain')}" /></label>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<label class="set-row">
|
||||
<span>Default volume</span>
|
||||
<input id="setVolume" type="range" min="0" max="1" step="0.01" value="${data.settings.volume}" />
|
||||
@@ -1055,6 +1097,79 @@ async function renderSettings() {
|
||||
if (e.target.checked) toast('Live sound processing on — streamed songs go silent when the screen locks', { duration: 4500 });
|
||||
});
|
||||
|
||||
const dspControls = $('jamesDspControls');
|
||||
const dspEngine = $('setSoundEngine');
|
||||
if (dspEngine) dspEngine.addEventListener('change', e => {
|
||||
const enable = e.target.value === 'jamesdsp';
|
||||
if (enable && EQ.isIOS() && !data.settings.liveAudioIOS) {
|
||||
e.target.value = 'standard';
|
||||
toast('On iPhone, turn on Live sound processing first.');
|
||||
return;
|
||||
}
|
||||
data.settings.jamesDsp = data.settings.jamesDsp || { enabled: false, preset: 'flat', params: {} };
|
||||
data.settings.jamesDsp.enabled = enable;
|
||||
if (dspControls) dspControls.hidden = !enable;
|
||||
persist();
|
||||
EQ.apply();
|
||||
});
|
||||
const dspConfig = data.settings.jamesDsp || (data.settings.jamesDsp = { enabled: false, preset: 'flat', params: {} });
|
||||
let dspApplyTimer = null;
|
||||
const applyDspSettings = () => {
|
||||
clearTimeout(dspApplyTimer);
|
||||
persist();
|
||||
dspApplyTimer = setTimeout(() => { if (data.settings.jamesDsp?.enabled) EQ.apply(); }, 120);
|
||||
};
|
||||
if ($('setDspPreset')) $('setDspPreset').addEventListener('change', e => {
|
||||
dspConfig.preset = e.target.value;
|
||||
dspConfig.params = {};
|
||||
wrap.querySelectorAll('[data-dsp-param]').forEach(input => { input.value = input.dataset.dspParam === 'limiter.threshold' ? '-0.1' : '0'; });
|
||||
applyDspSettings();
|
||||
});
|
||||
wrap.querySelectorAll('[data-dsp-param]').forEach(input => input.addEventListener('input', () => {
|
||||
const value = Number(input.value);
|
||||
dspConfig.params[input.dataset.dspParam] = value;
|
||||
const small = input.closest('.set-row')?.querySelector('small');
|
||||
if (small) small.textContent = `${value}${input.dataset.dspParam === 'stereo.mix' ? '%' : input.dataset.dspParam === 'bass.boost' || input.dataset.dspParam === 'tube.drive' || input.dataset.dspParam === 'limiter.threshold' || input.dataset.dspParam === 'gain' || input.dataset.dspParam.startsWith('eq.band') ? ' dB' : ''}`;
|
||||
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 ($('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(); });
|
||||
if ($('dspResetBtn')) $('dspResetBtn').addEventListener('click', () => {
|
||||
dspConfig.preset = 'flat'; dspConfig.params = {};
|
||||
if ($('setDspPreset')) $('setDspPreset').value = 'flat';
|
||||
wrap.querySelectorAll('[data-dsp-param]').forEach(input => { input.value = input.dataset.dspParam === 'limiter.threshold' ? '-0.1' : '0'; });
|
||||
if ($('setDspCrossfeed')) $('setDspCrossfeed').value = '0';
|
||||
if ($('setDspReverb')) $('setDspReverb').value = '0';
|
||||
if ($('setDspCompressor')) $('setDspCompressor').checked = false;
|
||||
applyDspSettings();
|
||||
toast('JamesDSP settings reset');
|
||||
});
|
||||
if ($('dspPresetExportBtn')) $('dspPresetExportBtn').addEventListener('click', () => {
|
||||
const params = { ...(dspConfig.params || {}) }; delete params['convolver.enabled'];
|
||||
const blob = new Blob([JSON.stringify({ version: 1, preset: dspConfig.preset || 'flat', params }, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob), a = document.createElement('a');
|
||||
a.href = url; a.download = 'ytplayer-jamesdsp-preset.json'; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
});
|
||||
if ($('dspPresetImportBtn')) $('dspPresetImportBtn').addEventListener('click', () => $('dspPresetFile')?.click());
|
||||
if ($('dspPresetFile')) $('dspPresetFile').addEventListener('change', async e => {
|
||||
const file = e.target.files?.[0]; if (!file) return;
|
||||
try {
|
||||
if (file.size > 65536) throw new Error('Preset files must be 64 KB or smaller.');
|
||||
const imported = parseJamesDspPreset(await file.text());
|
||||
dspConfig.preset = imported.preset; dspConfig.params = imported.params;
|
||||
if ($('setDspPreset')) $('setDspPreset').value = dspConfig.preset;
|
||||
applyDspSettings(); toast('JamesDSP preset imported');
|
||||
} catch (error) { toast(error?.message || 'Could not import preset'); }
|
||||
finally { e.target.value = ''; }
|
||||
});
|
||||
if ($('dspIrFile')) $('dspIrFile').addEventListener('change', async e => {
|
||||
const file = e.target.files?.[0]; if (!file) return;
|
||||
try { await EQ.loadDspIR(file); dspConfig.params['convolver.enabled'] = 1; applyDspSettings(); toast('Impulse response loaded for this session'); }
|
||||
catch (error) { toast(error?.message || 'Could not load impulse response'); }
|
||||
finally { e.target.value = ''; }
|
||||
});
|
||||
|
||||
|
||||
// ---- Saved searches manager ----
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user