From b140024ebf3d28e52b79fc60d37c4533d1a46da5 Mon Sep 17 00:00:00 2001 From: gramps Date: Fri, 9 Oct 2026 13:27:53 -0700 Subject: [PATCH] Audio: speech leveler (auto gain to -20 dBFS speech, -6..+20 dB, gated) between gate and compressor; honest mic meter (drop the +10 dB display boost and x1.2). Creator upload measured -28.5 LUFS; same audio through the new chain: -17.9 LUFS / -0.7 dBFS peak (Unit: audio loudness) --- Services/Audio/AudioLevelMeter.cs | 16 ++--- Services/Audio/VoiceFilterChain.cs | 96 ++++++++++++++++++++++++++++-- Services/index.md | 2 +- ViewModels/MainViewModel.Audio.cs | 2 +- ai.md | 14 ++++- ytLive.Tests/AudioMixerTests.cs | 20 +++---- ytLive.Tests/AudioPipelineTests.cs | 45 ++++++++++++++ 7 files changed, 169 insertions(+), 26 deletions(-) diff --git a/Services/Audio/AudioLevelMeter.cs b/Services/Audio/AudioLevelMeter.cs index 4202328..ca98286 100644 --- a/Services/Audio/AudioLevelMeter.cs +++ b/Services/Audio/AudioLevelMeter.cs @@ -16,18 +16,20 @@ public sealed class AudioLevelMeter /// /// Maps a linear level (0..1) onto the meter's display scale: -60 dBFS..0 dBFS - /// spread linearly across 0..1, with +10 dB of input amplification. Linear RMS - /// of real speech or game audio is ~0.01..0.1 (-40..-20 dBFS), which leaves a - /// flat (linear) meter looking dead; the log scale makes typical levels occupy - /// the bar and the amplification pushes real speech peaks into the red zone - /// (0.8+) at maxed volume instead of hovering at its edge. Inputs at or below - /// -60 dBFS (0.001 linear) read as zero — the meter never idles on background noise. + /// spread linearly across 0..1 — an HONEST dBFS scale. Linear RMS of real speech is + /// ~0.01..0.1 (-40..-20 dBFS), which leaves a flat (linear) meter looking dead; the + /// log scale makes typical levels occupy the bar. Inputs at or below -60 dBFS + /// (0.001 linear) read as zero — the meter never idles on background noise. + /// 2026-10-09: the +10 dB "input amplification" that used to sit here is gone. + /// It made a −30 dBFS voice look dialed in, and a creator's upload came out at + /// −28.5 LUFS. The speech leveler now puts voice at −20 dBFS, which lands at 2/3 of + /// the bar (the green/yellow edge); loud peaks reach the red. /// public static float ToDisplay(float linear) { if (linear <= 0.001f) return 0f; - var db = 20f * MathF.Log10(linear) + 10f; + var db = 20f * MathF.Log10(linear); return Math.Clamp(1f + db / 60f, 0f, 1f); } diff --git a/Services/Audio/VoiceFilterChain.cs b/Services/Audio/VoiceFilterChain.cs index 67cbfc2..15bc3c3 100644 --- a/Services/Audio/VoiceFilterChain.cs +++ b/Services/Audio/VoiceFilterChain.cs @@ -3,15 +3,21 @@ namespace ytLive.Services.Audio; /// /// The always-on voice processing chain for the mic (TASK 9 audio milestone). /// Pure, per-sample, unit-tested: bass boost → treble lift → noise gate → -/// compressor. Applied BEFORE the meter and the stream mix so what the creator -/// hears on the meter is what viewers hear — and so background hum is gated -/// out before it reaches the encoder. +/// speech leveler → compressor. Applied BEFORE the meter and the stream mix so +/// what the creator sees on the meter is what viewers hear — and so background hum +/// is gated out before it reaches the encoder. +/// The leveler (2026-10-09) is the gain stage the chain was missing: a creator's +/// upload measured −28.5 LUFS against YouTube's −14 reference because nothing ever +/// brought quiet speech UP — the compressor only folds peaks above −6 dBFS and the +/// master limiter only caps at −1 dBFS. Compressor → gain → limiter is the standard +/// streaming voice chain. /// public sealed class VoiceFilterChain { private readonly LowShelfFilter _bass; private readonly HighShelfFilter _treble; private readonly NoiseGate _gate; + private readonly SpeechLeveler _leveler; private readonly Compressor _compressor; /// The (post-resample) mic rate — the RBJ shelf @@ -21,15 +27,20 @@ public sealed class VoiceFilterChain _bass = new LowShelfFilter(sampleRate, 120f, 4f); _treble = new HighShelfFilter(sampleRate, 8000f, 3f); _gate = new NoiseGate(); + _leveler = new SpeechLeveler(sampleRate); _compressor = new Compressor(); } + /// The leveler's current gain (linear) — for diagnostics and tests. + public float LevelerGain => _leveler.Gain; + public float Process(float sample) { var bass = _bass.Process(sample); var treble = _treble.Process(bass); var gated = _gate.Process(treble); - return _compressor.Process(gated); + var leveled = _leveler.Process(gated, _gate.IsOpen); + return _compressor.Process(leveled); } public void Reset() @@ -37,10 +48,87 @@ public sealed class VoiceFilterChain _bass.Reset(); _treble.Reset(); _gate.Reset(); + _leveler.Reset(); _compressor.Reset(); } } +/// +/// Automatic gain for speech: tracks the voice's RMS over ~0.3 s and slowly steers a +/// gain so speech sits at (−20 dBFS mono ≈ −17 LUFS once the mic +/// is mixed to stereo — close to YouTube's −14 reference while leaving the limiter room). +/// Different mics differ by 20 dB+ in sensitivity; this is what makes "it just sounds +/// right" true for all of them. +/// +/// Only learns while the noise gate is open and the voice is above a silence floor — +/// pauses and background hum never get pumped up. +/// Gain rises at most and falls at most +/// (faster down than up, so a sudden shout is tamed quickly +/// and a quiet stretch isn't chased), within ... +/// Peaks it pushes up are caught by the after it and the +/// master limiter at the end of the mix. +/// +/// +public sealed class SpeechLeveler +{ + public const float TargetRms = 0.1f; // −20 dBFS + public const float MaxGain = 10f; // +20 dB + public const float MinGain = 0.5f; // −6 dB + public const float SilenceFloorRms = 0.002f; // −54 dBFS: below this, hold the gain + public const float RiseDbPerSecond = 10f; + public const float FallDbPerSecond = 30f; + private const float WindowSeconds = 0.3f; + private const float UpdateSeconds = 0.01f; + + private readonly float _meanAlpha; + private readonly int _updateEvery; + private readonly float _maxRiseDb; + private readonly float _maxFallDb; + private float _meanSquare; + private float _gainDb; + private float _gain = 1f; + private int _sinceUpdate; + + public SpeechLeveler(int sampleRate) + { + _meanAlpha = 1f / (WindowSeconds * sampleRate); + _updateEvery = Math.Max(1, (int)(UpdateSeconds * sampleRate)); + _maxRiseDb = RiseDbPerSecond * UpdateSeconds; + _maxFallDb = FallDbPerSecond * UpdateSeconds; + } + + public float Gain => _gain; + + public float Process(float sample, bool voiceActive) + { + if (voiceActive) + _meanSquare += (sample * sample - _meanSquare) * _meanAlpha; + + if (++_sinceUpdate >= _updateEvery) + { + _sinceUpdate = 0; + var rms = MathF.Sqrt(_meanSquare); + if (voiceActive && rms > SilenceFloorRms) + { + var desiredDb = 20f * MathF.Log10(Math.Clamp(TargetRms / rms, MinGain, MaxGain)); + var step = Math.Clamp(desiredDb - _gainDb, -_maxFallDb, _maxRiseDb); + _gainDb += step; + _gain = MathF.Pow(10f, _gainDb / 20f); + } + } + + return sample * _gain; + } + + public void Reset() + { + _meanSquare = 0; + _gainDb = 0; + _gain = 1f; + _sinceUpdate = 0; + } +} + /// /// RBJ audio EQ cookbook low-shelf biquad (bass boost). Transposed direct /// form II — stable, cheap, standard. diff --git a/Services/index.md b/Services/index.md index d2e2627..3828717 100644 --- a/Services/index.md +++ b/Services/index.md @@ -64,7 +64,7 @@ External-facing logic: YouTube API, persistence. See | `Audio/WasapiLoopbackAudioSource.cs` | Desktop/game capture: NAudio `WasapiLoopbackCapture` on the default render device — automatic at unity; the game bar's meter consumes it | | `Audio/WasapiMicAudioSource.cs` | Mic capture: NAudio `WasapiCapture` with the device resolved by `FriendlyName` matching `MicSourceName` (DisplayName only), default capture endpoint fallback; name provider `Func` re-read at each `Start` so a device picked mid-session takes effect immediately (the mixer restarts the mic on pick) | | `Audio/AudioMixer.cs` | Owns both sources; capture starts once at startup (`MainViewModel.StartMicCaptureAsync`) and stops on `Shutdown` — NOT go-live (preview monitoring). Mic samples → `AudioLevelMeter` → `MicLevelChanged`; loopback samples → the game bar's meter via `LoopbackLevelChanged`. Surfaces mic connection state: `MicConnected`/`MicFailed` (drives the status dot) + `RestartMic()` (device swap mid-session, keeps loopback). Failures log via `AppLog`; mic failure zeroes the meter, loopback failure doesn't kill the mic. Note: the meter `Push` is unconditional (the `?.` on the event would otherwise skip the argument when nothing is subscribed). **TASK 47 — alert audio:** `EnqueueAlertAudio` writes a dedicated 8s stereo-48k ring (`AlertBufferSeconds`), drained in `FillAndMix` pre-limiter and added at **unity (never ducked)**; `StartLive` clears the ring | -| `Audio/AudioLevelMeter.cs` | Pure smoothed RMS level (0..1): `Push(AudioSample)` + `Reset` — the unit-tested math behind `AudioLevel` and `GameAudioLevel`. `ToDisplay(float)` maps the raw linear RMS onto the meter's display scale (−60..0 dBFS spread across 0..1, with **+10 dB input amplification** so real speech peaks hit the red zone at maxed volume) — real speech/game RMS (~0.01..0.1) would otherwise leave a flat scale looking dead; ≤0.001 linear reads as zero (never idles on background noise) | +| `Audio/AudioLevelMeter.cs` | Pure smoothed RMS level (0..1): `Push(AudioSample)` + `Reset` — the unit-tested math behind `AudioLevel` and `GameAudioLevel`. `ToDisplay(float)` maps the raw linear RMS onto the meter's display scale (−60..0 dBFS spread across 0..1 — **honest since 2026-10-09**: the old +10 dB display boost made a −30 dBFS voice look dialed in; leveled speech (−20 dBFS) now sits at 2/3 of the bar) — real speech/game RMS (~0.01..0.1) would otherwise leave a flat scale looking dead; ≤0.001 linear reads as zero (never idles on background noise) | | `Audio/WaveToFloat.cs` | Pure WASAPI buffer → float conversion: IEEE float 32-bit direct, PCM 16-bit normalized, `WaveFormatExtensible` IEEE-float subformat GUID, trailing partial samples ignored | | `Audio/MasterLimiter.cs` | **Master output limiter**: a **−1 dBFS ceiling** (`Ceiling = 0.891`) applied after the live mix so the honest gains can never sum past 0 dBFS and clip the AAC encode. Instant attack per frame (a hot frame is scaled exactly to the ceiling — no overshoot), smoothed release toward unity so loud passages don't pump. Pure — unit-tested | diff --git a/ViewModels/MainViewModel.Audio.cs b/ViewModels/MainViewModel.Audio.cs index 4e14ba9..a991aa3 100644 --- a/ViewModels/MainViewModel.Audio.cs +++ b/ViewModels/MainViewModel.Audio.cs @@ -62,7 +62,7 @@ public partial class MainViewModel /// level flashes on the bar) so the creator sees where they're setting it; /// otherwise the realtime live level scaled by volume (raising the volume /// moves ambient noise up the bar). Read-only meter. - private double MeterLevel => MicMuted ? 0 : _volumeAdjusting || _volumeFlash ? MicVolume : Math.Min(1, AudioLevelMeter.ToDisplay((float)AudioLevel) * 1.2 * MicVolume); + private double MeterLevel => MicMuted ? 0 : _volumeAdjusting || _volumeFlash ? MicVolume : AudioLevelMeter.ToDisplay((float)(AudioLevel * MicVolume)); public double MeterFillWidth => MeterLevel * 288; diff --git a/ai.md b/ai.md index f3133ed..3fe943a 100644 --- a/ai.md +++ b/ai.md @@ -1038,11 +1038,21 @@ devices, no timers). `Shutdown` disposes it — NOT go-live — so the meters preview live. Mic samples: downmix to mono → `TinyResampler` → **`VoiceFilterChain` per sample** (bass `LowShelfFilter` 120 Hz +4 dB → treble `HighShelfFilter` 8 kHz +3 dB → `NoiseGate` (threshold 0.005, hysteresis 0.5, attack 0.5, release - 0.0005) → `Compressor` (threshold 0.5, ratio 4:1); all pure TDF2 DSP) → ring buffer → the + 0.0005) → **`SpeechLeveler`** (2026-10-09: automatic gain steering speech RMS to −20 dBFS over a + 0.3 s window, gain −6..+20 dB, rises ≤10 dB/s / falls ≤30 dB/s, learns only while the gate is open + and above −54 dBFS so pauses/hum are never pumped) → `Compressor` (threshold 0.5, ratio 4:1); all + pure DSP) → ring buffer → the **post-filter** `AudioLevelMeter` (RMS, 0.2 exponential smoothing) → `MicLevelChanged` → marshalled to the UI thread → `AudioLevel`. Loopback samples: resample → ring buffer → second meter → `LoopbackLevelChanged` → the game bar's `GameAudioLevel`. The raw linear level maps to the meter's - display scale by `AudioLevelMeter.ToDisplay` (−60..0 dBFS → 0..1 with **+10 dB input amplification**). + display scale by `AudioLevelMeter.ToDisplay` (−60..0 dBFS → 0..1, **honest** — the +10 dB display boost + was removed 2026-10-09; the mic meter is `ToDisplay(AudioLevel × MicVolume)`, true dB math, no ×1.2). + **Why (2026-10-09):** a creator's YouTube upload measured −28.5 LUFS / −8.9 dBFS peak against YouTube's + −14 LUFS reference (YouTube only turns loud videos down, never quiet ones up) while the boosted meter + looked "dialed in". Nothing in the chain added gain — the compressor folds only above −6 dBFS and the + limiter only caps. Re-running that same audio through the new chain (mic volume 0.8): **−17.9 LUFS, + −0.7 dBFS peak**. Recording and streaming share this one mix, so both were (and now are) the same. + Measure with the bundled ffmpeg: `ffmpeg -i file -af ebur128=peak=true:framelog=quiet -f null -`. **Mic connection state surfaces as events:** `MicConnected` (source `Started`), `MicFailed` (source `Failed`), and `RestartMic()` re-resolves + restarts just the mic (loopback keeps running). Failures log via `AppLog`; a mic failure zeroes the meter, a loopback failure never kills the mic. Meter `Push` diff --git a/ytLive.Tests/AudioMixerTests.cs b/ytLive.Tests/AudioMixerTests.cs index 98503e5..3f4b484 100644 --- a/ytLive.Tests/AudioMixerTests.cs +++ b/ytLive.Tests/AudioMixerTests.cs @@ -331,22 +331,20 @@ public class AudioLevelMeterTests Assert.Equal(0f, AudioLevelMeter.ToDisplay(0f)); Assert.Equal(0f, AudioLevelMeter.ToDisplay(0.001f), 2); - // -60 dBFS floor → 0, 0 dBFS → 1, and +10 dB amplification: a decade - // (~-20 dB) is 2/3 up the bar. + // -60 dBFS floor → 0, 0 dBFS → 1, honest in between (no hidden boost). Assert.Equal(1f, AudioLevelMeter.ToDisplay(1f), 3); - Assert.InRange(AudioLevelMeter.ToDisplay(0.0316f), 0.66f, 0.67f); - Assert.InRange(AudioLevelMeter.ToDisplay(0.1f), 0.83f, 0.84f); - Assert.InRange(AudioLevelMeter.ToDisplay(0.01f), 0.49f, 0.51f); + Assert.InRange(AudioLevelMeter.ToDisplay(0.0316f), 0.49f, 0.51f); + Assert.InRange(AudioLevelMeter.ToDisplay(0.1f), 0.66f, 0.67f); + Assert.InRange(AudioLevelMeter.ToDisplay(0.01f), 0.33f, 0.34f); } [Fact] - public void ToDisplay_Pushes_Speech_Peaks_Into_Red_At_Maxed_Volume() + public void ToDisplay_Puts_Leveled_Speech_At_The_Green_Yellow_Edge_And_Peaks_In_Red() { - // ~0.2 linear RMS (-14 dBFS) is a loud speech peak; at maxed volume it - // must land in the red zone (0.8+) and normal speech (~0.05, -26 dBFS) - // in the yellow — the meter uses the whole bar proportionately. - Assert.InRange(AudioLevelMeter.ToDisplay(0.2f), 0.92f, 0.95f); - Assert.InRange(AudioLevelMeter.ToDisplay(0.05f), 0.72f, 0.75f); + // The speech leveler targets -20 dBFS: that sits at 2/3 of the bar (the yellow + // zone starts at 60%); a hot -6 dBFS peak reaches the red (80%+). + Assert.InRange(AudioLevelMeter.ToDisplay(SpeechLeveler.TargetRms), 0.66f, 0.67f); + Assert.InRange(AudioLevelMeter.ToDisplay(0.5f), 0.89f, 0.91f); } [Fact] diff --git a/ytLive.Tests/AudioPipelineTests.cs b/ytLive.Tests/AudioPipelineTests.cs index 9971007..327f466 100644 --- a/ytLive.Tests/AudioPipelineTests.cs +++ b/ytLive.Tests/AudioPipelineTests.cs @@ -98,6 +98,51 @@ public class AudioPipelineTests } } + /// 2026-10-09 — a creator's upload measured −28.5 LUFS (YouTube's reference is + /// −14): nothing in the chain brought quiet speech up. THE test for the speech leveler: + /// syllable-shaped "speech" fed through the REAL voice chain lands at the leveler's + /// −20 dBFS target (±3 dB) whether the mic is quiet (−38 dBFS) or hot (−12 dBFS), and the + /// pauses between phrases stay silent (the gain is never pumped into the noise). + [Theory] + [InlineData(-38.0)] + [InlineData(-12.0)] + public void VoiceChain_LevelsQuietAndHotSpeech_ToTheTarget_AndKeepsPausesSilent(double inputDbfs) + { + const int rate = 48000; + var chain = new VoiceFilterChain(rate); + + // Unit-RMS "voice": three partials, 4 Hz syllable envelope, then scaled to the input level. + float Voice(int n) + { + var t = n / (float)rate; + var tone = MathF.Sin(2 * MathF.PI * 210 * t) + 0.6f * MathF.Sin(2 * MathF.PI * 420 * t) + 0.3f * MathF.Sin(2 * MathF.PI * 840 * t); + var syllable = MathF.Abs(MathF.Sin(2 * MathF.PI * 4 * t)); + return tone * syllable; + } + double unitSq = 0; + for (var n = 0; n < rate; n++) unitSq += Voice(n) * (double)Voice(n); + var scale = (float)(Math.Pow(10, inputDbfs / 20) / Math.Sqrt(unitSq / rate)); + + // 12 s: speak 2 s, pause 0.5 s, repeat. Measure the last 4 s. + double speechSq = 0; long speechN = 0; + var pauseMax = 0f; + var total = 12 * rate; + for (var n = 0; n < total; n++) + { + var phase = n % (int)(2.5 * rate); + var speaking = phase < 2 * rate; + var output = chain.Process(speaking ? Voice(n) * scale : 0f); + if (n < total - 4 * rate) continue; + if (speaking) { speechSq += output * (double)output; speechN++; } + else if (phase > 2 * rate + rate / 10) pauseMax = MathF.Max(pauseMax, MathF.Abs(output)); // after the gate's release tail + } + + var speechDb = 10 * Math.Log10(speechSq / speechN); + var targetDb = 20 * Math.Log10(SpeechLeveler.TargetRms); + Assert.InRange(speechDb, targetDb - 3, targetDb + 3); + Assert.InRange(pauseMax, 0f, 1e-4f); // < -80 dBFS: only the EQ's decaying tail, never boosted noise + } + // ─── Ring buffer ─── [Fact]