TRAX: toggle refreshes the status light on play/pause, terse scrolling error on failure, fixed quiet volume (0.5), ITraxPlayer seam. Deadlocked test fixed via RunAsync (was GetAwaiter().GetResult on the UI thread). 416 pass / 1 known env flake. Refs: https://learn.microsoft.com/en-us/dotnet/standard/asynchronous-programming-patterns/async-programming-model-tap#dont-block-on-async-code (Unit: trax toggle)

This commit is contained in:
2026-10-09 19:05:04 -07:00
parent 87f05ba2ce
commit 5685c5dc3a
6 changed files with 199 additions and 27 deletions
+22 -6
View File
@@ -899,12 +899,28 @@
VerticalAlignment="Stretch"/>
<Rectangle Width="2" Fill="#00000080" HorizontalAlignment="Left" Margin="230,0,0,0"
VerticalAlignment="Stretch"/>
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled"
Visibility="{Binding TraxError, Converter={StaticResource NotNullToVis}}"
VerticalAlignment="Center" Margin="4,0">
<TextBlock Text="{Binding TraxError}" Foreground="#e94560" FontSize="9"
FontWeight="SemiBold" VerticalAlignment="Center"/>
</ScrollViewer>
<!-- TRAX error: a terse message that scrolls across the bar
(marquee) while the TRAX dot is red. -->
<Canvas x:Name="TraxErrorMarquee" ClipToBounds="True"
Visibility="{Binding TraxError, Converter={StaticResource NotNullToVis}}">
<TextBlock x:Name="TraxErrorText" Text="{Binding TraxError}" Foreground="#ff6b81" FontSize="10"
FontWeight="SemiBold" Canvas.Top="0">
<TextBlock.RenderTransform>
<TranslateTransform x:Name="TraxErrorScroll"/>
</TextBlock.RenderTransform>
<TextBlock.Triggers>
<EventTrigger RoutedEvent="FrameworkElement.Loaded">
<BeginStoryboard>
<Storyboard RepeatBehavior="Forever">
<DoubleAnimation Storyboard.TargetName="TraxErrorScroll"
Storyboard.TargetProperty="X"
From="288" To="-220" Duration="0:0:7"/>
</Storyboard>
</BeginStoryboard>
</EventTrigger>
</TextBlock.Triggers>
</TextBlock>
</Canvas>
</Grid>
</Border>
<Grid Width="16" Height="16" Cursor="Hand" Margin="0,0,8,0"
+14
View File
@@ -0,0 +1,14 @@
namespace ytLive.Services.Audio;
/// <summary>Seam over the TRAX background-music player (<see cref="MusicPlayer"/>) so the
/// toggle/status-light logic is testable without a real audio device.</summary>
public interface ITraxPlayer : IDisposable
{
event Action<Exception?>? PlaybackEnded;
string? TrackName { get; }
bool IsPlaying { get; }
float LocalGain { get; set; }
void Load(string path);
void Play();
void Pause();
}
+10 -5
View File
@@ -11,12 +11,17 @@ namespace ytLive.Services.Audio;
/// The desktop/game bar's gain and mute scale <see cref="LocalGain"/>, so the
/// creator hears the game-audio controls work on the music too.
/// </summary>
public sealed class MusicPlayer : IDisposable
public sealed class MusicPlayer : ITraxPlayer
{
/// <summary>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 <see cref="LocalGain"/>.</summary>
public const float MusicVolume = 0.20f;
/// <summary>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 <see cref="LocalGain"/>.
/// <para>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.</para></summary>
public const float MusicVolume = 0.5f;
private MediaFoundationReader? _reader;
private VolumeWaveProvider16? _volume;
+60 -15
View File
@@ -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();
/// <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;
@@ -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));
}
}
/// <summary>TRAX status dot: green = playing, gray = idle or no track.</summary>
/// <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
: 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);
}
/// <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"/>.
@@ -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();
}
+1 -1
View File
@@ -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) |
+92
View File
@@ -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;
/// <summary>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.</summary>
[Collection("RealApp")]
public sealed class TraxToggleTests
{
private sealed class FakePlayer : ITraxPlayer
{
public bool Starts = true;
public event Action<Exception?>? 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 */ }
}
});
}
}