TASK 4 audio follow-up: game audio bar + mic status dot + always-on capture — the footer's second audio control (desktop/game, a mirror of the mic bar: meter + mute + volume) appears only while a full-screen game is producing sound (IGameAudioDetector seam + GameAudioHysteresis: show ~500ms of fullscreen+sound, hide ~1s after leaving fullscreen, silence never hides an active bar; VM polls on a 250ms timer); capture now runs for the app's lifetime so both meters preview live (started at startup via StartMicCaptureAsync, disposed in Shutdown — no longer go-live driven); MIC label is a button with a status dot (Models/MicStatus: green via the source Started event, yellow = mic problem, red = no device); PickMicrophone swaps the live device immediately via AudioMixer.RestartMic; fixed a latent ?.Invoke(meter.Push(...)) short-circuit that skipped the meter update when nothing subscribed — 167 tests passing, 0 warnings

This commit is contained in:
2026-08-13 11:29:44 -07:00
parent ea250c02a6
commit 9f9ed34627
20 changed files with 897 additions and 82 deletions
+94
View File
@@ -18,6 +18,7 @@ public class AudioMixerTests
public int StartCount { get; private set; }
public int StopCount { get; private set; }
public bool Disposed { get; private set; }
public event Action? Started;
public event Action<AudioSample>? SampleReady;
public event Action<Exception>? Failed;
@@ -25,6 +26,7 @@ public class AudioMixerTests
public void Stop() => StopCount++;
public void Dispose() => Disposed = true;
public void MarkStarted() => Started?.Invoke();
public void Emit(AudioSample sample) => SampleReady?.Invoke(sample);
public void Fail(Exception ex) => Failed?.Invoke(ex);
}
@@ -115,6 +117,98 @@ public class AudioMixerTests
Assert.Equal(0f, mixer.MicLevel);
}
[Fact]
public void MicSamples_DoNotChangeLoopbackLevel()
{
var mic = new FakeSource();
var mixer = new AudioMixer(mic, new FakeSource());
var levels = new List<float>();
mixer.LoopbackLevelChanged += l => levels.Add(l);
mixer.Start();
mic.Emit(new AudioSample(new[] { 1f, 1f, 1f, 1f }, 48000, 1));
Assert.Empty(levels);
Assert.Equal(0f, mixer.LoopbackLevel);
}
[Fact]
public void LoopbackSamples_DriveLoopbackLevelChanged()
{
var loopback = new FakeSource();
var mixer = new AudioMixer(new FakeSource(), loopback);
var levels = new List<float>();
mixer.LoopbackLevelChanged += l => levels.Add(l);
mixer.Start();
loopback.Emit(new AudioSample(new[] { 1f, 1f, 1f, 1f }, 48000, 2));
loopback.Emit(new AudioSample(new[] { -1f, -1f, -1f, -1f }, 48000, 2));
Assert.NotEmpty(levels);
Assert.All(levels, l => Assert.InRange(l, 0f, 1f));
}
[Fact]
public void MicStarted_RaisesMicConnected()
{
var mic = new FakeSource();
var mixer = new AudioMixer(mic, new FakeSource());
var connected = 0;
mixer.MicConnected += () => connected++;
mixer.Start();
mic.MarkStarted();
Assert.Equal(1, connected);
}
[Fact]
public void MicFailure_RaisesMicFailed()
{
var mic = new FakeSource();
var mixer = new AudioMixer(mic, new FakeSource());
Exception? failed = null;
mixer.MicFailed += ex => failed = ex;
mixer.Start();
mic.Fail(new InvalidOperationException("boom"));
Assert.NotNull(failed);
Assert.Equal("boom", failed!.Message);
}
[Fact]
public void RestartMic_StopsAndRestartsMic_KeepsLoopbackRunning()
{
var mic = new FakeSource();
var loopback = new FakeSource();
var mixer = new AudioMixer(mic, loopback);
mixer.Start();
mixer.RestartMic();
Assert.Equal(2, mic.StartCount);
Assert.Equal(1, mic.StopCount);
Assert.Equal(1, loopback.StartCount);
Assert.Equal(0, loopback.StopCount);
}
[Fact]
public void RestartMic_ResetsLevel()
{
var mic = new FakeSource();
var mixer = new AudioMixer(mic, new FakeSource());
mixer.Start();
mic.Emit(new AudioSample(new[] { 1f, 1f, 1f, 1f }, 48000, 1));
Assert.True(mixer.MicLevel > 0);
float? reset = null;
mixer.MicLevelChanged += l => reset = l;
mixer.RestartMic();
Assert.Equal(0f, reset);
}
[Fact]
public void MicFailure_LogsAndResetsLevel()
{