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 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. /// 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); } // ─── 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)); // Loud mic (filtered + compressed toward ~0.5) + loud loopback (ducked // from unity down toward 0.25). Pre-fill the ring buffers so the very // first tick written to the pipe already carries real audio. 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)); } 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); // 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"); } 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); } } }