diff --git a/Controls/AudioDock.xaml b/Controls/AudioDock.xaml
index 3c3c6b7..b679017 100644
--- a/Controls/AudioDock.xaml
+++ b/Controls/AudioDock.xaml
@@ -213,6 +213,28 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Controls/AudioDock.xaml.cs b/Controls/AudioDock.xaml.cs
index 21c910e..6a725a4 100644
--- a/Controls/AudioDock.xaml.cs
+++ b/Controls/AudioDock.xaml.cs
@@ -56,6 +56,13 @@ public partial class AudioDock : UserControl
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)
{
if (DataContext is ViewModels.MainViewModel vm) vm.TraxLeftClick();
diff --git a/Services/Audio/AudioMixer.cs b/Services/Audio/AudioMixer.cs
index 1dc1328..436048f 100644
--- a/Services/Audio/AudioMixer.cs
+++ b/Services/Audio/AudioMixer.cs
@@ -34,6 +34,9 @@ public sealed class AudioMixer : IDisposable
private readonly TimeSpan _mixInterval;
private readonly Func? _syncOffsetMs;
private readonly AudioSyncDelay _syncDelay;
+ private readonly AudioSyncDelay _micSyncDelay;
+ private readonly Func? _micSyncOffsetMs;
+ private float[]? _delayedMic;
private int _advanceSamplesRemaining;
private bool _started;
@@ -97,7 +100,8 @@ public sealed class AudioMixer : IDisposable
Func? micGain = null,
Func? loopbackGain = null,
Func? syncOffsetMs = null,
- TimeSpan? mixInterval = null)
+ TimeSpan? mixInterval = null,
+ Func? micSyncOffsetMs = null)
{
_mic = mic;
_loopback = loopback;
@@ -108,6 +112,11 @@ public sealed class AudioMixer : IDisposable
_loopbackGain = loopbackGain;
_syncOffsetMs = syncOffsetMs;
_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;
var seconds = _mixInterval.TotalSeconds;
@@ -506,6 +515,20 @@ public sealed class AudioMixer : IDisposable
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 micGain = (float)(_micGain?.Invoke() ?? 1.0);
var loopGain = (float)(_loopbackGain?.Invoke() ?? 1.0) * duck;
diff --git a/Services/Audio/AudioSyncDelay.cs b/Services/Audio/AudioSyncDelay.cs
index 5f1c387..1ecbd1b 100644
--- a/Services/Audio/AudioSyncDelay.cs
+++ b/Services/Audio/AudioSyncDelay.cs
@@ -3,8 +3,8 @@ 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
+/// 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
@@ -20,14 +20,19 @@ namespace ytLive.Services.Audio;
public sealed class AudioSyncDelay
{
private readonly int _sampleRate;
+ private readonly int _channels;
private float[] _buffer = Array.Empty();
private int _delaySamples;
private int _writePos;
- public AudioSyncDelay(int sampleRate)
+ /// 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);
}
@@ -45,13 +50,13 @@ public sealed class AudioSyncDelay
var delaySamples = Frames(delayMs);
if (delaySamples == _delaySamples && _buffer.Length > 0) return;
_delaySamples = delaySamples;
- _buffer = new float[(_delaySamples + _sampleRate) * 2 + 1024];
+ _buffer = new float[(_delaySamples + _sampleRate) * _channels + 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.
+ /// 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)
@@ -87,8 +92,8 @@ public sealed class AudioSyncDelay
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;
+ // 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;
}
}
diff --git a/Services/LayoutStore.Settings.cs b/Services/LayoutStore.Settings.cs
index 35a9b92..a2c6817 100644
--- a/Services/LayoutStore.Settings.cs
+++ b/Services/LayoutStore.Settings.cs
@@ -117,6 +117,23 @@ public partial class LayoutStore : IDisposable
return int.TryParse(value, out var ms) ? Math.Clamp(ms, -500, 500) : 0;
}
+ /// 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.
+ public const int DefaultMicSyncOffsetMs = 175;
+
+ /// 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.
+ 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)
{
UpsertSetting("Audio.SyncOffsetMs", Math.Clamp(ms, -500, 500).ToString());
diff --git a/ViewModels/MainViewModel.Audio.cs b/ViewModels/MainViewModel.Audio.cs
index c38796c..40591d0 100644
--- a/ViewModels/MainViewModel.Audio.cs
+++ b/ViewModels/MainViewModel.Audio.cs
@@ -74,6 +74,7 @@ public partial class MainViewModel
private bool _gameMuted;
private double? _gameVolumeBeforeMute;
private int _audioSyncOffsetMs;
+ private int _micSyncOffsetMs = LayoutStore.DefaultMicSyncOffsetMs;
private AudioGainProvider? _gainProvider;
private readonly IMicrophoneEnumerator _microphoneEnumerator;
private readonly AudioMixer _audioMixer;
@@ -246,8 +247,30 @@ public partial class MainViewModel
}
}
- // ─── Master strip ────────────────────────────────────────────────────────
+ ///
+ /// 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.
+ ///
+ /// 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.
+ ///
+ 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;
/// Collapse the output to mono. Persisted, because a creator who needs it needs it on
diff --git a/ViewModels/MainViewModel.cs b/ViewModels/MainViewModel.cs
index 2cf2aff..14b719f 100644
--- a/ViewModels/MainViewModel.cs
+++ b/ViewModels/MainViewModel.cs
@@ -368,6 +368,7 @@ public partial class MainViewModel : ViewModelBase
// (yellow) instead of silently falling back to the default.
MicSourceName = _layoutStore.LoadMicSourceName();
AudioSyncOffsetMs = _layoutStore.LoadAudioSyncOffsetMs();
+ MicSyncOffsetMs = _layoutStore.LoadMicSyncOffsetMs();
_zoomFollowsBox = _layoutStore.LoadZoomFollowBox();
_autoChromaScreen = _layoutStore.LoadAutoChromaScreen();
_steamLicenceDismissed = _layoutStore.LoadSteamLicenceIndicatorDismissed();
@@ -413,7 +414,8 @@ public partial class MainViewModel : ViewModelBase
message => AppLog.Write(message),
micGain: _gainProvider.MicGain,
loopbackGain: _gainProvider.LoopbackGain,
- syncOffsetMs: () => AudioSyncOffsetMs);
+ syncOffsetMs: () => AudioSyncOffsetMs,
+ micSyncOffsetMs: () => MicSyncOffsetMs);
_audioMixer.MicLevelChanged += OnMicLevelChanged;
_audioMixer.LoopbackLevelChanged += OnLoopbackLevelChanged;
_audioMixer.MicConnected += OnMicConnected;
@@ -641,6 +643,7 @@ public partial class MainViewModel : ViewModelBase
_layoutStore.SaveTransitionSettings(
SelectedTransitionType.ToString(), TransitionDurationMs);
_layoutStore.SaveAudioSyncOffsetMs(AudioSyncOffsetMs);
+ _layoutStore.SaveMicSyncOffsetMs(MicSyncOffsetMs);
}
catch (Exception ex)
{
diff --git a/docs/ai/output.md b/docs/ai/output.md
index 29381ed..2bec4a9 100644
--- a/docs/ai/output.md
+++ b/docs/ai/output.md
@@ -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 +
numeric tooltip carry the state). Changing the delay flushes the line (a live change clicks
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 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`
(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`
diff --git a/ytLive.Tests/AudioSyncDelayTests.cs b/ytLive.Tests/AudioSyncDelayTests.cs
index 8e3c55e..d48d356 100644
--- a/ytLive.Tests/AudioSyncDelayTests.cs
+++ b/ytLive.Tests/AudioSyncDelayTests.cs
@@ -86,6 +86,19 @@ public class AudioSyncDelayTests
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)
{
if (configureMs >= 0)