diff --git a/HANDOFF.md b/HANDOFF.md
index 1c2fe5e..d8d4974 100644
--- a/HANDOFF.md
+++ b/HANDOFF.md
@@ -8,8 +8,16 @@
## Session state (last updated: 2026-08-15)
- **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
- `social-bar`/`webcam-validation` untouched (no secrets).
+ audio bar always-visible change** + **TASK 25 (master limiter)** are committed + pushed. Local
+ 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
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 +
@@ -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
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)
The creator's feature review settled this as the single next branch ("all the audio issues done and
diff --git a/Services/Audio/AudioMixer.cs b/Services/Audio/AudioMixer.cs
index 9fcd441..f45b666 100644
--- a/Services/Audio/AudioMixer.cs
+++ b/Services/Audio/AudioMixer.cs
@@ -11,8 +11,9 @@ namespace ytLive.Services.Audio;
/// level-metered and queued; loopback samples are resampled to 48 kHz stereo
/// and queued. While live (), a 10 ms loop drains both
/// queues, applies the honest gains (mic = MicVolume, loopback = GameAudioVolume
-/// × auto-duck when the mic is hot), mixes them to stereo float and writes the
-/// chunk to the encoder's audio pipe.
+/// × auto-duck when the mic is hot), mixes them to stereo float, runs the
+/// master limiter (a -1 dBFS ceiling so the sum never clips the encoder), and
+/// writes the chunk to the encoder's audio pipe.
///
public sealed class AudioMixer : IDisposable
{
@@ -36,6 +37,7 @@ public sealed class AudioMixer : IDisposable
private readonly AudioRingBuffer _loopbackBuffer;
private readonly VoiceFilterChain _voiceChain;
private readonly AutoDucker _ducker;
+ private readonly MasterLimiter _masterLimiter = new();
private TinyResampler? _micResampler;
private TinyResampler? _loopbackResampler;
private CancellationTokenSource? _liveCts;
@@ -330,6 +332,7 @@ public sealed class AudioMixer : IDisposable
mix[i * 2] = m + loopbackChunk[i * 2] * loopGain;
mix[i * 2 + 1] = m + loopbackChunk[i * 2 + 1] * loopGain;
}
+ _masterLimiter.Process(mix);
return micRms;
}
}
diff --git a/Services/Audio/MasterLimiter.cs b/Services/Audio/MasterLimiter.cs
new file mode 100644
index 0000000..f786490
--- /dev/null
+++ b/Services/Audio/MasterLimiter.cs
@@ -0,0 +1,45 @@
+namespace ytLive.Services.Audio;
+
+///
+/// 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.
+///
+public sealed class MasterLimiter
+{
+ /// -1 dBFS: standard streaming headroom for encoder overshoot.
+ public const float Ceiling = 0.891f;
+
+ private const float Release = 0.005f;
+ private float _gain = 1f;
+
+ /// The current gain multiplier (≤ 1, so the limiter never boosts).
+ public float Gain => _gain;
+
+ /// Scales the frame by the current gain. Gain drops instantly to
+ /// ceiling/peak when the frame exceeds the ceiling and recovers
+ /// toward unity slowly.
+ 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;
+}
diff --git a/Services/index.md b/Services/index.md
index aed2fc7..e40d65b 100644
--- a/Services/index.md
+++ b/Services/index.md
@@ -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/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/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)
(no DI container yet). Models in [`Models/index.md`](../Models/index.md).
diff --git a/TASKS.md b/TASKS.md
index 37b2b00..0864605 100644
--- a/TASKS.md
+++ b/TASKS.md
@@ -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)
1. v0.2 — Recording to local file (recordings carry the branding flash — see TASK 3 / `ai.md` Monetization)
diff --git a/ai.md b/ai.md
index dbb3ebc..d85fc94 100644
--- a/ai.md
+++ b/ai.md
@@ -403,8 +403,9 @@ devices, no timers).
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` seams
(`micGain = MicMuted ? 0 : MicVolume`; `loopbackGain = GameMuted ? 0 : GameAudioVolume`, × duck),
- mixes mono mic + stereo loopback into interleaved stereo (no clamp — gains are user-owned), and
- writes the floats to the pipe. **The gains are wired through `AudioGainProvider`**
+ mixes mono mic + stereo loopback into interleaved stereo, runs the
+ **`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
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` →
diff --git a/ytLive.Tests/AudioPipelineTests.cs b/ytLive.Tests/AudioPipelineTests.cs
index 22c781b..0389204 100644
--- a/ytLive.Tests/AudioPipelineTests.cs
+++ b/ytLive.Tests/AudioPipelineTests.cs
@@ -8,9 +8,9 @@ namespace ytLive.Tests;
///
/// 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
-/// isolation. The single integration test drives the REAL chain + mixer +
-/// ducker + named-pipe writer against fakes and reads the float stream back —
-/// the Good Dog Rule keeps it to exactly one integration test per branch.
+/// isolation. The integration tests drive the REAL chain + mixer + ducker +
+/// named-pipe writer against fakes and read the float stream back — the Good
+/// Dog Rule keeps it to exactly one integration test per branch.
///
public class AudioPipelineTests
{
@@ -226,6 +226,45 @@ public class AudioPipelineTests
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
// ─── + 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));
}
+ [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 ReadFullyAsync(NamedPipeClientStream client, int count, TimeSpan timeout)
{
var ms = new MemoryStream();