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
+87 -3
View File
@@ -8,9 +8,9 @@ namespace ytLive.Tests;
/// <summary>
/// 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.
/// </summary>
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<byte[]> ReadFullyAsync(NamedPipeClientStream client, int count, TimeSpan timeout)
{
var ms = new MemoryStream();