using System; namespace ytLive.Services.Audio; /// /// TASK 22: a fixed end-to-end audio delay applied either to the interleaved /// stereo mix or to one mono source in . 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 , not here: the /// mixer eats the first |N| ms of the live stream head (OBS's negative sync — /// see ), 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. /// public sealed class AudioSyncDelay { private readonly int _sampleRate; private readonly int _channels; private float[] _buffer = Array.Empty(); private int _delaySamples; private int _writePos; /// Samples per frame in the chunks passed to : 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. public AudioSyncDelay(int sampleRate, int channels = 2) { _sampleRate = Math.Max(1, sampleRate); _channels = Math.Clamp(channels, 1, 2); Configure(0); } /// Sets the delay in whole milliseconds (clamped to 0..500). A no-op /// when the delay hasn't actually changed — /// 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). 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; } /// Delays one interleaved chunk (stereo or mono, per the constructor) by the configured /// offset. The returned length equals 's; the head is silence until the /// line fills. public void Process(ReadOnlySpan input, Span 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; } }