Files
LlamaCasty/ViewModels/MainViewModel.Audio.cs
T
gramps b22d08eca6 feat: signed A/V sync offset (−500..+500), negative advances by eating live stream head (OBS eat-head semantics)
Positive offsets still delay the whole mix via the delay line (lip-sync fix);
negative offsets now ARM once at StartLive and drop |N| ms off the pipe's write
head so audio events land earlier when audio runs BEHIND video. Slider relabeled
AUDIO SYNC, Min −500, locked while live/recording (IsEditMode). LayoutStore and
VM clamp to −500..500.

OBS reference for eat-the-head negative sync: https://obsproject.com/kb/obs-studio/buffering-time (negative sync values pull audio earlier by discarding buffered player audio).

Test: StartLive_NegativeOffset_AdvancesAudio_ByDroppingTheStreamHead (6x0.9 head
must be eaten before 0.2 bed reaches the wire).
2026-09-14 12:30:44 -07:00

440 lines
16 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Windows;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Threading;
using NAudio.CoreAudioApi;
using ytLive;
using ytLive.Helpers;
using ytLive.Models;
using ytLive.Services;
using ytLive.Services.Audio;
namespace ytLive.ViewModels;
public partial class MainViewModel
{
private readonly DispatcherTimer _volumeFlashTimer;
private readonly DispatcherTimer _gameVolumeFlashTimer;
private double _audioLevel;
private bool _volumeAdjusting;
private bool _volumeFlash;
private double _micVolume = 0.8;
private bool _micMuted;
private double? _volumeBeforeMute;
private string? _micSourceName;
private MicStatus _micStatus = MicStatus.NotConnected;
private double _gameAudioLevel;
private bool _gameVolumeAdjusting;
private bool _gameVolumeFlash;
private double _gameVolume = 1.0;
private bool _gameMuted;
private double? _gameVolumeBeforeMute;
private int _audioSyncOffsetMs;
private AudioGainProvider? _gainProvider;
private readonly IMicrophoneEnumerator _microphoneEnumerator;
private readonly AudioMixer _audioMixer;
public ICommand ToggleMicMuteCommand { get; }
public ICommand ToggleGameMuteCommand { get; }
public ICommand OpenMicPickerCommand { get; }
// ─── Audio: the mic bar (meter + volume + mute + status dot) is always
// ─── visible; the game bar (meter + volume + mute) appears only while a
// ─── full-screen game is producing sound. Both meters preview live. ───
/// <summary>Live mic input level (0..1) — fed by the audio mixer once capture
/// lands; 0 with no input. Read by the meter, scaled by MicVolume.</summary>
public double AudioLevel
{
get => _audioLevel;
set
{
if (SetProperty(ref _audioLevel, Math.Clamp(value, 0, 1)))
{
OnPropertyChanged(nameof(MeterFillWidth));
OnPropertyChanged(nameof(MeterBrush));
}
}
}
/// <summary>Displayed meter level: 0 while muted; the volume position while
/// the slider is being dragged (or briefly after an unmute, so the restored
/// level flashes on the bar) so the creator sees where they're setting it;
/// otherwise the realtime live level scaled by volume (raising the volume
/// moves ambient noise up the bar). Read-only meter.</summary>
private double MeterLevel => MicMuted ? 0 : _volumeAdjusting || _volumeFlash ? MicVolume : Math.Min(1, AudioLevelMeter.ToDisplay((float)AudioLevel) * 1.2 * MicVolume);
public double MeterFillWidth => MeterLevel * 288;
public string MeterBrush => MeterLevel switch
{
< 0.6 => "#22c55e",
< 0.8 => "#eab308",
_ => "#ef4444",
};
/// <summary>Previewes the volume position while the slider is dragged; the
/// bar returns to the live level on release (empty when there is no input).
/// Starting a drag cancels any pending unmute flash.</summary>
public void SetVolumeAdjusting(bool adjusting)
{
if (adjusting)
CancelVolumeFlash();
if (SetProperty(ref _volumeAdjusting, adjusting))
{
OnPropertyChanged(nameof(MeterFillWidth));
OnPropertyChanged(nameof(MeterBrush));
}
}
private void BeginVolumeFlash()
{
_volumeFlash = true;
OnPropertyChanged(nameof(MeterFillWidth));
OnPropertyChanged(nameof(MeterBrush));
_volumeFlashTimer.Stop();
_volumeFlashTimer.Start();
}
private void EndVolumeFlash()
{
_volumeFlashTimer.Stop();
if (_volumeFlash)
{
_volumeFlash = false;
OnPropertyChanged(nameof(MeterFillWidth));
OnPropertyChanged(nameof(MeterBrush));
}
}
private void CancelVolumeFlash()
{
_volumeFlashTimer.Stop();
_volumeFlash = false;
}
/// <summary>Mic gain (0..1). Running to 0 mutes (the speaker flips to muted);
/// moving up from 0 unmutes (the mute indicator clears). The level being left
/// is remembered so the speaker button can restore it.</summary>
public double MicVolume
{
get => _micVolume;
set
{
var clamped = Math.Clamp(value, 0, 1);
if (clamped == 0 && !_micMuted)
_volumeBeforeMute ??= _micVolume;
if (SetProperty(ref _micVolume, clamped))
{
var muted = clamped == 0;
if (_micMuted != muted)
{
_micMuted = muted;
OnPropertyChanged(nameof(MicMuted));
OnPropertyChanged(nameof(MicMuteText));
}
if (!muted)
_volumeBeforeMute = null;
OnPropertyChanged(nameof(MeterFillWidth));
OnPropertyChanged(nameof(MeterBrush));
}
}
}
/// <summary>Read-only: true whenever the volume is 0. Only MicVolume may
/// change it, so the slider and the speaker can never disagree.</summary>
public bool MicMuted => _micMuted;
public string MicMuteText => MicMuted ? "Unmute" : "Mute";
/// <summary>Name of the picked voice source, shown left-justified INSIDE the meter
/// bar (FontSize 10, ellipsized to the bar) — ai.md is the authority here.</summary>
public string? MicSourceName
{
get => _micSourceName;
private set => SetProperty(ref _micSourceName, value);
}
/// <summary>Global audio-sync offset in milliseconds (−500..500, default 0):
/// POSITIVE delays the whole mix so audio lands on the video when it runs
/// AHEAD (OBS's lip-sync fix); NEGATIVE advances it by dropping the first
/// |N| ms of the live stream head when audio runs BEHIND the video.
/// Consumed live by the mixer via a Func seam; persisted with the layout.</summary>
public int AudioSyncOffsetMs
{
get => _audioSyncOffsetMs;
set
{
var clamped = Math.Clamp(value, -500, 500);
if (SetProperty(ref _audioSyncOffsetMs, clamped))
ScheduleSave();
}
}
private void ToggleMicMute()
{
if (MicMuted)
{
MicVolume = _volumeBeforeMute ?? 0.8;
BeginVolumeFlash();
}
else
{
_volumeBeforeMute = MicVolume;
MicVolume = 0;
}
}
private void PickMicrophone()
{
var dialog = new MicPickerDialog(new MicPickerViewModel(_microphoneEnumerator))
{
Owner = System.Windows.Application.Current?.MainWindow
};
if (dialog.ShowDialog() == true && dialog.PickedDevice != null)
{
MicSourceName = dialog.PickedDevice.DisplayName;
_layoutStore.SaveMicSourceName(MicSourceName); // persist across restarts
_audioMixer.RestartMic(); // swap the live device immediately
}
}
// ─── Mic status dot (the MIC button) ───
private static readonly SolidColorBrush MicProblemBrush = CreateBrush("#f1c40f");
/// <summary>Mic connection state, driven by the mixer's MicConnected/MicFailed
/// events and the startup device check (see StartMicCaptureAsync).</summary>
public MicStatus MicStatus
{
get => _micStatus;
private set
{
if (SetProperty(ref _micStatus, value))
{
OnPropertyChanged(nameof(MicStatusBrush));
OnPropertyChanged(nameof(MicStatusToolTip));
}
}
}
/// <summary>Status dot: green = connected, yellow = problem with the requested
/// connection, red = not connected (no device / not started).</summary>
public SolidColorBrush MicStatusBrush => MicStatus switch
{
MicStatus.Connected => BarOnBrush,
MicStatus.Problem => MicProblemBrush,
_ => BarOffBrush,
};
public string MicStatusToolTip => MicStatus switch
{
MicStatus.Connected => "Mic connected — click to change",
MicStatus.Problem => "Mic problem — the requested microphone is unavailable (in use or unplugged). Click to change",
_ => "No mic connected — click to choose a microphone",
};
// ─── Game audio bar (desktop/game): always visible — a mirror of the mic bar
// ─── (meter + mute + volume). Desktop/game audio is automatic (WASAPI loopback). ───
// Test seam (mirrors LayoutPathOverride/RegistrarOverride): the volume slider is
// stream-honest AND speaker-honest — the real path writes the Windows endpoint.
// Tests must never hijack the machine's actual volume, so they swap the push out.
internal static Action<double>? VolumePushOverride;
private static void PushSystemVolume(double volume)
{
if (VolumePushOverride is { } push)
{
push(volume);
return;
}
try
{
using var enumerator = new MMDeviceEnumerator();
using var device = enumerator.GetDefaultAudioEndpoint(DataFlow.Render, Role.Multimedia);
device.AudioEndpointVolume.MasterVolumeLevelScalar = (float)volume;
}
catch { }
}
/// <summary>Live desktop/game input level (0..1), fed by the mixer's loopback
/// capture. Read by the game meter, scaled by GameAudioVolume.</summary>
public double GameAudioLevel
{
get => _gameAudioLevel;
set
{
if (SetProperty(ref _gameAudioLevel, Math.Clamp(value, 0, 1)))
{
OnPropertyChanged(nameof(GameMeterFillWidth));
OnPropertyChanged(nameof(GameMeterBrush));
}
}
}
/// <summary>Displayed game meter level: 0 while muted; the volume position
/// while the slider is dragged (or briefly after an unmute flash); otherwise
/// the realtime live level scaled by volume — the WASAPI loopback tap is
/// PRE-endpoint-volume (creator's 20%-volume/pegged-meter observation,
/// 2026-09-01), so the meter must multiply by GameAudioVolume itself, exactly
/// like the stream gain does (the mic formula always did; the clone dropped it).</summary>
private double GameMeterLevel => GameMuted ? 0 : _gameVolumeAdjusting || _gameVolumeFlash ? GameAudioVolume : Math.Min(1, AudioLevelMeter.ToDisplay((float)GameAudioLevel) * GameAudioVolume);
public double GameMeterFillWidth => GameMeterLevel * 288;
public string GameMeterBrush => GameMeterLevel switch
{
< 0.6 => "#22c55e",
< 0.8 => "#eab308",
_ => "#ef4444",
};
public void SetGameVolumeAdjusting(bool adjusting)
{
if (adjusting)
CancelGameVolumeFlash();
if (SetProperty(ref _gameVolumeAdjusting, adjusting))
{
OnPropertyChanged(nameof(GameMeterFillWidth));
OnPropertyChanged(nameof(GameMeterBrush));
}
}
private void BeginGameVolumeFlash()
{
_gameVolumeFlash = true;
OnPropertyChanged(nameof(GameMeterFillWidth));
OnPropertyChanged(nameof(GameMeterBrush));
_gameVolumeFlashTimer.Stop();
_gameVolumeFlashTimer.Start();
}
private void EndGameVolumeFlash()
{
_gameVolumeFlashTimer.Stop();
if (_gameVolumeFlash)
{
_gameVolumeFlash = false;
OnPropertyChanged(nameof(GameMeterFillWidth));
OnPropertyChanged(nameof(GameMeterBrush));
}
}
private void CancelGameVolumeFlash()
{
_gameVolumeFlashTimer.Stop();
_gameVolumeFlash = false;
}
/// <summary>Game audio gain (0..1, unity default). Running to 0 mutes; the
/// prior level is remembered so the speaker button can restore it.</summary>
public double GameAudioVolume
{
get => _gameVolume;
set
{
var clamped = Math.Clamp(value, 0, 1);
if (clamped == 0 && !_gameMuted)
_gameVolumeBeforeMute ??= _gameVolume;
if (SetProperty(ref _gameVolume, clamped))
{
var muted = clamped == 0;
if (_gameMuted != muted)
{
_gameMuted = muted;
OnPropertyChanged(nameof(GameMuted));
OnPropertyChanged(nameof(GameMuteText));
}
if (!muted)
_gameVolumeBeforeMute = null;
OnPropertyChanged(nameof(GameMeterFillWidth));
OnPropertyChanged(nameof(GameMeterBrush));
UpdateTraxLocalGain();
PushSystemVolume(clamped);
}
}
}
/// <summary>Read-only: true whenever the volume is 0 (the slider and the
/// speaker can never disagree).</summary>
public bool GameMuted => _gameMuted;
public string GameMuteText => GameMuted ? "Unmute" : "Mute";
private void ToggleGameMute()
{
if (GameMuted)
{
GameAudioVolume = _gameVolumeBeforeMute ?? 1.0;
BeginGameVolumeFlash();
}
else
{
_gameVolumeBeforeMute = GameAudioVolume;
GameAudioVolume = 0;
}
}
private void OnMicLevelChanged(float level)
{
// NAudio raises on its capture thread; marshal to the UI thread so the
// meter binding updates safely.
var dispatcher = System.Windows.Application.Current?.Dispatcher;
if (dispatcher != null && !dispatcher.CheckAccess())
dispatcher.BeginInvoke(() => AudioLevel = level);
else
AudioLevel = level;
}
private void OnLoopbackLevelChanged(float level)
{
var dispatcher = System.Windows.Application.Current?.Dispatcher;
if (dispatcher != null && !dispatcher.CheckAccess())
dispatcher.BeginInvoke(() => GameAudioLevel = level);
else
GameAudioLevel = level;
}
private void OnMicConnected()
{
var dispatcher = System.Windows.Application.Current?.Dispatcher;
if (dispatcher != null && !dispatcher.CheckAccess())
dispatcher.BeginInvoke(() => MicStatus = MicStatus.Connected);
else
MicStatus = MicStatus.Connected;
}
private void OnMicFailed(Exception ex)
{
// The mixer logs the failure detail; here we only flip the dot to yellow.
var dispatcher = System.Windows.Application.Current?.Dispatcher;
if (dispatcher != null && !dispatcher.CheckAccess())
dispatcher.BeginInvoke(() => MicStatus = MicStatus.Problem);
else
MicStatus = MicStatus.Problem;
}
/// <summary>Starts capture once at startup: with no mic device present the
/// dot stays red and capture never starts; otherwise the mixer starts and
/// raises MicConnected (green) or MicFailed (yellow).</summary>
private async Task StartMicCaptureAsync()
{
try
{
var mics = await _microphoneEnumerator.GetMicrophonesAsync();
if (mics.Count == 0)
{
MicStatus = MicStatus.NotConnected;
AppLog.Write("Mic: no capture devices found — mic capture not started");
_notifications.Warning("No microphone found",
"Mic capture wasn't started — plug one in and pick it from the mixer.");
return;
}
_audioMixer.Start();
AppLog.Write("Audio: using system default mic + default output — assuming they are configured and available (device issues are out of scope).");
}
catch (Exception ex)
{
AppLog.Write($"Mic: device check failed: {ex.Message}");
}
}
}