diff --git a/ViewModels/MainViewModel.Audio.cs b/ViewModels/MainViewModel.Audio.cs new file mode 100644 index 0000000..1ca0ccc --- /dev/null +++ b/ViewModels/MainViewModel.Audio.cs @@ -0,0 +1,408 @@ +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}"); + } + } +} diff --git a/ViewModels/MainViewModel.cs b/ViewModels/MainViewModel.cs index 8debead..9f716eb 100644 --- a/ViewModels/MainViewModel.cs +++ b/ViewModels/MainViewModel.cs @@ -27,25 +27,9 @@ public partial class MainViewModel : ViewModelBase private readonly YouTubeStreamService _youtubeStream; private readonly YouTubeChatService _youtubeChat; private readonly DispatcherTimer _liveTimer; - private readonly DispatcherTimer _volumeFlashTimer; - private readonly DispatcherTimer _gameVolumeFlashTimer; private readonly DispatcherTimer _healthPollTimer; private SceneElement? _selectedElement; - 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; // TRAX (TASK 9): free background music at a fixed quiet volume, playing to // the default device so it rides the loopback into the stream. The dot is @@ -53,7 +37,6 @@ public partial class MainViewModel : ViewModelBase private readonly MusicPlayer _musicPlayer = new(); private Music? _music; private bool _musicPlaying; - private AudioGainProvider? _gainProvider; private StreamStatus _streamStatus = StreamStatus.Offline; private StreamHealth _currentHealth = new(); private string _streamTitle = string.Empty; @@ -108,15 +91,6 @@ public partial class MainViewModel : ViewModelBase private SocialsConfig? _socials; private VideoFrame? _socialBarFrame; - private readonly IMicrophoneEnumerator _microphoneEnumerator; - - // Live audio capture (TASK 4 ship step 4): desktop/game via WASAPI loopback, - // mic via WASAPI capture, both owned by the mixer. Capture runs for the app's - // lifetime (started at startup, stopped on shutdown) so the footer meters - // stay live in preview. Mic level feeds AudioLevel (the meter); loopback - // feeds the game audio bar's meter. Private by design — the mixer surfaces - // the levels + mic connection state to the UI. - private readonly AudioMixer _audioMixer; // Live frame pipeline (TASK 4 ship step 5): composites the active scene at the // tier's FPS and paces frames into the encoder. The RTMP URL seam stays null @@ -260,190 +234,7 @@ public partial class MainViewModel : ViewModelBase } } - // ─── 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"); private static readonly SolidColorBrush TraxIdleBrush = CreateBrush("#555"); - - /// 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). ─── - /// True while the TRAX background track is playing. public bool IsMusicPlaying { @@ -557,131 +348,6 @@ public partial class MainViewModel : ViewModelBase _musicPlayer.LocalGain = GameMuted ? 0f : 1f; } - 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; - } - } public bool IsOffline => StreamStatus == StreamStatus.Offline; public bool IsLive => StreamStatus == StreamStatus.Streaming; @@ -1041,9 +707,6 @@ public partial class MainViewModel : ViewModelBase public ICommand RemoveSourceCommand { get; } public ICommand EditElementCommand { get; } public ICommand ToggleElementVisibilityCommand { get; } - public ICommand ToggleMicMuteCommand { get; } - public ICommand ToggleGameMuteCommand { get; } - public ICommand OpenMicPickerCommand { get; } public ICommand OpenSocialDialogCommand { get; } public ICommand StartStreamCommand { get; } public ICommand EndStreamCommand { get; } @@ -2083,68 +1746,6 @@ public partial class MainViewModel : ViewModelBase : Environment.GetFolderPath(Environment.SpecialFolder.MyVideos); } - 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}"); - } - } // Scene-element → latest frame, for the live compositor. The map mirrors the // preview: webcam by DeviceId, live captures (the background) by CaptureKey, diff --git a/ViewModels/index.md b/ViewModels/index.md index 0c3de11..9ee6889 100644 --- a/ViewModels/index.md +++ b/ViewModels/index.md @@ -57,13 +57,24 @@ Progress (Commit N/12 · tag): `CanChangeWebcam`/`CanShowWebcamInStagedScene`/`IsWebcamSelected`/`WebcamError`, size safeguard `WebcamMax*`/`ClampWebcamToBounds`/`HealLegacySquareRect`, `AddWebcamCommand`/`ChangeWebcamCommand`/`ShowWebcamCommand`). +- **Commit 6/12 · `refactor-commit-6`: Audio landed** → `MainViewModel.Audio.cs` + (mic bar `AudioLevel`/`MeterFillWidth`/`MeterBrush`/`MicVolume`/`MicMuted`/ + `MicMuteText`/`MicSourceName`/`ToggleMicMute`/`PickMicrophone`, volume flash + `SetVolumeAdjusting`/`BeginVolumeFlash`/`EndVolumeFlash`/`CancelVolumeFlash`, + mic status dot `MicStatus`/`MicStatusBrush`/`MicStatusToolTip`/`MicProblemBrush`, + game bar `GameAudioLevel`/`GameMeterLevel`/`GameMeterFillWidth`/`GameMeterBrush`/ + `SetGameVolumeAdjusting`/`GameAudioVolume`/`GameMuted`/`GameMuteText`/ + `ToggleGameMute`, `PushSystemVolume`, mixer handlers `OnMicLevelChanged`/ + `OnLoopbackLevelChanged`/`OnMicConnected`/`OnMicFailed`, `StartMicCaptureAsync`, + audio fields + `ToggleMicMuteCommand`/`ToggleGameMuteCommand`/`OpenMicPickerCommand`). + TRAX stays in core (next commit). - Core file keeps: fields, ctor, `Shutdown`/`SaveLayoutNow`/`ScheduleSave`/`LoadLayout`, `SetPanel`/`ShowOverlay`/`ShowLicenses`, `CreateBrush`, `OpenUrl`/`ComposeEmail`, `SubmitBug`/`SubmitFeature`, window-geometry pass-through. - Partials: `Account` ✔ (Commit 1), `License` ✔ (Commit 2), `Scenes` ✔ (Commit 3), `Background` ✔ (Commit 4), - `Webcam` ✔ (Commit 5), - `Audio` (mic + game bars + TRAX), `Trax` (music player), `Socials` (social bar + dialog), + `Webcam` ✔ (Commit 5), `Audio` ✔ (Commit 6), + `Trax` (music player), `Socials` (social bar + dialog), `Streaming` (go-live/record + health + quality tiers + media source pipeline), `Chat` (YouTube chat + mock preview + chatbox render), `Overlays` (settings/bug/feature/about panels + license entry).