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 */ } + } + }); + } +}