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:
+30
-2
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<double>` 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` →
|
||||
|
||||
@@ -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