Files
LlamaCasty/ytLive.Tests/AudioSyncDelayTests.cs
gramps b22d08eca6 feat: signed A/V sync offset (−500..+500), negative advances by eating live stream head (OBS eat-head semantics)
Positive offsets still delay the whole mix via the delay line (lip-sync fix);
negative offsets now ARM once at StartLive and drop |N| ms off the pipe's write
head so audio events land earlier when audio runs BEHIND video. Slider relabeled
AUDIO SYNC, Min −500, locked while live/recording (IsEditMode). LayoutStore and
VM clamp to −500..500.

OBS reference for eat-the-head negative sync: https://obsproject.com/kb/obs-studio/buffering-time (negative sync values pull audio earlier by discarding buffered player audio).

Test: StartLive_NegativeOffset_AdvancesAudio_ByDroppingTheStreamHead (6x0.9 head
must be eaten before 0.2 bed reaches the wire).
2026-09-14 12:30:44 -07:00

117 lines
4.3 KiB
C#

using Xunit;
using ytLive.Services.Audio;
namespace ytLive.Tests;
/// <summary>
/// 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 <see cref="AudioPipelineTests.StartLive_NegativeOffset_AdvancesAudio_ByDroppingTheStreamHead"/>.
/// </summary>
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));
}
/// <summary>2026-09-12 total-silence regression: <see cref="AudioMixer.FillAndMix"/>
/// calls <c>Configure(_syncOffsetMs?.Invoke() ?? 0)</c> 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.</summary>
[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;
}
}