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