fix(audio): game meter scales by GameAudioVolume — meter/stream/headphones all follow the one knob
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.
This commit is contained in:
@@ -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<double>? 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
|
||||
|
||||
/// <summary>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.</summary>
|
||||
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).</summary>
|
||||
private double GameMeterLevel => GameMuted ? 0 : _gameVolumeAdjusting || _gameVolumeFlash ? GameAudioVolume : Math.Min(1, AudioLevelMeter.ToDisplay((float)GameAudioLevel) * GameAudioVolume);
|
||||
|
||||
public double GameMeterFillWidth => GameMeterLevel * 288;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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<double>();
|
||||
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 */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user