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
-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/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 |
| `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)
(no DI container yet). Models in [`Models/index.md`](../Models/index.md).