diff --git a/docs/equalizer.md b/docs/equalizer.md
new file mode 100644
index 0000000..d8bd83c
--- /dev/null
+++ b/docs/equalizer.md
@@ -0,0 +1,7 @@
+# Equalizer and stereo width
+
+`frontend/eq-core.js` is the single band/preset definition for both live EQ and EqRender: 31, 62, 125, 250, 500, 1000, 2000, 4000, 8000 and 16000 Hz. Existing five-band curves migrate by logarithmic frequency interpolation; edge gains extend to the new endpoints. Migration preserves user curves instead of replacing them with a new preset.
+
+The headphone widener is off by default. It delays the right channel by up to 15 ms (Haas effect); it can colour speaker playback and mono mixes. Live EQ and saved-song renders use the same widener graph, vocal matrix and bands. Render signatures include all three, so changed settings invalidate old processed copies. iPhone streams still require the existing Live sound processing opt-in; saved copies use OfflineAudioContext. A new rendered copy is used on a subsequent playback.
+
+Review with actual headphones and an iPhone: centre image at 0%, mono compatibility at higher values, rendered/live EQ correspondence, and lock-screen playback of saved songs. Model-based separation is outside this feature; see vocal-stems.md.
diff --git a/frontend/app.js b/frontend/app.js
index a8881c0..acb1212 100755
--- a/frontend/app.js
+++ b/frontend/app.js
@@ -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(); }
diff --git a/frontend/eq-core.js b/frontend/eq-core.js
new file mode 100644
index 0000000..2e84752
--- /dev/null
+++ b/frontend/eq-core.js
@@ -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);
diff --git a/frontend/eq-core.test.js b/frontend/eq-core.test.js
new file mode 100644
index 0000000..486d43e
--- /dev/null
+++ b/frontend/eq-core.test.js
@@ -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); });
diff --git a/frontend/index.html b/frontend/index.html
index 77bc9cf..110cb0f 100755
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -662,6 +662,7 @@
+
diff --git a/frontend/styles.css b/frontend/styles.css
index 62e698f..b350358 100755
--- a/frontend/styles.css
+++ b/frontend/styles.css
@@ -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)); } }
diff --git a/frontend/sw.js b/frontend/sw.js
index 3cec2b6..098991a 100644
--- a/frontend/sw.js
+++ b/frontend/sw.js
@@ -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',
diff --git a/tests/settings-sections.spec.js b/tests/settings-sections.spec.js
index 72a65d5..bb74c1b 100644
--- a/tests/settings-sections.spec.js
+++ b/tests/settings-sections.spec.js
@@ -56,3 +56,12 @@ for (const layout of ['classic', 'glass-stage']) for (const width of [390, 1440]
expect(await page.locator('.app').evaluate(el => el.inert)).toBe(true);
await page.getByRole('button', { name: 'Exit Car mode' }).click(); await expect(page.locator('.car-mode')).toHaveCount(0); expect(await page.locator('.app').evaluate(el => el.inert)).toBe(false);
});
+for (const layout of ['classic', 'glass-stage']) for (const width of [390, 1440]) test(`${layout} ten-band EQ at ${width}px`, async ({ page }) => {
+ await page.setViewportSize({ width, height: 900 }); await openClassic(page, { settings: { eq: { preset: 'custom', g: [6,2,-2,4,0] } } });
+ await page.evaluate(layout => { data.settings.layout = layout; applyAppearance(); EQ.open(); }, layout);
+ await expect(page.locator('.eq-band input')).toHaveCount(10);
+ expect(await page.evaluate(() => data.settings.eq.g.length)).toBe(10);
+ await page.getByRole('button', { name: 'Vocal clarity', exact: true }).click();
+ expect(await page.evaluate(() => data.settings.eq.g[7])).toBe(4);
+ expect(await page.locator('.eq-panel').evaluate(el => el.scrollWidth <= el.clientWidth + 1)).toBe(true);
+});