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