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:
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user