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:
+125
-17
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user