Add centre-panned vocal reduction to live and saved audio
This commit is contained in:
@@ -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