using System.Buffers; using System.IO.Pipes; using Xunit; using ytLive.Services.Audio; 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 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 { // ─── Voice chain ─── [Fact] public void LowShelf_DcGain_MatchesShelfGain() { var filter = new LowShelfFilter(48000, 120, 4f); float last = 0; for (var i = 0; i < 2000; i++) last = filter.Process(1f); // +4 dB → A² ≈ 1.585 at DC. Assert.InRange(last, 1.5f, 1.7f); } [Fact] public void HighShelf_PassesDc() { var filter = new HighShelfFilter(48000, 8000, 3f); float last = 0; for (var i = 0; i < 2000; i++) last = filter.Process(1f); Assert.InRange(last, 0.98f, 1.02f); } [Fact] public void NoiseGate_StaysClosedOnSilence() { var gate = new NoiseGate(); for (var i = 0; i < 1000; i++) Assert.Equal(0f, gate.Process(0.001f)); Assert.False(gate.IsOpen); } [Fact] public void NoiseGate_OpensOnLoudInput_AndHoldsThroughQuiet() { var gate = new NoiseGate(); for (var i = 0; i < 50; i++) gate.Process(0.5f); Assert.True(gate.IsOpen); // A brief quiet dip must NOT close it (release hysteresis) — no chatter. for (var i = 0; i < 20; i++) Assert.Equal(0.001f, gate.Process(0.001f), 6); Assert.True(gate.IsOpen); } [Fact] public void Compressor_AtOrBelowThreshold_PassesThrough() { var compressor = new Compressor(); Assert.Equal(0.4f, compressor.Process(0.4f)); Assert.Equal(0.5f, compressor.Process(0.5f)); Assert.Equal(-0.5f, compressor.Process(-0.5f)); } [Fact] public void Compressor_FoldsLoudSignalAtRatio() { var compressor = new Compressor(); Assert.Equal(0.625f, compressor.Process(1f), 4); Assert.Equal(-0.625f, compressor.Process(-1f), 4); } [Fact] public void VoiceChain_SilenceStaysSilent() { var chain = new VoiceFilterChain(48000); for (var i = 0; i < 500; i++) Assert.Equal(0f, chain.Process(0f), 6); } [Fact] public void VoiceChain_NeverClipsLoudSignal() { var chain = new VoiceFilterChain(48000); for (var i = 0; i < 500; i++) { var outSample = chain.Process(1f); Assert.InRange(outSample, -1.1f, 1.1f); } } // ─── Ring buffer ─── [Fact] public void RingBuffer_ReadsBackInOrder() { var buffer = new AudioRingBuffer(8); buffer.Write(new[] { 1f, 2f, 3f }); buffer.Write(new[] { 4f, 5f }); var dest = new float[8]; Assert.Equal(5, buffer.Read(dest, 8)); Assert.Equal(new[] { 1f, 2f, 3f, 4f, 5f, 0f, 0f, 0f }, dest); Assert.Equal(0, buffer.Count); } [Fact] public void RingBuffer_OverwritesOldestWhenFull() { var buffer = new AudioRingBuffer(4); buffer.Write(new[] { 1f, 2f, 3f }); buffer.Write(new[] { 4f, 5f, 6f }); // drops 1,2 var dest = new float[4]; Assert.Equal(4, buffer.Read(dest, 4)); Assert.Equal(new[] { 3f, 4f, 5f, 6f }, dest); } [Fact] public void RingBuffer_ReadUnderrun_ReturnsFewer() { var buffer = new AudioRingBuffer(8); buffer.Write(new[] { 1f, 2f }); var dest = new float[8]; Assert.Equal(2, buffer.Read(dest, 8)); Assert.Equal(0, buffer.Count); } [Fact] public void RingBuffer_WrapsAround() { var buffer = new AudioRingBuffer(4); buffer.Write(new[] { 1f, 2f, 3f }); buffer.Read(new float[4], 2); // consume 1,2 — head now past the ring buffer.Write(new[] { 4f, 5f, 6f }); // wraps around the tail var dest = new float[4]; Assert.Equal(4, buffer.Read(dest, 4)); Assert.Equal(new[] { 3f, 4f, 5f, 6f }, dest); } // ─── Auto-duck ─── [Fact] public void Ducker_Silence_KeepsUnity() { var ducker = new AutoDucker(); for (var i = 0; i < 100; i++) Assert.Equal(1f, ducker.Update(0f)); } [Fact] public void Ducker_HotMic_ConvergesToDuckGain() { var ducker = new AutoDucker(); for (var i = 0; i < 500; i++) ducker.Update(0.2f); Assert.Equal(AutoDucker.DuckGain, ducker.CurrentGain, 2); } [Fact] public void Ducker_RecoversWhenMicStops() { var ducker = new AutoDucker(); for (var i = 0; i < 500; i++) ducker.Update(0.2f); Assert.InRange(ducker.CurrentGain, 0.1f, 0.4f); for (var i = 0; i < 3000; i++) ducker.Update(0f); Assert.Equal(1f, ducker.CurrentGain, 2); } // ─── Resampler ─── [Fact] public void Resampler_SameRate_PassesThrough() { var resampler = new TinyResampler(48000, 48000); var input = new float[] { 0.1f, 0.2f, 0.3f }; Assert.Same(input, resampler.Process(input)); } [Fact] public void Resampler_Downsample_ChangesLength() { var resampler = new TinyResampler(44100, 48000); var output = resampler.Process(Enumerable.Repeat(0.5f, 4410).ToArray()); Assert.Equal(4800, output.Length); Assert.All(output, v => Assert.Equal(0.5f, v, 3)); } [Fact] public void Resampler_Upsample_ChangesLength() { var resampler = new TinyResampler(48000, 44100); var output = resampler.Process(Enumerable.Repeat(0.25f, 4800).ToArray()); Assert.Equal(4410, output.Length); Assert.All(output, v => Assert.Equal(0.25f, v, 3)); } [Fact] public void Resampler_IsStatefulAcrossChunks() { var resampler = new TinyResampler(48000, 16000); // 3:1 downsample var input = Enumerable.Range(0, 4800).Select(i => (float)i).ToArray(); var first = resampler.Process(input); var second = resampler.Process(input); // No double-counting of the boundary: the phase carries over, so the // first output sample of chunk 2 is the next input index, not index 0. Assert.Equal(1600, first.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 // ─── + named pipe, fakes for the capture sources, read back over the wire. ─── private sealed class FakeSource : IAudioSource { public event Action? Started; public event Action? SampleReady; public event Action? Failed; public void Start() => Started?.Invoke(); public void Stop() { } public void Dispose() { } public void Emit(AudioSample sample) => SampleReady?.Invoke(sample); public void Fail(Exception ex) => Failed?.Invoke(ex); } [Fact] public async Task Mix_WithFiltersDuckAndGain_Lands_On_AudioPipe() { 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)); using var client = new NamedPipeClientStream(".", pipeName, PipeDirection.In, PipeOptions.Asynchronous); var connected = client.ConnectAsync(); mixer.StartLive(pipeName); // Loud mic (filtered + compressed toward ~0.5) + loud loopback (ducked // from unity down toward 0.25). Emitted right after StartLive — the // rings are cleared at go-live so pre-live backlog never reaches the // wire (the 2026-09-14 ~2s sync fix); dripping into the connected pipe // is dropped, so the ring stays primed for the client. var micChunk = new float[240]; Array.Fill(micChunk, 0.5f); var loopChunk = new float[480]; Array.Fill(loopChunk, 0.8f); for (var i = 0; i < 200; i++) { mic.Emit(new AudioSample(micChunk, 48000, 1)); loopback.Emit(new AudioSample(loopChunk, 48000, 2)); } 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); // The chain compresses the mic and the ducker folds the loopback down — // the result must be a bounded, finite, non-silent signal. Assert.All(floats, f => Assert.InRange(f, -2f, 2f)); Assert.True(floats.Any(f => MathF.Abs(f) > 0.05f), "the pipe carried silence"); } [Fact] public async Task Mix_HonorsProviderGains_AndGameMute_KillsTheLoopback() { // THE integration test for the polish batch: the AudioGainProvider's // Func seams are exactly what the VM hands to the mixer, so this proves // the desktop/game volume slider and mute button actually reach the live // mix (before wiring they defaulted to unity and did nothing). var pipeName = "ytllive_test_" + Guid.NewGuid().ToString("N"); var mic = new FakeSource(); var loopback = new FakeSource(); var gameVolume = 0.5; var gameMuted = false; var provider = new AudioGainProvider( micMuted: () => false, micVolume: () => 1.0, gameMuted: () => gameMuted, gameVolume: () => gameVolume); using var mixer = new AudioMixer( mic, loopback, log: null, micGain: provider.MicGain, loopbackGain: provider.LoopbackGain, mixInterval: TimeSpan.FromMilliseconds(5)); using var client = new NamedPipeClientStream(".", pipeName, PipeDirection.In, PipeOptions.Asynchronous); var connected = client.ConnectAsync(); mixer.StartLive(pipeName); // Mic silent (so the ducker stays at unity) + a loud loopback bed that // scales to 0.8 * 0.5 = 0.4 on the wire. Emitted right after StartLive — // the 2026-09-14 fix clears pre-live ring backlog at go-live, so this is // the fresh bed the client pulls from. var loopChunk = new float[480]; Array.Fill(loopChunk, 0.8f); for (var i = 0; i < 200; i++) loopback.Emit(new AudioSample(loopChunk, 48000, 2)); // Phase 1: unmuted at 0.5 → scaled but clearly audible, bounded. await connected.WaitAsync(TimeSpan.FromSeconds(5)); var bytes = await ReadFullyAsync(client, 240 * 2 * 4, TimeSpan.FromSeconds(5)); var floats = new float[bytes.Length / 4]; Buffer.BlockCopy(bytes, 0, floats, 0, bytes.Length); Assert.True(floats.Any(f => MathF.Abs(f) > 0.1f), "scaled loopback should be audible"); Assert.All(floats, f => Assert.InRange(f, -0.55f, 0.55f)); // Phase 2: mute flips the provider's seam → the pipe must go silent. gameMuted = true; var mutedBytes = await ReadFullyAsync(client, 240 * 2 * 4, TimeSpan.FromSeconds(5)); mixer.StopLive(); Assert.True(mutedBytes.Length >= 240 * 2 * 4, "expected at least one full stereo frame after mute"); var mutedFloats = new float[mutedBytes.Length / 4]; Buffer.BlockCopy(mutedBytes, 0, mutedFloats, 0, mutedBytes.Length); 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)); using var client = new NamedPipeClientStream(".", pipeName, PipeDirection.In, PipeOptions.Asynchronous); var connected = client.ConnectAsync(); mixer.StartLive(pipeName); // Mic silent (the ducker stays at unity) + a 0.95 loopback bed — hotter // than the -1 dBFS ceiling, so every tick must be trimmed. Emitted right // after StartLive — the 2026-09-14 fix clears pre-live ring backlog at // go-live, so this is the fresh bed the client pulls from. var loopChunk = new float[480]; Array.Fill(loopChunk, 0.95f); for (var i = 0; i < 200; i++) loopback.Emit(new AudioSample(loopChunk, 48000, 2)); 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"); } [Fact] public async Task StartLive_DiscardsPreLiveBacklog_SoFirstAudioIsCurrent() { // THE integration test for the 2026-09-14 sync fix: capture runs from // app STARTUP for the meters, so by the time the user hits record both // rings are full of ~2s of pre-live audio. Draining that backlog put // every recorded event ~2s late in the audio track. StartLive must // discard it so the recording begins at go-live. 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, mixInterval: TimeSpan.FromMilliseconds(5)); // Pre-live: saturate the loopback ring (0.8 — a full ring depth) with // the OLD code this is exactly the backlog that leaked onto the wire. var stale = new float[480]; Array.Fill(stale, 0.8f); for (var i = 0; i < 500; i++) loopback.Emit(new AudioSample(stale, 48000, 2)); using var client = new NamedPipeClientStream(".", pipeName, PipeDirection.In, PipeOptions.Asynchronous); var connected = client.ConnectAsync(); mixer.StartLive(pipeName); // Post-live: fresh audio at 0.2 — the loopback path has no voice chain, // unity gain, ducker silent, limiter quiet: the wire carries it nearly // 1:1. If the stale 0.8 survived the clear, it would dominate. var fresh = new float[480]; Array.Fill(fresh, 0.2f); for (var i = 0; i < 50; i++) loopback.Emit(new AudioSample(fresh, 48000, 2)); await connected.WaitAsync(TimeSpan.FromSeconds(5)); var bytes = await ReadFullyAsync(client, 240 * 2 * 4, TimeSpan.FromSeconds(5)); mixer.StopLive(); var floats = new float[bytes.Length / 4]; Buffer.BlockCopy(bytes, 0, floats, 0, bytes.Length); Assert.True(floats.Length > 0, "expected wired audio"); Assert.Equal(0.2f, floats.Max(), 3); Assert.True(floats.Max() < 0.3f, "stale pre-live backlog leaked onto the wire"); } private static async Task ReadFullyAsync(NamedPipeClientStream client, int count, TimeSpan timeout) { var ms = new MemoryStream(); var buffer = ArrayPool.Shared.Rent(4096); try { var deadline = DateTime.UtcNow + timeout; while (ms.Length < count && DateTime.UtcNow < deadline) { var read = await client.ReadAsync(buffer, 0, buffer.Length); if (read == 0) break; ms.Write(buffer, 0, read); } return ms.ToArray(); } finally { ArrayPool.Shared.Return(buffer); } } }