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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user