b22d08eca6
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).
440 lines
16 KiB
C#
440 lines
16 KiB
C#
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}");
|
||
}
|
||
}
|
||
}
|