TASK 4 ship step 4: WASAPI audio capture — NAudio loopback + mic behind an IAudioSource seam, AudioMixer driving AudioLevel while live, pure level meter + WaveToFloat, NAudio.Wasapi 2.2.1 (MIT, notices item 9) — 139 tests passing, 0 warnings
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
namespace ytLive.Services.Audio;
|
||||
|
||||
/// <summary>
|
||||
/// Computes the smoothed mic level (0..1) from captured samples. Pure —
|
||||
/// unit-tested; the <see cref="AudioMixer"/> only feeds it and forwards the
|
||||
/// result. RMS-based so it tracks perceived loudness, smoothed so the meter
|
||||
/// does not flicker.
|
||||
/// </summary>
|
||||
public sealed class AudioLevelMeter
|
||||
{
|
||||
private const float Smoothing = 0.2f;
|
||||
private float _level;
|
||||
|
||||
public float Level => _level;
|
||||
|
||||
public float Push(AudioSample sample)
|
||||
{
|
||||
if (sample.Samples.Length == 0)
|
||||
return _level;
|
||||
|
||||
double sumSquares = 0;
|
||||
var count = 0;
|
||||
foreach (var value in sample.Samples)
|
||||
{
|
||||
sumSquares += value * value;
|
||||
count++;
|
||||
}
|
||||
|
||||
var rms = (float)Math.Sqrt(sumSquares / count);
|
||||
_level = _level * (1 - Smoothing) + rms * Smoothing;
|
||||
return _level;
|
||||
}
|
||||
|
||||
public void Reset() => _level = 0;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
using ytLive.Services;
|
||||
|
||||
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).
|
||||
/// </summary>
|
||||
public sealed class AudioMixer : IDisposable
|
||||
{
|
||||
private readonly IAudioSource _mic;
|
||||
private readonly IAudioSource _loopback;
|
||||
private readonly AudioLevelMeter _meter;
|
||||
private readonly Action<string>? _log;
|
||||
private bool _started;
|
||||
|
||||
public AudioMixer(IAudioSource mic, IAudioSource loopback, Action<string>? log = null)
|
||||
{
|
||||
_mic = mic;
|
||||
_loopback = loopback;
|
||||
_meter = new AudioLevelMeter();
|
||||
_log = log;
|
||||
|
||||
_mic.SampleReady += OnMicSample;
|
||||
_loopback.SampleReady += OnLoopbackSample;
|
||||
_mic.Failed += OnMicFailed;
|
||||
_loopback.Failed += OnLoopbackFailed;
|
||||
}
|
||||
|
||||
/// <summary>Current smoothed mic level (0..1).</summary>
|
||||
public float MicLevel => _meter.Level;
|
||||
|
||||
/// <summary>Raised whenever the smoothed mic level changes.</summary>
|
||||
public event Action<float>? MicLevelChanged;
|
||||
|
||||
public void Start()
|
||||
{
|
||||
if (_started)
|
||||
return;
|
||||
|
||||
_started = true;
|
||||
_meter.Reset();
|
||||
_loopback.Start();
|
||||
_mic.Start();
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
if (!_started)
|
||||
return;
|
||||
|
||||
_started = false;
|
||||
_mic.Stop();
|
||||
_loopback.Stop();
|
||||
_meter.Reset();
|
||||
MicLevelChanged?.Invoke(0);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Stop();
|
||||
_mic.SampleReady -= OnMicSample;
|
||||
_loopback.SampleReady -= OnLoopbackSample;
|
||||
_mic.Failed -= OnMicFailed;
|
||||
_loopback.Failed -= OnLoopbackFailed;
|
||||
_mic.Dispose();
|
||||
_loopback.Dispose();
|
||||
}
|
||||
|
||||
private void OnMicSample(AudioSample sample)
|
||||
{
|
||||
MicLevelChanged?.Invoke(_meter.Push(sample));
|
||||
}
|
||||
|
||||
private void OnLoopbackSample(AudioSample sample)
|
||||
{
|
||||
// Desktop/game audio: captured for the future encoder mix; no UI yet.
|
||||
}
|
||||
|
||||
private void OnMicFailed(Exception ex)
|
||||
{
|
||||
_log?.Invoke($"Mic capture failed: {ex.Message}");
|
||||
MicLevelChanged?.Invoke(0);
|
||||
}
|
||||
|
||||
private void OnLoopbackFailed(Exception ex)
|
||||
{
|
||||
_log?.Invoke($"Desktop audio capture failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace ytLive.Services.Audio;
|
||||
|
||||
/// <summary>
|
||||
/// A chunk of interleaved PCM float samples (-1..1) with its format. This is
|
||||
/// the unit every <see cref="IAudioSource"/> produces and the
|
||||
/// <see cref="AudioMixer"/> consumes (TASK 4 ship step 4).
|
||||
/// </summary>
|
||||
public sealed class AudioSample
|
||||
{
|
||||
public float[] Samples { get; }
|
||||
public int SampleRate { get; }
|
||||
public int Channels { get; }
|
||||
|
||||
public AudioSample(float[] samples, int sampleRate, int channels)
|
||||
{
|
||||
Samples = samples;
|
||||
SampleRate = sampleRate;
|
||||
Channels = channels;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
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.
|
||||
/// </summary>
|
||||
public interface IAudioSource : IDisposable
|
||||
{
|
||||
/// <summary>Starts capturing. Safe to call only once per Stop.</summary>
|
||||
void Start();
|
||||
|
||||
/// <summary>Stops capturing; a later Start begins a fresh session.</summary>
|
||||
void Stop();
|
||||
|
||||
/// <summary>Raises each captured chunk (interleaved PCM float, -1..1).</summary>
|
||||
event Action<AudioSample>? SampleReady;
|
||||
|
||||
/// <summary>Raises when capture dies or fails to start (e.g. no device).</summary>
|
||||
event Action<Exception>? Failed;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using NAudio.Wave;
|
||||
|
||||
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.
|
||||
/// </summary>
|
||||
public sealed class WasapiLoopbackAudioSource : IAudioSource
|
||||
{
|
||||
private WasapiLoopbackCapture? _capture;
|
||||
|
||||
public event Action<AudioSample>? SampleReady;
|
||||
public event Action<Exception>? Failed;
|
||||
|
||||
public void Start()
|
||||
{
|
||||
if (_capture != null)
|
||||
throw new InvalidOperationException("Already started.");
|
||||
|
||||
try
|
||||
{
|
||||
_capture = new WasapiLoopbackCapture();
|
||||
_capture.DataAvailable += OnDataAvailable;
|
||||
_capture.RecordingStopped += OnRecordingStopped;
|
||||
_capture.StartRecording();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Stop();
|
||||
Failed?.Invoke(ex);
|
||||
}
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
if (_capture == null)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
_capture.StopRecording();
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
_capture.Dispose();
|
||||
_capture = null;
|
||||
}
|
||||
|
||||
public void Dispose() => Stop();
|
||||
|
||||
private void OnDataAvailable(object? sender, WaveInEventArgs e)
|
||||
{
|
||||
if (e.BytesRecorded <= 0)
|
||||
return;
|
||||
|
||||
var format = _capture?.WaveFormat ?? WaveFormat.CreateIeeeFloatWaveFormat(48000, 2);
|
||||
var samples = WaveToFloat.Convert(e.Buffer, e.BytesRecorded, format);
|
||||
if (samples.Length > 0)
|
||||
SampleReady?.Invoke(new AudioSample(samples, format.SampleRate, format.Channels));
|
||||
}
|
||||
|
||||
private void OnRecordingStopped(object? sender, StoppedEventArgs e)
|
||||
{
|
||||
if (e.Exception != null)
|
||||
Failed?.Invoke(e.Exception);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
using NAudio.CoreAudioApi;
|
||||
using NAudio.Wave;
|
||||
|
||||
namespace ytLive.Services.Audio;
|
||||
|
||||
/// <summary>
|
||||
/// Captures the selected microphone (TASK 4 ship step 4) via WASAPI. Resolves
|
||||
/// the NAudio device by FriendlyName matching <c>MicSourceName</c> (the app only
|
||||
/// persists DisplayName), falling back to the default capture endpoint.
|
||||
/// </summary>
|
||||
public sealed class WasapiMicAudioSource : IAudioSource
|
||||
{
|
||||
private readonly Func<string?> _micNameProvider;
|
||||
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>
|
||||
public WasapiMicAudioSource(Func<string?> micNameProvider)
|
||||
{
|
||||
_micNameProvider = micNameProvider;
|
||||
}
|
||||
|
||||
public event Action<AudioSample>? SampleReady;
|
||||
public event Action<Exception>? Failed;
|
||||
|
||||
public void Start()
|
||||
{
|
||||
if (_capture != null)
|
||||
throw new InvalidOperationException("Already started.");
|
||||
|
||||
try
|
||||
{
|
||||
var device = ResolveDevice();
|
||||
_capture = device != null ? new WasapiCapture(device) : new WasapiCapture();
|
||||
_capture.DataAvailable += OnDataAvailable;
|
||||
_capture.RecordingStopped += OnRecordingStopped;
|
||||
_capture.StartRecording();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Stop();
|
||||
Failed?.Invoke(ex);
|
||||
}
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
if (_capture == null)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
_capture.StopRecording();
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
_capture.Dispose();
|
||||
_capture = null;
|
||||
}
|
||||
|
||||
public void Dispose() => Stop();
|
||||
|
||||
private MMDevice? ResolveDevice()
|
||||
{
|
||||
var micName = _micNameProvider();
|
||||
if (string.IsNullOrWhiteSpace(micName))
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
using var enumerator = new MMDeviceEnumerator();
|
||||
foreach (var endpoint in enumerator.EnumerateAudioEndPoints(DataFlow.Capture, DeviceState.Active))
|
||||
{
|
||||
if (string.Equals(endpoint.FriendlyName, micName, StringComparison.OrdinalIgnoreCase))
|
||||
return endpoint;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private void OnDataAvailable(object? sender, WaveInEventArgs e)
|
||||
{
|
||||
if (e.BytesRecorded <= 0)
|
||||
return;
|
||||
|
||||
var format = _capture?.WaveFormat ?? WaveFormat.CreateIeeeFloatWaveFormat(48000, 1);
|
||||
var samples = WaveToFloat.Convert(e.Buffer, e.BytesRecorded, format);
|
||||
if (samples.Length > 0)
|
||||
SampleReady?.Invoke(new AudioSample(samples, format.SampleRate, format.Channels));
|
||||
}
|
||||
|
||||
private void OnRecordingStopped(object? sender, StoppedEventArgs e)
|
||||
{
|
||||
if (e.Exception != null)
|
||||
Failed?.Invoke(e.Exception);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using NAudio.Dmo;
|
||||
using NAudio.Wave;
|
||||
|
||||
namespace ytLive.Services.Audio;
|
||||
|
||||
/// <summary>
|
||||
/// Converts NAudio's raw WASAPI capture buffers (byte[], whatever bit depth the
|
||||
/// device's mix format reports) into interleaved PCM float samples. Pure —
|
||||
/// unit-tested; the two WASAPI sources share it.
|
||||
/// </summary>
|
||||
public static class WaveToFloat
|
||||
{
|
||||
public static float[] Convert(byte[] buffer, int bytesRecorded, WaveFormat format)
|
||||
{
|
||||
if (format.Encoding == WaveFormatEncoding.IeeeFloat && format.BitsPerSample == 32)
|
||||
return ConvertIeeeFloat(buffer, bytesRecorded);
|
||||
if (format.Encoding == WaveFormatEncoding.Pcm && format.BitsPerSample == 16)
|
||||
return ConvertPcm16(buffer, bytesRecorded);
|
||||
if (format is WaveFormatExtensible extensible
|
||||
&& extensible.SubFormat == AudioMediaSubtypes.MEDIASUBTYPE_IEEE_FLOAT)
|
||||
return ConvertIeeeFloat(buffer, bytesRecorded);
|
||||
return ConvertPcm16(buffer, bytesRecorded);
|
||||
}
|
||||
|
||||
private static float[] ConvertIeeeFloat(byte[] buffer, int bytesRecorded)
|
||||
{
|
||||
var count = bytesRecorded / 4;
|
||||
var result = new float[count];
|
||||
Buffer.BlockCopy(buffer, 0, result, 0, count * 4);
|
||||
return result;
|
||||
}
|
||||
|
||||
private static float[] ConvertPcm16(byte[] buffer, int bytesRecorded)
|
||||
{
|
||||
var count = bytesRecorded / 2;
|
||||
var result = new float[count];
|
||||
for (var i = 0; i < count; i++)
|
||||
result[i] = BitConverter.ToInt16(buffer, i * 2) / 32768f;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user