diff --git a/Controls/PreviewPane.xaml b/Controls/PreviewPane.xaml
index 1f36bdf..2b4ad71 100644
--- a/Controls/PreviewPane.xaml
+++ b/Controls/PreviewPane.xaml
@@ -899,12 +899,28 @@
VerticalAlignment="Stretch"/>
-
-
-
+
+
Seam over the TRAX background-music player () so the
+/// toggle/status-light logic is testable without a real audio device.
+public interface ITraxPlayer : IDisposable
+{
+ event Action? PlaybackEnded;
+ string? TrackName { get; }
+ bool IsPlaying { get; }
+ float LocalGain { get; set; }
+ void Load(string path);
+ void Play();
+ void Pause();
+}
diff --git a/Services/Audio/MusicPlayer.cs b/Services/Audio/MusicPlayer.cs
index 6d82ba1..1be0cef 100644
--- a/Services/Audio/MusicPlayer.cs
+++ b/Services/Audio/MusicPlayer.cs
@@ -11,12 +11,17 @@ namespace ytLive.Services.Audio;
/// The desktop/game bar's gain and mute scale , so the
/// creator hears the game-audio controls work on the music too.
///
-public sealed class MusicPlayer : IDisposable
+public sealed class MusicPlayer : ITraxPlayer
{
- /// The one and only music bed — quiet, no slider (the creator's
- /// voice must stay on top; game/music loudness is the ducker's job). The
- /// desktop audio bar scales this via .
- public const float MusicVolume = 0.20f;
+ /// The one and only music bed — no slider (the creator's voice must stay on
+ /// top; that's the ducker's job, −12 dB while they talk). The desktop audio bar scales
+ /// this via .
+ /// 2026-10-09: was 0.20 (−14 dB) — "even at max volume I can barely discern it".
+ /// The creator's track masters at −11.3 LUFS (typical: −9..−14), so 0.20 put the bed at
+ /// ~−25 LUFS, and ducked under speech ~−37 LUFS: inaudible on stream too, because the
+ /// loopback carries exactly what plays locally. 0.5 (−6 dB) lands a typical master at
+ /// ~−15..−20 LUFS — beside the leveled voice (≈ −17 LUFS), and well under it when ducked.
+ public const float MusicVolume = 0.5f;
private MediaFoundationReader? _reader;
private VolumeWaveProvider16? _volume;
diff --git a/ViewModels/MainViewModel.Trax.cs b/ViewModels/MainViewModel.Trax.cs
index 8b7da8b..e21f544 100644
--- a/ViewModels/MainViewModel.Trax.cs
+++ b/ViewModels/MainViewModel.Trax.cs
@@ -10,9 +10,13 @@ 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 dot is
- // red (no track) / yellow (loaded, stopped) / green (playing).
- private readonly MusicPlayer _musicPlayer = new();
+ // 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();
+
+ /// Test seam: a fake TRAX player (no audio device). Set before constructing.
+ internal static Func? TraxPlayerOverride { get; set; }
private Music? _music;
private bool _musicPlaying;
private bool _traxLoadInFlight;
@@ -46,13 +50,18 @@ public partial class MainViewModel
public string? TraxError
{
get => _traxError;
- private set => SetProperty(ref _traxError, value);
+ private set
+ {
+ if (SetProperty(ref _traxError, value))
+ OnPropertyChanged(nameof(TraxDotBrush));
+ }
}
- /// TRAX status dot: green = playing, gray = idle or no track.
+ /// TRAX status dot: green = playing, red = the last load/play failed (the
+ /// message scrolls in the desktop-audio bar), gray = idle or no track.
public SolidColorBrush TraxDotBrush => _musicPlayer.IsPlaying
? BarOnBrush
- : TraxIdleBrush;
+ : TraxError != null ? BarOffBrush : TraxIdleBrush;
public string TraxToolTip => _musicPlayer.TrackName != null
? $"TRAX: {_musicPlayer.TrackName} — left-click {(IsMusicPlaying ? "pauses" : "plays")}, right-click chooses a track"
@@ -82,8 +91,15 @@ public partial class MainViewModel
if (dialog.ShowDialog() != true)
return;
- _music = new Music { TrackPath = dialog.FileName, IsEnabled = true };
- LoadTrax(play: true);
+ _ = SetTraxTrackAsync(dialog.FileName, play: true);
+ }
+
+ /// 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.
+ internal Task SetTraxTrackAsync(string path, bool play)
+ {
+ _music = new Music { TrackPath = path, IsEnabled = true };
+ return LoadTraxAsync(play);
}
/// Probes a path off the UI thread and gives up at .
@@ -128,20 +144,20 @@ public partial class MainViewModel
UpdateTraxLocalGain();
music.IsEnabled = true;
TraxError = null;
- if (play)
- _musicPlayer.Play();
loaded = true;
+ if (play)
+ PlayTrax();
}
else
{
AppLog.Write($"TRAX: '{path}' unreachable within {TraxProbeBudget.TotalSeconds:0}s — not loading");
- TraxError = $"Can't reach {Path.GetFileName(path)} — is the drive on?";
+ TraxError = "TRAX: can't reach the track";
}
}
catch (Exception ex)
{
AppLog.Write($"TRAX: failed to load '{music.TrackPath}': {ex.Message}");
- TraxError = $"Failed to load: {Path.GetFileName(music.TrackPath)} — {ex.Message}";
+ TraxError = "TRAX: can't load the track";
}
finally
{
@@ -152,16 +168,45 @@ public partial class MainViewModel
}
}
- private void PlayTrax() => _musicPlayer.Play();
+ // 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() => _musicPlayer.Pause();
+ 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 = $"Playback error: {ex.Message}";
+ TraxError = "TRAX: playback stopped";
}
RefreshTraxUi();
}
diff --git a/ai.md b/ai.md
index 5e1e427..2e9f2e1 100644
--- a/ai.md
+++ b/ai.md
@@ -239,7 +239,7 @@ C# / WPF (.NET 8) following MVVM:
|------|------|
| `Models/` | Plain data types — Scene, Source (incl. `ClipShape`, `IsMirrored`, `VideoImageSource`), QualityOption, StreamConfig, StreamHealth, YouTubeChannel, ChatMessage, **Socials (`SocialService` enum + `SocialEntry`/`SocialsConfig` + `SocialServiceIcons`) — the social bar** |
| `ViewModels/` | MainViewModel — `public partial class`, one file per functional area (Scenes, Background, Webcam, Audio, Trax, Socials, Streaming, Chat, Overlays, Account, License, Recording — split complete, see `ViewModels/index.md`); **Chat.cs is a thin delegating facade over `Services/ChatOverlayLayer.cs` (Commit G, first true decomposition), and now also over `Services/AlertOverlayLayer.cs` (TASK 43 — the native alert box, same true-component pattern)**; GoLiveViewModel, ReuseImageViewModel, CameraPickerViewModel, **SocialsDialogViewModel** |
-| `Services/` | YouTube OAuth2, stream/broadcast management, live chat polling, LayoutStore (SQLite), **SocialValidator (`ISocialValidator` seam + `HttpSocialValidator` default)**, **webcam: `VideoFrame` seam + `CameraDeviceInfo`/`ICameraEnumerator`/`ICameraFrameSource` interfaces + `MediaCaptureCameraEnumerator`/`MediaCaptureFrameSource` (WinRT) + `CameraManager`**, **screen capture: `IFullScreenDetector`/`Win32FullScreenDetector` + `IScreenCaptureSource`/`ScreenCaptureFrameSource` (WinRT GraphicsCapture) + `ScreenCaptureManager` + `ScreenCaptureSourceFactory` + `Direct3D11Helper`/`CaptureInterop` (COM bridges)**, **media source: `IMediaFrameSource` + `MediaVideoSource` (spawns ffmpeg `rawvideo` BGRA decode) + `MediaVideoSourceManager` (refcount-by-path session owner) + pure `RawVideoFrameReader` + `IDecodeProcess`/`FfmpegDecodeProcess` process seam (binary-stdout mirror of `IEncoderProcess`; see "Media source")**, **compositor: `SceneCompositor` + `CompositorOptions` + pure `StretchMath` + `StaticPixelCache` (see "Scene compositor")**, **alerts: `ChatOverlayLayer` + `AlertOverlayLayer`/`AlertRenderer` (the native celebration-zone overlay, TASK 43 — see "Native events & alerts")**, **audio: `IAudioSource` seam + `WasapiLoopbackAudioSource`/`WasapiMicAudioSource` (NAudio WASAPI) + `AudioMixer` + pure `AudioLevelMeter`/`WaveToFloat`/`VoiceFilterChain`/`LowShelfFilter`/`HighShelfFilter`/`NoiseGate`/`Compressor`/`AutoDucker`/`AudioRingBuffer`/`TinyResampler`/`AudioSyncDelay` + `MusicPlayer` + `IAudioPipeWriter`/`NamedPipeAudioWriter` (see "Live audio capture")**, **encoder: `IFfmpegEncoder`/`FfmpegEncoder` + `IEncoderProcess`/`FfmpegEncoderProcess` + `IFfmpegLocator`/`FfmpegLocator` + pure `FfmpegArgs`/`FfmpegProgressParser`/`FfmpegEncoderPicker` + the `FramePump` frame producer (see "Live encoder" + "Live frame pipeline")**, **notifications: `INotificationService` seam (`AppNotificationSeverity` Info/Success/Warning/Error) + `NotificationService` (Notification.Wpf toasts, see "Toast notifications")** |
+| `Services/` | YouTube OAuth2, stream/broadcast management, live chat polling, LayoutStore (SQLite), **SocialValidator (`ISocialValidator` seam + `HttpSocialValidator` default)**, **webcam: `VideoFrame` seam + `CameraDeviceInfo`/`ICameraEnumerator`/`ICameraFrameSource` interfaces + `MediaCaptureCameraEnumerator`/`MediaCaptureFrameSource` (WinRT) + `CameraManager`**, **screen capture: `IFullScreenDetector`/`Win32FullScreenDetector` + `IScreenCaptureSource`/`ScreenCaptureFrameSource` (WinRT GraphicsCapture) + `ScreenCaptureManager` + `ScreenCaptureSourceFactory` + `Direct3D11Helper`/`CaptureInterop` (COM bridges)**, **media source: `IMediaFrameSource` + `MediaVideoSource` (spawns ffmpeg `rawvideo` BGRA decode) + `MediaVideoSourceManager` (refcount-by-path session owner) + pure `RawVideoFrameReader` + `IDecodeProcess`/`FfmpegDecodeProcess` process seam (binary-stdout mirror of `IEncoderProcess`; see "Media source")**, **compositor: `SceneCompositor` + `CompositorOptions` + pure `StretchMath` + `StaticPixelCache` (see "Scene compositor")**, **alerts: `ChatOverlayLayer` + `AlertOverlayLayer`/`AlertRenderer` (the native celebration-zone overlay, TASK 43 — see "Native events & alerts")**, **audio: `IAudioSource` seam + `WasapiLoopbackAudioSource`/`WasapiMicAudioSource` (NAudio WASAPI) + `AudioMixer` + pure `AudioLevelMeter`/`WaveToFloat`/`VoiceFilterChain`/`LowShelfFilter`/`HighShelfFilter`/`NoiseGate`/`Compressor`/`AutoDucker`/`AudioRingBuffer`/`TinyResampler`/`AudioSyncDelay` + `MusicPlayer` + `ITraxPlayer` (TRAX toggle seam — FakePlayer in tests; the dot is green/red/gray and the error scrolls terse in the desktop-audio bar; MusicVolume fixed 0.5) + `IAudioPipeWriter`/`NamedPipeAudioWriter` (see "Live audio capture")**, **encoder: `IFfmpegEncoder`/`FfmpegEncoder` + `IEncoderProcess`/`FfmpegEncoderProcess` + `IFfmpegLocator`/`FfmpegLocator` + pure `FfmpegArgs`/`FfmpegProgressParser`/`FfmpegEncoderPicker` + the `FramePump` frame producer (see "Live encoder" + "Live frame pipeline")**, **notifications: `INotificationService` seam (`AppNotificationSeverity` Info/Success/Warning/Error) + `NotificationService` (Notification.Wpf toasts, see "Toast notifications")** |
| `Helpers/` | ViewModelBase (INotifyPropertyChanged), RelayCommand, ImageCache, AppLog (file logger), FocusPreservingListBox, OAuthCredentials, **TokenStore (DPAPI session persistence)**, **BuildStamp (wordmark release counter `#N`, +1 per commit, generated by `GenerateBuildStamp` in ytLive.csproj via git rev-list; the per-build GUID now logs to startup.log only — 2026-09-04)**, visibility converters |
| `Themes/` | `Controls.xaml` — the single dark-theme source, merged once in `App.xaml` (see `Themes/index.md`) |
| `MainWindow.xaml` | Dark theme; layout: top bar (controls), center (preview + live controls below), left (scenes/sources), right (chat), bottom (gear + stream stats + resolution) |
diff --git a/ytLive.Tests/TraxToggleTests.cs b/ytLive.Tests/TraxToggleTests.cs
new file mode 100644
index 0000000..d770006
--- /dev/null
+++ b/ytLive.Tests/TraxToggleTests.cs
@@ -0,0 +1,92 @@
+using System.IO;
+using System.Threading.Tasks;
+using Microsoft.Data.Sqlite;
+using Xunit;
+using ytLive.Services.Audio;
+using ytLive.ViewModels;
+
+namespace ytLive.Tests;
+
+/// 2026-10-09 bug: pressing TRAX started the music but the status light stayed off —
+/// and the toggle couldn't pause, because the playing state was never refreshed after Play.
+/// TRAX is a toggle: the light is green while playing, red with a terse scrolling message on
+/// failure, gray when idle.
+[Collection("RealApp")]
+public sealed class TraxToggleTests
+{
+ private sealed class FakePlayer : ITraxPlayer
+ {
+ public bool Starts = true;
+ public event Action? PlaybackEnded;
+ public string? TrackName { get; private set; }
+ public bool IsPlaying { get; private set; }
+ public float LocalGain { get; set; } = 1f;
+ public void Load(string path) => TrackName = Path.GetFileNameWithoutExtension(path);
+ public void Play() => IsPlaying = Starts;
+ public void Pause() => IsPlaying = false;
+ public void Dispose() { }
+ public void Fail() { IsPlaying = false; PlaybackEnded?.Invoke(new IOException("device lost")); }
+ }
+
+ private readonly RealAppHost _app;
+ public TraxToggleTests(RealAppHost app) => _app = app;
+
+ [Fact]
+ public async Task Trax_Toggle_Lights_Green_While_Playing_And_Red_With_A_Terse_Error()
+ {
+ await _app.RunAsync(async () =>
+ {
+ var tempDb = Path.Combine(Path.GetTempPath(), $"ytLlive-test-{Guid.NewGuid():N}.db");
+ var track = Path.Combine(Path.GetTempPath(), $"trax-{Guid.NewGuid():N}.mp3");
+ File.WriteAllBytes(track, new byte[16]);
+ var player = new FakePlayer();
+ MainViewModel.LayoutPathOverride = tempDb;
+ MainViewModel.TraxPlayerOverride = () => player;
+ var window = new MainWindow();
+ var vm = (MainViewModel)window.DataContext;
+ try
+ {
+ await vm.SetTraxTrackAsync(track, play: false);
+ Assert.False(vm.IsMusicPlaying);
+ var idle = vm.TraxDotBrush.Color;
+
+ vm.TraxLeftClick(); // press: plays → light on
+ Assert.True(vm.IsMusicPlaying);
+ var playing = vm.TraxDotBrush.Color;
+ Assert.NotEqual(idle, playing);
+ Assert.Null(vm.TraxError);
+
+ vm.TraxLeftClick(); // press again: pauses → light off
+ Assert.False(vm.IsMusicPlaying);
+ Assert.Equal(idle, vm.TraxDotBrush.Color);
+
+ player.Starts = false; // press: playback can't start
+ vm.TraxLeftClick();
+ Assert.False(vm.IsMusicPlaying);
+ Assert.NotNull(vm.TraxError);
+ Assert.True(vm.TraxError!.Length <= 32, $"error not terse: '{vm.TraxError}'");
+ var red = vm.TraxDotBrush.Color;
+ Assert.NotEqual(idle, red);
+ Assert.NotEqual(playing, red);
+
+ player.Starts = true; // recovery clears the error
+ vm.TraxLeftClick();
+ Assert.True(vm.IsMusicPlaying);
+ Assert.Null(vm.TraxError);
+ Assert.Equal(playing, vm.TraxDotBrush.Color);
+
+ player.Fail(); // device lost mid-play
+ Assert.False(vm.IsMusicPlaying);
+ Assert.Equal(red, vm.TraxDotBrush.Color);
+ }
+ finally
+ {
+ window.Close();
+ MainViewModel.LayoutPathOverride = null;
+ MainViewModel.TraxPlayerOverride = null;
+ SqliteConnection.ClearAllPools();
+ try { File.Delete(tempDb); File.Delete(track); } catch { /* best-effort cleanup */ }
+ }
+ });
+ }
+}