596 lines
23 KiB
C#
596 lines
23 KiB
C#
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);
|
||
}
|
||
}
|
||
}
|