Files
LlamaCasty/ytLive.Tests/AudioPipelineTests.cs
T

596 lines
23 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 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.
/// </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);
}
}
/// <summary>2026-10-09 — a creator's upload measured −28.5 LUFS (YouTube's reference is
/// −14): nothing in the chain brought quiet speech up. THE test for the speech leveler:
/// syllable-shaped "speech" fed through the REAL voice chain lands at the leveler's
/// −20 dBFS target (±3 dB) whether the mic is quiet (−38 dBFS) or hot (−12 dBFS), and the
/// pauses between phrases stay silent (the gain is never pumped into the noise).</summary>
[Theory]
[InlineData(-38.0)]
[InlineData(-12.0)]
public void VoiceChain_LevelsQuietAndHotSpeech_ToTheTarget_AndKeepsPausesSilent(double inputDbfs)
{
const int rate = 48000;
var chain = new VoiceFilterChain(rate);
// Unit-RMS "voice": three partials, 4 Hz syllable envelope, then scaled to the input level.
float Voice(int n)
{
var t = n / (float)rate;
var tone = MathF.Sin(2 * MathF.PI * 210 * t) + 0.6f * MathF.Sin(2 * MathF.PI * 420 * t) + 0.3f * MathF.Sin(2 * MathF.PI * 840 * t);
var syllable = MathF.Abs(MathF.Sin(2 * MathF.PI * 4 * t));
return tone * syllable;
}
double unitSq = 0;
for (var n = 0; n < rate; n++) unitSq += Voice(n) * (double)Voice(n);
var scale = (float)(Math.Pow(10, inputDbfs / 20) / Math.Sqrt(unitSq / rate));
// 12 s: speak 2 s, pause 0.5 s, repeat. Measure the last 4 s.
double speechSq = 0; long speechN = 0;
var pauseMax = 0f;
var total = 12 * rate;
for (var n = 0; n < total; n++)
{
var phase = n % (int)(2.5 * rate);
var speaking = phase < 2 * rate;
var output = chain.Process(speaking ? Voice(n) * scale : 0f);
if (n < total - 4 * rate) continue;
if (speaking) { speechSq += output * (double)output; speechN++; }
else if (phase > 2 * rate + rate / 10) pauseMax = MathF.Max(pauseMax, MathF.Abs(output)); // after the gate's release tail
}
var speechDb = 10 * Math.Log10(speechSq / speechN);
var targetDb = 20 * Math.Log10(SpeechLeveler.TargetRms);
Assert.InRange(speechDb, targetDb - 3, targetDb + 3);
Assert.InRange(pauseMax, 0f, 1e-4f); // < -80 dBFS: only the EQ's decaying tail, never boosted noise
}
// ─── 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<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));
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");
}
[Fact]
public async Task StartLive_NegativeOffset_AdvancesAudio_ByDroppingTheStreamHead()
{
// THE integration test for the signed-sync branch (2026-09-14): a
// NEGATIVE offset must ADVANCE audio — OBS eats the buffer head so the
// audio lands earlier on the video. StartLive arms a |N| ms sample
// budget; the live loop skips that much off the write head. With a -40ms
// offset at a 5ms mix interval (480 samples/tick at 48k), the budget is
// 3840 samples = a full 8 ticks. The first 6 ticks carry fresh 0.9 audio,
// which MUST be eaten; the long 0.2 bed behind it is what the wire shows
// (0.2 on the wire, never 0.9). WITHOUT the advance, 0.9 leaks straight
// through and the test fails.
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,
syncOffsetMs: () => -40,
mixInterval: TimeSpan.FromMilliseconds(5));
using var client = new NamedPipeClientStream(".", pipeName, PipeDirection.In, PipeOptions.Asynchronous);
var connected = client.ConnectAsync();
mixer.StartLive(pipeName);
// 6 ticks of head 0.9 (all within the 8-tick advance budget) immediately
// after StartLive, then a long 0.2 bed. Zero mic, unity gains, silent
// ducker, quiet limiter — the loopback bed rides the wire ~1:1.
var head = new float[480];
Array.Fill(head, 0.9f);
for (var i = 0; i < 6; i++)
loopback.Emit(new AudioSample(head, 48000, 2));
var bed = new float[480];
Array.Fill(bed, 0.2f);
for (var i = 0; i < 60; i++)
loopback.Emit(new AudioSample(bed, 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.True(floats.Max() < 0.3f, "the negative-offset head was NOT eaten (0.9 leaked)");
Assert.True(floats.Any(f => MathF.Abs(f) > 0.05f), "the pipe carried only 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);
}
}
}