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]