From 7f2bda8ed31bcc3aa1f97fb3f8c202b0228080f3 Mon Sep 17 00:00:00 2001 From: gramps Date: Tue, 1 Sep 2026 21:22:57 -0700 Subject: [PATCH] =?UTF-8?q?fix(audio):=20game=20meter=20scales=20by=20Game?= =?UTF-8?q?AudioVolume=20=E2=80=94=20meter/stream/headphones=20all=20follo?= =?UTF-8?q?w=20the=20one=20knob?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Creator report 2026-09-01: with the desktop slider dragged to 20%, the game bar's meter stayed pegged yellow/red. Root cause: the mic meter's formula (level x volume) was cloned to the game bar WITHOUT the x-volume factor, and the clone inherited a false premise — that WASAPI loopback capture tracks the endpoint volume. Tonight's observation disproves it (the tap is pre-endpoint-volume), so both the meter (display) and the mix (AudioGainProvider.LoopbackGain, fixed in 5ead064) must scale it themselves. ai.md's two 'loopback scales with the slider' sentences corrected in the same pass. Test seam: VolumePushOverride (internal static, mirrors LayoutPathOverride pattern) so the test never hijacks the machine's real volume. GameMeterHonestyTests (RealApp + temp DB): unity -> hot/red, 20% -> ~1/5 width/green, push observed. --- ViewModels/MainViewModel.Audio.cs | 17 ++++++- ai.md | 14 ++++-- ytLive.Tests/GameMeterHonestyTests.cs | 72 +++++++++++++++++++++++++++ 3 files changed, 96 insertions(+), 7 deletions(-) create mode 100644 ytLive.Tests/GameMeterHonestyTests.cs diff --git a/ViewModels/MainViewModel.Audio.cs b/ViewModels/MainViewModel.Audio.cs index e29125e..db876cd 100644 --- a/ViewModels/MainViewModel.Audio.cs +++ b/ViewModels/MainViewModel.Audio.cs @@ -234,8 +234,18 @@ public partial class MainViewModel }; // ─── Game audio bar (desktop/game): always visible — a mirror of the mic bar // ─── (meter + mute + volume). Desktop/game audio is automatic (WASAPI loopback). ─── + // Test seam (mirrors LayoutPathOverride/RegistrarOverride): the volume slider is + // stream-honest AND speaker-honest — the real path writes the Windows endpoint. + // Tests must never hijack the machine's actual volume, so they swap the push out. + internal static Action? VolumePushOverride; + private static void PushSystemVolume(double volume) { + if (VolumePushOverride is { } push) + { + push(volume); + return; + } try { using var enumerator = new MMDeviceEnumerator(); @@ -262,8 +272,11 @@ public partial class MainViewModel /// Displayed game meter level: 0 while muted; the volume position /// while the slider is dragged (or briefly after an unmute flash); otherwise - /// the realtime live level scaled by volume. - private double GameMeterLevel => GameMuted ? 0 : _gameVolumeAdjusting || _gameVolumeFlash ? GameAudioVolume : Math.Min(1, AudioLevelMeter.ToDisplay((float)GameAudioLevel)); + /// the realtime live level scaled by volume — the WASAPI loopback tap is + /// PRE-endpoint-volume (creator's 20%-volume/pegged-meter observation, + /// 2026-09-01), so the meter must multiply by GameAudioVolume itself, exactly + /// like the stream gain does (the mic formula always did; the clone dropped it). + private double GameMeterLevel => GameMuted ? 0 : _gameVolumeAdjusting || _gameVolumeFlash ? GameAudioVolume : Math.Min(1, AudioLevelMeter.ToDisplay((float)GameAudioLevel) * GameAudioVolume); public double GameMeterFillWidth => GameMeterLevel * 288; diff --git a/ai.md b/ai.md index c7bbeb2..e5540ca 100644 --- a/ai.md +++ b/ai.md @@ -625,8 +625,10 @@ devices, no timers). and let tracks play once then stop). The desktop/game bar's mute toggles `LocalGain` (0 or 1; `UpdateTraxLocalGain` runs on every `GameMuted` change and on TRAX load), so muting kills TRAX in the headphones — matching the stream, where it rides the loopback channel. The volume slider - drives system volume (`PushSystemVolume`), which scales the entire loopback signal including - TRAX — one knob controls everything. No third mixer input. The footer + drives system volume (`PushSystemVolume`) — which scales the HEADPHONES; the loopback capture is + pre-endpoint-volume (disproven-then-fixed 2026-09-01), so the STREAM follows the knob via + `AudioGainProvider.LoopbackGain = GameMuted ? 0 : GameAudioVolume`, and the game meter multiplies + its display by the same. One knob, three honest paths. No third mixer input. The footer **TRAX** button lives **inside the game audio bar** (in the preview overlay), **left of the "Desktop Audio" label** (the creator's pick — it rides the desktop channel, so the music control sits with the desktop audio controls): status dot (**red** no track / **yellow** loaded stopped / @@ -645,9 +647,11 @@ devices, no timers). silently falling back to the default endpoint.** - **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 drives system volume** (`PushSystemVolume` - scales the entire WASAPI endpoint, so both the live headphones and the loopback capture reflect the - slider) **and mute kills TRAX** (`MusicPlayer.LocalGain` = 0). Relabelled + grid** — the meter is display-only (fill = `ToDisplay(GameAudioLevel) × GameAudioVolume` — the ×volume + term was missing until 2026-09-01: loopback capture does NOT shrink with the endpoint, so an + unscaled meter left it pegged at 20% volume; `GameMeterHonestyTests` guards it), but its **volume + slider drives system volume** (`PushSystemVolume` — live headphones follow the knob; the capture + side is scaled in the mix, see TRAX note above) **and mute kills TRAX** (`MusicPlayer.LocalGain` = 0). Relabelled **"Desktop Audio"** (TASK 8) since TRAX rides the same channel. - **`WaveToFloat`** (pure, shared): WASAPI mix formats → interleaved float — IEEE float 32-bit direct, PCM 16-bit normalized to -1..1, `WaveFormatExtensible` with the IEEE-float subformat GUID diff --git a/ytLive.Tests/GameMeterHonestyTests.cs b/ytLive.Tests/GameMeterHonestyTests.cs new file mode 100644 index 0000000..c18da0a --- /dev/null +++ b/ytLive.Tests/GameMeterHonestyTests.cs @@ -0,0 +1,72 @@ +using System; +using System.Collections.Generic; +using System.IO; +using Microsoft.Data.Sqlite; +using Xunit; +using ytLive.Services; +using ytLive.ViewModels; + +namespace ytLive.Tests; + +/// +/// Game-bar meter honesty (creator bug 2026-09-01): the WASAPI loopback tap is +/// PRE-endpoint-volume, so lowering the desktop slider left the meter pegged +/// yellow/red at 20% — the mic formula scales level × volume, the game clone +/// dropped the factor. The meter, the stream gain, and the headphones must all +/// follow the one knob. +/// +[Collection("RealApp")] +public sealed class GameMeterHonestyTests +{ + private readonly RealAppHost _app; + + public GameMeterHonestyTests(RealAppHost app) => _app = app; + + [Fact] + public void Game_Meter_Scales_By_Volume() + { + _app.Run(Run); + } + + private void Run() + { + var tempDb = Path.Combine(Path.GetTempPath(), $"ytLlive-gamemeter-{Guid.NewGuid():N}.db"); + MainViewModel.LayoutPathOverride = tempDb; + var pushed = new List(); + MainViewModel.VolumePushOverride = v => pushed.Add(v); + var window = default(MainWindow); + try + { + using (var schema = new LayoutStore(tempDb)) { } + SqliteConnection.ClearAllPools(); + + window = new MainWindow(); + var vm = (MainViewModel)window.DataContext; + window.Show(); + + // Hot desktop bed pegged at full linear level (ToDisplay ~= 1.0). + vm.GameAudioVolume = 1.0; + vm.GameAudioLevel = 1.0; + Assert.True(vm.GameMeterFillWidth > 230, "unity volume must keep the meter hot"); + Assert.Equal("#ef4444", vm.GameMeterBrush); + + // Drop the knob to 20% — the captured level does NOT change (the OS + // tap is pre-endpoint-volume); the meter must scale it anyway. + vm.GameAudioVolume = 0.2; + vm.GameAudioLevel = 1.0; + Assert.InRange(vm.GameMeterFillWidth, 40, 80); + Assert.Equal("#22c55e", vm.GameMeterBrush); + + // And the push really reached the (faked) endpoint — one knob, honest. + Assert.Contains(0.2, pushed); + } + finally + { + MainViewModel.VolumePushOverride = null; + if (window != null) window.Close(); + MainViewModel.LayoutPathOverride = null; + SqliteConnection.ClearAllPools(); + try { File.Delete(tempDb); } catch { /* best-effort */ } + } + } +}