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 @@ + diff --git a/frontend/sw.js b/frontend/sw.js index ddc33d8..6a743f5 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -88,6 +88,7 @@ const SHELL = [ '/lyrics-window.js', '/midi.js', '/lower-third.js', + '/vocal-reducer.js', '/lower-third.css', '/settings-sections.css', '/app.js', diff --git a/frontend/vocal-reducer.js b/frontend/vocal-reducer.js new file mode 100644 index 0000000..812d256 --- /dev/null +++ b/frontend/vocal-reducer.js @@ -0,0 +1,21 @@ +/* Mid/side centre reduction shared by live EQ and offline iPhone renders. */ +(function(root) { + 'use strict'; + const amount = value => Math.max(0, Math.min(100, Number.isFinite(Number(value)) ? Number(value) : 100)); + function coefficients(value) { const v = amount(value) / 100; return { same: (1 + v) / 2, cross: (v - 1) / 2 }; } + function process(left, right, value) { const c = coefficients(value); return [left * c.same + right * c.cross, right * c.same + left * c.cross]; } + function create(context, source, value = 100) { + const input = context.createGain(); input.channelCount = 2; input.channelCountMode = 'explicit'; input.channelInterpretation = 'speakers'; source.connect(input); + const splitter = context.createChannelSplitter(2), output = context.createChannelMerger(2); input.connect(splitter); + const matrix = []; + for (let from = 0; from < 2; from++) for (let to = 0; to < 2; to++) { const gain = context.createGain(); splitter.connect(gain, from); gain.connect(output, 0, to); matrix.push({ gain, same: from === to }); } + function set(next) { const c = coefficients(next); for (const entry of matrix) entry.gain.gain.setTargetAtTime(entry.same ? c.same : c.cross, context.currentTime, .03); } + // Initial values must be exact for OfflineAudioContext, without a ramp. + const c = coefficients(value); for (const entry of matrix) entry.gain.gain.value = entry.same ? c.same : c.cross; + return { output, set }; + } + function register(adapter) { root.SettingsSections.register({ id: 'vocal-reducer', title: 'Vocal reducer', cluster: 'Listening', summary: () => `${amount(adapter.settings().vocalLevel ?? 100)}% · centre-panned vocals`, render(container) { const doc = container.ownerDocument, label = doc.createElement('label'); label.className = 'set-row'; const text = doc.createElement('span'); text.textContent = 'Vocal — reduces centre-panned vocals'; const input = doc.createElement('input'); input.type = 'range'; input.min = '0'; input.max = '100'; input.step = '1'; input.value = String(amount(adapter.settings().vocalLevel ?? 100)); input.setAttribute('aria-label', 'Vocal level: reduces centre-panned vocals'); const output = doc.createElement('output'); output.textContent = `${input.value}%`; input.oninput = () => { adapter.settings().vocalLevel = Number(input.value); output.textContent = `${input.value}%`; adapter.persist(); adapter.apply(); }; label.append(text, input, output); const note = doc.createElement('p'); note.textContent = '100% keeps the original mix; 0% removes its stereo centre. This can also reduce bass, drums and other centred instruments. Mono recordings may become silent. On iPhone, saved songs use processed copies; streamed songs require Live sound processing.'; container.append(label, note); } }); } + // TODO: model-based Demucs separation needs an optional server worker. See docs/vocal-stems.md. + function stems() { return { available: false, reason: 'Model-based stem separation is not implemented.' }; } + const api = { amount, coefficients, process, create, register, stems }; if (typeof module !== 'undefined') module.exports = api; else root.VocalReducer = api; +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/frontend/vocal-reducer.test.js b/frontend/vocal-reducer.test.js new file mode 100644 index 0000000..a818ec0 --- /dev/null +++ b/frontend/vocal-reducer.test.js @@ -0,0 +1,3 @@ +const { test } = require('node:test'), assert = require('node:assert/strict'); const { process, coefficients } = require('./vocal-reducer'); +test('100% preserves channels; zero cancels centre and retains side', () => { assert.deepEqual(process(.7, .2, 100), [.7, .2]); assert.deepEqual(process(.7, .7, 0), [0,0]); assert.deepEqual(process(1,-1,0), [1,-1]); assert.deepEqual(process(1,1,50), [.5,.5]); }); +test('vocal settings clamp invalid values to safe bounds', () => { assert.deepEqual(coefficients(-99), coefficients(0)); assert.deepEqual(coefficients(999), coefficients(100)); assert.deepEqual(coefficients('invalid'), coefficients(100)); });