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)
|
## Session state (last updated: 2026-08-15)
|
||||||
|
|
||||||
- **Branch:** `main`, tracking `origin/main`. Working tree: the **TASK 24 polish batch** + the **game
|
- **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
|
audio bar always-visible change** + **TASK 25 (master limiter)** are committed + pushed. Local
|
||||||
`social-bar`/`webcam-validation` untouched (no secrets).
|
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
|
- **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
|
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 +
|
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
|
- **MainWindow.xaml:** dropped the `Visibility` binding on the preview-overlay game bar; it renders
|
||||||
unconditionally (TRAX button + "Desktop Audio" label + meter + mute + volume unchanged).
|
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)
|
## 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
|
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
|
/// 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
|
/// and queued. While live (<see cref="StartLive"/>), a 10 ms loop drains both
|
||||||
/// queues, applies the honest gains (mic = MicVolume, loopback = GameAudioVolume
|
/// queues, applies the honest gains (mic = MicVolume, loopback = GameAudioVolume
|
||||||
/// × auto-duck when the mic is hot), mixes them to stereo float and writes the
|
/// × auto-duck when the mic is hot), mixes them to stereo float, runs the
|
||||||
/// chunk to the encoder's audio pipe.
|
/// master limiter (a -1 dBFS ceiling so the sum never clips the encoder), and
|
||||||
|
/// writes the chunk to the encoder's audio pipe.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class AudioMixer : IDisposable
|
public sealed class AudioMixer : IDisposable
|
||||||
{
|
{
|
||||||
@@ -36,6 +37,7 @@ public sealed class AudioMixer : IDisposable
|
|||||||
private readonly AudioRingBuffer _loopbackBuffer;
|
private readonly AudioRingBuffer _loopbackBuffer;
|
||||||
private readonly VoiceFilterChain _voiceChain;
|
private readonly VoiceFilterChain _voiceChain;
|
||||||
private readonly AutoDucker _ducker;
|
private readonly AutoDucker _ducker;
|
||||||
|
private readonly MasterLimiter _masterLimiter = new();
|
||||||
private TinyResampler? _micResampler;
|
private TinyResampler? _micResampler;
|
||||||
private TinyResampler? _loopbackResampler;
|
private TinyResampler? _loopbackResampler;
|
||||||
private CancellationTokenSource? _liveCts;
|
private CancellationTokenSource? _liveCts;
|
||||||
@@ -330,6 +332,7 @@ public sealed class AudioMixer : IDisposable
|
|||||||
mix[i * 2] = m + loopbackChunk[i * 2] * loopGain;
|
mix[i * 2] = m + loopbackChunk[i * 2] * loopGain;
|
||||||
mix[i * 2 + 1] = m + loopbackChunk[i * 2 + 1] * loopGain;
|
mix[i * 2 + 1] = m + loopbackChunk[i * 2 + 1] * loopGain;
|
||||||
}
|
}
|
||||||
|
_masterLimiter.Process(mix);
|
||||||
return micRms;
|
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/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/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/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)
|
Related: constructed in [`ViewModels/MainViewModel.cs`](../ViewModels/MainViewModel.cs)
|
||||||
(no DI container yet). Models in [`Models/index.md`](../Models/index.md).
|
(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)
|
## Backlog (future versions)
|
||||||
|
|
||||||
1. v0.2 — Recording to local file (recordings carry the branding flash — see TASK 3 / `ai.md` Monetization)
|
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,
|
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
|
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),
|
(`micGain = MicMuted ? 0 : MicVolume`; `loopbackGain = GameMuted ? 0 : GameAudioVolume`, × duck),
|
||||||
mixes mono mic + stereo loopback into interleaved stereo (no clamp — gains are user-owned), and
|
mixes mono mic + stereo loopback into interleaved stereo, runs the
|
||||||
writes the floats to the pipe. **The gains are wired through `AudioGainProvider`**
|
**`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
|
(`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.
|
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` →
|
(Before this wiring the seams defaulted to unity and the controls were decorative.) `StopStream` →
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ namespace ytLive.Tests;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// TASK 9 audio milestone units: the voice chain (shelf/gate/compressor), the
|
/// 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
|
/// ring buffer, the auto-ducker, and the resampler are all pure and tested in
|
||||||
/// isolation. The single integration test drives the REAL chain + mixer +
|
/// isolation. The integration tests drive the REAL chain + mixer + ducker +
|
||||||
/// ducker + named-pipe writer against fakes and reads the float stream back —
|
/// named-pipe writer against fakes and read the float stream back — the Good
|
||||||
/// the Good Dog Rule keeps it to exactly one integration test per branch.
|
/// Dog Rule keeps it to exactly one integration test per branch.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class AudioPipelineTests
|
public class AudioPipelineTests
|
||||||
{
|
{
|
||||||
@@ -226,6 +226,45 @@ public class AudioPipelineTests
|
|||||||
Assert.Equal(1600, second.Length);
|
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
|
// ─── The ONE integration test (Good Dog Rule): real chain + mixer + ducker
|
||||||
// ─── + named pipe, fakes for the capture sources, read back over the wire. ───
|
// ─── + 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));
|
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)
|
private static async Task<byte[]> ReadFullyAsync(NamedPipeClientStream client, int count, TimeSpan timeout)
|
||||||
{
|
{
|
||||||
var ms = new MemoryStream();
|
var ms = new MemoryStream();
|
||||||
|
|||||||
Reference in New Issue
Block a user