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).