using System; namespace ytLive.Services.Audio; /// /// TASK 22: a fixed end-to-end audio delay applied to the interleaved stereo /// mix 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 ( /// would need a video-side delay and is out of scope for the audio layer. /// 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 float[] _buffer = Array.Empty(); private int _delaySamples; private int _writePos; public AudioSyncDelay(int sampleRate) { _sampleRate = Math.Max(1, sampleRate); 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) * 2 + 1024]; _writePos = 0; } /// Delays an interleaved stereo chunk 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); // 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; } }