Files
LlamaCasty/ViewModels/MainViewModel.Audio.cs
T
gramps 839bc991a2 fix(dock): meters beat on their own 20 Hz clock, and TRAX silence is diagnosed
Reported: TRAX plays audibly but no ladder registers anywhere.

Two distinct faults, one of them mine:
1. The meter readouts were refreshed only from the MIC's level callback. With a silent or absent
   mic there is no tick, so a playing TRAX never updated its ladder — the level was measured, the
   UI simply never asked for it. Meters now have a 20 Hz DispatcherTimer of their own.
2. If the player's own meter still reads 0 while playing, the samples are not reaching the device
   (or metering is not attached). That now writes ONE line to startup.log after 2s — so the next
   report says which side is broken instead of another round of guessing.

Note: TRAX rides the desktop loopback, so once the mix captures it the GAME ladder should move too;
if it does not, the loopback endpoint and the music's output device differ.
2026-10-30 13:24:29 -07:00

593 lines
22 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
/// <summary>The dock's heartbeat: every meter readout is refreshed here, 20 times a second,
/// from whatever the audio threads last measured.</summary>
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();
}
/// <summary>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.</summary>
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 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. ───
/// <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;
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));
}
}
}
/// <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 : 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"),
};
/// <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)
{
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;
}
/// <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>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.
/// </summary>
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));
}
}
/// <summary>"-6.0 dB" beside the fader; "−∞ dB" at the floor.</summary>
public string MicVolumeDbLabel => Helpers.GainDb.Label(MicVolumeDb) + " dB";
/// <summary>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.</summary>
public double MicMeterFraction => MeterLevel;
/// <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 left-justified INSIDE the meter
/// bar (FontSize 10, ellipsized to the bar) — ai.md is the authority here.</summary>
public string? MicSourceName
{
get => _micSourceName;
private set => SetProperty(ref _micSourceName, value);
}
/// <summary>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.</summary>
public int AudioSyncOffsetMs
{
get => _audioSyncOffsetMs;
set
{
var clamped = Math.Clamp(value, -500, 500);
if (!SetProperty(ref _audioSyncOffsetMs, clamped)) return;
OnPropertyChanged(nameof(SyncOffsetLabel));
ScheduleSave();
}
}
// ─── Master strip ────────────────────────────────────────────────────────
private bool _monoOutput;
/// <summary>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.</summary>
public bool MonoOutput
{
get => _monoOutput;
set
{
if (!SetProperty(ref _monoOutput, value)) return;
_audioMixer.MonoOutput = value;
_layoutStore.SaveMonoOutput(value);
}
}
/// <summary>Master ladder input: the measured mix, 0…1 on the same display scale.</summary>
public double MasterMeterFraction => AudioLevelMeter.ToDisplay(_audioMixer.MasterLevel);
public string MasterLevelLabel => Helpers.GainDb.Label(Helpers.GainDb.ToDb(_audioMixer.MasterLevel)) + " dB";
/// <summary>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.</summary>
public bool MasterImbalance => _audioMixer.MasterImbalanceDb > 6f;
private void RaiseMasterReadouts()
{
OnPropertyChanged(nameof(MasterMeterFraction));
OnPropertyChanged(nameof(MasterLevelLabel));
OnPropertyChanged(nameof(MasterImbalance));
}
/// <summary>"0 ms (aligned)", "+50 ms", "−120 ms" — what the dock shows beside the slider.</summary>
public string SyncOffsetLabel => AudioSyncOffsetMs == 0
? "0 ms (aligned)"
: (AudioSyncOffsetMs > 0 ? "+" : "−") + Math.Abs(AudioSyncOffsetMs) + " ms";
/// <summary>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.</summary>
internal void SetSyncOffset(int milliseconds) => AudioSyncOffsetMs = milliseconds;
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");
/// <summary>Mic connection state, driven by the mixer's MicConnected/MicFailed
/// events and the startup device check (see StartMicCaptureAsync).</summary>
public MicStatus MicStatus
{
get => _micStatus;
private set
{
if (SetProperty(ref _micStatus, value))
{
OnPropertyChanged(nameof(MicStatusBrush));
OnPropertyChanged(nameof(MicStatusToolTip));
}
}
}
/// <summary>Status dot: green = connected, yellow = problem with the requested
/// connection, red = not connected (no device / not started).</summary>
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<double>? 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 { }
}
/// <summary>Live desktop/game input level (0..1), fed by the mixer's loopback
/// capture. Read by the game meter, scaled by GameAudioVolume.</summary>
public double GameAudioLevel
{
get => _gameAudioLevel;
set
{
if (SetProperty(ref _gameAudioLevel, Math.Clamp(value, 0, 1)))
{
OnPropertyChanged(nameof(GameMeterFillWidth));
OnPropertyChanged(nameof(GameMeterBrush));
}
}
}
/// <summary>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).</summary>
private double GameMeterLevel => GameMuted ? 0 : _gameVolumeAdjusting || _gameVolumeFlash ? GameAudioVolume : Math.Min(1, AudioLevelMeter.ToDisplay((float)GameAudioLevel) * GameAudioVolume);
public double GameMeterFillWidth => GameMeterLevel * 288;
/// <summary>Ladder input: a 0…1 display fraction of what was measured.</summary>
public double GameMeterFraction => GameMeterLevel;
/// <summary>The game fader's dB face (−60 … +6). Same rule as the mic: the model keeps linear
/// gain; the dock shows decibels.</summary>
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";
/// <summary>True while the mic is ducking the game/desktop bed (the mixer's own value).</summary>
public bool IsGameDucking { get; private set; }
/// <summary>"−12.0 dB" — the badge text, computed from the same gain the mixer applied.</summary>
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;
}
/// <summary>Game audio gain (0..1, unity default). Running to 0 mutes; the
/// prior level is remembered so the speaker button can restore it.</summary>
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);
}
}
}
/// <summary>Read-only: true whenever the volume is 0 (the slider and the
/// speaker can never disagree).</summary>
public bool GameMuted => _gameMuted;
public string GameMuteText => GameMuted ? "Unmute" : "Mute";
/// <summary>Badge state follows the mix, not a timer of its own: the level tick is already the
/// moment the ducker has just run.</summary>
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;
}
/// <summary>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).</summary>
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}");
}
}
}