Merge pull request #101 from got-feedBack/fix/backing-leveler-per-song-trim
Addon CI / addon (arm64, macos-14, mac) (push) Waiting to run
Addon CI / addon (x64, ubuntu-22.04, linux) (push) Waiting to run
Addon CI / addon (x64, windows-latest, win) (push) Waiting to run
Ship CI / CI (push) Waiting to run

fix(audio): BackingLeveler as per-song trim — stop flattening song dynamics
This commit is contained in:
Jorge Fritis
2026-07-20 12:36:11 -04:00
committed by GitHub
+48 -12
View File
@@ -5,9 +5,23 @@
// ── Backing-track loudness normalizer ───────────────────────────────────────
// Brings the SONG's backing track to a target loudness (default -12 LUFS) so
// every song sits at the same level, BEFORE the mixer's backing-volume fader
// (so lowering that fader still lowers it). Short-term BS.1770 K-weighted AGC
// (slow, no pumping) + a brickwall limiter to keep boosted peaks safe.
// RT-safe: no allocation in process(). Standard K-weighting here (full-mix
// (so lowering that fader still lowers it).
//
// v2 — PER-SONG TRIM, not a running AGC. The first version tracked SHORT-TERM
// (400 ms) loudness with a ~300 ms gain follower and ±24 dB of authority: that
// re-converges on every musical section, so quiet verses got boosted toward
// -12 and loud choruses pulled down — the song's own macro-dynamics were
// flattened, and loud→quiet transitions left the gain low for a beat ("the
// song suddenly plays quiet, then swells back"). Loudness normalization should
// behave like a per-track gain (Spotify-style), not a compressor.
//
// Design: BS.1770 K-weighted **integrated** loudness accumulated over the
// song (gated below -50 LUFS so silence/noise doesn't dilute it). The make-up
// gain slews toward (target integrated) FAST while the measurement is young
// (first ~8 s of signal: up to 6 dB/s, inaudible as the song is just starting)
// and then locks down to a barely-moving trim (0.25 dB/s) — verse/chorus
// dynamics pass through untouched. A -1 dBFS brickwall still guards boosted
// peaks. RT-safe: no allocation in process(). Standard K-weighting (full-mix
// music) — unlike the per-tone leveler which is flattened for bass fidelity.
class BackingLeveler
{
@@ -17,6 +31,9 @@ public:
sr = (sampleRate > 0.0) ? sampleRate : 48000.0;
designKWeighting(sr);
msEnv = 0.0;
intSum = 0.0;
intSamples = 0;
signalSeconds = 0.0;
currentGainDb = 0.0;
limGain = 1.0f;
for (int ch = 0; ch < 2; ++ch) { kPre[ch].reset(); kRlb[ch].reset(); }
@@ -28,7 +45,8 @@ public:
const int nc = juce::jmin(2, buf.getNumChannels());
if (nc <= 0 || numSamples <= 0) return;
// Short-term (~400 ms) K-weighted mean-square, integrated per sample.
// K-weighted mean-square: a short envelope for the signal gate, and a
// gated INTEGRATED accumulator for the actual measurement.
const double rmsCoef = 1.0 - std::exp(-1.0 / (0.400 * sr));
for (int i = 0; i < numSamples; ++i)
{
@@ -40,17 +58,32 @@ public:
}
sq /= (double) nc;
msEnv += rmsCoef * (sq - msEnv);
// Gate the integration on the short-term envelope so leading
// silence / count-ins / fade tails don't dilute the measurement.
if (msEnv > 1.0e-5) // ≈ -50 LUFS
{
intSum += sq;
++intSamples;
}
}
const double lufs = (msEnv > 1e-12) ? (-0.691 + 10.0 * std::log10(msEnv)) : -120.0;
const bool hasSignal = lufs > -50.0; // gate: don't lift silence/noise
double wantedDb = currentGainDb;
if (hasSignal)
wantedDb = juce::jlimit(-24.0, 24.0, (double) targetLufs - lufs);
const bool haveMeasure = intSamples > (juce::int64) (0.5 * sr); // ≥ 0.5 s of signal
if (haveMeasure)
{
const double intMs = intSum / (double) intSamples;
const double integratedLufs = -0.691 + 10.0 * std::log10(juce::jmax(1.0e-12, intMs));
const double wantedDb = juce::jlimit(-12.0, 12.0, (double) targetLufs - integratedLufs);
// Slow gain follower (~300 ms) so it normalizes loudness without pumping.
const double smCoef = 1.0 - std::exp(-(double) numSamples / (0.300 * sr));
currentGainDb += (wantedDb - currentGainDb) * juce::jlimit(0.0, 1.0, smCoef);
// Slew limit instead of a time-constant follower: fast while the
// song is starting (the measurement is still forming), then locked
// to a creep so in-song dynamics are never ridden.
const double blockSec = (double) numSamples / sr;
signalSeconds += blockSec;
const double maxDbPerSec = (signalSeconds < 8.0) ? 6.0 : 0.25;
const double step = juce::jlimit(-maxDbPerSec * blockSec, maxDbPerSec * blockSec,
wantedDb - currentGainDb);
currentGainDb += step;
}
const float g = (float) juce::Decibels::decibelsToGain(currentGainDb);
// Brickwall limiter (-1 dBFS ceiling): instant attack, ~100 ms release.
@@ -108,6 +141,9 @@ private:
}
}
double sr = 48000.0, msEnv = 0.0, currentGainDb = 0.0;
double intSum = 0.0;
juce::int64 intSamples = 0;
double signalSeconds = 0.0;
float limGain = 1.0f;
Biquad kPre[2], kRlb[2];
};