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:
+26
-5
@@ -7,11 +7,17 @@
|
||||
|
||||
## Session state (last updated: 2026-08-15)
|
||||
|
||||
- **Branch:** `main`, tracking `origin/main`. Working tree: the **TASK 24 polish batch** below is
|
||||
committed + pushed. Local branches `social-bar`/`webcam-validation` untouched (no secrets).
|
||||
- **Branch:** `main`, tracking `origin/main`. Working tree: the **TASK 24 polish batch** + the **game
|
||||
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
|
||||
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 +
|
||||
auto-duck + free TRAX background music. Commits `6d71ace` (milestone) + `f2f6401` (secrets cleanup),
|
||||
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 +
|
||||
`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)
|
||||
|
||||
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`.
|
||||
- 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.
|
||||
- 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
|
||||
edit it — a concurrent-mutation exception is contained (logged + `Failed` +
|
||||
the pump stops), not a crash.
|
||||
|
||||
+6
-8
@@ -754,16 +754,14 @@
|
||||
</Border>
|
||||
|
||||
<!-- Desktop/game audio bar: overlaid at the bottom of the
|
||||
preview while a full-screen game is producing sound or
|
||||
TRAX music is playing (the game audio detector). Meter
|
||||
+ mute + volume control live here; desktop audio itself
|
||||
is automatic WASAPI loopback. Volume + mute are
|
||||
stream-honest AND scale the TRAX music in the
|
||||
headphones, so the creator hears the control work. -->
|
||||
preview, always visible. Meter + mute + volume control
|
||||
live here; desktop audio itself is automatic WASAPI
|
||||
loopback. Volume + mute are stream-honest AND scale the
|
||||
TRAX music in the headphones, so the creator hears the
|
||||
control work. -->
|
||||
<Border HorizontalAlignment="Center" VerticalAlignment="Bottom"
|
||||
Background="#D9000000" CornerRadius="6" Padding="12,8"
|
||||
Margin="0,0,0,12"
|
||||
Visibility="{Binding IsGameAudioBarVisible, Converter={StaticResource BoolToVis}}">
|
||||
Margin="0,0,0,12">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<!-- TRAX sits left of the "Desktop Audio" label
|
||||
(the creator's pick): it rides the desktop
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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).
|
||||
- **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.
|
||||
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.
|
||||
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.
|
||||
|
||||
@@ -28,7 +28,6 @@ public class MainViewModel : ViewModelBase
|
||||
private readonly DispatcherTimer _liveTimer;
|
||||
private readonly DispatcherTimer _volumeFlashTimer;
|
||||
private readonly DispatcherTimer _gameVolumeFlashTimer;
|
||||
private readonly DispatcherTimer _gameAudioTimer;
|
||||
|
||||
private Scene? _activeScene;
|
||||
private SceneElement? _selectedElement;
|
||||
@@ -50,7 +49,6 @@ public class MainViewModel : ViewModelBase
|
||||
private double _gameVolume = 1.0;
|
||||
private bool _gameMuted;
|
||||
private double? _gameVolumeBeforeMute;
|
||||
private bool _gameAudioBarActive;
|
||||
|
||||
// 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
|
||||
@@ -122,7 +120,6 @@ public class MainViewModel : ViewModelBase
|
||||
private readonly IFullScreenDetector _fullScreenDetector;
|
||||
private readonly ScreenCaptureManager _screenCaptureManager;
|
||||
private readonly ScreenCaptureSourceFactory _screenCaptureFactory;
|
||||
private readonly IGameAudioDetector _gameAudioDetector;
|
||||
private int? _lastForegroundFullScreenMonitor;
|
||||
private CancellationTokenSource? _deactivateCts;
|
||||
private ImageSource? _backdropImage;
|
||||
@@ -491,14 +488,8 @@ public class MainViewModel : ViewModelBase
|
||||
_ => "No mic connected — click to choose a microphone",
|
||||
};
|
||||
|
||||
// ─── Game audio bar (desktop/game): visible only while a full-screen game
|
||||
// ─── is producing sound (IGameAudioDetector). Meter + mute + volume mirror
|
||||
// ─── 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;
|
||||
// ─── Game audio bar (desktop/game): always visible — a mirror of the mic bar
|
||||
// ─── (meter + mute + volume). Desktop/game audio is automatic (WASAPI loopback). ───
|
||||
|
||||
/// <summary>True while the TRAX background track is playing.</summary>
|
||||
public bool IsMusicPlaying
|
||||
@@ -510,7 +501,6 @@ public class MainViewModel : ViewModelBase
|
||||
{
|
||||
OnPropertyChanged(nameof(TraxDotBrush));
|
||||
OnPropertyChanged(nameof(TraxToolTip));
|
||||
OnPropertyChanged(nameof(IsGameAudioBarVisible));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1202,13 +1192,6 @@ public class MainViewModel : ViewModelBase
|
||||
foreach (var display in _fullScreenDetector.GetDisplays())
|
||||
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(
|
||||
() => new WindowInteropHelper(System.Windows.Application.Current?.MainWindow).Handle);
|
||||
_screenCaptureManager = new ScreenCaptureManager(
|
||||
@@ -1546,7 +1529,6 @@ public class MainViewModel : ViewModelBase
|
||||
{
|
||||
_saveDebounce?.Stop();
|
||||
SaveLayoutNow();
|
||||
_gameAudioTimer.Stop();
|
||||
_audioMixer.Dispose();
|
||||
_framePump.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
|
||||
// preview: webcam by DeviceId, live captures (the backdrop) by CaptureKey,
|
||||
// images/background by AssetId. A null frame leaves the element transparent.
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -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** |
|
||||
| `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 |
|
||||
| `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) |
|
||||
@@ -435,13 +435,7 @@ devices, no timers).
|
||||
`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
|
||||
silently falling back to the default endpoint.**
|
||||
- **Game audio bar** (desktop/game, only while a full-screen game is up in the preview):
|
||||
`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
|
||||
- **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
|
||||
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
|
||||
`AudioGainProvider.LoopbackGain`) **and drive TRAX locally** (`MusicPlayer.LocalGain`). Relabelled
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user