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();