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