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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user