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 _meterTimer; private int _traxSilentTicks; private bool _traxSilenceLogged; /// The dock's heartbeat: every meter readout is refreshed here, 20 times a second, /// from whatever the audio threads last measured. private void TickMeters() { OnPropertyChanged(nameof(MicMeterFraction)); OnPropertyChanged(nameof(MeterFillWidth)); OnPropertyChanged(nameof(MeterBrush)); OnPropertyChanged(nameof(GameMeterFraction)); OnPropertyChanged(nameof(GameMeterFillWidth)); OnPropertyChanged(nameof(GameMeterBrush)); OnPropertyChanged(nameof(TraxMeterFraction)); RaiseMasterReadouts(); UpdateDuckState(); CheckTraxSilence(); TickScoreboard(); } /// One honest diagnostic: "playing" with a flat player meter means the samples never /// reached the device (or the metering was not attached). Logged once per playback, so the next /// run says which side is broken instead of another round of guessing. private void CheckTraxSilence() { if (!IsMusicPlaying) { _traxSilentTicks = 0; _traxSilenceLogged = false; return; } if (_musicPlayer.Level > 0f) { _traxSilentTicks = 0; _traxSilenceLogged = false; return; } if (++_traxSilentTicks < 40 || _traxSilenceLogged) return; // 2s at 20 Hz _traxSilenceLogged = true; AppLog.Write("TRAX: playing but the player meter reads 0 — the output device is silent or " + "the metering stream is not attached (the dock's ladder has nothing to show)"); } 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 int _audioSyncOffsetMs; private int _micSyncOffsetMs = LayoutStore.DefaultMicSyncOffsetMs; 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)); OnPropertyChanged(nameof(MicMeterFraction)); UpdateDuckState(); RaiseMasterReadouts(); OnPropertyChanged(nameof(TraxMeterFraction)); OnPropertyChanged(nameof(MicMeterFraction)); OnPropertyChanged(nameof(MicVolumeDb)); OnPropertyChanged(nameof(MicVolumeDbLabel)); } } } /// 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 : AudioLevelMeter.ToDisplay((float)(AudioLevel * MicVolume)); public double MeterFillWidth => MeterLevel * 288; public string MeterBrush => MeterLevel switch { < 0.6 => ThemeBrush.Hex("Brush.Meter.Nominal"), < 0.8 => ThemeBrush.Hex("Brush.Meter.Caution"), _ => ThemeBrush.Hex("Brush.Meter.Peak"), }; /// 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)); } } } /// The fader's face, in dB (−60 … +6, 0 = unity). The model keeps linear gain; this is /// the same value expressed the way a person reads a mixer — and the only thing the dock binds. /// public double MicVolumeDb { get => Helpers.GainDb.ToDb(MicVolume); set { var gain = Helpers.GainDb.FromDb(value); if (Math.Abs(gain - MicVolume) < 1e-6) return; MicVolume = gain; OnPropertyChanged(nameof(MicVolumeDb)); OnPropertyChanged(nameof(MicVolumeDbLabel)); } } /// "-6.0 dB" beside the fader; "−∞ dB" at the floor. public string MicVolumeDbLabel => Helpers.GainDb.Label(MicVolumeDb) + " dB"; /// The ladder's input: a 0…1 display fraction of what was MEASURED (already scaled by /// the volume and muted state), never the gain setting. public double MicMeterFraction => MeterLevel; /// 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); } /// Global audio-sync offset in milliseconds (−500..500, default 0): /// POSITIVE delays the whole mix so audio lands on the video when it runs /// AHEAD (OBS's lip-sync fix); NEGATIVE advances it by dropping the first /// |N| ms of the live stream head when audio runs BEHIND the video. /// Consumed live by the mixer via a Func seam; persisted with the layout. public int AudioSyncOffsetMs { get => _audioSyncOffsetMs; set { var clamped = Math.Clamp(value, -500, 500); if (!SetProperty(ref _audioSyncOffsetMs, clamped)) return; OnPropertyChanged(nameof(SyncOffsetLabel)); ScheduleSave(); } } /// /// Microphone-only sync delay in milliseconds (0..500, default 175). A camera's frames reach the /// compositor later than the mic's samples reach the audio pipe, so by default the voice runs /// AHEAD of the lips; delaying the mic alone closes the gap without touching desktop or alert audio, /// which have no camera to follow. The creator can dial it to taste in the audio dock. /// /// 175 ms is the creator's own measurement for a C920 pipeline (they asked for +150..+200); /// it is a persisted setting, not a constant, because the right number is the machine's, not ours. /// public int MicSyncOffsetMs { get => _micSyncOffsetMs; set { var clamped = Math.Clamp(value, 0, 500); if (!SetProperty(ref _micSyncOffsetMs, clamped)) return; OnPropertyChanged(nameof(MicSyncOffsetLabel)); ScheduleSave(); } } public string MicSyncOffsetLabel => _micSyncOffsetMs == 0 ? "off" : $"+{_micSyncOffsetMs} ms"; // ─── Master strip ──────────────────────────────────────────────────────── private bool _monoOutput; /// Collapse the output to mono. Persisted, because a creator who needs it needs it on /// every session — an accessibility switch that resets is an accessibility failure. public bool MonoOutput { get => _monoOutput; set { if (!SetProperty(ref _monoOutput, value)) return; _audioMixer.MonoOutput = value; _layoutStore.SaveMonoOutput(value); } } /// Master ladder input: the MEASURED mix while a session runs (that is the buffer the /// encoder receives), and an estimate from the measured sources when idle — otherwise the master /// sits at zero between sessions while every bay below it moves. public double MasterMeterFraction => IsLive || IsRecording ? AudioLevelMeter.ToDisplay(_audioMixer.MasterLevel) : Helpers.MeterScale.MonitorFraction((float)AudioLevel, MicVolume, (float)GameAudioLevel, GameAudioVolume); public string MasterLevelLabel => Helpers.GainDb.Label(Helpers.GainDb.ToDb(_audioMixer.MasterLevel)) + " dB"; /// True when the mix's channels differ by more than 6 dB — the one thing L/R is /// genuinely good for, shown as one flag instead of two ladders. public bool MasterImbalance => _audioMixer.MasterImbalanceDb > 6f; private void RaiseMasterReadouts() { OnPropertyChanged(nameof(MasterMeterFraction)); OnPropertyChanged(nameof(MasterLevelLabel)); OnPropertyChanged(nameof(MasterImbalance)); } /// "0 ms (aligned)", "+50 ms", "−120 ms" — what the dock shows beside the slider. public string SyncOffsetLabel => AudioSyncOffsetMs == 0 ? "0 ms (aligned)" : (AudioSyncOffsetMs > 0 ? "+" : "−") + Math.Abs(AudioSyncOffsetMs) + " ms"; /// Preset buttons in the LIP-SYNC bay. 0 is the detent: the offset that means "the /// lip-sync is right", so it deserves one click, not a drag. internal void SetSyncOffset(int milliseconds) => AudioSyncOffsetMs = milliseconds; /// TASK 35: mute/unmute from a scene entry. Idempotent — a scene that says "mute" while /// the mic is already muted must not overwrite the level it would restore. internal void SetMicMuted(bool muted) { if (muted == MicMuted) return; if (muted) { _volumeBeforeMute = MicVolume; MicVolume = 0; } else { MicVolume = _volumeBeforeMute ?? 0.8; BeginVolumeFlash(); } } /// TASK 35: as , for desktop/game audio. internal void SetGameMuted(bool muted) { if (muted == GameMuted) return; if (muted) { _gameVolumeBeforeMute = GameAudioVolume; GameAudioVolume = 0; } else { GameAudioVolume = _gameVolumeBeforeMute ?? 1.0; } } 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 SolidColorBrush MicProblemBrush => ThemeBrush.Get("Brush.Secondary"); /// 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). ─── // Test seam (mirrors LayoutPathOverride/RegistrarOverride): the volume slider is // stream-honest AND speaker-honest — the real path writes the Windows endpoint. // Tests must never hijack the machine's actual volume, so they swap the push out. internal static Action? VolumePushOverride; private static void PushSystemVolume(double volume) { if (VolumePushOverride is { } push) { push(volume); return; } 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 — the WASAPI loopback tap is /// PRE-endpoint-volume (creator's 20%-volume/pegged-meter observation, /// 2026-09-01), so the meter must multiply by GameAudioVolume itself, exactly /// like the stream gain does (the mic formula always did; the clone dropped it). private double GameMeterLevel => GameMuted ? 0 : _gameVolumeAdjusting || _gameVolumeFlash ? GameAudioVolume : Math.Min(1, AudioLevelMeter.ToDisplay((float)GameAudioLevel) * GameAudioVolume); public double GameMeterFillWidth => GameMeterLevel * 288; /// Ladder input: a 0…1 display fraction of what was measured. public double GameMeterFraction => GameMeterLevel; /// The game fader's dB face (−60 … +6). Same rule as the mic: the model keeps linear /// gain; the dock shows decibels. public double GameVolumeDb { get => Helpers.GainDb.ToDb(GameAudioVolume); set { var gain = Helpers.GainDb.FromDb(value); if (Math.Abs(gain - GameAudioVolume) < 1e-6) return; GameAudioVolume = gain; OnPropertyChanged(nameof(GameVolumeDb)); OnPropertyChanged(nameof(GameVolumeDbLabel)); } } public string GameVolumeDbLabel => Helpers.GainDb.Label(GameVolumeDb) + " dB"; /// True while the mic is ducking the game/desktop bed (the mixer's own value). public bool IsGameDucking { get; private set; } /// "−12.0 dB" — the badge text, computed from the same gain the mixer applied. public string GameDuckLabel => Helpers.GainDb.Label(Helpers.GainDb.ToDb(_audioMixer.DuckGain)) + " DUCK"; public string GameMeterBrush => GameMeterLevel switch { < 0.6 => ThemeBrush.Hex("Brush.Meter.Nominal"), < 0.8 => ThemeBrush.Hex("Brush.Meter.Caution"), _ => ThemeBrush.Hex("Brush.Meter.Peak"), }; 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"; /// Badge state follows the mix, not a timer of its own: the level tick is already the /// moment the ducker has just run. private void UpdateDuckState() { var ducking = _audioMixer.DuckGain < 0.99f; if (ducking == IsGameDucking) return; IsGameDucking = ducking; OnPropertyChanged(nameof(IsGameDucking)); OnPropertyChanged(nameof(GameDuckLabel)); } 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(); AppLog.Write("Audio: using system default mic + default output — assuming they are configured and available (device issues are out of scope)."); } catch (Exception ex) { AppLog.Write($"Mic: device check failed: {ex.Message}"); } } }