Upgrade to ten-band EQ with migrated curves and headphone widening
This commit is contained in:
@@ -6541,8 +6541,9 @@ const EqRender = (() => {
|
||||
const lufs = Level.enabled() ? Level.remembered(id) : null;
|
||||
const levelDb = lufs == null ? 0 : Math.round(Level.gainFor(lufs) * 2) / 2;
|
||||
const vocal = VocalReducer.amount(data.settings.vocalLevel ?? 100);
|
||||
if (g.every((v) => v === 0) && Math.abs(levelDb) < 0.5 && vocal === 100) return null;
|
||||
return { gains: g, levelDb, vocal, sig: g.join(',') + '|' + levelDb + '|vocal:' + vocal };
|
||||
const width = EqCore.width(data.settings.stereoWidth);
|
||||
if (g.every((v) => v === 0) && Math.abs(levelDb) < 0.5 && vocal === 100 && width === 0) return null;
|
||||
return { gains: g, levelDb, vocal, width, sig: g.join(',') + '|' + levelDb + '|vocal:' + vocal + '|width:' + width };
|
||||
}
|
||||
async function dir() { return (await navigator.storage.getDirectory()).getDirectoryHandle(DIR, { create: true }); }
|
||||
async function drop(id) {
|
||||
@@ -6611,7 +6612,8 @@ const EqRender = (() => {
|
||||
const lim = off.createDynamicsCompressor();
|
||||
lim.threshold.value = -1; lim.knee.value = 0; lim.ratio.value = 20; lim.attack.value = 0.003; lim.release.value = 0.25;
|
||||
const vocal = VocalReducer.create(off, node, w.vocal);
|
||||
vocal.output.connect(gain); gain.connect(lim); lim.connect(off.destination);
|
||||
const widener = EqCore.createWidener(off, vocal.output, w.width);
|
||||
widener.output.connect(gain); gain.connect(lim); lim.connect(off.destination);
|
||||
src.start();
|
||||
const out = await off.startRendering();
|
||||
if (wanted(id)?.sig !== w.sig) return; // settings changed meanwhile — the queue re-runs it
|
||||
@@ -6687,26 +6689,14 @@ const PiP = (() => {
|
||||
})();
|
||||
|
||||
// ============================================================================
|
||||
// Equalizer — 5-band graphic EQ (Web Audio)
|
||||
// Equalizer — 10-band graphic EQ (Web Audio)
|
||||
// ============================================================================
|
||||
// Media elements are routed through BiquadFilters only once a non-flat curve
|
||||
// is chosen (createMediaElementSource can't be undone; afterwards "Flat"
|
||||
// simply zeroes the gains). Disabled on iOS: Safari suspends Web Audio when
|
||||
// the screen locks, which would silence background playback.
|
||||
const EQ_BANDS = [
|
||||
{ f: 60, type: 'lowshelf', label: '60' },
|
||||
{ f: 250, type: 'peaking', label: '250' },
|
||||
{ f: 1000, type: 'peaking', label: '1k' },
|
||||
{ f: 4000, type: 'peaking', label: '4k' },
|
||||
{ f: 12000, type: 'highshelf', label: '12k' },
|
||||
];
|
||||
const EQ_PRESETS = {
|
||||
flat: { name: 'Flat', g: [0, 0, 0, 0, 0] },
|
||||
bass: { name: 'Bass boost', g: [7, 4, 0, 0, 0] },
|
||||
vocal: { name: 'Vocal', g: [-2, -1, 3, 4, 1] },
|
||||
classical: { name: 'Classical', g: [4, 2, -1, 1, 3] },
|
||||
treble: { name: 'Treble boost', g: [0, 0, 0, 3, 6] },
|
||||
};
|
||||
const EQ_BANDS = EqCore.bands;
|
||||
const EQ_PRESETS = EqCore.presets;
|
||||
const EQ = (() => {
|
||||
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
||||
const AC = window.AudioContext || window.webkitAudioContext;
|
||||
@@ -6721,7 +6711,7 @@ const EQ = (() => {
|
||||
const rendered = () => !!(current && current.eqAudioUrl);
|
||||
const gains = () => {
|
||||
const g = data.settings.eq && Array.isArray(data.settings.eq.g) ? data.settings.eq.g : EQ_PRESETS.flat.g;
|
||||
return EQ_BANDS.map((_, i) => Math.max(-12, Math.min(12, Number(g[i]) || 0)));
|
||||
return EqCore.migrate(g);
|
||||
};
|
||||
const isFlat = () => gains().every((v) => v === 0);
|
||||
|
||||
@@ -6744,7 +6734,8 @@ const EQ = (() => {
|
||||
st.limiter.threshold.value = -1; st.limiter.knee.value = 0; st.limiter.ratio.value = 20;
|
||||
st.limiter.attack.value = 0.003; st.limiter.release.value = 0.25;
|
||||
st.vocal = VocalReducer.create(st.ctx, st.filters[st.filters.length - 1]);
|
||||
st.vocal.output.connect(st.level);
|
||||
st.widener = EqCore.createWidener(st.ctx, st.vocal.output);
|
||||
st.widener.output.connect(st.level);
|
||||
st.level.connect(st.limiter);
|
||||
st.limiter.connect(st.ctx.destination);
|
||||
return true;
|
||||
@@ -6763,13 +6754,14 @@ const EQ = (() => {
|
||||
// Apply the stored curve; wires the graph up the first time it's non-flat.
|
||||
function apply() {
|
||||
if (!available()) return;
|
||||
if (!st.ctx && isFlat() && VocalReducer.amount(data.settings.vocalLevel ?? 100) === 100) return;
|
||||
if (!st.ctx && isFlat() && VocalReducer.amount(data.settings.vocalLevel ?? 100) === 100 && EqCore.width(data.settings.stereoWidth) === 0) return;
|
||||
if (!build()) return;
|
||||
attach(els.video);
|
||||
attach(els.audio);
|
||||
const g = rendered() ? EQ_BANDS.map(() => 0) : gains();
|
||||
st.filters.forEach((f, i) => { f.gain.value = g[i]; });
|
||||
st.vocal.set(rendered() ? 100 : data.settings.vocalLevel ?? 100);
|
||||
st.widener.set(rendered() ? 0 : data.settings.stereoWidth);
|
||||
if (st.ctx.state === 'suspended') st.ctx.resume().catch(() => {});
|
||||
renderButton();
|
||||
}
|
||||
@@ -12462,7 +12454,9 @@ async function boot() {
|
||||
PiP.render();
|
||||
LyricsWindow.configure({ prepare: () => { if (!isServiceModeOpen()) Notes.stageLyrics.set(true); }, view: () => isServiceModeOpen() && data.settings.serviceLyrics ? $('serviceLyrics') : $('stageLyrics'), line: () => $('serviceLyricsList')?.querySelector('.active')?.textContent || $('stageLyricsList')?.querySelector('.active')?.textContent || current?.meta?.title || 'Nothing playing', toggle: () => { Player.toggle(); updatePlayBtn(); }, next: playNext, refresh: () => { Notes.stageLyrics.render(true); Notes.serviceRefresh(); }, toast });
|
||||
EQ.renderButton();
|
||||
if (data.settings.eq?.g?.length === 5) { data.settings.eq.g = EqCore.migrate(data.settings.eq.g); persist(); }
|
||||
EQ.apply();
|
||||
EqCore.register({ settings: () => data.settings, persist, open: EQ.open, apply: () => { EQ.apply(); EqRender.scheduleAll(); } });
|
||||
VocalReducer.register({ settings: () => data.settings, persist, apply: () => { EQ.apply(); EqRender.scheduleAll(); } });
|
||||
MidiPedals.configure({ settings: () => data.settings, persist, action: action => {
|
||||
if (action === 'toggle') { Player.toggle(); updatePlayBtn(); }
|
||||
|
||||
34
frontend/eq-core.js
Normal file
34
frontend/eq-core.js
Normal file
@@ -0,0 +1,34 @@
|
||||
/* Shared live/offline EQ definitions and legacy curve migration. */
|
||||
(function(root) {
|
||||
'use strict';
|
||||
const frequencies = [31,62,125,250,500,1000,2000,4000,8000,16000];
|
||||
const bands = frequencies.map((f,i) => ({ f, label: f >= 1000 ? `${f/1000}k` : String(f), type: i === 0 ? 'lowshelf' : i === 9 ? 'highshelf' : 'peaking' }));
|
||||
const clamp = n => Math.max(-12, Math.min(12, Number(n) || 0));
|
||||
function migrate(values) {
|
||||
if (!Array.isArray(values)) return frequencies.map(() => 0);
|
||||
if (values.length !== 5) return frequencies.map((_,i) => clamp(values[i]));
|
||||
const old = [60,250,1000,4000,12000], curve = values.map(clamp);
|
||||
return frequencies.map(f => { if (f <= old[0]) return curve[0]; if (f >= old[4]) return curve[4]; const i = old.findIndex(x => x >= f), weight = Math.log(f / old[i-1]) / Math.log(old[i] / old[i-1]); return Math.round((curve[i-1] * (1-weight) + curve[i] * weight) * 10) / 10; });
|
||||
}
|
||||
const presets = {
|
||||
flat: { name: 'Flat', g: frequencies.map(() => 0) },
|
||||
bass: { name: 'Bass boost', g: [5,7,5,3,1,0,0,0,0,0] },
|
||||
vocal: { name: 'Vocal clarity', g: [-3,-4,-2,0,1,2,3,4,1,0] },
|
||||
acoustic: { name: 'Acoustic live', g: [-2,-1,0,-2,-1,1,2,2,1,0] },
|
||||
classical: { name: 'Classical', g: migrate([4,2,-1,1,3]) },
|
||||
treble: { name: 'Treble boost', g: [0,0,0,0,0,0,1,3,5,6] },
|
||||
};
|
||||
const width = value => Math.max(0, Math.min(100, Number(value) || 0));
|
||||
function createWidener(context, source, value = 0) {
|
||||
const splitter = context.createChannelSplitter(2), output = context.createChannelMerger(2), delay = context.createDelay(.05);
|
||||
source.connect(splitter); splitter.connect(output, 0, 0); splitter.connect(delay, 1); delay.connect(output, 0, 1);
|
||||
delay.delayTime.value = width(value) / 100 * .015;
|
||||
return { output, set: next => delay.delayTime.setTargetAtTime(width(next) / 100 * .015, context.currentTime, .03) };
|
||||
}
|
||||
function register(adapter) { root.SettingsSections.register({ id: 'equalizer', title: 'Equalizer & stereo', cluster: 'Listening', summary: () => `${presets[adapter.settings().eq?.preset]?.name || 'Custom'} · ${width(adapter.settings().stereoWidth) ? 'Widened' : 'Original stereo'}`, render(container) {
|
||||
const doc = container.ownerDocument, button = doc.createElement('button'); button.type = 'button'; button.className = 'settings-back'; button.textContent = 'Open ten-band equalizer'; button.onclick = adapter.open;
|
||||
const label = doc.createElement('label'); label.className = 'set-row'; label.textContent = 'Headphone stereo widener (optional)'; const input = doc.createElement('input'); input.type = 'range'; input.min = '0'; input.max = '100'; input.value = String(width(adapter.settings().stereoWidth)); input.setAttribute('aria-label', 'Headphone stereo width'); const out = doc.createElement('output'); out.textContent = `${input.value}%`; input.oninput = () => { adapter.settings().stereoWidth = Number(input.value); out.textContent = `${input.value}%`; adapter.persist(); adapter.apply(); }; label.append(input, out);
|
||||
const note = doc.createElement('p'); note.textContent = 'Off at 0%. Adds up to 15 ms delay to the right channel for headphones. May colour the sound on speakers or when summed to mono. On iPhone, saved songs use processed copies; streamed songs require Live sound processing.'; container.append(button,label,note);
|
||||
} }); }
|
||||
const api = { bands, presets, migrate, width, createWidener, register }; if (typeof module !== 'undefined') module.exports = api; else root.EqCore = api;
|
||||
})(typeof window !== 'undefined' ? window : globalThis);
|
||||
4
frontend/eq-core.test.js
Normal file
4
frontend/eq-core.test.js
Normal file
@@ -0,0 +1,4 @@
|
||||
const { test } = require('node:test'), assert = require('node:assert/strict'); const { bands, presets, migrate, width } = require('./eq-core');
|
||||
test('ten bands and every preset share one frequency definition', () => { assert.deepEqual(bands.map(b=>b.f), [31,62,125,250,500,1000,2000,4000,8000,16000]); for (const preset of Object.values(presets)) assert.equal(preset.g.length, 10); });
|
||||
test('legacy curves interpolate in log frequency and retain anchor values', () => { const result = migrate([6,2,-2,4,0]); assert.equal(result[0],6); assert.equal(result[3],2); assert.equal(result[5],-2); assert.equal(result[7],4); assert.equal(result[9],0); assert.ok(result[4] > -2 && result[4] < 2); assert.deepEqual(migrate([0,0,0,0,0]), presets.flat.g); });
|
||||
test('current curves are stable and clamp bad gains; widening defaults off', () => { assert.deepEqual(migrate(presets.acoustic.g), presets.acoustic.g); assert.equal(migrate([99,-99,NaN])[0],12); assert.equal(migrate([99,-99,NaN])[1],-12); assert.equal(width(undefined),0); assert.equal(width(500),100); });
|
||||
@@ -662,6 +662,7 @@
|
||||
<script src="midi.js"></script>
|
||||
<script src="lower-third.js"></script>
|
||||
<script src="vocal-reducer.js"></script>
|
||||
<script src="eq-core.js"></script>
|
||||
<script src="setlist-import.js"></script>
|
||||
<script src="car-mode.js"></script>
|
||||
<script src="app.js"></script>
|
||||
|
||||
@@ -3973,9 +3973,9 @@ body.presenter-open { overflow: hidden; }
|
||||
}
|
||||
.sleep-curtain span { color: #666; font-size: 14px; transition: opacity 2s; }
|
||||
.sleep-curtain.dim span { opacity: 0; }
|
||||
.eq-bands { display: grid; grid-template-columns: repeat(5, 1fr); gap: 8px; margin-top: 8px; }
|
||||
.eq-bands { display: grid; grid-template-columns: repeat(5, minmax(44px, 1fr)); gap: 8px; margin-top: 8px; }
|
||||
.eq-band { display: flex; flex-direction: column; align-items: center; gap: 6px; font-size: 11px; color: var(--text-dim); }
|
||||
.eq-band input[type=range] { writing-mode: vertical-lr; direction: rtl; width: 28px; height: 150px; accent-color: var(--accent); }
|
||||
.eq-band input[type=range] { writing-mode: vertical-lr; direction: rtl; width: 44px; height: 130px; accent-color: var(--accent); }
|
||||
.eq-val { font-family: var(--mono); font-size: 11px; color: var(--text); min-height: 14px; }
|
||||
.eq-f { font-family: var(--mono); }
|
||||
|
||||
@@ -4351,3 +4351,5 @@ html.ios-nozoom, html.ios-nozoom body { touch-action: manipulation; }
|
||||
.cache-fold > summary:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
.cache-fold-count { margin-left: auto; font-weight: 500; font-size: 13px; color: var(--text-dim); font-variant-numeric: tabular-nums; }
|
||||
.cache-fold > .cache-list { margin: 0; padding: 4px 10px 10px; }
|
||||
|
||||
@media (min-width: 900px) { .modal:has(.eq-panel) { width: min(92vw, 780px); max-width: 780px; } .eq-bands { grid-template-columns: repeat(10, minmax(44px, 1fr)); } }
|
||||
|
||||
@@ -89,6 +89,7 @@ const SHELL = [
|
||||
'/midi.js',
|
||||
'/lower-third.js',
|
||||
'/vocal-reducer.js',
|
||||
'/eq-core.js',
|
||||
'/setlist-import.js',
|
||||
'/car-mode.js',
|
||||
'/car-mode.css',
|
||||
|
||||
Reference in New Issue
Block a user