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 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)); } } } /// 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 : 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", }; /// 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)); } } } /// 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); } 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"); /// 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). ─── private static void PushSystemVolume(double volume) { 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. private double GameMeterLevel => GameMuted ? 0 : _gameVolumeAdjusting || _gameVolumeFlash ? GameAudioVolume : Math.Min(1, AudioLevelMeter.ToDisplay((float)GameAudioLevel)); 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; } /// 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"; 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(); } catch (Exception ex) { AppLog.Write($"Mic: device check failed: {ex.Message}"); } } }