Add centre-panned vocal reduction to live and saved audio
This commit is contained in:
5
docs/vocal-stems.md
Normal file
5
docs/vocal-stems.md
Normal file
@@ -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.
|
||||
@@ -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();
|
||||
|
||||
@@ -660,6 +660,7 @@
|
||||
<script src="lyrics-window.js"></script>
|
||||
<script src="midi.js"></script>
|
||||
<script src="lower-third.js"></script>
|
||||
<script src="vocal-reducer.js"></script>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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',
|
||||
|
||||
21
frontend/vocal-reducer.js
Normal file
21
frontend/vocal-reducer.js
Normal file
@@ -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);
|
||||
3
frontend/vocal-reducer.test.js
Normal file
3
frontend/vocal-reducer.test.js
Normal file
@@ -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)); });
|
||||
Reference in New Issue
Block a user