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
+5 -2
View File
@@ -11,8 +11,9 @@ namespace ytLive.Services.Audio;
/// 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
/// 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.
/// </summary>
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;
}
}
+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;
}