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.
This commit is contained in:
2026-11-12 11:15:50 -08:00
parent f6fdab3bf2
commit 7f89534309
9 changed files with 135 additions and 13 deletions
+22
View File
@@ -213,6 +213,28 @@
<Button Content="+100" Width="34" Height="16" FontSize="9" Padding="0" <Button Content="+100" Width="34" Height="16" FontSize="9" Padding="0"
Style="{StaticResource YtButtonSecondary}" Click="SyncPreset_Click" Tag="100"/> Style="{StaticResource YtButtonSecondary}" Click="SyncPreset_Click" Tag="100"/>
</StackPanel> </StackPanel>
<!-- Microphone-only delay: the voice waits for the camera's picture. The
whole-mix slider above would drag desktop and alert audio with it. -->
<Grid Margin="0,5,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Border Background="{StaticResource Brush.SurfaceContainerHighest}" CornerRadius="{StaticResource Radius.Sm}" Padding="5,1">
<TextBlock Text="MIC" FontSize="9" FontWeight="Bold" Foreground="{StaticResource Brush.OnSurfaceVariant}"/>
</Border>
<TextBlock Grid.Column="1" Margin="6,0,0,0" FontSize="10" VerticalAlignment="Center"
FontFamily="{StaticResource FontFamily.Mono}" TextAlignment="Right"
Foreground="{StaticResource Brush.OnSurfaceVariant}" Text="{Binding MicSyncOffsetLabel}"/>
</Grid>
<StackPanel Orientation="Horizontal" Margin="0,2,0,0" HorizontalAlignment="Right">
<Button Content="off" Width="26" Height="16" FontSize="9" Padding="0" Margin="0,0,4,0"
Style="{StaticResource YtButtonSecondary}" Click="MicSyncPreset_Click" Tag="0"/>
<Button Content="150" Width="30" Height="16" FontSize="9" Padding="0" Margin="0,0,4,0"
Style="{StaticResource YtButtonSecondary}" Click="MicSyncPreset_Click" Tag="150"/>
<Button Content="200" Width="30" Height="16" FontSize="9" Padding="0"
Style="{StaticResource YtButtonSecondary}" Click="MicSyncPreset_Click" Tag="200"/>
</StackPanel>
</StackPanel> </StackPanel>
</Grid> </Grid>
</Border> </Border>
+7
View File
@@ -56,6 +56,13 @@ public partial class AudioDock : UserControl
vm.SetSyncOffset(ms); vm.SetSyncOffset(ms);
} }
private void MicSyncPreset_Click(object sender, RoutedEventArgs e)
{
if (DataContext is not ViewModels.MainViewModel vm) return;
if (sender is Button { Tag: string tag } && int.TryParse(tag, out var ms))
vm.MicSyncOffsetMs = ms;
}
private void TraxPlayPause_Click(object sender, RoutedEventArgs e) private void TraxPlayPause_Click(object sender, RoutedEventArgs e)
{ {
if (DataContext is ViewModels.MainViewModel vm) vm.TraxLeftClick(); if (DataContext is ViewModels.MainViewModel vm) vm.TraxLeftClick();
+24 -1
View File
@@ -34,6 +34,9 @@ public sealed class AudioMixer : IDisposable
private readonly TimeSpan _mixInterval; private readonly TimeSpan _mixInterval;
private readonly Func<int>? _syncOffsetMs; private readonly Func<int>? _syncOffsetMs;
private readonly AudioSyncDelay _syncDelay; private readonly AudioSyncDelay _syncDelay;
private readonly AudioSyncDelay _micSyncDelay;
private readonly Func<int>? _micSyncOffsetMs;
private float[]? _delayedMic;
private int _advanceSamplesRemaining; private int _advanceSamplesRemaining;
private bool _started; private bool _started;
@@ -97,7 +100,8 @@ public sealed class AudioMixer : IDisposable
Func<double>? micGain = null, Func<double>? micGain = null,
Func<double>? loopbackGain = null, Func<double>? loopbackGain = null,
Func<int>? syncOffsetMs = null, Func<int>? syncOffsetMs = null,
TimeSpan? mixInterval = null) TimeSpan? mixInterval = null,
Func<int>? micSyncOffsetMs = null)
{ {
_mic = mic; _mic = mic;
_loopback = loopback; _loopback = loopback;
@@ -108,6 +112,11 @@ public sealed class AudioMixer : IDisposable
_loopbackGain = loopbackGain; _loopbackGain = loopbackGain;
_syncOffsetMs = syncOffsetMs; _syncOffsetMs = syncOffsetMs;
_syncDelay = new AudioSyncDelay(OutputSampleRate); _syncDelay = new AudioSyncDelay(OutputSampleRate);
// The microphone alone, mono: a camera's frames reach the compositor later than the mic's
// samples reach the pipe, so the voice has to wait for the picture. Loopback and alert audio
// are not tied to a camera frame and must not move with it.
_micSyncOffsetMs = micSyncOffsetMs;
_micSyncDelay = new AudioSyncDelay(OutputSampleRate, channels: 1);
_mixInterval = mixInterval ?? DefaultMixInterval; _mixInterval = mixInterval ?? DefaultMixInterval;
var seconds = _mixInterval.TotalSeconds; var seconds = _mixInterval.TotalSeconds;
@@ -506,6 +515,20 @@ public sealed class AudioMixer : IDisposable
Array.Clear(alertChunk, alertCount, alertChunk.Length - alertCount); Array.Clear(alertChunk, alertCount, alertChunk.Length - alertCount);
} }
// Mic-only delay: applied to the source, before the mix, so the desktop and alert audio keep
// their timing. Reconfigured every tick like the mix line above (same 2026-09-12 trap: a
// reallocating Configure would discard the buffered voice every 10 ms).
var micDelayMs = _micSyncOffsetMs?.Invoke() ?? 0;
if (micDelayMs > 0)
{
_micSyncDelay.Configure(micDelayMs);
var delayedMic = _delayedMic;
if (delayedMic is null || delayedMic.Length < micChunk.Length)
delayedMic = _delayedMic = new float[micChunk.Length];
_micSyncDelay.Process(micChunk, delayedMic);
Array.Copy(delayedMic, micChunk, micChunk.Length);
}
var duck = _ducker.Update(micRms); var duck = _ducker.Update(micRms);
var micGain = (float)(_micGain?.Invoke() ?? 1.0); var micGain = (float)(_micGain?.Invoke() ?? 1.0);
var loopGain = (float)(_loopbackGain?.Invoke() ?? 1.0) * duck; var loopGain = (float)(_loopbackGain?.Invoke() ?? 1.0) * duck;
+15 -10
View File
@@ -3,8 +3,8 @@ using System;
namespace ytLive.Services.Audio; namespace ytLive.Services.Audio;
/// <summary> /// <summary>
/// TASK 22: a fixed end-to-end audio delay applied to the interleaved stereo /// TASK 22: a fixed end-to-end audio delay applied either to the interleaved
/// mix in <see cref="AudioMixer"/>. This is OBS's documented fix for the /// 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 /// "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, /// 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 /// the same audio N ms later out — so it is trivially testable and carries no
@@ -20,14 +20,19 @@ namespace ytLive.Services.Audio;
public sealed class AudioSyncDelay public sealed class AudioSyncDelay
{ {
private readonly int _sampleRate; private readonly int _sampleRate;
private readonly int _channels;
private float[] _buffer = Array.Empty<float>(); private float[] _buffer = Array.Empty<float>();
private int _delaySamples; private int _delaySamples;
private int _writePos; private int _writePos;
public AudioSyncDelay(int sampleRate) /// <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); _sampleRate = Math.Max(1, sampleRate);
_channels = Math.Clamp(channels, 1, 2);
Configure(0); Configure(0);
} }
@@ -45,13 +50,13 @@ public sealed class AudioSyncDelay
var delaySamples = Frames(delayMs); var delaySamples = Frames(delayMs);
if (delaySamples == _delaySamples && _buffer.Length > 0) return; if (delaySamples == _delaySamples && _buffer.Length > 0) return;
_delaySamples = delaySamples; _delaySamples = delaySamples;
_buffer = new float[(_delaySamples + _sampleRate) * 2 + 1024]; _buffer = new float[(_delaySamples + _sampleRate) * _channels + 1024];
_writePos = 0; _writePos = 0;
} }
/// <summary>Delays an interleaved stereo chunk by the configured offset. /// <summary>Delays one interleaved chunk (stereo or mono, per the constructor) by the configured
/// The returned length equals <paramref name="input"/>'s; the head is /// offset. The returned length equals <paramref name="input"/>'s; the head is silence until the
/// silence until the line fills.</summary> /// line fills.</summary>
public void Process(ReadOnlySpan<float> input, Span<float> output) public void Process(ReadOnlySpan<float> input, Span<float> output)
{ {
if (output.Length < input.Length) if (output.Length < input.Length)
@@ -87,8 +92,8 @@ public sealed class AudioSyncDelay
private int Frames(int delayMs) private int Frames(int delayMs)
{ {
var clamped = Math.Clamp(delayMs, 0, 500); var clamped = Math.Clamp(delayMs, 0, 500);
// Interleaved stereo: each frame is two samples (L, R), so the delay // A frame is `_channels` samples, so the delay in buffer positions is
// in buffer positions is twice the frame count. // the frame count times the channel count.
return (int)Math.Round(_sampleRate / 1000.0 * clamped) * 2; return (int)Math.Round(_sampleRate / 1000.0 * clamped) * _channels;
} }
} }
+17
View File
@@ -117,6 +117,23 @@ public partial class LayoutStore : IDisposable
return int.TryParse(value, out var ms) ? Math.Clamp(ms, -500, 500) : 0; return int.TryParse(value, out var ms) ? Math.Clamp(ms, -500, 500) : 0;
} }
/// <summary>The shipped default for the microphone-only delay. The creator's measurement for a
/// C920 pipeline (asked for +150..+200 ms); a camera with less latency wants less.</summary>
public const int DefaultMicSyncOffsetMs = 175;
/// <summary>Microphone-only sync delay (0..500 ms), so the voice can wait for the camera's picture
/// without moving desktop or alert audio. Absent means the shipped default.</summary>
public int LoadMicSyncOffsetMs()
{
var value = GetSetting("Audio.MicSyncOffsetMs");
return int.TryParse(value, out var ms)
? Math.Clamp(ms, 0, 500)
: DefaultMicSyncOffsetMs;
}
public void SaveMicSyncOffsetMs(int ms)
=> UpsertSetting("Audio.MicSyncOffsetMs", Math.Clamp(ms, 0, 500).ToString());
public void SaveAudioSyncOffsetMs(int ms) public void SaveAudioSyncOffsetMs(int ms)
{ {
UpsertSetting("Audio.SyncOffsetMs", Math.Clamp(ms, -500, 500).ToString()); UpsertSetting("Audio.SyncOffsetMs", Math.Clamp(ms, -500, 500).ToString());
+24 -1
View File
@@ -74,6 +74,7 @@ public partial class MainViewModel
private bool _gameMuted; private bool _gameMuted;
private double? _gameVolumeBeforeMute; private double? _gameVolumeBeforeMute;
private int _audioSyncOffsetMs; private int _audioSyncOffsetMs;
private int _micSyncOffsetMs = LayoutStore.DefaultMicSyncOffsetMs;
private AudioGainProvider? _gainProvider; private AudioGainProvider? _gainProvider;
private readonly IMicrophoneEnumerator _microphoneEnumerator; private readonly IMicrophoneEnumerator _microphoneEnumerator;
private readonly AudioMixer _audioMixer; private readonly AudioMixer _audioMixer;
@@ -246,8 +247,30 @@ public partial class MainViewModel
} }
} }
// ─── Master strip ──────────────────────────────────────────────────────── /// <summary>
/// Microphone-only sync delay in milliseconds (0..500, default 175). A camera's frames reach the
/// compositor later than the mic's samples reach the audio pipe, so by default the voice runs
/// AHEAD of the lips; delaying the mic alone closes the gap without touching desktop or alert audio,
/// which have no camera to follow. The creator can dial it to taste in the audio dock.
///
/// <para>175 ms is the creator's own measurement for a C920 pipeline (they asked for +150..+200);
/// it is a persisted setting, not a constant, because the right number is the machine's, not ours.</para>
/// </summary>
public int MicSyncOffsetMs
{
get => _micSyncOffsetMs;
set
{
var clamped = Math.Clamp(value, 0, 500);
if (!SetProperty(ref _micSyncOffsetMs, clamped)) return;
OnPropertyChanged(nameof(MicSyncOffsetLabel));
ScheduleSave();
}
}
public string MicSyncOffsetLabel => _micSyncOffsetMs == 0 ? "off" : $"+{_micSyncOffsetMs} ms";
// ─── Master strip ────────────────────────────────────────────────────────
private bool _monoOutput; private bool _monoOutput;
/// <summary>Collapse the output to mono. Persisted, because a creator who needs it needs it on /// <summary>Collapse the output to mono. Persisted, because a creator who needs it needs it on
+4 -1
View File
@@ -368,6 +368,7 @@ public partial class MainViewModel : ViewModelBase
// (yellow) instead of silently falling back to the default. // (yellow) instead of silently falling back to the default.
MicSourceName = _layoutStore.LoadMicSourceName(); MicSourceName = _layoutStore.LoadMicSourceName();
AudioSyncOffsetMs = _layoutStore.LoadAudioSyncOffsetMs(); AudioSyncOffsetMs = _layoutStore.LoadAudioSyncOffsetMs();
MicSyncOffsetMs = _layoutStore.LoadMicSyncOffsetMs();
_zoomFollowsBox = _layoutStore.LoadZoomFollowBox(); _zoomFollowsBox = _layoutStore.LoadZoomFollowBox();
_autoChromaScreen = _layoutStore.LoadAutoChromaScreen(); _autoChromaScreen = _layoutStore.LoadAutoChromaScreen();
_steamLicenceDismissed = _layoutStore.LoadSteamLicenceIndicatorDismissed(); _steamLicenceDismissed = _layoutStore.LoadSteamLicenceIndicatorDismissed();
@@ -413,7 +414,8 @@ public partial class MainViewModel : ViewModelBase
message => AppLog.Write(message), message => AppLog.Write(message),
micGain: _gainProvider.MicGain, micGain: _gainProvider.MicGain,
loopbackGain: _gainProvider.LoopbackGain, loopbackGain: _gainProvider.LoopbackGain,
syncOffsetMs: () => AudioSyncOffsetMs); syncOffsetMs: () => AudioSyncOffsetMs,
micSyncOffsetMs: () => MicSyncOffsetMs);
_audioMixer.MicLevelChanged += OnMicLevelChanged; _audioMixer.MicLevelChanged += OnMicLevelChanged;
_audioMixer.LoopbackLevelChanged += OnLoopbackLevelChanged; _audioMixer.LoopbackLevelChanged += OnLoopbackLevelChanged;
_audioMixer.MicConnected += OnMicConnected; _audioMixer.MicConnected += OnMicConnected;
@@ -641,6 +643,7 @@ public partial class MainViewModel : ViewModelBase
_layoutStore.SaveTransitionSettings( _layoutStore.SaveTransitionSettings(
SelectedTransitionType.ToString(), TransitionDurationMs); SelectedTransitionType.ToString(), TransitionDurationMs);
_layoutStore.SaveAudioSyncOffsetMs(AudioSyncOffsetMs); _layoutStore.SaveAudioSyncOffsetMs(AudioSyncOffsetMs);
_layoutStore.SaveMicSyncOffsetMs(MicSyncOffsetMs);
} }
catch (Exception ex) catch (Exception ex)
{ {
+9
View File
@@ -290,6 +290,15 @@ devices, no timers).
(a one-time status dot next to it was removed 2026-09-23 per creator feedback — the slider + (a one-time status dot next to it was removed 2026-09-23 per creator feedback — the slider +
numeric tooltip carry the state). Changing the delay flushes the line (a live change clicks numeric tooltip carry the state). Changing the delay flushes the line (a live change clicks
rather than smears). rather than smears).
- **Microphone-only delay (2026-11-12):** a camera's frames reach the compositor later than the mic's
samples reach the pipe, so the voice runs ahead of the lips. `AudioSyncDelay` now takes a channel
count, and `FillAndMix` runs a second line (**mono**) over `micChunk` *before* the mix — loopback and
alert audio are not tied to a camera frame and must not move with it. Seam: `Func<int> micSyncOffsetMs`
(VM `MicSyncOffsetMs`, **0..500 ms**, persisted `Audio.MicSyncOffsetMs`, shipped default **175** —
the creator's own C920 measurement, not a constant: the right number belongs to the machine).
Presets `off / 150 / 200` sit in the LIP-SYNC bay under the whole-mix slider. Both lines are
reconfigured every tick, so both carry the 2026-09-12 warning: a `Configure` that reallocates
discards the buffered audio every 10 ms.
- **TRAX — free background music (TASK 8):** `MusicPlayer` = NAudio `MediaFoundationReader` - **TRAX — free background music (TASK 8):** `MusicPlayer` = NAudio `MediaFoundationReader`
(mp3/wav/m4a) → `VolumeWaveProvider16` at the hardcoded **0.20** bed (no slider) → `WaveOutEvent` on (mp3/wav/m4a) → `VolumeWaveProvider16` at the hardcoded **0.20** bed (no slider) → `WaveOutEvent` on
the default device, **looping on any clean natural end** (`PlaybackStopped` with `e.Exception == null` the default device, **looping on any clean natural end** (`PlaybackStopped` with `e.Exception == null`
+13
View File
@@ -86,6 +86,19 @@ public class AudioSyncDelayTests
Assert.Equal(960, foundAt); // same 10ms-delay shift as the single-Configure test Assert.Equal(960, foundAt); // same 10ms-delay shift as the single-Configure test
} }
[Fact]
public void AMonoSourceIsDelayedInFrames_NotInDoubledSamples()
{
// 48 kHz, 175 ms = 8400 frames. For the microphone (mono) that is 8400 samples…
var mic = new AudioSyncDelay(48000, channels: 1);
Assert.Equal(8400, MarkerShift(mic, 9600, configureMs: 175));
// …while the same class in its stereo role, guarding the whole mix, counts frames in pairs:
// the same 175 ms must still be 175 ms, not 87.5.
var mix = new AudioSyncDelay(48000);
Assert.Equal(16800, MarkerShift(mix, 19200, configureMs: 175));
}
private static int MarkerShift(AudioSyncDelay delay, int chunkLen, int configureMs = -1) private static int MarkerShift(AudioSyncDelay delay, int chunkLen, int configureMs = -1)
{ {
if (configureMs >= 0) if (configureMs >= 0)