TASK 9 audio milestone: real stream audio + voice filters + auto-duck + free TRAX music — the mixer now feeds the encoder real audio (ffmpeg reads a Windows named pipe, -f f32le -ar 48000 -ac 2 -i \.\pipe\ytllive_audio, replacing anullsrc silence; explicit -map 0:v -map 1:a via EncoderOptions.AudioPipeName), with honest gains reaching the stream (MicVolume scales mic, GameAudioVolume + mute scale loopback, Func<double> seams on AudioMixer), an always-on pure-C# voice chain on the mic BEFORE the meter and mix (LowShelfFilter 120Hz +4dB → HighShelfFilter 8kHz +3dB → NoiseGate 0.005/hysteresis 0.5 → Compressor 0.5 4:1, all TDF2), AutoDucker (mic RMS>0.02 → loopback ×0.25, attack 0.05/release 0.005), and TRAX free background music (MusicPlayer = MediaFoundationReader → VolumeWaveProvider16 at fixed 0.20 → WaveOutEvent via the new sibling NAudio.WinMM 2.2.1 package; plays to the default device so the existing loopback carries it, ducked with game; footer TRAX button with red/yellow/green status dot, left-click toggles/picks, right-click opens the OpenFileDialog picker, tooltip shows the track name; persisted via schema v9 single-row Music). Build-time deviations from the plan (recorded in ai.md + TASKS.md): WasapiCapture in NAudio 2.2.1 exposes no overridable GetDefaultMixFormat so sources run device mix format and the mixer's TinyResampler normalizes any rate to 48kHz (resampler IS the design); FfmpegEncoder.cs untouched — MainViewModel.StopStream calls _audioMixer.StopLive() (pipe EOF) before the frame pump stops; sound-bar relabelled 'Desktop Audio', IsGameAudioBarVisible = game sound OR music playing. Tests: new AudioPipelineTests (DSP/ring-buffer/ducker/resampler units + the ONE integration test Mix_WithFiltersDuckAndGain_Lands_On_AudioPipe reading real pipe bytes; ring-buffer overwrite bug found + fixed), FfmpegEncoderTests/LayoutStorePersistenceTests updated — 196 tests passing, 0 warnings

This commit is contained in:
2026-08-14 18:09:26 -07:00
parent 3d92bd0225
commit 6d71acef8c
22 changed files with 1605 additions and 123 deletions
+311
View File
@@ -0,0 +1,311 @@
using System.Buffers;
using System.IO.Pipes;
using Xunit;
using ytLive.Services.Audio;
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.
/// </summary>
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<AudioSample>? SampleReady;
public event Action<Exception>? 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<byte[]> ReadFullyAsync(NamedPipeClientStream client, int count, TimeSpan timeout)
{
var ms = new MemoryStream();
var buffer = ArrayPool<byte>.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<byte>.Shared.Return(buffer);
}
}
}