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).
This commit is contained in:
2026-09-14 12:30:44 -07:00
parent 11a7af2dc0
commit b22d08eca6
12 changed files with 219 additions and 144 deletions
+48
View File
@@ -478,6 +478,54 @@ public class AudioPipelineTests
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();