Add centre-panned vocal reduction to live and saved audio

This commit is contained in:
Jonathan Sykes
2026-10-03 14:59:44 +08:00
parent 7d84fdb3dc
commit de89508bf4
6 changed files with 41 additions and 5 deletions

View File

@@ -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();

View File

@@ -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>

View File

@@ -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
View 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);

View 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)); });