using Xunit; using ytLive.Services.Audio; namespace ytLive.Tests; /// /// TASK 22: the global audio-sync delay line. It is a pure fixed positive /// delay on the interleaved stereo mix — the OBS fix for audio running ahead /// of video. Positive-only: the line clamps negatives to 0; the NEGATIVE /// (advance) side — audio running behind video, the OBS "eat the buffer head" /// case — is covered by . /// public class AudioSyncDelayTests { [Fact] public void ZeroDelay_PassesThroughUnchanged() { var delay = new AudioSyncDelay(48000); delay.Configure(0); var input = new float[16]; for (var i = 0; i < input.Length; i++) input[i] = i - 8; var output = new float[16]; delay.Process(input, output); Assert.Equal(input, output); } [Fact] public void ConfigureClampsNegativeToZero_AndBeyond500msTo500ms() { var delay = new AudioSyncDelay(48000); delay.Configure(-50); Assert.Equal(0, MarkerShift(delay, 1000)); // 500 ms at 48 kHz = 24000 frames = 48000 interleaved samples. delay.Configure(6000); Assert.Equal(48000, MarkerShift(delay, 48000)); } [Fact] public void TenMsDelay_EmitsTheSignal960SamplesLater() { // 10 ms at 48 kHz = 480 frames; interleaved stereo = 960 samples. var delay = new AudioSyncDelay(48000); Assert.Equal(960, MarkerShift(delay, 480 * 2, configureMs: 10)); } /// 2026-09-12 total-silence regression: /// calls Configure(_syncOffsetMs?.Invoke() ?? 0) on EVERY ~10ms tick (it /// re-reads the live UI setting each mix, not just on change) — the old /// `Configure` unconditionally reallocated and zeroed the delay line and reset /// `_writePos` every single call, discarding the buffered marker before the /// delay offset could ever read it back. Any persisted non-zero /// `Audio.SyncOffsetMs` (this repo's default is 0, but TASK 22 testing/a user /// slider left 500 saved) turned the WHOLE recording into digital silence: /// mic/loop level meters (sampled upstream of this stage) stayed honest while /// the delay line fed the encoder pipe pure zeros forever. This test /// reconfigures with the SAME delay before every Process call — exactly the /// mixer's calling pattern — and the marker must still survive. [Fact] public void RepeatedConfigureWithSameDelay_EveryTick_StillDelivers_TheMarker() { const int chunkLen = 960; // 10ms of 48kHz interleaved stereo var delay = new AudioSyncDelay(48000); var chunk = new float[chunkLen]; var output = new float[chunkLen]; chunk[0] = 1f; // sole non-zero: left of frame 0 var foundAt = -1; var total = 0; for (var c = 0; c < 6 && foundAt < 0; c++) { delay.Configure(10); // the mixer calls this every tick, not just on change delay.Process(chunk, output); for (var i = 0; i < chunkLen; i++) { if (output[i] != 0f) { foundAt = total + i; break; } } total += chunkLen; chunk[0] = 0f; // only the first tick carries the marker } Assert.Equal(960, foundAt); // same 10ms-delay shift as the single-Configure test } private static int MarkerShift(AudioSyncDelay delay, int chunkLen, int configureMs = -1) { if (configureMs >= 0) delay.Configure(configureMs); var chunk = new float[chunkLen]; var output = new float[chunkLen]; chunk[0] = 1f; // sole non-zero: left of frame 0 var foundAt = -1; var total = 0; for (var c = 0; c < 4 && foundAt < 0; c++) { delay.Process(chunk, output); for (var i = 0; i < chunkLen; i++) { if (output[i] != 0f) { foundAt = total + i; break; } } total += chunkLen; } Assert.True(foundAt >= 0, "marker never appeared in the delayed output"); return foundAt; } }