TASK 22: global audio sync offset (positive delay at the mixer out)
Adds AudioSyncOffsetMs (0..500ms, default 0) that delays the whole interleaved-stereo mix so audio lands on the video when it runs ahead — OBS's documented lip-sync fix. Positive-only: advancing audio needs a video-side delay and is out of the audio layer's scope. - Services/Audio/AudioSyncDelay.cs: pure delay line, flushed on Configure - AudioMixer: Func<int> syncOffsetMs seam + delay applied post-limiter - LayoutStore.Settings + MainViewModel.Audio/VM: load/save + binding - PreviewPane mic bar: SYNC slider + status dot (IntToSyncBrushConverter) - AudioSyncDelayTests: identity, negative/beyond-500 clamps, 10ms→960 samples Reference (external scan): https://obs-versions.com/blog/how-to-fix-audio-delay-on-obs (audio ahead => positive delay). Verified: build 0 warnings; 3/3 delay tests pass.
This commit is contained in:
@@ -29,6 +29,8 @@ public sealed class AudioMixer : IDisposable
|
||||
private readonly Func<double>? _micGain;
|
||||
private readonly Func<double>? _loopbackGain;
|
||||
private readonly TimeSpan _mixInterval;
|
||||
private readonly Func<int>? _syncOffsetMs;
|
||||
private readonly AudioSyncDelay _syncDelay;
|
||||
private bool _started;
|
||||
|
||||
// Live path (TASK 9): per-source resampling → voice chain on the mic →
|
||||
@@ -45,6 +47,7 @@ public sealed class AudioMixer : IDisposable
|
||||
private float[]? _micChunk;
|
||||
private float[]? _loopbackChunk;
|
||||
private float[]? _mixBuffer;
|
||||
private float[]? _delayedMix;
|
||||
|
||||
public AudioMixer(
|
||||
IAudioSource mic,
|
||||
@@ -52,6 +55,7 @@ public sealed class AudioMixer : IDisposable
|
||||
Action<string>? log = null,
|
||||
Func<double>? micGain = null,
|
||||
Func<double>? loopbackGain = null,
|
||||
Func<int>? syncOffsetMs = null,
|
||||
TimeSpan? mixInterval = null)
|
||||
{
|
||||
_mic = mic;
|
||||
@@ -61,6 +65,8 @@ public sealed class AudioMixer : IDisposable
|
||||
_log = log;
|
||||
_micGain = micGain;
|
||||
_loopbackGain = loopbackGain;
|
||||
_syncOffsetMs = syncOffsetMs;
|
||||
_syncDelay = new AudioSyncDelay(OutputSampleRate);
|
||||
_mixInterval = mixInterval ?? DefaultMixInterval;
|
||||
|
||||
var seconds = _mixInterval.TotalSeconds;
|
||||
@@ -333,6 +339,13 @@ public sealed class AudioMixer : IDisposable
|
||||
mix[i * 2 + 1] = m + loopbackChunk[i * 2 + 1] * loopGain;
|
||||
}
|
||||
_masterLimiter.Process(mix);
|
||||
|
||||
_syncDelay.Configure(_syncOffsetMs?.Invoke() ?? 0);
|
||||
var delayed = _delayedMix!;
|
||||
if (delayed.Length < mix.Length)
|
||||
delayed = _delayedMix = new float[mix.Length];
|
||||
_syncDelay.Process(mix, delayed);
|
||||
Array.Copy(delayed, mix, mix.Length);
|
||||
return micRms;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
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 (<see cref="TimeSpan"/>
|
||||
/// 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.
|
||||
/// </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 and
|
||||
/// flushed).</summary>
|
||||
public void Configure(int delayMs)
|
||||
{
|
||||
_delaySamples = Frames(delayMs);
|
||||
_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;
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,19 @@ public partial class LayoutStore : IDisposable
|
||||
insert.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
/// <summary>The global audio-sync delay in milliseconds (TASK 22), 0..500,
|
||||
/// default 0. Restored at startup so the mixer starts on the user's offset.</summary>
|
||||
public int LoadAudioSyncOffsetMs()
|
||||
{
|
||||
var value = GetSetting("Audio.SyncOffsetMs");
|
||||
return int.TryParse(value, out var ms) ? Math.Clamp(ms, 0, 500) : 0;
|
||||
}
|
||||
|
||||
public void SaveAudioSyncOffsetMs(int ms)
|
||||
{
|
||||
UpsertSetting("Audio.SyncOffsetMs", Math.Clamp(ms, 0, 500).ToString());
|
||||
}
|
||||
|
||||
/// <summary>The user's chosen record output folder (TASK 18), or null to use the
|
||||
/// default <c>%APPDATA%\ytLlive\recordings</c>. Restored at startup.</summary>
|
||||
public string? LoadRecordFolder()
|
||||
|
||||
Reference in New Issue
Block a user