diff --git a/docs/vocal-stems.md b/docs/vocal-stems.md
new file mode 100644
index 0000000..6404786
--- /dev/null
+++ b/docs/vocal-stems.md
@@ -0,0 +1,5 @@
+# Vocal reduction and future stems
+
+The Vocal control uses a stereo mid/side matrix, not source separation. The same `frontend/vocal-reducer.js` graph runs in live Web Audio and EqRender's OfflineAudioContext. At 100% it is identity; at 0% it retains the stereo side and removes the centre. Centred instruments are reduced too, and mono can disappear. Live processing on iPhone requires the existing opt-in; saved songs render in the background and keep lock-screen playback.
+
+TODO: add optional Demucs source separation. Follow the lyrics-worker deployment pattern, with its own model volume, token and resource limits. Suggested contract: `POST /api/media/:id/stems` starts an idempotent job, `GET /api/media/:id/stems` returns queued/running/ready/failed plus vocal/instrumental URLs. The worker fetches server audio over the private network, runs Demucs, atomically publishes stems and retains model/version/source-hash provenance. Authenticate job creation, cap concurrency and file size, and never fetch arbitrary user URLs. Keep the worker and feature off by default. No stems API or model currently runs.
diff --git a/frontend/app.js b/frontend/app.js
index 2d16bac..129bf93 100755
--- a/frontend/app.js
+++ b/frontend/app.js
@@ -6540,8 +6540,9 @@ const EqRender = (() => {
const g = EQ.gains();
const lufs = Level.enabled() ? Level.remembered(id) : null;
const levelDb = lufs == null ? 0 : Math.round(Level.gainFor(lufs) * 2) / 2;
- if (g.every((v) => v === 0) && Math.abs(levelDb) < 0.5) return null;
- return { gains: g, levelDb, sig: g.join(',') + '|' + levelDb };
+ 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 };
}
async function dir() { return (await navigator.storage.getDirectory()).getDirectoryHandle(DIR, { create: true }); }
async function drop(id) {
@@ -6609,7 +6610,8 @@ const EqRender = (() => {
const gain = off.createGain(); gain.gain.value = Math.pow(10, w.levelDb / 20);
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;
- node.connect(gain); gain.connect(lim); lim.connect(off.destination);
+ const vocal = VocalReducer.create(off, node, w.vocal);
+ vocal.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
@@ -6741,7 +6743,8 @@ const EQ = (() => {
st.limiter = st.ctx.createDynamicsCompressor();
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.filters[st.filters.length - 1].connect(st.level);
+ st.vocal = VocalReducer.create(st.ctx, st.filters[st.filters.length - 1]);
+ st.vocal.output.connect(st.level);
st.level.connect(st.limiter);
st.limiter.connect(st.ctx.destination);
return true;
@@ -6760,12 +6763,13 @@ 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()) return;
+ if (!st.ctx && isFlat() && VocalReducer.amount(data.settings.vocalLevel ?? 100) === 100) 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);
if (st.ctx.state === 'suspended') st.ctx.resume().catch(() => {});
renderButton();
}
@@ -12457,6 +12461,7 @@ async function boot() {
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();
EQ.apply();
+ 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(); }
else if (action === 'next') playNext();
diff --git a/frontend/index.html b/frontend/index.html
index 8a19a29..de15de2 100755
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -660,6 +660,7 @@
+