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)

This commit is contained in:
2026-10-09 13:27:53 -07:00
parent 8303b1cf7f
commit b140024ebf
7 changed files with 169 additions and 26 deletions
+9 -7
View File
@@ -16,18 +16,20 @@ public sealed class AudioLevelMeter
/// <summary>
/// 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.
/// <para>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.</para>
/// </summary>
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);
}
+92 -4
View File
@@ -3,15 +3,21 @@ namespace ytLive.Services.Audio;
/// <summary>
/// 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.
/// <b>speech leveler</b> → 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.
/// <para>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.</para>
/// </summary>
public sealed class VoiceFilterChain
{
private readonly LowShelfFilter _bass;
private readonly HighShelfFilter _treble;
private readonly NoiseGate _gate;
private readonly SpeechLeveler _leveler;
private readonly Compressor _compressor;
/// <param name="sampleRate">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();
}
/// <summary>The leveler's current gain (linear) — for diagnostics and tests.</summary>
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();
}
}
/// <summary>
/// Automatic gain for speech: tracks the voice's RMS over ~0.3 s and slowly steers a
/// gain so speech sits at <see cref="TargetRms"/> (−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.
/// <list type="bullet">
/// <item>Only learns while the noise gate is open and the voice is above a silence floor —
/// pauses and background hum never get pumped up.</item>
/// <item>Gain rises at most <see cref="RiseDbPerSecond"/> and falls at most
/// <see cref="FallDbPerSecond"/> (faster down than up, so a sudden shout is tamed quickly
/// and a quiet stretch isn't chased), within <see cref="MinGain"/>..<see cref="MaxGain"/>.</item>
/// <item>Peaks it pushes up are caught by the <see cref="Compressor"/> after it and the
/// master limiter at the end of the mix.</item>
/// </list>
/// </summary>
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;
}
}
/// <summary>
/// RBJ audio EQ cookbook low-shelf biquad (bass boost). Transposed direct
/// form II — stable, cheap, standard.
+1 -1
View File
@@ -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<string?>` 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 |
+1 -1
View File
@@ -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.</summary>
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;
+12 -2
View File
@@ -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`
+9 -11
View File
@@ -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]
+45
View File
@@ -98,6 +98,51 @@ public class AudioPipelineTests
}
}
/// <summary>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).</summary>
[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]