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. ───
/// 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.
public double AudioLevel
{
get => _audioLevel;
set
{
if (SetProperty(ref _audioLevel, Math.Clamp(value, 0, 1)))
{
OnPropertyChanged(nameof(MeterFillWidth));
OnPropertyChanged(nameof(MeterBrush));
OnPropertyChanged(nameof(MicMeterFraction));
UpdateDuckState();
RaiseMasterReadouts();
OnPropertyChanged(nameof(TraxMeterFraction));
OnPropertyChanged(nameof(MicMeterFraction));
OnPropertyChanged(nameof(MicVolumeDb));
OnPropertyChanged(nameof(MicVolumeDbLabel));
}
}
}
/// 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.
private double MeterLevel => MicMuted ? 0 : _volumeAdjusting || _volumeFlash ? MicVolume : AudioLevelMeter.ToDisplay((float)(AudioLevel * MicVolume));
public double MeterFillWidth => MeterLevel * 288;
public string MeterBrush => MeterLevel switch
{
< 0.6 => ThemeBrush.Hex("Brush.Meter.Nominal"),
< 0.8 => ThemeBrush.Hex("Brush.Meter.Caution"),
_ => ThemeBrush.Hex("Brush.Meter.Peak"),
};
/// 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.
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;
}
/// 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.
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));
}
}
}
/// The fader's face, in dB (−60 … +6, 0 = unity). The model keeps linear gain; this is
/// the same value expressed the way a person reads a mixer — and the only thing the dock binds.
///
public double MicVolumeDb
{
get => Helpers.GainDb.ToDb(MicVolume);
set
{
var gain = Helpers.GainDb.FromDb(value);
if (Math.Abs(gain - MicVolume) < 1e-6) return;
MicVolume = gain;
OnPropertyChanged(nameof(MicVolumeDb));
OnPropertyChanged(nameof(MicVolumeDbLabel));
}
}
/// "-6.0 dB" beside the fader; "−∞ dB" at the floor.
public string MicVolumeDbLabel => Helpers.GainDb.Label(MicVolumeDb) + " dB";
/// The ladder's input: a 0…1 display fraction of what was MEASURED (already scaled by
/// the volume and muted state), never the gain setting.
public double MicMeterFraction => MeterLevel;
/// Read-only: true whenever the volume is 0. Only MicVolume may
/// change it, so the slider and the speaker can never disagree.
public bool MicMuted => _micMuted;
public string MicMuteText => MicMuted ? "Unmute" : "Mute";
/// 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.
public string? MicSourceName
{
get => _micSourceName;
private set => SetProperty(ref _micSourceName, value);
}
/// 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.
public int AudioSyncOffsetMs
{
get => _audioSyncOffsetMs;
set
{
var clamped = Math.Clamp(value, -500, 500);
if (!SetProperty(ref _audioSyncOffsetMs, clamped)) return;
OnPropertyChanged(nameof(SyncOffsetLabel));
ScheduleSave();
}
}
// ─── Master strip ────────────────────────────────────────────────────────
private bool _monoOutput;
/// Collapse the output to mono. Persisted, because a creator who needs it needs it on
/// every session — an accessibility switch that resets is an accessibility failure.
public bool MonoOutput
{
get => _monoOutput;
set
{
if (!SetProperty(ref _monoOutput, value)) return;
_audioMixer.MonoOutput = value;
_layoutStore.SaveMonoOutput(value);
}
}
/// Master ladder input: the measured mix, 0…1 on the same display scale.
public double MasterMeterFraction => AudioLevelMeter.ToDisplay(_audioMixer.MasterLevel);
public string MasterLevelLabel => Helpers.GainDb.Label(Helpers.GainDb.ToDb(_audioMixer.MasterLevel)) + " dB";
/// True when the mix's channels differ by more than 6 dB — the one thing L/R is
/// genuinely good for, shown as one flag instead of two ladders.
public bool MasterImbalance => _audioMixer.MasterImbalanceDb > 6f;
private void RaiseMasterReadouts()
{
OnPropertyChanged(nameof(MasterMeterFraction));
OnPropertyChanged(nameof(MasterLevelLabel));
OnPropertyChanged(nameof(MasterImbalance));
}
/// "0 ms (aligned)", "+50 ms", "−120 ms" — what the dock shows beside the slider.
public string SyncOffsetLabel => AudioSyncOffsetMs == 0
? "0 ms (aligned)"
: (AudioSyncOffsetMs > 0 ? "+" : "−") + Math.Abs(AudioSyncOffsetMs) + " ms";
/// Preset buttons in the LIP-SYNC bay. 0 is the detent: the offset that means "the
/// lip-sync is right", so it deserves one click, not a drag.
internal void SetSyncOffset(int milliseconds) => AudioSyncOffsetMs = milliseconds;
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 SolidColorBrush MicProblemBrush => ThemeBrush.Get("Brush.Secondary");
/// Mic connection state, driven by the mixer's MicConnected/MicFailed
/// events and the startup device check (see StartMicCaptureAsync).
public MicStatus MicStatus
{
get => _micStatus;
private set
{
if (SetProperty(ref _micStatus, value))
{
OnPropertyChanged(nameof(MicStatusBrush));
OnPropertyChanged(nameof(MicStatusToolTip));
}
}
}
/// Status dot: green = connected, yellow = problem with the requested
/// connection, red = not connected (no device / not started).
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? 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 { }
}
/// Live desktop/game input level (0..1), fed by the mixer's loopback
/// capture. Read by the game meter, scaled by GameAudioVolume.
public double GameAudioLevel
{
get => _gameAudioLevel;
set
{
if (SetProperty(ref _gameAudioLevel, Math.Clamp(value, 0, 1)))
{
OnPropertyChanged(nameof(GameMeterFillWidth));
OnPropertyChanged(nameof(GameMeterBrush));
}
}
}
/// 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).
private double GameMeterLevel => GameMuted ? 0 : _gameVolumeAdjusting || _gameVolumeFlash ? GameAudioVolume : Math.Min(1, AudioLevelMeter.ToDisplay((float)GameAudioLevel) * GameAudioVolume);
public double GameMeterFillWidth => GameMeterLevel * 288;
/// Ladder input: a 0…1 display fraction of what was measured.
public double GameMeterFraction => GameMeterLevel;
/// The game fader's dB face (−60 … +6). Same rule as the mic: the model keeps linear
/// gain; the dock shows decibels.
public double GameVolumeDb
{
get => Helpers.GainDb.ToDb(GameAudioVolume);
set
{
var gain = Helpers.GainDb.FromDb(value);
if (Math.Abs(gain - GameAudioVolume) < 1e-6) return;
GameAudioVolume = gain;
OnPropertyChanged(nameof(GameVolumeDb));
OnPropertyChanged(nameof(GameVolumeDbLabel));
}
}
public string GameVolumeDbLabel => Helpers.GainDb.Label(GameVolumeDb) + " dB";
/// True while the mic is ducking the game/desktop bed (the mixer's own value).
public bool IsGameDucking { get; private set; }
/// "−12.0 dB" — the badge text, computed from the same gain the mixer applied.
public string GameDuckLabel => Helpers.GainDb.Label(Helpers.GainDb.ToDb(_audioMixer.DuckGain)) + " DUCK";
public string GameMeterBrush => GameMeterLevel switch
{
< 0.6 => ThemeBrush.Hex("Brush.Meter.Nominal"),
< 0.8 => ThemeBrush.Hex("Brush.Meter.Caution"),
_ => ThemeBrush.Hex("Brush.Meter.Peak"),
};
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;
}
/// Game audio gain (0..1, unity default). Running to 0 mutes; the
/// prior level is remembered so the speaker button can restore it.
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);
}
}
}
/// Read-only: true whenever the volume is 0 (the slider and the
/// speaker can never disagree).
public bool GameMuted => _gameMuted;
public string GameMuteText => GameMuted ? "Unmute" : "Mute";
/// Badge state follows the mix, not a timer of its own: the level tick is already the
/// moment the ducker has just run.
private void UpdateDuckState()
{
var ducking = _audioMixer.DuckGain < 0.99f;
if (ducking == IsGameDucking) return;
IsGameDucking = ducking;
OnPropertyChanged(nameof(IsGameDucking));
OnPropertyChanged(nameof(GameDuckLabel));
}
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;
}
/// 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).
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}");
}
}
}