Files
LlamaCasty/Services/Audio/AudioSyncDelay.cs
T
gramps b22d08eca6 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).
2026-09-14 12:30:44 -07:00

95 lines
3.6 KiB
C#

using System;
namespace ytLive.Services.Audio;
/// <summary>
/// TASK 22: a fixed end-to-end audio delay applied to the interleaved stereo
/// mix in <see cref="AudioMixer"/>. This is OBS's documented fix for the
/// "audio is ahead of video" lip-sync case: delay the audio by a few
/// milliseconds so it lands on the video. It is a pure delay line — input in,
/// 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 (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>
public sealed class AudioSyncDelay
{
private readonly int _sampleRate;
private float[] _buffer = Array.Empty<float>();
private int _delaySamples;
private int _writePos;
public AudioSyncDelay(int sampleRate)
{
_sampleRate = Math.Max(1, sampleRate);
Configure(0);
}
/// <summary>Sets the delay in whole milliseconds (clamped to 0..500). A no-op
/// when the delay hasn't actually changed — <see cref="AudioMixer.FillAndMix"/>
/// calls this every ~10ms tick (it re-reads the live UI setting each mix), and
/// unconditionally reallocating+zeroing the line every tick (the 2026-09-12
/// total-silence bug: any non-zero saved offset wiped the ring before its own
/// delayed samples could ever be read back — mic/loop meters, sampled BEFORE
/// this stage, kept showing real levels while the pipe stayed pure silence)
/// discarded the entire buffered history every 10ms. Changing the delay still
/// flushes the line (a live slider change clicks rather than smearing).</summary>
public void Configure(int delayMs)
{
var delaySamples = Frames(delayMs);
if (delaySamples == _delaySamples && _buffer.Length > 0) return;
_delaySamples = delaySamples;
_buffer = new float[(_delaySamples + _sampleRate) * 2 + 1024];
_writePos = 0;
}
/// <summary>Delays an interleaved stereo chunk by the configured offset.
/// The returned length equals <paramref name="input"/>'s; the head is
/// silence until the line fills.</summary>
public void Process(ReadOnlySpan<float> input, Span<float> output)
{
if (output.Length < input.Length)
throw new ArgumentException("output must be at least as long as input", nameof(output));
var len = input.Length;
if (_delaySamples == 0)
{
input.CopyTo(output);
return;
}
var line = _buffer;
var lineLen = line.Length;
for (var i = 0; i < len; i++)
{
line[_writePos] = input[i];
_writePos++;
if (_writePos == lineLen)
_writePos = 0;
}
var readStart = (_writePos - _delaySamples - len + lineLen * 2) % lineLen;
for (var i = 0; i < len; i++)
{
var idx = readStart + i;
if (idx >= lineLen)
idx -= lineLen;
output[i] = line[idx];
}
}
private int Frames(int delayMs)
{
var clamped = Math.Clamp(delayMs, 0, 500);
// Interleaved stereo: each frame is two samples (L, R), so the delay
// in buffer positions is twice the frame count.
return (int)Math.Round(_sampleRate / 1000.0 * clamped) * 2;
}
}