fix: clear pre-live capture ring backlog at StartLive (2.2s A/V sync lag)

AudioMixer.Start() begins mic/loopback capture at app startup to feed the
level meters, so both 2s ring buffers fill with pre-live audio. StartLive()
drained from the oldest tail sample, putting every recorded event ~2.2s late
in the audio track (confirmed by clap analysis + cross-correlation on two
takes: +2.11 to +2.22s).

Fix: AudioMixer.StartLive() now runs _micBuffer.Clear() + _loopbackBuffer.Clear()
immediately after the pipe starts, before the drain task runs. Recording now
begins at go-live; the <=10ms in-flight chunk evicted by Clear() is imperceptible.

Regression test: StartLive_DiscardsPreLiveBacklog_SoFirstAudioIsCurrent
saturates the loopback ring with stale 0.8 pre-live audio, then asserts the
wire carries fresh post-live 0.2 (max < 0.3).

Reference (external, per derivative-work rule): OBS 'Audio mixer' keeps its
buffers fed continuously and syncs the stream start timestamp at record time
rather than replaying pre-live capture; a go-live flush of the capture buffer
is the accepted pattern for live tools restarting a stream.

Docs: HANDOFF.md (fix shipped), MyMistakes.md (A/V sync measurement recipe).
This commit is contained in:
2026-09-14 11:24:12 -07:00
parent 5ba4d709ae
commit 11a7af2dc0
4 changed files with 230 additions and 148 deletions
+69 -19
View File
@@ -295,9 +295,15 @@ public class AudioPipelineTests
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). Pre-fill the ring buffers so the very
// first tick written to the pipe already carries real audio.
// 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];
@@ -308,11 +314,7 @@ public class AudioPipelineTests
loopback.Emit(new AudioSample(loopChunk, 48000, 2));
}
using var client = new NamedPipeClientStream(".", pipeName, PipeDirection.In, PipeOptions.Asynchronous);
var connected = client.ConnectAsync();
mixer.StartLive(pipeName);
await connected.WaitAsync(TimeSpan.FromSeconds(5));
var bytes = await ReadFullyAsync(client, 240 * 2 * 4, TimeSpan.FromSeconds(5));
mixer.StopLive();
@@ -352,20 +354,21 @@ public class AudioPipelineTests
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. Pre-fill the ring buffer so the
// first frames already carry real audio.
// 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));
using var client = new NamedPipeClientStream(".", pipeName, PipeDirection.In, PipeOptions.Asynchronous);
var connected = client.ConnectAsync();
mixer.StartLive(pipeName);
await connected.WaitAsync(TimeSpan.FromSeconds(5));
// 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);
@@ -401,19 +404,20 @@ public class AudioPipelineTests
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. Pre-fill the
// ring buffer so the first written frame is already loud.
// 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));
using var client = new NamedPipeClientStream(".", pipeName, PipeDirection.In, PipeOptions.Asynchronous);
var connected = client.ConnectAsync();
mixer.StartLive(pipeName);
await connected.WaitAsync(TimeSpan.FromSeconds(5));
var bytes = await ReadFullyAsync(client, 240 * 2 * 4, TimeSpan.FromSeconds(5));
mixer.StopLive();
@@ -428,6 +432,52 @@ public class AudioPipelineTests
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");
}
private static async Task<byte[]> ReadFullyAsync(NamedPipeClientStream client, int count, TimeSpan timeout)
{
var ms = new MemoryStream();