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:
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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 */ }
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user