TASK 24 post-pause polish batch (creator's 8 review issues, 2026-08-15): (1) desktop/game audio volume slider + (2) mute were no-ops — the AudioMixer's gain Func seams defaulted to unity because the VM never passed them; new AudioGainProvider (Services/Audio/) wires MicMuted/MicVolume/GameMuted/GameAudioVolume into the mixer at construction, read live each mix tick, so sliders and mutes are stream-honest; the game bar also scales TRAX locally via new MusicPlayer.LocalGain (UpdateTraxLocalGain on every game volume/mute change + on load) so the creator HEARS the controls work on the music in the headphones. (3) TRAX played once then stopped — OnPlaybackStopped only looped on Position>=Length (unreliable for MediaFoundationReader); now any clean stop rewinds + replays, errors/explicit stops surface via PlaybackEnded; unused using dropped. (4+6) TRAX moved right of Socials in the footer's left cluster (centered under scenes/sources); the mic cluster is now MIC + meter + mute + volume. (5) mic source persists — LayoutStore gains a Settings key/value table (SaveMicSourceName/LoadMicSourceName); the VM saves on pick and restores before the mixer's first Start, so a restart reconnects the same vetted device (green) or reports it missing (yellow). (7) Backdrop's missing trashcan shifted the edit/eye icons right — new HiddenBoolToVisibilityConverter keeps the trash column reserved (Hidden, not Collapsed) so every source row's icons stay in fixed columns. (8) a 1px hairline with top/bottom padding separates scenes from sources in the left panel. Docs in the same commit: ai.md (gains honest + drive TRAX locally, TRAX loops, mic persistence, footer layout), TASKS.md TASK 24, HANDOFF.md shipped state, ViewModels/index.md. ONE integration test: AudioPipelineTests.Mix_HonorsProviderGains_AndGameMute_KillsTheLoopback (real mixer + provider gains through the pipe harness: scaled loopback audible, silence after mute). Build 0 warnings, 197 tests passing

This commit is contained in:
2026-08-15 10:23:49 -07:00
parent 86fcd868a8
commit a0a8331569
11 changed files with 369 additions and 93 deletions
+57
View File
@@ -287,6 +287,63 @@ public class AudioPipelineTests
Assert.True(floats.Any(f => MathF.Abs(f) > 0.05f), "the pipe carried silence");
}
[Fact]
public async Task Mix_HonorsProviderGains_AndGameMute_KillsTheLoopback()
{
// THE integration test for the polish batch: the AudioGainProvider's
// Func seams are exactly what the VM hands to the mixer, so this proves
// the desktop/game volume slider and mute button actually reach the live
// mix (before wiring they defaulted to unity and did nothing).
var pipeName = "ytllive_test_" + Guid.NewGuid().ToString("N");
var mic = new FakeSource();
var loopback = new FakeSource();
var gameVolume = 0.5;
var gameMuted = false;
var provider = new AudioGainProvider(
micMuted: () => false,
micVolume: () => 1.0,
gameMuted: () => gameMuted,
gameVolume: () => gameVolume);
using var mixer = new AudioMixer(
mic, loopback,
log: null,
micGain: provider.MicGain,
loopbackGain: provider.LoopbackGain,
mixInterval: TimeSpan.FromMilliseconds(5));
// Mic silent (so the ducker stays at unity) + a loud loopback bed that
// scales to 0.8 * 0.5 = 0.4 on the wire. Pre-fill the ring buffer so the
// first frames already carry real audio.
var loopChunk = new float[480];
Array.Fill(loopChunk, 0.8f);
for (var i = 0; i < 200; i++)
loopback.Emit(new AudioSample(loopChunk, 48000, 2));
using var client = new NamedPipeClientStream(".", pipeName, PipeDirection.In, PipeOptions.Asynchronous);
var connected = client.ConnectAsync();
mixer.StartLive(pipeName);
await connected.WaitAsync(TimeSpan.FromSeconds(5));
// Phase 1: unmuted at 0.5 → scaled but clearly audible, bounded.
var bytes = await ReadFullyAsync(client, 240 * 2 * 4, TimeSpan.FromSeconds(5));
var floats = new float[bytes.Length / 4];
Buffer.BlockCopy(bytes, 0, floats, 0, bytes.Length);
Assert.True(floats.Any(f => MathF.Abs(f) > 0.1f), "scaled loopback should be audible");
Assert.All(floats, f => Assert.InRange(f, -0.55f, 0.55f));
// Phase 2: mute flips the provider's seam → the pipe must go silent.
gameMuted = true;
var mutedBytes = await ReadFullyAsync(client, 240 * 2 * 4, TimeSpan.FromSeconds(5));
mixer.StopLive();
Assert.True(mutedBytes.Length >= 240 * 2 * 4, "expected at least one full stereo frame after mute");
var mutedFloats = new float[mutedBytes.Length / 4];
Buffer.BlockCopy(mutedBytes, 0, mutedFloats, 0, mutedBytes.Length);
Assert.All(mutedFloats, f => Assert.Equal(0f, f, 6));
}
private static async Task<byte[]> ReadFullyAsync(NamedPipeClientStream client, int count, TimeSpan timeout)
{
var ms = new MemoryStream();