TASK 25: master limiter on the live mix (2026-08-15, queued from the TRAX discussion) — the review confirmed two of the creator's three instincts were already satisfied (MediaFoundationReader streams from disk so file size needs no guard; the MusicPlayer 0.20 bed rides the same loopback gain as the game, so music is always exactly 20% of the desktop volume by construction) and found one real gap: AudioMixer.FillAndMix summed mic + loopback with no output ceiling, so hot gains could pass 0 dBFS and clip the AAC encode. New pure Services/Audio/MasterLimiter.cs: -1 dBFS ceiling (Ceiling = 0.891), 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, gain never exceeds 1. Applied at the end of FillAndMix right before the pipe write. 3 unit tests (trim-to-ceiling, never-boost, recovery) + ONE integration test (MasterLimiter_CapsTheLiveMix_OnThePipe: a 0.95 loopback bed capped to exactly 0.891 on the wire while staying audible). Docs in the same commit: ai.md go-live audio section, Services/index.md new row, TASKS.md TASK 25 record, HANDOFF.md shipped state. Build 0 warnings, 193 tests passing (189 + 4 new)

This commit is contained in:
2026-08-15 16:10:16 -07:00
parent 24f16b2cd9
commit 92f1471ab4
7 changed files with 201 additions and 9 deletions
+30 -2
View File
@@ -8,8 +8,16 @@
## Session state (last updated: 2026-08-15) ## Session state (last updated: 2026-08-15)
- **Branch:** `main`, tracking `origin/main`. Working tree: the **TASK 24 polish batch** + the **game - **Branch:** `main`, tracking `origin/main`. Working tree: the **TASK 24 polish batch** + the **game
audio bar always-visible change** (below) are committed + pushed. Local branches audio bar always-visible change** + **TASK 25 (master limiter)** are committed + pushed. Local
`social-bar`/`webcam-validation` untouched (no secrets). branches `social-bar`/`webcam-validation` untouched (no secrets).
- **TASK 25 — MASTER LIMITER — COMMITTED + PUSHED 2026-08-15.** Queued from the TRAX discussion: the
live mix summed mic + loopback with no ceiling, so hot gains could pass 0 dBFS and clip the AAC
encode. New pure `Services/Audio/MasterLimiter.cs` (−1 dBFS ceiling, instant attack per frame,
smoothed release) applied at the end of `AudioMixer.FillAndMix`. ONE integration test
(`MasterLimiter_CapsTheLiveMix_OnThePipe`). The review also confirmed file size needs no guard
(`MediaFoundationReader` streams) and the music **0.20 cap is already relative by construction**
(music rides the same loopback gain as the game → always exactly 20% of the desktop volume). Build
**0 warnings**, full suite green.
- **GAME AUDIO BAR ALWAYS VISIBLE — COMMITTED + PUSHED 2026-08-15.** The TASK 4 show/hide gating was a - **GAME AUDIO BAR ALWAYS VISIBLE — COMMITTED + PUSHED 2026-08-15.** The TASK 4 show/hide gating was a
UX bug: the desktop/game meter kept vanishing whenever no full-screen game with sound was up (or no UX bug: the desktop/game meter kept vanishing whenever no full-screen game with sound was up (or no
TRAX music played). The whole `IGameAudioDetector`/`GameAudioDetector`/`GameAudioHysteresis` stack + TRAX music played). The whole `IGameAudioDetector`/`GameAudioDetector`/`GameAudioHysteresis` stack +
@@ -84,6 +92,26 @@ sound or TRAX played, so it sat hidden during normal use. Fix = the bar is now *
- **MainWindow.xaml:** dropped the `Visibility` binding on the preview-overlay game bar; it renders - **MainWindow.xaml:** dropped the `Visibility` binding on the preview-overlay game bar; it renders
unconditionally (TRAX button + "Desktop Audio" label + meter + mute + volume unchanged). unconditionally (TRAX button + "Desktop Audio" label + meter + mute + volume unchanged).
## TASK 25 — master limiter 2026-08-15 (what changed)
Came out of the TRAX discussion (file-size guard? 20% cap? sound-event balance?). Verdict: two of the
three instincts were already satisfied, one real gap existed:
- **No file-size guard needed** — `MediaFoundationReader` streams from disk; memory is flat (~a few MB)
whatever the file size.
- **The 0.20 music cap is relative by construction** — music rides the same loopback gain as the game,
so music:game is always exactly 0.20:1 at any slider position; it cannot rise above 20% of the
current desktop volume. (Per-channel hierarchy: voice on top via the ducker −12 dB, then game, then
music at 20%.)
- **The gap:** `FillAndMix` summed mic + loopback with no ceiling — hot gains could pass 0 dBFS and
clip the AAC encode.
- **Fix:** new pure `Services/Audio/MasterLimiter.cs` — **−1 dBFS ceiling** (`Ceiling = 0.891`),
**instant attack per frame** (hot frames scaled exactly to the ceiling, no overshoot), **smoothed
release** toward unity (no pumping); gain never exceeds 1. Applied at the end of `AudioMixer.FillAndMix`.
- **ONE integration test:** `MasterLimiter_CapsTheLiveMix_OnThePipe` — real mixer + pipe harness, a
0.95 loopback bed capped to exactly 0.891 on the wire while staying audible. Plus 3 unit tests for
the pure math.
## TASK 9 audio milestone — SHIPPED 2026-08-14 (what changed) ## TASK 9 audio milestone — SHIPPED 2026-08-14 (what changed)
The creator's feature review settled this as the single next branch ("all the audio issues done and The creator's feature review settled this as the single next branch ("all the audio issues done and
+5 -2
View File
@@ -11,8 +11,9 @@ namespace ytLive.Services.Audio;
/// level-metered and queued; loopback samples are resampled to 48 kHz stereo /// level-metered and queued; loopback samples are resampled to 48 kHz stereo
/// and queued. While live (<see cref="StartLive"/>), a 10 ms loop drains both /// and queued. While live (<see cref="StartLive"/>), a 10 ms loop drains both
/// queues, applies the honest gains (mic = MicVolume, loopback = GameAudioVolume /// queues, applies the honest gains (mic = MicVolume, loopback = GameAudioVolume
/// × auto-duck when the mic is hot), mixes them to stereo float and writes the /// × auto-duck when the mic is hot), mixes them to stereo float, runs the
/// chunk to the encoder's audio pipe. /// master limiter (a -1 dBFS ceiling so the sum never clips the encoder), and
/// writes the chunk to the encoder's audio pipe.
/// </summary> /// </summary>
public sealed class AudioMixer : IDisposable public sealed class AudioMixer : IDisposable
{ {
@@ -36,6 +37,7 @@ public sealed class AudioMixer : IDisposable
private readonly AudioRingBuffer _loopbackBuffer; private readonly AudioRingBuffer _loopbackBuffer;
private readonly VoiceFilterChain _voiceChain; private readonly VoiceFilterChain _voiceChain;
private readonly AutoDucker _ducker; private readonly AutoDucker _ducker;
private readonly MasterLimiter _masterLimiter = new();
private TinyResampler? _micResampler; private TinyResampler? _micResampler;
private TinyResampler? _loopbackResampler; private TinyResampler? _loopbackResampler;
private CancellationTokenSource? _liveCts; private CancellationTokenSource? _liveCts;
@@ -330,6 +332,7 @@ public sealed class AudioMixer : IDisposable
mix[i * 2] = m + loopbackChunk[i * 2] * loopGain; mix[i * 2] = m + loopbackChunk[i * 2] * loopGain;
mix[i * 2 + 1] = m + loopbackChunk[i * 2 + 1] * loopGain; mix[i * 2 + 1] = m + loopbackChunk[i * 2 + 1] * loopGain;
} }
_masterLimiter.Process(mix);
return micRms; return micRms;
} }
} }
+45
View File
@@ -0,0 +1,45 @@
namespace ytLive.Services.Audio;
/// <summary>
/// Master output limiter — the last stage before the live mix reaches the
/// encoder pipe: a peak ceiling of -1 dBFS so mic + loopback (game + TRAX music)
/// can never sum past 0 dBFS and clip in 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.
/// </summary>
public sealed class MasterLimiter
{
/// <summary>-1 dBFS: standard streaming headroom for encoder overshoot.</summary>
public const float Ceiling = 0.891f;
private const float Release = 0.005f;
private float _gain = 1f;
/// <summary>The current gain multiplier (≤ 1, so the limiter never boosts).</summary>
public float Gain => _gain;
/// <summary>Scales the frame by the current gain. Gain drops instantly to
/// <c>ceiling/peak</c> when the frame exceeds the ceiling and recovers
/// toward unity slowly.</summary>
public void Process(float[] samples)
{
var peak = 0f;
foreach (var s in samples)
{
var abs = MathF.Abs(s);
if (abs > peak)
peak = abs;
}
var target = peak > Ceiling ? Ceiling / peak : 1f;
if (target < _gain)
_gain = target;
else
_gain += (target - _gain) * Release;
for (var i = 0; i < samples.Length; i++)
samples[i] *= _gain;
}
public void Reset() => _gain = 1f;
}
+1
View File
@@ -51,6 +51,7 @@ External-facing logic: YouTube API, persistence. See
| `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) | | `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) |
| `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, 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/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/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 |
Related: constructed in [`ViewModels/MainViewModel.cs`](../ViewModels/MainViewModel.cs) Related: constructed in [`ViewModels/MainViewModel.cs`](../ViewModels/MainViewModel.cs)
(no DI container yet). Models in [`Models/index.md`](../Models/index.md). (no DI container yet). Models in [`Models/index.md`](../Models/index.md).
+30
View File
@@ -761,6 +761,36 @@ The creator reviewed the TASK 9 build and filed 8 issues. All fixed in one branc
--- ---
## TASK 25 — Master limiter on the live mix (2026-08-15)
**Queued by the creator during the TRAX discussion:** "should the soundtrack be limited to avoid
squandering resources / should it cap at 20% / how do the three sound events balance?" Review
conclusions (all three instincts checked out, only ONE real gap):
- **File size — no guard needed.** `MusicPlayer` uses `MediaFoundationReader`, which **streams from
disk** (progressive source): memory is flat (~a few MB) regardless of file size; CPU negligible.
- **20% cap — already enforced by construction.** `MusicPlayer.MusicVolume = 0.20f` + music rides the
SAME WASAPI loopback (and therefore the same gain) as game audio, so music:game is always exactly
**0.20:1 at any slider position** — it literally cannot rise above 20% of the current desktop volume.
- **The gap:** `AudioMixer.FillAndMix` summed mic + loopback with **no output ceiling** — mic 100% +
loud game/music could pass 0 dBFS and clip the AAC encode.
**Shipped:**
1. ✅ **`Services/Audio/MasterLimiter.cs`** (pure, unit-tested) — a **−1 dBFS ceiling** (`Ceiling =
0.891`), **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; gain never exceeds 1 (no boosting).
Applied at the end of `AudioMixer.FillAndMix`, right before the pipe write.
2. ✅ **Unit tests** — over-ceiling frames trimmed to ≤ ceiling; sub-ceiling frames never boosted; gain
recovers to unity after the loud frame ends.
3. ✅ **ONE integration test** (`MasterLimiter_CapsTheLiveMix_OnThePipe`) — real mixer + pipe harness:
a 0.95 loopback bed (hotter than the ceiling) is capped to exactly 0.891 on the wire while staying
audible.
4. ✅ **Docs in the same commit** — `ai.md` (go-live audio section), `Services/index.md` (new row).
No changes to `MusicPlayer`, the 0.20 cap, the ducker, or the meter zones. Build 0 warnings.
---
## Backlog (future versions) ## Backlog (future versions)
1. v0.2 — Recording to local file (recordings carry the branding flash — see TASK 3 / `ai.md` Monetization) 1. v0.2 — Recording to local file (recordings carry the branding flash — see TASK 3 / `ai.md` Monetization)
+3 -2
View File
@@ -403,8 +403,9 @@ devices, no timers).
underruns), computes the post-filter mic RMS → `AutoDucker` (threshold 0.02, duck ×0.25 / −12 dB, underruns), computes the post-filter mic RMS → `AutoDucker` (threshold 0.02, duck ×0.25 / −12 dB,
attack 0.05, release 0.005, always on), applies the **honest gains** via `Func<double>` seams attack 0.05, release 0.005, always on), applies the **honest gains** via `Func<double>` seams
(`micGain = MicMuted ? 0 : MicVolume`; `loopbackGain = GameMuted ? 0 : GameAudioVolume`, × duck), (`micGain = MicMuted ? 0 : MicVolume`; `loopbackGain = GameMuted ? 0 : GameAudioVolume`, × duck),
mixes mono mic + stereo loopback into interleaved stereo (no clamp — gains are user-owned), and mixes mono mic + stereo loopback into interleaved stereo, runs the
writes the floats to the pipe. **The gains are wired through `AudioGainProvider`** **`MasterLimiter`** (a **−1 dBFS ceiling** so the honest gains can never sum past 0 dBFS and clip the
AAC encode — instant attack per frame, smoothed release toward unity), and writes the floats to the pipe. **The gains are wired through `AudioGainProvider`**
(`Services/Audio/AudioGainProvider.cs`) — the VM hands its `MicGain`/`LoopbackGain` Funcs to the (`Services/Audio/AudioGainProvider.cs`) — the VM hands its `MicGain`/`LoopbackGain` Funcs to the
mixer at construction, read live each tick, so the volume sliders and mute buttons are stream-honest. mixer at construction, read live each tick, so the volume sliders and mute buttons are stream-honest.
(Before this wiring the seams defaulted to unity and the controls were decorative.) `StopStream` → (Before this wiring the seams defaulted to unity and the controls were decorative.) `StopStream` →
+87 -3
View File
@@ -8,9 +8,9 @@ namespace ytLive.Tests;
/// <summary> /// <summary>
/// TASK 9 audio milestone units: the voice chain (shelf/gate/compressor), the /// TASK 9 audio milestone units: the voice chain (shelf/gate/compressor), the
/// ring buffer, the auto-ducker, and the resampler are all pure and tested in /// ring buffer, the auto-ducker, and the resampler are all pure and tested in
/// isolation. The single integration test drives the REAL chain + mixer + /// isolation. The integration tests drive the REAL chain + mixer + ducker +
/// ducker + named-pipe writer against fakes and reads the float stream back — /// named-pipe writer against fakes and read the float stream back — the Good
/// the Good Dog Rule keeps it to exactly one integration test per branch. /// Dog Rule keeps it to exactly one integration test per branch.
/// </summary> /// </summary>
public class AudioPipelineTests public class AudioPipelineTests
{ {
@@ -226,6 +226,45 @@ public class AudioPipelineTests
Assert.Equal(1600, second.Length); Assert.Equal(1600, second.Length);
} }
// ─── Master limiter ───
[Fact]
public void MasterLimiter_TrimsOverCeilingFrame_ToCeiling()
{
var limiter = new MasterLimiter();
var frame = new float[] { -1f, 0.95f, 0.3f, -0.9f };
limiter.Process(frame);
Assert.All(frame, s => Assert.InRange(MathF.Abs(s), 0f, MasterLimiter.Ceiling + 0.001f));
Assert.True(limiter.Gain < 1f, "a hot frame must pull the gain below unity");
}
[Fact]
public void MasterLimiter_NeverBoostsQuietFrames()
{
var limiter = new MasterLimiter();
var input = new float[] { 0.2f, -0.15f, 0.05f, 0f };
limiter.Process(input);
Assert.All(input, s => Assert.InRange(s, -0.2f, 0.2f));
Assert.Equal(1f, limiter.Gain, 6);
}
[Fact]
public void MasterLimiter_RecoversToUnityAfterLoudFrame()
{
var limiter = new MasterLimiter();
limiter.Process(new[] { 1f, 1f });
Assert.True(limiter.Gain < 1f);
for (var i = 0; i < 2000; i++)
limiter.Process(new[] { 0.1f, -0.1f });
Assert.Equal(1f, limiter.Gain, 4);
}
// ─── The ONE integration test (Good Dog Rule): real chain + mixer + ducker // ─── The ONE integration test (Good Dog Rule): real chain + mixer + ducker
// ─── + named pipe, fakes for the capture sources, read back over the wire. ─── // ─── + named pipe, fakes for the capture sources, read back over the wire. ───
@@ -344,6 +383,51 @@ public class AudioPipelineTests
Assert.All(mutedFloats, f => Assert.Equal(0f, f, 6)); Assert.All(mutedFloats, f => Assert.Equal(0f, f, 6));
} }
[Fact]
public async Task MasterLimiter_CapsTheLiveMix_OnThePipe()
{
// THE integration test for the master-limiter branch: a hot desktop/game
// channel (loud game + TRAX riding the loopback, the creator not talking)
// sums past 0 dBFS — the limiter must hold every wire sample at -1 dBFS
// while the signal stays audible.
var pipeName = "ytllive_test_" + Guid.NewGuid().ToString("N");
var mic = new FakeSource();
var loopback = new FakeSource();
using var mixer = new AudioMixer(
mic, loopback,
log: null,
micGain: () => 1.0,
loopbackGain: () => 1.0,
mixInterval: TimeSpan.FromMilliseconds(5));
// Mic silent (the ducker stays at unity) + a 0.95 loopback bed — hotter
// than the -1 dBFS ceiling, so every tick must be trimmed. Pre-fill the
// ring buffer so the first written frame is already loud.
var loopChunk = new float[480];
Array.Fill(loopChunk, 0.95f);
for (var i = 0; i < 200; i++)
loopback.Emit(new AudioSample(loopChunk, 48000, 2));
using var client = new NamedPipeClientStream(".", pipeName, PipeDirection.In, PipeOptions.Asynchronous);
var connected = client.ConnectAsync();
mixer.StartLive(pipeName);
await connected.WaitAsync(TimeSpan.FromSeconds(5));
var bytes = await ReadFullyAsync(client, 240 * 2 * 4, TimeSpan.FromSeconds(5));
mixer.StopLive();
Assert.True(bytes.Length >= 240 * 2 * 4, "expected at least one full stereo frame");
var floats = new float[bytes.Length / 4];
Buffer.BlockCopy(bytes, 0, floats, 0, bytes.Length);
// Raw 0.95 would ride the wire un-trimmed; the limiter must cap every
// sample at the ceiling (0.891), with the signal still clearly audible.
Assert.All(floats, f => Assert.InRange(f, -MasterLimiter.Ceiling - 0.001f, MasterLimiter.Ceiling + 0.001f));
Assert.Equal(MasterLimiter.Ceiling, floats.Max(f => MathF.Abs(f)), 3);
Assert.True(floats.Any(f => MathF.Abs(f) > 0.5f), "the capped mix must still be audible");
}
private static async Task<byte[]> ReadFullyAsync(NamedPipeClientStream client, int count, TimeSpan timeout) private static async Task<byte[]> ReadFullyAsync(NamedPipeClientStream client, int count, TimeSpan timeout)
{ {
var ms = new MemoryStream(); var ms = new MemoryStream();