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:
@@ -213,6 +213,28 @@
|
||||
<Button Content="+100" Width="34" Height="16" FontSize="9" Padding="0"
|
||||
Style="{StaticResource YtButtonSecondary}" Click="SyncPreset_Click" Tag="100"/>
|
||||
</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>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -34,6 +34,9 @@ public sealed class AudioMixer : IDisposable
|
||||
private readonly TimeSpan _mixInterval;
|
||||
private readonly Func<int>? _syncOffsetMs;
|
||||
private readonly AudioSyncDelay _syncDelay;
|
||||
private readonly AudioSyncDelay _micSyncDelay;
|
||||
private readonly Func<int>? _micSyncOffsetMs;
|
||||
private float[]? _delayedMic;
|
||||
private int _advanceSamplesRemaining;
|
||||
private bool _started;
|
||||
|
||||
@@ -97,7 +100,8 @@ public sealed class AudioMixer : IDisposable
|
||||
Func<double>? micGain = null,
|
||||
Func<double>? loopbackGain = null,
|
||||
Func<int>? syncOffsetMs = null,
|
||||
TimeSpan? mixInterval = null)
|
||||
TimeSpan? mixInterval = null,
|
||||
Func<int>? 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;
|
||||
|
||||
@@ -3,8 +3,8 @@ 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
|
||||
/// TASK 22: a fixed end-to-end audio delay applied either to the interleaved
|
||||
/// 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
|
||||
/// 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<float>();
|
||||
private int _delaySamples;
|
||||
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);
|
||||
_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;
|
||||
}
|
||||
|
||||
/// <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>
|
||||
/// <summary>Delays one interleaved chunk (stereo or mono, per the constructor) 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)
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,6 +117,23 @@ public partial class LayoutStore : IDisposable
|
||||
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)
|
||||
{
|
||||
UpsertSetting("Audio.SyncOffsetMs", Math.Clamp(ms, -500, 500).ToString());
|
||||
|
||||
@@ -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 ────────────────────────────────────────────────────────
|
||||
/// <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;
|
||||
|
||||
/// <summary>Collapse the output to mono. Persisted, because a creator who needs it needs it on
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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<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`
|
||||
(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`
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user