Mic audio UX: realtime read-only meter + mic picker

- Meter is a READ-ONLY realtime level display: fill = Math.Min(1, AudioLevel * MicVolume) (AudioLevel = live input from the future mixer, 0 with no input; MicVolume is gain on ambient noise). While the slider is dragged the bar previews the slider position (PreviewMouseLeftButtonDown/Up + LostMouseCapture -> SetVolumeAdjusting); on release it returns to the live level (bounces to 0 with no input). Clicking the meter does nothing.
- MicVolume drives MicMuted (read-only, muted <=> volume 0): sliding to 0 flips the speaker to the red slashed mute icon, sliding up from 0 clears it; speaker button runs volume to 0 or restores the prior level (_volumeBeforeMute, default 0.8) and flashes the meter to the restored position for ~300ms (BeginVolumeFlash/EndVolumeFlash DispatcherTimer, cancelled if the slider is grabbed).
- MIC label opens MicPickerDialog (WinRT audio-capture device enumeration, mirrors camera picker, no new packages); the chosen voice source name shows left-justified INSIDE the meter bar (fill at 75% opacity so text + ruler markings show through).
- New files: Services/IMicrophoneEnumerator, MicrophoneDeviceInfo, WinRtMicrophoneEnumerator; ViewModels/MicPickerViewModel; MicPickerDialog.
- Docs updated (ai.md, TASKS.md, Services/index.md, ViewModels/index.md); build 0 warnings, 65 tests passing
This commit is contained in:
2026-08-07 17:44:16 -07:00
parent 4b3fb04322
commit b00a4cbd5e
14 changed files with 536 additions and 78 deletions
+125 -17
View File
@@ -22,6 +22,7 @@ public class MainViewModel : ViewModelBase
private readonly YouTubeStreamService _youtubeStream;
private readonly YouTubeChatService _youtubeChat;
private readonly DispatcherTimer _liveTimer;
private readonly DispatcherTimer _volumeFlashTimer;
private Scene? _activeScene;
private SceneElement? _selectedElement;
@@ -30,8 +31,12 @@ public class MainViewModel : ViewModelBase
private string _accountAvatarUrl = string.Empty;
private string _accountDisplayName = string.Empty;
private double _audioLevel;
private double _micVolume = 1.0;
private bool _volumeAdjusting;
private bool _volumeFlash;
private double _micVolume = 0.8;
private bool _micMuted;
private double? _volumeBeforeMute;
private string? _micSourceName;
private StreamStatus _streamStatus = StreamStatus.Offline;
private StreamHealth _currentHealth = new();
private string _streamTitle = string.Empty;
@@ -70,6 +75,8 @@ public class MainViewModel : ViewModelBase
private readonly CameraManager _cameraManager;
private Webcam? _webcam;
private readonly IMicrophoneEnumerator _microphoneEnumerator;
// Screen backdrop: a permanent live capture (desktop/game) that every scene
// shows at the bottom layer. One shared capture session per key — the
// ScreenCaptureManager refcounts by key, mirroring CameraManager.
@@ -209,8 +216,8 @@ public class MainViewModel : ViewModelBase
// ─── Audio (KISS: desktop/game audio is automatic — zero UI. The creator's
// ─── only audio control is the mic: meter + volume + mute.) ───
/// <summary>Current level 0..1 — fed by the audio mixer once capture lands.
/// Drives the sound meter's fill width + zone color.</summary>
/// <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;
@@ -224,33 +231,127 @@ public class MainViewModel : ViewModelBase
}
}
public double MeterFillWidth => AudioLevel * 360;
/// <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, AudioLevel * MicVolume);
public string MeterBrush => AudioLevel switch
public double MeterFillWidth => MeterLevel * 288;
public string MeterBrush => MeterLevel switch
{
< 0.6 => "#22c55e",
< 0.8 => "#eab308",
_ => "#ef4444",
};
public double MicVolume
/// <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)
{
get => _micVolume;
set => SetProperty(ref _micVolume, Math.Clamp(value, 0, 1));
}
public bool MicMuted
{
get => _micMuted;
set
if (adjusting)
CancelVolumeFlash();
if (SetProperty(ref _volumeAdjusting, adjusting))
{
if (SetProperty(ref _micMuted, value))
OnPropertyChanged(nameof(MicMuteText));
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 under the meter on line 2.</summary>
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;
}
public bool IsOffline => StreamStatus == StreamStatus.Offline;
public bool IsLive => StreamStatus == StreamStatus.Streaming;
public bool LiveIndicatorVisible => IsLive;
@@ -598,6 +699,7 @@ public class MainViewModel : ViewModelBase
public ICommand RefreshCaptureCommand { get; }
public ICommand SetBackdropDisplayCommand { get; }
public ICommand ToggleMicMuteCommand { get; }
public ICommand OpenMicPickerCommand { get; }
public ICommand StartStreamCommand { get; }
public ICommand EndStreamCommand { get; }
public ICommand OpenSettingsCommand { get; }
@@ -629,6 +731,9 @@ public class MainViewModel : ViewModelBase
_liveTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };
_liveTimer.Tick += OnLiveTimerTick;
_volumeFlashTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(300) };
_volumeFlashTimer.Tick += (_, _) => EndVolumeFlash();
_saveDebounce = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(1500) };
_saveDebounce.Tick += (_, _) => SaveLayoutNow();
@@ -652,7 +757,8 @@ public class MainViewModel : ViewModelBase
ChangeCaptureCommand = new RelayCommand(_ => _ = ChangeBackdropCaptureAsync());
RefreshCaptureCommand = new RelayCommand(_ => RefreshBackdropAutoCapture());
SetBackdropDisplayCommand = new RelayCommand(display => SetBackdropCapture(display as DisplayInfo));
ToggleMicMuteCommand = new RelayCommand(_ => MicMuted = !MicMuted);
ToggleMicMuteCommand = new RelayCommand(_ => ToggleMicMute());
OpenMicPickerCommand = new RelayCommand(_ => PickMicrophone());
OpenSettingsCommand = new RelayCommand(_ => ShowOverlay(nameof(IsSettingsOpen), "App Settings"));
OpenBugCommand = new RelayCommand(_ => ShowOverlay(nameof(IsBugOpen), "Report Bug"));
OpenFeatureCommand = new RelayCommand(_ => ShowOverlay(nameof(IsFeatureOpen), "Feature Request"));
@@ -677,6 +783,8 @@ public class MainViewModel : ViewModelBase
System.Windows.Application.Current?.Dispatcher);
_cameraManager.PreviewBitmapChanged += OnCameraPreviewBitmapChanged;
_microphoneEnumerator = new WinRtMicrophoneEnumerator();
_fullScreenDetector = new Win32FullScreenDetector();
foreach (var display in _fullScreenDetector.GetDisplays())
Displays.Add(display);