Files
LlamaCasty/ViewModels/MainViewModel.Trax.cs
T
gramps ec293e54b0 feat(dock): TRAX becomes a background level, not a channel strip
Creator ruling (2026-10-30): TRAX is background music and is controlled separately from desktop and
mic — so KISS it:

- TRAX loses its own ladder and slider. It is one number plus ±5 dB buttons, disabled at the ends
  (−60 dB floor, +6 dB ceiling).
- It STARTS at −50 dB, taken from the creator's own meter readings, and moves in 5 dB clicks. If that
  proves inaudible on their setup, six clicks lands at −20 — the buttons exist for exactly that, and
  `TraxDefaultDb` is one constant.
- Layout follows: Desktop's volume now sits directly beneath its ladder, and the TRAX row (name,
  play/pause, level) sits beneath that, inside the GAME bay. The bays are MIC | GAME+TRAX | LIP-SYNC.
- The metering tap on the player stays (it is how "is the music producing sound" is answered) but has
  no ladder of its own; the desktop bed already shows the music.

Guards updated: TraxBayTests now asserts the stepper wiring and that the row has NO SegmentMeter;
GameBayTests asserts the desktop fader sits below the desktop ladder.
2026-10-30 13:46:05 -07:00

276 lines
10 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.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>Where TRAX starts: quiet background under the desktop bed. The ±5 dB buttons are the
/// control surface — a creator who wants it louder clicks up three times rather than hunting a
/// 6px thumb on a 148px dock. (Creator ruling 2026-10-30, from their own meter readings.)</summary>
public const double TraxDefaultDb = -50;
public const double TraxStepDb = 5;
/// <summary>+6 dB is the ceiling; −60 dB is silence. The buttons disable at both ends.</summary>
public bool CanRaiseTrax => TraxVolumeDb < Helpers.GainDb.MaxDb;
public bool CanLowerTrax => TraxVolumeDb > Helpers.GainDb.FloorDb;
public void StepTrax(int steps) => TraxVolumeDb += steps * TraxStepDb;
/// <summary>The dock's TRAX level, 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));
OnPropertyChanged(nameof(CanRaiseTrax));
OnPropertyChanged(nameof(CanLowerTrax));
}
}
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;
}
}