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
+29 -2
View File
@@ -32,6 +32,7 @@ public sealed class AudioMixer : IDisposable
private readonly TimeSpan _mixInterval;
private readonly Func<int>? _syncOffsetMs;
private readonly AudioSyncDelay _syncDelay;
private int _advanceSamplesRemaining;
private bool _started;
// Live path (TASK 9): per-source resampling → voice chain on the mic →
@@ -168,6 +169,18 @@ public sealed class AudioMixer : IDisposable
_micBuffer.Clear();
_loopbackBuffer.Clear();
// Negative sync offset (audio runs BEHIND video): OBS's fix is to "eat
// the head of the buffer" — discard the first |N| ms of the live stream
// so every subsequent audio event lands |N| ms EARLIER in the recording.
// Armed once at go-live (you can't eat the head mid-stream); positive
// offsets stay live-reactive through the delay line in FillAndMix.
var syncMs = _syncOffsetMs?.Invoke() ?? 0;
_advanceSamplesRemaining = syncMs < 0
? (int)Math.Round(OutputSampleRate / 1000.0 * -syncMs) * 2
: 0;
if (_advanceSamplesRemaining > 0)
_log?.Invoke($"Audio live: advance armed ({-syncMs} ms ahead)");
var cts = new CancellationTokenSource();
_liveCts = cts;
_pipe = new NamedPipeAudioWriter();
@@ -293,11 +306,25 @@ public sealed class AudioMixer : IDisposable
try
{
var (_, micDrained, loopDrained) = FillAndMix(micChunk, loopbackChunk, mixBuffer);
await pipe.WriteAsync(mixBuffer, cancellationToken).ConfigureAwait(false);
// Negative offset: skip the armed |N| ms of stream head. The pipe
// writer tolerates a partial chunk (any float length is valid);
// dropping the head re-anchors the audio stream against the video
// stream, making each event land earlier — OBS's eat-the-head fix.
var writeFrom = 0;
if (_advanceSamplesRemaining > 0)
{
var drop = Math.Min(_advanceSamplesRemaining, mixBuffer.Length);
_advanceSamplesRemaining -= drop;
writeFrom = drop;
}
if (writeFrom < mixBuffer.Length)
await pipe.WriteAsync(mixBuffer.AsMemory(writeFrom), cancellationToken).ConfigureAwait(false);
_micDrainedTotal += micDrained;
_loopDrainedTotal += loopDrained;
for (var i = 0; i < mixBuffer.Length; i++)
for (var i = writeFrom; i < mixBuffer.Length; i++)
{
var abs = Math.Abs(mixBuffer[i]);
if (abs > _peakMix) _peakMix = abs;
+4 -2
View File
@@ -10,8 +10,10 @@ namespace ytLive.Services.Audio;
/// the same audio N ms later out — so it is trivially testable and carries no
/// state outside its own ring.
///
/// Positive offsets only (0..500 ms). Advancing audio (<see cref="TimeSpan"/>
/// would need a video-side delay and is out of scope for the audio layer.
/// Positive offsets only (0..500 ms). Advancing audio (the audio runs BEHIND
/// the video) is handled upstream in <see cref="AudioMixer"/>, not here: the
/// mixer eats the first |N| ms of the live stream head (OBS's negative sync —
/// see <see cref="AudioMixer.StartLive"/>), keeping this line a pure delay.
/// Changing the delay flushes the line, so a live slider change clicks rather
/// than smearing. Safe to call from any thread — the mixer owns it.
/// </summary>