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
+46 -5
View File
@@ -4,15 +4,18 @@ namespace ytLive.Services.Audio;
/// <summary>
/// Owns the mic + desktop/game capture sources (TASK 4 ship step 4) and drives
/// the live mic meter. Runs only while live: started when go-live succeeds,
/// stopped on end-stream. Mic samples are level-metered and forwarded; loopback
/// samples are currently dropped (consumed by the encoder mix in a later step).
/// the footer meters. Capture runs for the app's lifetime (started once at
/// startup, stopped on shutdown) so both bars stay live in preview: mic samples
/// are level-metered and forwarded, loopback samples feed the game bar's meter.
/// The mixer surfaces mic connection state (Connected/Failed) for the status
/// dot and can restart the mic source mid-session when a device is re-picked.
/// </summary>
public sealed class AudioMixer : IDisposable
{
private readonly IAudioSource _mic;
private readonly IAudioSource _loopback;
private readonly AudioLevelMeter _meter;
private readonly AudioLevelMeter _loopbackMeter;
private readonly Action<string>? _log;
private bool _started;
@@ -21,8 +24,10 @@ public sealed class AudioMixer : IDisposable
_mic = mic;
_loopback = loopback;
_meter = new AudioLevelMeter();
_loopbackMeter = new AudioLevelMeter();
_log = log;
_mic.Started += OnMicStarted;
_mic.SampleReady += OnMicSample;
_loopback.SampleReady += OnLoopbackSample;
_mic.Failed += OnMicFailed;
@@ -32,9 +37,21 @@ public sealed class AudioMixer : IDisposable
/// <summary>Current smoothed mic level (0..1).</summary>
public float MicLevel => _meter.Level;
/// <summary>Current smoothed desktop/game level (0..1).</summary>
public float LoopbackLevel => _loopbackMeter.Level;
/// <summary>Raised whenever the smoothed mic level changes.</summary>
public event Action<float>? MicLevelChanged;
/// <summary>Raised whenever the smoothed desktop/game level changes.</summary>
public event Action<float>? LoopbackLevelChanged;
/// <summary>Raised when the mic capture comes up (the status dot goes green).</summary>
public event Action? MicConnected;
/// <summary>Raised when the mic capture fails or dies (the status dot goes yellow).</summary>
public event Action<Exception>? MicFailed;
public void Start()
{
if (_started)
@@ -46,6 +63,17 @@ public sealed class AudioMixer : IDisposable
_mic.Start();
}
/// <summary>Swaps the mic source without touching loopback — used when the
/// creator picks a different device mid-session. The level resets and the
/// new source raises <see cref="MicConnected"/> or <see cref="MicFailed"/>.</summary>
public void RestartMic()
{
_mic.Stop();
_meter.Reset();
MicLevelChanged?.Invoke(0);
_mic.Start();
}
public void Stop()
{
if (!_started)
@@ -55,12 +83,15 @@ public sealed class AudioMixer : IDisposable
_mic.Stop();
_loopback.Stop();
_meter.Reset();
_loopbackMeter.Reset();
MicLevelChanged?.Invoke(0);
LoopbackLevelChanged?.Invoke(0);
}
public void Dispose()
{
Stop();
_mic.Started -= OnMicStarted;
_mic.SampleReady -= OnMicSample;
_loopback.SampleReady -= OnLoopbackSample;
_mic.Failed -= OnMicFailed;
@@ -69,20 +100,30 @@ public sealed class AudioMixer : IDisposable
_loopback.Dispose();
}
private void OnMicStarted()
{
MicConnected?.Invoke();
}
private void OnMicSample(AudioSample sample)
{
MicLevelChanged?.Invoke(_meter.Push(sample));
// Push unconditionally: the ?. on the event would otherwise skip the
// argument (and the meter update) when nothing is subscribed yet.
var level = _meter.Push(sample);
MicLevelChanged?.Invoke(level);
}
private void OnLoopbackSample(AudioSample sample)
{
// Desktop/game audio: captured for the future encoder mix; no UI yet.
var level = _loopbackMeter.Push(sample);
LoopbackLevelChanged?.Invoke(level);
}
private void OnMicFailed(Exception ex)
{
_log?.Invoke($"Mic capture failed: {ex.Message}");
MicLevelChanged?.Invoke(0);
MicFailed?.Invoke(ex);
}
private void OnLoopbackFailed(Exception ex)
+8 -3
View File
@@ -2,9 +2,10 @@ namespace ytLive.Services.Audio;
/// <summary>
/// A live audio capture source (TASK 4 ship step 4 seam): produces PCM float
/// chunks, runs only while live. The default implementations wrap NAudio's
/// WASAPI capture (mic) / loopback (desktop/game); the mixer and the tests
/// consume this interface, never NAudio directly.
/// chunks. The default implementations wrap NAudio's WASAPI capture (mic) /
/// loopback (desktop/game); the mixer and the tests consume this interface,
/// never NAudio directly. Capture now runs for the app's lifetime so the
/// footer meters stay live in preview — the mixer owns start/stop.
/// </summary>
public interface IAudioSource : IDisposable
{
@@ -14,6 +15,10 @@ public interface IAudioSource : IDisposable
/// <summary>Stops capturing; a later Start begins a fresh session.</summary>
void Stop();
/// <summary>Raises once capture is live (right after recording starts).
/// Never raised when Start fails — <see cref="Failed"/> fires instead.</summary>
event Action? Started;
/// <summary>Raises each captured chunk (interleaved PCM float, -1..1).</summary>
event Action<AudioSample>? SampleReady;
+4 -1
View File
@@ -4,12 +4,14 @@ namespace ytLive.Services.Audio;
/// <summary>
/// Captures the user's desktop/game audio (TASK 4 ship step 4) via WASAPI
/// loopback on the default render device. Starts/stops with go-live only.
/// loopback on the default render device. Runs for the app's lifetime so the
/// game audio bar stays live in preview.
/// </summary>
public sealed class WasapiLoopbackAudioSource : IAudioSource
{
private WasapiLoopbackCapture? _capture;
public event Action? Started;
public event Action<AudioSample>? SampleReady;
public event Action<Exception>? Failed;
@@ -24,6 +26,7 @@ public sealed class WasapiLoopbackAudioSource : IAudioSource
_capture.DataAvailable += OnDataAvailable;
_capture.RecordingStopped += OnRecordingStopped;
_capture.StartRecording();
Started?.Invoke();
}
catch (Exception ex)
{
+4 -1
View File
@@ -14,12 +14,14 @@ public sealed class WasapiMicAudioSource : IAudioSource
private WasapiCapture? _capture;
/// <param name="micNameProvider">Returns the current mic DisplayName; read
/// at each Start so a device picked mid-session takes effect next go-live.</param>
/// at each Start so a device picked mid-session takes effect immediately
/// (the mixer restarts the mic on pick).</param>
public WasapiMicAudioSource(Func<string?> micNameProvider)
{
_micNameProvider = micNameProvider;
}
public event Action? Started;
public event Action<AudioSample>? SampleReady;
public event Action<Exception>? Failed;
@@ -35,6 +37,7 @@ public sealed class WasapiMicAudioSource : IAudioSource
_capture.DataAvailable += OnDataAvailable;
_capture.RecordingStopped += OnRecordingStopped;
_capture.StartRecording();
Started?.Invoke();
}
catch (Exception ex)
{