Game audio bar is now ALWAYS visible (TASK 4 gating removed, 2026-08-15): the show/hide machinery kept hiding the desktop/game meter whenever no full-screen game with sound was up or no TRAX music played, which the creator read as the meter being lost. Deleted IGameAudioDetector/GameAudioDetector/GameAudioHysteresis (Services/), the VM's 250ms DispatcherTimer poll + OnGameAudioActiveChanged/OnGameAudioPollTick + IsGameAudioBarVisible + _gameAudioBarActive/_gameAudioTimer/_gameAudioDetector, and the two detector test files (GameAudioDetectorTests, GameAudioHysteresisTests); MainWindow.xaml drops the Visibility binding so the bar renders unconditionally. Desktop audio is just automatic WASAPI loopback now. Docs in the same commit: ai.md (audio section + Services inventory), Services/index.md, ViewModels/index.md, TASKS.md TASK 9 item 7 follow-up, HANDOFF.md shipped state + stale poll-timer landmine removed. Build 0 warnings, 189 tests passing (8 detector tests removed)

This commit is contained in:
2026-08-15 15:53:51 -07:00
parent cc71ed5066
commit 24f16b2cd9
12 changed files with 38 additions and 345 deletions
+26 -5
View File
@@ -7,11 +7,17 @@
## Session state (last updated: 2026-08-15) ## Session state (last updated: 2026-08-15)
- **Branch:** `main`, tracking `origin/main`. Working tree: the **TASK 24 polish batch** below is - **Branch:** `main`, tracking `origin/main`. Working tree: the **TASK 24 polish batch** + the **game
committed + pushed. Local branches `social-bar`/`webcam-validation` untouched (no secrets). audio bar always-visible change** (below) are committed + pushed. Local branches
`social-bar`/`webcam-validation` untouched (no secrets).
- **GAME AUDIO BAR ALWAYS VISIBLE — COMMITTED + PUSHED 2026-08-15.** The TASK 4 show/hide gating was a
UX bug: the desktop/game meter kept vanishing whenever no full-screen game with sound was up (or no
TRAX music played). The whole `IGameAudioDetector`/`GameAudioDetector`/`GameAudioHysteresis` stack +
the VM's 250ms poll timer + its two test files were **deleted**; the bar is now permanently overlaid
at the bottom of the preview. Build **0 warnings**, full suite green.
- **TASK 24 — POST-PAUSE POLISH BATCH — SHIPPED + COMMITTED + PUSHED 2026-08-15.** All 8 creator - **TASK 24 — POST-PAUSE POLISH BATCH — SHIPPED + COMMITTED + PUSHED 2026-08-15.** All 8 creator
review issues fixed in one branch (details below). Build **0 warnings**, full suite **197 passing** review issues fixed in one branch (details below). Build **0 warnings**, full suite **197 passing**
(ONE integration test for this branch: `AudioPipelineTests.Mix_HonorsProviderGains_AndGameMute_KillsTheLoopback`). (ONE integration test for that branch: `AudioPipelineTests.Mix_HonorsProviderGains_AndGameMute_KillsTheLoopback`).
- **AUDIO MILESTONE (TASK 9) — SHIPPED + COMMITTED + PUSHED.** Real stream audio + voice filters + - **AUDIO MILESTONE (TASK 9) — SHIPPED + COMMITTED + PUSHED.** Real stream audio + voice filters +
auto-duck + free TRAX background music. Commits `6d71ace` (milestone) + `f2f6401` (secrets cleanup), auto-duck + free TRAX background music. Commits `6d71ace` (milestone) + `f2f6401` (secrets cleanup),
force-pushed (`725f5a7...f2f6401`). force-pushed (`725f5a7...f2f6401`).
@@ -61,6 +67,23 @@ The creator's review of the TASK 9 build, all fixed in one branch (full record i
**THE ONE integration test:** `Mix_HonorsProviderGains_AndGameMute_KillsTheLoopback` — real mixer + **THE ONE integration test:** `Mix_HonorsProviderGains_AndGameMute_KillsTheLoopback` — real mixer +
`AudioGainProvider` gains through the pipe harness: scaled loopback audible at 0.5, silence after mute. `AudioGainProvider` gains through the pipe harness: scaled loopback audible at 0.5, silence after mute.
## Game audio bar — always visible 2026-08-15 (what changed)
The creator reported the desktop/game sound meter "lost" — it was the TASK 4 show/hide gating
(`_gameAudioBarActive || IsMusicPlaying`): the bar only rendered while a full-screen game produced
sound or TRAX played, so it sat hidden during normal use. Fix = the bar is now **always visible**:
- **Deleted** `Services/IGameAudioDetector.cs`, `Services/GameAudioDetector.cs`,
`Services/GameAudioHysteresis.cs`, `ytLive.Tests/GameAudioDetectorTests.cs`,
`ytLive.Tests/GameAudioHysteresisTests.cs` — the stack's only output (`_gameAudioBarActive`) fed
`IsGameAudioBarVisible`, which no longer exists.
- **MainViewModel.cs:** removed `_gameAudioTimer` (250ms `DispatcherTimer`), `_gameAudioDetector`,
`_gameAudioBarActive`, the constructor wiring, `OnGameAudioActiveChanged`, `OnGameAudioPollTick`,
`IsGameAudioBarVisible`, and the `IsMusicPlaying` → visibility notification. Desktop audio is just
automatic WASAPI loopback now.
- **MainWindow.xaml:** dropped the `Visibility` binding on the preview-overlay game bar; it renders
unconditionally (TRAX button + "Desktop Audio" label + meter + mute + volume unchanged).
## TASK 9 audio milestone — SHIPPED 2026-08-14 (what changed) ## TASK 9 audio milestone — SHIPPED 2026-08-14 (what changed)
The creator's feature review settled this as the single next branch ("all the audio issues done and The creator's feature review settled this as the single next branch ("all the audio issues done and
@@ -108,8 +131,6 @@ track (YouTube mutes VODs with copyrighted music — future feature), `PremiumUr
only after `Started`, yellow on `Failed`. only after `Started`, yellow on `Failed`.
- Zero mic devices at startup = red dot AND the mixer is never started, so - Zero mic devices at startup = red dot AND the mixer is never started, so
loopback + the game bar can't run either (no capture at all) — acceptable. loopback + the game bar can't run either (no capture at all) — acceptable.
- The game detector is polled on the UI thread via a 250ms `DispatcherTimer`;
`OnGameAudioPollTick` wraps `Poll()` in try/catch + `AppLog`.
- The pump reads the active scene on a background thread while the UI can still - The pump reads the active scene on a background thread while the UI can still
edit it — a concurrent-mutation exception is contained (logged + `Failed` + edit it — a concurrent-mutation exception is contained (logged + `Failed` +
the pump stops), not a crash. the pump stops), not a crash.
+6 -8
View File
@@ -754,16 +754,14 @@
</Border> </Border>
<!-- Desktop/game audio bar: overlaid at the bottom of the <!-- Desktop/game audio bar: overlaid at the bottom of the
preview while a full-screen game is producing sound or preview, always visible. Meter + mute + volume control
TRAX music is playing (the game audio detector). Meter live here; desktop audio itself is automatic WASAPI
+ mute + volume control live here; desktop audio itself loopback. Volume + mute are stream-honest AND scale the
is automatic WASAPI loopback. Volume + mute are TRAX music in the headphones, so the creator hears the
stream-honest AND scale the TRAX music in the control work. -->
headphones, so the creator hears the control work. -->
<Border HorizontalAlignment="Center" VerticalAlignment="Bottom" <Border HorizontalAlignment="Center" VerticalAlignment="Bottom"
Background="#D9000000" CornerRadius="6" Padding="12,8" Background="#D9000000" CornerRadius="6" Padding="12,8"
Margin="0,0,0,12" Margin="0,0,0,12">
Visibility="{Binding IsGameAudioBarVisible, Converter={StaticResource BoolToVis}}">
<StackPanel Orientation="Horizontal"> <StackPanel Orientation="Horizontal">
<!-- TRAX sits left of the "Desktop Audio" label <!-- TRAX sits left of the "Desktop Audio" label
(the creator's pick): it rides the desktop (the creator's pick): it rides the desktop
-46
View File
@@ -1,46 +0,0 @@
namespace ytLive.Services;
/// <summary>
/// Default <see cref="IGameAudioDetector"/>: samples a full-screen monitor
/// provider (the existing <c>IFullScreenDetector</c>) and the live loopback
/// level and advances a <see cref="GameAudioHysteresis"/>. The pure transitions
/// live in GameAudioHysteresis (unit-tested); this wrapper owns the providers
/// and the raised-changed event. WPF-free — the VM owns the poll timer.
/// </summary>
public sealed class GameAudioDetector : IGameAudioDetector
{
private const float SoundFloor = 0.005f;
private readonly Func<int?> _foregroundFullScreenMonitorProvider;
private readonly Func<float> _loopbackLevelProvider;
private readonly Func<DateTime> _now;
private readonly GameAudioHysteresis _hysteresis = new();
/// <param name="foregroundFullScreenMonitorProvider">Returns the monitor a
/// full-screen foreground window covers, or null when windowed/none.</param>
/// <param name="loopbackLevelProvider">Current smoothed desktop/game level (0..1).</param>
/// <param name="now">Clock for the hysteresis windows; injectable for tests.</param>
public GameAudioDetector(
Func<int?> foregroundFullScreenMonitorProvider,
Func<float> loopbackLevelProvider,
Func<DateTime>? now = null)
{
_foregroundFullScreenMonitorProvider = foregroundFullScreenMonitorProvider;
_loopbackLevelProvider = loopbackLevelProvider;
_now = now ?? (() => DateTime.Now);
}
public bool IsGameAudioActive => _hysteresis.IsActive;
public event Action<bool>? IsGameAudioActiveChanged;
public void Poll()
{
var before = _hysteresis.IsActive;
var isFullScreen = _foregroundFullScreenMonitorProvider() != null;
var hasSound = _loopbackLevelProvider() > SoundFloor;
_hysteresis.Update(isFullScreen, hasSound, _now());
if (before != _hysteresis.IsActive)
IsGameAudioActiveChanged?.Invoke(_hysteresis.IsActive);
}
}
-57
View File
@@ -1,57 +0,0 @@
namespace ytLive.Services;
/// <summary>
/// Pure show/hide state machine for the game audio bar (TASK 4 game audio).
/// SHOW: a full-screen foreground app has been producing desktop audio for at
/// least <see cref="ShowAfterSound"/> — "a working game with sound". HIDE: the
/// app leaves fullscreen/foreground for <see cref="HideAfterWindowed"/> — the
/// game is no longer up in the preview. Silence NEVER hides an active bar; it
/// only matters for the initial show. No timers; <see cref="Update"/> is fed by
/// the caller with a clock.
/// </summary>
public sealed class GameAudioHysteresis
{
private readonly TimeSpan _showAfterSound = TimeSpan.FromMilliseconds(500);
private readonly TimeSpan _hideAfterWindowed = TimeSpan.FromSeconds(1);
private DateTime? _soundSince;
private DateTime? _windowedSince;
public bool IsActive { get; private set; }
public void Update(bool isFullScreen, bool hasSound, DateTime now)
{
if (isFullScreen)
{
_windowedSince = null;
if (IsActive)
return;
if (!hasSound)
{
_soundSince = null;
return;
}
_soundSince ??= now;
if (now - _soundSince >= _showAfterSound)
IsActive = true;
}
else
{
_soundSince = null;
if (!IsActive)
{
_windowedSince = null;
return;
}
_windowedSince ??= now;
if (now - _windowedSince >= _hideAfterWindowed)
{
IsActive = false;
_windowedSince = null;
}
}
}
}
-20
View File
@@ -1,20 +0,0 @@
namespace ytLive.Services;
/// <summary>
/// Detects "a working game with sound is up in the preview" (TASK 4 game audio
/// bar): becomes active once a full-screen foreground app is producing desktop
/// audio, and stays active as long as that full-screen app remains — silence
/// never hides the bar, only the game leaving fullscreen/foreground does. Seam
/// so the VM and tests never touch Win32 or audio interop directly.
/// </summary>
public interface IGameAudioDetector
{
/// <summary>True while the game audio bar should be visible.</summary>
bool IsGameAudioActive { get; }
/// <summary>Raised when the bar should appear or disappear.</summary>
event Action<bool>? IsGameAudioActiveChanged;
/// <summary>Samples the injected providers and advances the state machine.</summary>
void Poll();
}
-3
View File
@@ -51,9 +51,6 @@ External-facing logic: YouTube API, persistence. See
| `Audio/AudioMixer.cs` | Owns both sources; capture starts once at startup (`MainViewModel.StartMicCaptureAsync`) and stops on `Shutdown` — NOT go-live (preview monitoring). Mic samples → `AudioLevelMeter` → `MicLevelChanged`; loopback samples → the game bar's meter via `LoopbackLevelChanged`. Surfaces mic connection state: `MicConnected`/`MicFailed` (drives the status dot) + `RestartMic()` (device swap mid-session, keeps loopback). Failures log via `AppLog`; mic failure zeroes the meter, loopback failure doesn't kill the mic. Note: the meter `Push` is unconditional (the `?.` on the event would otherwise skip the argument when nothing is subscribed) | | `Audio/AudioMixer.cs` | Owns both sources; capture starts once at startup (`MainViewModel.StartMicCaptureAsync`) and stops on `Shutdown` — NOT go-live (preview monitoring). Mic samples → `AudioLevelMeter` → `MicLevelChanged`; loopback samples → the game bar's meter via `LoopbackLevelChanged`. Surfaces mic connection state: `MicConnected`/`MicFailed` (drives the status dot) + `RestartMic()` (device swap mid-session, keeps loopback). Failures log via `AppLog`; mic failure zeroes the meter, loopback failure doesn't kill the mic. Note: the meter `Push` is unconditional (the `?.` on the event would otherwise skip the argument when nothing is subscribed) |
| `Audio/AudioLevelMeter.cs` | Pure smoothed RMS level (0..1): `Push(AudioSample)` + `Reset` — the unit-tested math behind `AudioLevel` and `GameAudioLevel`. `ToDisplay(float)` maps the raw linear RMS onto the meter's display scale (−60..0 dBFS spread across 0..1, with **+10 dB input amplification** so real speech peaks hit the red zone at maxed volume) — real speech/game RMS (~0.01..0.1) would otherwise leave a flat scale looking dead; ≤0.001 linear reads as zero (never idles on background noise) | | `Audio/AudioLevelMeter.cs` | Pure smoothed RMS level (0..1): `Push(AudioSample)` + `Reset` — the unit-tested math behind `AudioLevel` and `GameAudioLevel`. `ToDisplay(float)` maps the raw linear RMS onto the meter's display scale (−60..0 dBFS spread across 0..1, with **+10 dB input amplification** so real speech peaks hit the red zone at maxed volume) — real speech/game RMS (~0.01..0.1) would otherwise leave a flat scale looking dead; ≤0.001 linear reads as zero (never idles on background noise) |
| `Audio/WaveToFloat.cs` | Pure WASAPI buffer → float conversion: IEEE float 32-bit direct, PCM 16-bit normalized, `WaveFormatExtensible` IEEE-float subformat GUID, trailing partial samples ignored | | `Audio/WaveToFloat.cs` | Pure WASAPI buffer → float conversion: IEEE float 32-bit direct, PCM 16-bit normalized, `WaveFormatExtensible` IEEE-float subformat GUID, trailing partial samples ignored |
| `IGameAudioDetector.cs` | **TASK 4 game audio bar seam**: `IsGameAudioActive` + `IsGameAudioActiveChanged` + `Poll()` — detects "a working game with sound is up in the preview" |
| `GameAudioHysteresis.cs` | Pure show/hide state machine for the game bar: SHOW = full-screen app holds sound ~500ms; HIDE = the app leaves fullscreen ~1s. **Silence never hides an active bar** — only the game leaving the preview does (creator's rule). No timers; `Update(isFullScreen, hasSound, now)` |
| `GameAudioDetector.cs` | Default `IGameAudioDetector`: composes a full-screen monitor provider (`IFullScreenDetector.GetForegroundFullScreenMonitorIndex`) + the live loopback level (floor 0.5%) into a `GameAudioHysteresis`. WPF-free; the VM owns the 250ms poll timer |
Related: constructed in [`ViewModels/MainViewModel.cs`](../ViewModels/MainViewModel.cs) Related: constructed in [`ViewModels/MainViewModel.cs`](../ViewModels/MainViewModel.cs)
(no DI container yet). Models in [`Models/index.md`](../Models/index.md). (no DI container yet). Models in [`Models/index.md`](../Models/index.md).
+1 -1
View File
@@ -543,7 +543,7 @@ the validator → persisted), compositor bar overlay (top/bottom + above-flash),
- **Right-click** → always opens the in-app track picker (`TraxButton_PreviewMouseRightButtonUp`, code-behind pattern like `GameSpeaker_MouseLeftButtonUp`, `e.Handled = true` so no context menu). - **Right-click** → always opens the in-app track picker (`TraxButton_PreviewMouseRightButtonUp`, code-behind pattern like `GameSpeaker_MouseLeftButtonUp`, `e.Handled = true` so no context menu).
- **Tooltip** → playing/loaded track name; "No track — right-click to choose background music" when empty. - **Tooltip** → playing/loaded track name; "No track — right-click to choose background music" when empty.
- **No slider** — volume is the 0.20 constant. Picker = plain OS `OpenFileDialog` filtered to mp3/wav/m4a/aac/flac/ogg. - **No slider** — volume is the 0.20 constant. Picker = plain OS `OpenFileDialog` filtered to mp3/wav/m4a/aac/flac/ogg.
7. ✅ **Sound bar shows music** — `IsGameAudioBarVisible = gameDetectorProducingSound || isMusicPlaying`; relabelled "Game Audio Capture" → **"Desktop Audio"** (TRAX rides the same channel). 7. ✅ **Sound bar shows music** — `IsGameAudioBarVisible = gameDetectorProducingSound || isMusicPlaying`; relabelled "Game Audio Capture" → **"Desktop Audio"** (TRAX rides the same channel). **Follow-up (2026-08-15): the gating is gone — the game audio bar is ALWAYS visible** (the `IGameAudioDetector`/`GameAudioDetector`/`GameAudioHysteresis` stack + the VM's 250ms poll timer and their tests were deleted; the bar kept hiding the desktop meter when no full-screen game with sound was up, and the creator wants it pinned).
8. ✅ **Capture hardening (design changed at build time)** — the plan's "force IEEE-float 48 kHz on both WASAPI sources" was **dropped**: NAudio 2.2.1's `WasapiCapture` exposes no overridable `GetDefaultMixFormat`, so both sources capture the device's own mix format and the mixer's pure `TinyResampler` normalizes any rate/channel count to 48 kHz stereo (the resampler IS the design, not a fallback). Stop path disposes the audio pipe (EOF) in `MainViewModel.StopStream` before the pump stops, so both ffmpeg inputs end in order. 8. ✅ **Capture hardening (design changed at build time)** — the plan's "force IEEE-float 48 kHz on both WASAPI sources" was **dropped**: NAudio 2.2.1's `WasapiCapture` exposes no overridable `GetDefaultMixFormat`, so both sources capture the device's own mix format and the mixer's pure `TinyResampler` normalizes any rate/channel count to 48 kHz stereo (the resampler IS the design, not a fallback). Stop path disposes the audio pipe (EOF) in `MainViewModel.StopStream` before the pump stops, so both ffmpeg inputs end in order.
9. ✅ **Schema v9** — single-row `Music` (`TrackPath`, `IsEnabled`; volume is the 0.20 constant) + migration in `LayoutStore.cs` (`user_version` 9; `Save` gained an optional `Music? music = null` param so existing 3-arg callers still compile; load query + null reset). 9. ✅ **Schema v9** — single-row `Music` (`TrackPath`, `IsEnabled`; volume is the 0.20 constant) + migration in `LayoutStore.cs` (`user_version` 9; `Save` gained an optional `Music? music = null` param so existing 3-arg callers still compile; load query + null reset).
10. ✅ **Docs in the same commit** — `ai.md` audio section rewritten ("one knob per input", 2-in mix, auto-duck, TRAX free, VOD-mute wrinkle), TASK 22's status, this task's status, HANDOFF. 10. ✅ **Docs in the same commit** — `ai.md` audio section rewritten ("one knob per input", 2-in mix, auto-duck, TRAX free, VOD-mute wrinkle), TASK 22's status, this task's status, HANDOFF.
+2 -38
View File
@@ -28,7 +28,6 @@ public class MainViewModel : ViewModelBase
private readonly DispatcherTimer _liveTimer; private readonly DispatcherTimer _liveTimer;
private readonly DispatcherTimer _volumeFlashTimer; private readonly DispatcherTimer _volumeFlashTimer;
private readonly DispatcherTimer _gameVolumeFlashTimer; private readonly DispatcherTimer _gameVolumeFlashTimer;
private readonly DispatcherTimer _gameAudioTimer;
private Scene? _activeScene; private Scene? _activeScene;
private SceneElement? _selectedElement; private SceneElement? _selectedElement;
@@ -50,7 +49,6 @@ public class MainViewModel : ViewModelBase
private double _gameVolume = 1.0; private double _gameVolume = 1.0;
private bool _gameMuted; private bool _gameMuted;
private double? _gameVolumeBeforeMute; private double? _gameVolumeBeforeMute;
private bool _gameAudioBarActive;
// TRAX (TASK 9): free background music at a fixed quiet volume, playing to // 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 // the default device so it rides the loopback into the stream. The dot is
@@ -122,7 +120,6 @@ public class MainViewModel : ViewModelBase
private readonly IFullScreenDetector _fullScreenDetector; private readonly IFullScreenDetector _fullScreenDetector;
private readonly ScreenCaptureManager _screenCaptureManager; private readonly ScreenCaptureManager _screenCaptureManager;
private readonly ScreenCaptureSourceFactory _screenCaptureFactory; private readonly ScreenCaptureSourceFactory _screenCaptureFactory;
private readonly IGameAudioDetector _gameAudioDetector;
private int? _lastForegroundFullScreenMonitor; private int? _lastForegroundFullScreenMonitor;
private CancellationTokenSource? _deactivateCts; private CancellationTokenSource? _deactivateCts;
private ImageSource? _backdropImage; private ImageSource? _backdropImage;
@@ -491,14 +488,8 @@ public class MainViewModel : ViewModelBase
_ => "No mic connected — click to choose a microphone", _ => "No mic connected — click to choose a microphone",
}; };
// ─── Game audio bar (desktop/game): visible only while a full-screen game // ─── Game audio bar (desktop/game): always visible — a mirror of the mic bar
// ─── is producing sound (IGameAudioDetector). Meter + mute + volume mirror // ─── (meter + mute + volume). Desktop/game audio is automatic (WASAPI loopback). ───
// ─── the mic bar. ───
/// <summary>True while the game audio bar should be shown: a full-screen game
/// producing sound (IGameAudioDetector) OR background music playing (TASK 9)
/// — the meter bounces during scene setup even with no game up.</summary>
public bool IsGameAudioBarVisible => _gameAudioBarActive || IsMusicPlaying;
/// <summary>True while the TRAX background track is playing.</summary> /// <summary>True while the TRAX background track is playing.</summary>
public bool IsMusicPlaying public bool IsMusicPlaying
@@ -510,7 +501,6 @@ public class MainViewModel : ViewModelBase
{ {
OnPropertyChanged(nameof(TraxDotBrush)); OnPropertyChanged(nameof(TraxDotBrush));
OnPropertyChanged(nameof(TraxToolTip)); OnPropertyChanged(nameof(TraxToolTip));
OnPropertyChanged(nameof(IsGameAudioBarVisible));
} }
} }
} }
@@ -1202,13 +1192,6 @@ public class MainViewModel : ViewModelBase
foreach (var display in _fullScreenDetector.GetDisplays()) foreach (var display in _fullScreenDetector.GetDisplays())
Displays.Add(display); Displays.Add(display);
_gameAudioDetector = new GameAudioDetector(
() => _fullScreenDetector.GetForegroundFullScreenMonitorIndex(),
() => (float)GameAudioLevel);
_gameAudioDetector.IsGameAudioActiveChanged += OnGameAudioActiveChanged;
_gameAudioTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(250) };
_gameAudioTimer.Tick += OnGameAudioPollTick;
_gameAudioTimer.Start();
_screenCaptureFactory = new ScreenCaptureSourceFactory( _screenCaptureFactory = new ScreenCaptureSourceFactory(
() => new WindowInteropHelper(System.Windows.Application.Current?.MainWindow).Handle); () => new WindowInteropHelper(System.Windows.Application.Current?.MainWindow).Handle);
_screenCaptureManager = new ScreenCaptureManager( _screenCaptureManager = new ScreenCaptureManager(
@@ -1546,7 +1529,6 @@ public class MainViewModel : ViewModelBase
{ {
_saveDebounce?.Stop(); _saveDebounce?.Stop();
SaveLayoutNow(); SaveLayoutNow();
_gameAudioTimer.Stop();
_audioMixer.Dispose(); _audioMixer.Dispose();
_framePump.Dispose(); _framePump.Dispose();
_cameraManager.Dispose(); _cameraManager.Dispose();
@@ -2261,24 +2243,6 @@ public class MainViewModel : ViewModelBase
} }
} }
private void OnGameAudioActiveChanged(bool active)
{
_gameAudioBarActive = active;
OnPropertyChanged(nameof(IsGameAudioBarVisible));
}
private void OnGameAudioPollTick(object? sender, EventArgs e)
{
try
{
_gameAudioDetector.Poll();
}
catch (Exception ex)
{
AppLog.Write($"Game audio detection failed: {ex.Message}");
}
}
// Scene-element → latest frame, for the live compositor. The map mirrors the // Scene-element → latest frame, for the live compositor. The map mirrors the
// preview: webcam by DeviceId, live captures (the backdrop) by CaptureKey, // preview: webcam by DeviceId, live captures (the backdrop) by CaptureKey,
// images/background by AssetId. A null frame leaves the element transparent. // images/background by AssetId. A null frame leaves the element transparent.
+1 -1
View File
File diff suppressed because one or more lines are too long
+2 -8
View File
@@ -91,7 +91,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** | | `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 — exposes collections + commands for the UI; GoLiveViewModel, ReuseImageViewModel, CameraPickerViewModel, **SocialsDialogViewModel** | | `ViewModels/` | MainViewModel — exposes collections + commands for the UI; 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)**, **compositor: `SceneCompositor` + `CompositorOptions` + pure `StretchMath` + `StaticPixelCache` (see "Scene compositor")**, **audio: `IAudioSource` seam + `WasapiLoopbackAudioSource`/`WasapiMicAudioSource` (NAudio WASAPI) + `AudioMixer` + pure `AudioLevelMeter`/`WaveToFloat`/`VoiceFilterChain`/`LowShelfFilter`/`HighShelfFilter`/`NoiseGate`/`Compressor`/`AutoDucker`/`AudioRingBuffer`/`TinyResampler` + `MusicPlayer` + `IAudioPipeWriter`/`NamedPipeAudioWriter` + game bar: `IGameAudioDetector`/`GameAudioHysteresis`/`GameAudioDetector` (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")** | | `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)**, **compositor: `SceneCompositor` + `CompositorOptions` + pure `StretchMath` + `StaticPixelCache` (see "Scene compositor")**, **audio: `IAudioSource` seam + `WasapiLoopbackAudioSource`/`WasapiMicAudioSource` (NAudio WASAPI) + `AudioMixer` + pure `AudioLevelMeter`/`WaveToFloat`/`VoiceFilterChain`/`LowShelfFilter`/`HighShelfFilter`/`NoiseGate`/`Compressor`/`AutoDucker`/`AudioRingBuffer`/`TinyResampler` + `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")** |
| `Helpers/` | ViewModelBase (INotifyPropertyChanged), RelayCommand, ImageCache, AppLog (file logger), FocusPreservingListBox, OAuthCredentials, **TokenStore (DPAPI session persistence)**, visibility converters | | `Helpers/` | ViewModelBase (INotifyPropertyChanged), RelayCommand, ImageCache, AppLog (file logger), FocusPreservingListBox, OAuthCredentials, **TokenStore (DPAPI session persistence)**, visibility converters |
| `Themes/` | `Controls.xaml` — the single dark-theme source, merged once in `App.xaml` (see `Themes/index.md`) | | `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), left (scenes/sources), right (chat), bottom (health) | | `MainWindow.xaml` | Dark theme; layout: top bar (controls), center (preview), left (scenes/sources), right (chat), bottom (health) |
@@ -435,13 +435,7 @@ devices, no timers).
`SaveMicSourceName`), and the VM restores it at construction BEFORE the mixer's first `Start` — so a `SaveMicSourceName`), and the VM restores it at construction BEFORE the mixer's first `Start` — so a
restart reconnects the same already-vetted device (green) or reports it missing (yellow), instead of restart reconnects the same already-vetted device (green) or reports it missing (yellow), instead of
silently falling back to the default endpoint.** silently falling back to the default endpoint.**
- **Game audio bar** (desktop/game, only while a full-screen game is up in the preview): - **Game audio bar** (desktop/game, **always visible** — TASK 4's `IGameAudioDetector` show/hide gating was **removed 2026-08-15**: the bar used to appear only while a full-screen game with sound was up, which kept hiding the creator's desktop meter; the detector stack is gone, desktop audio is just automatic WASAPI loopback): overlaid **at the bottom of the preview window** (bottom-center, dark translucent
`IGameAudioDetector` seam (`Services/IGameAudioDetector.cs`), pure `GameAudioHysteresis` (SHOW after
~500ms of fullscreen + sound, HIDE after ~1s away from fullscreen, **silence never hides an active
bar**), and the default `GameAudioDetector` composing `IFullScreenDetector` + the live loopback level
(floor 0.5%). WPF-free — the VM owns a 250ms `DispatcherTimer` that polls it and flips
`IsGameAudioBarVisible` (`_gameAudioBarActive || IsMusicPlaying` — music bounces the bar even with no
game). The bar is **overlaid at the bottom of the preview window** (bottom-center, dark translucent
chip, a mirror of the mic bar: meter + mute + volume slider). It's monitoring UI **in the preview chip, a mirror of the mic bar: meter + mute + volume slider). It's monitoring UI **in the preview
grid** — the meter is display-only, but its **volume slider + mute are stream-honest** (via grid** — the meter is display-only, but its **volume slider + mute are stream-honest** (via
`AudioGainProvider.LoopbackGain`) **and drive TRAX locally** (`MusicPlayer.LocalGain`). Relabelled `AudioGainProvider.LoopbackGain`) **and drive TRAX locally** (`MusicPlayer.LocalGain`). Relabelled
-76
View File
@@ -1,76 +0,0 @@
using Xunit;
using ytLive.Services;
namespace ytLive.Tests;
/// <summary>
/// TASK 4 game audio bar: the default detector's provider wiring — it composes
/// the full-screen monitor + loopback level into the hysteresis and raises
/// IsGameAudioActiveChanged on transitions. The transition math itself lives in
/// GameAudioHysteresisTests.
/// </summary>
public class GameAudioDetectorTests
{
[Fact]
public void Poll_RaisesChanged_WhenGameAppearsAndLeaves()
{
var now = new DateTime(2026, 8, 13, 12, 0, 0);
int? monitor = 0;
var level = 0f;
var detector = new GameAudioDetector(() => monitor, () => level, () => now);
var changes = new List<bool>();
detector.IsGameAudioActiveChanged += a => changes.Add(a);
level = 0.9f;
detector.Poll();
Assert.False(detector.IsGameAudioActive);
now = now.AddMilliseconds(600);
detector.Poll();
Assert.True(detector.IsGameAudioActive);
Assert.Equal(new[] { true }, changes);
level = 0f;
now = now.AddSeconds(2);
detector.Poll();
Assert.True(detector.IsGameAudioActive);
Assert.Equal(new[] { true }, changes);
monitor = null;
detector.Poll();
Assert.True(detector.IsGameAudioActive);
now = now.AddSeconds(2);
detector.Poll();
Assert.False(detector.IsGameAudioActive);
Assert.Equal(new[] { true, false }, changes);
}
[Fact]
public void Poll_StaysInactive_WhenNoFullScreenMonitor()
{
var detector = new GameAudioDetector(() => null, () => 0.9f);
var changes = 0;
detector.IsGameAudioActiveChanged += _ => changes++;
for (var i = 0; i < 5; i++)
detector.Poll();
Assert.False(detector.IsGameAudioActive);
Assert.Equal(0, changes);
}
[Fact]
public void Poll_StaysInactive_WhileLoopbackSilent()
{
var detector = new GameAudioDetector(() => 0, () => 0f);
var changes = 0;
detector.IsGameAudioActiveChanged += _ => changes++;
for (var i = 0; i < 5; i++)
detector.Poll();
Assert.False(detector.IsGameAudioActive);
Assert.Equal(0, changes);
}
}
-82
View File
@@ -1,82 +0,0 @@
using Xunit;
using ytLive.Services;
namespace ytLive.Tests;
/// <summary>
/// TASK 4 game audio bar: the pure show/hide state machine. Show = a full-screen
/// app holds sound for half a second; hide = the app leaves fullscreen for a
/// second. Silence never hides an active bar — only the game leaving the preview
/// does (per the creator's rule).
/// </summary>
public class GameAudioHysteresisTests
{
private static readonly DateTime T0 = new(2026, 8, 13, 12, 0, 0);
[Fact]
public void StaysInactive_WhileSilent()
{
var h = new GameAudioHysteresis();
for (var i = 0; i < 30; i++)
{
h.Update(true, false, T0.AddSeconds(i));
Assert.False(h.IsActive);
}
}
[Fact]
public void StaysInactive_UntilSoundHoldsHalfSecond()
{
var h = new GameAudioHysteresis();
h.Update(true, true, T0);
Assert.False(h.IsActive);
h.Update(true, true, T0.AddMilliseconds(400));
Assert.False(h.IsActive);
h.Update(true, true, T0.AddMilliseconds(600));
Assert.True(h.IsActive);
}
[Fact]
public void NeverHides_WhileGameStillFullScreen_EvenWhenSilent()
{
var h = new GameAudioHysteresis();
h.Update(true, true, T0);
h.Update(true, true, T0.AddSeconds(1));
Assert.True(h.IsActive);
for (var i = 2; i < 40; i++)
{
h.Update(true, false, T0.AddSeconds(i));
Assert.True(h.IsActive);
}
}
[Fact]
public void Hides_AfterAppLeavesFullScreen()
{
var h = new GameAudioHysteresis();
h.Update(true, true, T0);
h.Update(true, true, T0.AddSeconds(1));
Assert.True(h.IsActive);
h.Update(false, true, T0.AddSeconds(2));
Assert.True(h.IsActive);
h.Update(false, true, T0.AddSeconds(4));
Assert.False(h.IsActive);
}
[Fact]
public void StaysInactive_WhenNoFullScreen_EvenWithSound()
{
var h = new GameAudioHysteresis();
for (var i = 0; i < 30; i++)
{
h.Update(false, true, T0.AddSeconds(i));
Assert.False(h.IsActive);
}
}
}