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.
100 lines
4.1 KiB
C#
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;
|
|
}
|
|
}
|