Creator: "why is there no meter for when trax is playing?" Routing answer: TRAX rides the desktop loopback, so it already moved the GAME ladder — there was no separate TRAX signal in the mix. But that ladder cannot answer the question a deaf creator actually has: "is the music producing sound at all?" The play glyph cannot either — an open file with a dead decoder still reports Playing. So the player now measures its own output: \`MusicPlayer\` wraps the volume stage in an NAudio \`MeteringSampleProvider\` and exposes \`Level\`; \`ITraxPlayer.Level\` keeps the fake honest. The VM maps it through the same display scale (\`TraxMeterFraction\`), zeroed when not playing, and the TRAX bay carries a 24-segment ladder above its fader. This reverses my earlier "no ladder, nothing honest to show" — the honest thing arrived with a real measurement. TraxBayTests flipped accordingly (it now asserts the ladder AND its source).
260 lines
9.5 KiB
C#
260 lines
9.5 KiB
C#
using System.IO;
|
|
using System.Windows.Media;
|
|
using Microsoft.Win32;
|
|
using ytLive.Helpers;
|
|
using ytLive.Models;
|
|
using ytLive.Services.Audio;
|
|
|
|
namespace ytLive.ViewModels;
|
|
|
|
public partial class MainViewModel
|
|
{
|
|
// 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 button is a
|
|
// toggle; its dot is green (playing) / red (error — a terse message scrolls in the
|
|
// desktop-audio bar) / gray (idle or no track).
|
|
private readonly ITraxPlayer _musicPlayer = TraxPlayerOverride?.Invoke() ?? new MusicPlayer();
|
|
|
|
/// <summary>Test seam: a fake TRAX player (no audio device). Set before constructing.</summary>
|
|
internal static Func<ITraxPlayer>? TraxPlayerOverride { get; set; }
|
|
private Music? _music;
|
|
private bool _musicPlaying;
|
|
private bool _traxLoadInFlight;
|
|
|
|
/// <summary>Hard ceiling on the TRAX reachability probe. A track on an offline
|
|
/// network share blocks ~40s inside <c>MediaFoundationReader</c> before it throws
|
|
/// 0x80070035, and that call sat on the startup path inside LoadLayout, so the
|
|
/// window froze for the whole SMB timeout. The probe runs off the UI thread and is
|
|
/// abandoned at this budget; the orphan thread stays parked until SMB gives up,
|
|
/// which is the point — it is not the thread the creator is waiting on.</summary>
|
|
internal static readonly TimeSpan TraxProbeBudget = TimeSpan.FromSeconds(2);
|
|
|
|
private static readonly SolidColorBrush TraxIdleBrush = CreateBrush("#555");
|
|
/// <summary>True while the TRAX background track is playing.</summary>
|
|
public bool IsMusicPlaying
|
|
{
|
|
get => _musicPlaying;
|
|
private set
|
|
{
|
|
if (SetProperty(ref _musicPlaying, value))
|
|
{
|
|
OnPropertyChanged(nameof(TraxDotBrush));
|
|
OnPropertyChanged(nameof(TraxToolTip));
|
|
}
|
|
}
|
|
}
|
|
|
|
private string? _traxError;
|
|
|
|
/// <summary>Error message from the last TRAX load/playback failure; null = no error.</summary>
|
|
public string? TraxError
|
|
{
|
|
get => _traxError;
|
|
private set
|
|
{
|
|
if (SetProperty(ref _traxError, value))
|
|
OnPropertyChanged(nameof(TraxDotBrush));
|
|
}
|
|
}
|
|
|
|
/// <summary>TRAX status dot: green = playing, red = the last load/play failed (the
|
|
/// message scrolls in the desktop-audio bar), gray = idle or no track.</summary>
|
|
public SolidColorBrush TraxDotBrush => _musicPlayer.IsPlaying
|
|
? BarOnBrush
|
|
: TraxError != null ? BarOffBrush : TraxIdleBrush;
|
|
|
|
/// <summary>The dock's TRAX fader, in dB. Unity means the bed's design level, not the raw file.</summary>
|
|
public double TraxVolumeDb
|
|
{
|
|
get => Helpers.GainDb.ToDb(_musicPlayer.UserGain);
|
|
set
|
|
{
|
|
var gain = Helpers.GainDb.FromDb(value);
|
|
if (Math.Abs(gain - _musicPlayer.UserGain) < 1e-6) return;
|
|
_musicPlayer.UserGain = (float)gain;
|
|
OnPropertyChanged(nameof(TraxVolumeDb));
|
|
OnPropertyChanged(nameof(TraxVolumeDbLabel));
|
|
}
|
|
}
|
|
|
|
public string TraxVolumeDbLabel => Helpers.GainDb.Label(TraxVolumeDb) + " dB";
|
|
|
|
/// <summary>The TRAX ladder: the player's own measured output, so "is music actually making
|
|
/// sound" is answerable without hearing it.</summary>
|
|
public double TraxMeterFraction =>
|
|
IsMusicPlaying ? Services.Audio.AudioLevelMeter.ToDisplay(_musicPlayer.Level) : 0;
|
|
|
|
/// <summary>Track name, or an honest "No track".</summary>
|
|
public string TraxTrackLabel => _musicPlayer.TrackName ?? "No track";
|
|
|
|
/// <summary>Same ducker signal as the game bay — the bed ducks with it.</summary>
|
|
public bool IsTraxDucking => IsGameDucking;
|
|
|
|
public string TraxToolTip => _musicPlayer.TrackName != null
|
|
? $"TRAX: {_musicPlayer.TrackName} — left-click {(IsMusicPlaying ? "pauses" : "plays")}, right-click chooses a track"
|
|
: "No track — left-click or right-click to choose background music";
|
|
|
|
/// <summary>Left-click on TRAX: no track → pick one; otherwise toggle play/pause.</summary>
|
|
public void TraxLeftClick()
|
|
{
|
|
if (_music == null || string.IsNullOrWhiteSpace(_music.TrackPath))
|
|
PickTraxTrack();
|
|
else if (IsMusicPlaying)
|
|
PauseTrax();
|
|
else
|
|
PlayTrax();
|
|
}
|
|
|
|
/// <summary>Right-click on TRAX: always open the track picker (never the OS viewer).</summary>
|
|
public void TraxRightClick() => PickTraxTrack();
|
|
|
|
/// <summary>Open the track picker (the dock's right-click on the TRAX name).</summary>
|
|
internal void ChooseTrack() => PickTraxTrack();
|
|
|
|
private void PickTraxTrack()
|
|
{
|
|
var dialog = new OpenFileDialog
|
|
{
|
|
Title = "Choose background music",
|
|
Filter = "Music (*.mp3;*.wav;*.m4a;*.aac;*.flac;*.ogg)|*.mp3;*.wav;*.m4a;*.aac;*.flac;*.ogg|All files (*.*)|*.*",
|
|
};
|
|
if (dialog.ShowDialog() != true)
|
|
return;
|
|
|
|
_ = SetTraxTrackAsync(dialog.FileName, play: true);
|
|
}
|
|
|
|
/// <summary>Points TRAX at a track and loads it (optionally playing). The picker's path;
|
|
/// internal so tests can load a track without the file dialog.</summary>
|
|
internal Task SetTraxTrackAsync(string path, bool play)
|
|
{
|
|
_music = new Music { TrackPath = path, IsEnabled = true };
|
|
return LoadTraxAsync(play);
|
|
}
|
|
|
|
/// <summary>Probes a path off the UI thread and gives up at <paramref name="budget"/>.
|
|
/// A probe may time out — that is the whole point. False means offline, missing, or
|
|
/// slower than the budget, and every one of those is a normal state, not an error.</summary>
|
|
internal static async Task<bool> IsTrackReachableAsync(string path, TimeSpan budget, Func<string, bool> probe)
|
|
{
|
|
var probeTask = Task.Run(() =>
|
|
{
|
|
try
|
|
{
|
|
return probe(path);
|
|
}
|
|
catch
|
|
{
|
|
return false;
|
|
}
|
|
});
|
|
|
|
var finished = await Task.WhenAny(probeTask, Task.Delay(budget)).ConfigureAwait(false);
|
|
return finished == probeTask && await probeTask.ConfigureAwait(false);
|
|
}
|
|
|
|
private void LoadTrax(bool play) => _ = LoadTraxAsync(play);
|
|
|
|
private async Task LoadTraxAsync(bool play)
|
|
{
|
|
var music = _music;
|
|
if (music == null || string.IsNullOrWhiteSpace(music.TrackPath) || _traxLoadInFlight)
|
|
return;
|
|
|
|
_traxLoadInFlight = true;
|
|
var loaded = false;
|
|
try
|
|
{
|
|
var path = music.TrackPath;
|
|
if (await IsTrackReachableAsync(path, TraxProbeBudget, File.Exists))
|
|
{
|
|
// Reached the share, so this bind is cheap and stays on the UI thread
|
|
// with every other MusicPlayer mutation — no new race to reason about.
|
|
_musicPlayer.Load(path);
|
|
UpdateTraxLocalGain();
|
|
music.IsEnabled = true;
|
|
TraxError = null;
|
|
loaded = true;
|
|
if (play)
|
|
PlayTrax();
|
|
}
|
|
else
|
|
{
|
|
AppLog.Write($"TRAX: '{path}' unreachable within {TraxProbeBudget.TotalSeconds:0}s — not loading");
|
|
TraxError = "TRAX: can't reach the track";
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
AppLog.Write($"TRAX: failed to load '{music.TrackPath}': {ex.Message}");
|
|
TraxError = "TRAX: can't load the track";
|
|
}
|
|
finally
|
|
{
|
|
_traxLoadInFlight = false;
|
|
RefreshTraxUi();
|
|
if (loaded)
|
|
ScheduleSave();
|
|
}
|
|
}
|
|
|
|
// Both toggle halves MUST refresh the UI: the dot and IsMusicPlaying used to update only
|
|
// on load/playback-ended, so pressing TRAX started the music with the light still off —
|
|
// and because IsMusicPlaying stayed false, the next press tried to play again instead of
|
|
// pausing (2026-10-09 bug).
|
|
private void PlayTrax()
|
|
{
|
|
try
|
|
{
|
|
_musicPlayer.Play();
|
|
TraxError = _musicPlayer.IsPlaying ? null : "TRAX: no audio output";
|
|
if (TraxError != null) AppLog.Write("TRAX: Play() returned but playback did not start (no output device?)");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
AppLog.Write($"TRAX: play failed: {ex.Message}");
|
|
TraxError = "TRAX: can't play the track";
|
|
}
|
|
RefreshTraxUi();
|
|
}
|
|
|
|
private void PauseTrax()
|
|
{
|
|
try
|
|
{
|
|
_musicPlayer.Pause();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
AppLog.Write($"TRAX: pause failed: {ex.Message}");
|
|
}
|
|
RefreshTraxUi();
|
|
}
|
|
|
|
private void OnTraxPlaybackEnded(Exception? ex)
|
|
{
|
|
if (ex != null)
|
|
{
|
|
AppLog.Write($"TRAX: playback error: {ex.Message}");
|
|
TraxError = "TRAX: playback stopped";
|
|
}
|
|
RefreshTraxUi();
|
|
}
|
|
|
|
private void RefreshTraxUi()
|
|
{
|
|
IsMusicPlaying = _musicPlayer.IsPlaying;
|
|
OnPropertyChanged(nameof(TraxDotBrush));
|
|
OnPropertyChanged(nameof(TraxToolTip));
|
|
}
|
|
|
|
/// <summary>Mirrors the desktop/game audio bar's volume and mute onto the
|
|
/// music so the creator hears the controls work in the headphones (the
|
|
/// track rides the loopback channel, so it must duck the same way locally).
|
|
/// Called whenever the game volume/mute change and on TRAX load.</summary>
|
|
private void UpdateTraxLocalGain()
|
|
{
|
|
_musicPlayer.LocalGain = GameMuted ? 0f : 1f;
|
|
}
|
|
}
|