Files
LlamaCasty/Services/Audio/AudioSyncDelay.cs
T
gramps 7f89534309 feat(audio): microphone-only sync delay, on by default at 175 ms
The webcam's frames reach the compositor later than the mic's samples reach the audio
pipe, so the voice runs ahead of the lips. The existing lip-sync offset could not fix
that: it delays the WHOLE mix, which drags desktop and alert audio — neither of which
has a camera to follow — out of sync in the process.

So the delay line learned channels (mono for a source, stereo for the mix, offset always
in milliseconds so the frame count is what stays constant), and FillAndMix runs a second
line over micChunk before the mix. Slider-side: presets off / 150 / 200 in the LIP-SYNC
bay under the whole-mix control, persisted as Audio.MicSyncOffsetMs.

Default 175 ms is the creator's own number for a C920 pipeline (they asked for
+150..+200). It is a setting, not a constant, because the right figure belongs to the
camera and the machine — a faster camera wants less, no camera wants none.

Both delay lines are reconfigured every tick, so the 2026-09-12 trap still applies and is
documented at both sites: a Configure that reallocates discards the buffered voice every
10 ms.

673 pass, 0 warnings. Ear check pending: this is an audio change.
2026-11-12 11:15:50 -08:00

100 lines
4.1 KiB
C#

using System;
namespace ytLive.Services.Audio;
/// <summary>
/// TASK 22: a fixed end-to-end audio delay applied either to the interleaved
/// stereo mix or to one mono source 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 readonly int _channels;
private float[] _buffer = Array.Empty<float>();
private int _delaySamples;
private int _writePos;
/// <param name="channels">Samples per frame in the chunks passed to <see cref="Process"/>: 2 for
/// the interleaved stereo mix, 1 for a mono source such as the microphone. The offset is always
/// specified in milliseconds, so the frame count — not the sample count — is what stays constant.</param>
public AudioSyncDelay(int sampleRate, int channels = 2)
{
_sampleRate = Math.Max(1, sampleRate);
_channels = Math.Clamp(channels, 1, 2);
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) * _channels + 1024];
_writePos = 0;
}
/// <summary>Delays one interleaved chunk (stereo or mono, per the constructor) 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);
// A frame is `_channels` samples, so the delay in buffer positions is
// the frame count times the channel count.
return (int)Math.Round(_sampleRate / 1000.0 * clamped) * _channels;
}
}