364 lines
9.0 KiB
C#
364 lines
9.0 KiB
C#
using System;
|
|
using System.Threading.Tasks;
|
|
using FractalVisualizer.Models;
|
|
using NAudio.CoreAudioApi;
|
|
using NAudio.CoreAudioApi.Interfaces;
|
|
using NAudio.Wave;
|
|
|
|
namespace FractalVisualizer.Audio;
|
|
|
|
public sealed class AudioCapture : IDisposable
|
|
{
|
|
private readonly object _stateSync = new();
|
|
|
|
private readonly MMDeviceEnumerator _deviceEnumerator;
|
|
private readonly EndpointNotificationClient _notificationClient;
|
|
|
|
private IWaveIn? _capture;
|
|
private bool _isRunning;
|
|
private bool _isRestarting;
|
|
private bool _disposed;
|
|
|
|
public AudioCapture(int ringBufferCapacity = 48000 * 8)
|
|
{
|
|
RingBuffer = new AudioSampleRingBuffer(ringBufferCapacity);
|
|
CurrentMode = AudioInputMode.Microphone;
|
|
SampleRate = 48000;
|
|
|
|
_deviceEnumerator = new MMDeviceEnumerator();
|
|
_notificationClient = new EndpointNotificationClient(this);
|
|
}
|
|
|
|
public AudioSampleRingBuffer RingBuffer { get; }
|
|
|
|
public AudioInputMode CurrentMode { get; private set; }
|
|
|
|
public int SampleRate { get; private set; }
|
|
|
|
public event Action<AudioInputMode>? ModeChanged;
|
|
|
|
public event Action<int>? SampleRateChanged;
|
|
|
|
public void Start()
|
|
{
|
|
ThrowIfDisposed();
|
|
|
|
lock (_stateSync)
|
|
{
|
|
if (_isRunning)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_deviceEnumerator.RegisterEndpointNotificationCallback(_notificationClient);
|
|
_isRunning = true;
|
|
StartCaptureForCurrentModeNoLock(clearBuffer: true);
|
|
}
|
|
}
|
|
|
|
public void Stop()
|
|
{
|
|
lock (_stateSync)
|
|
{
|
|
if (!_isRunning)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_isRunning = false;
|
|
StopAndDisposeCaptureNoLock();
|
|
_deviceEnumerator.UnregisterEndpointNotificationCallback(_notificationClient);
|
|
}
|
|
}
|
|
|
|
public void SwitchMode(AudioInputMode newMode)
|
|
{
|
|
ThrowIfDisposed();
|
|
|
|
lock (_stateSync)
|
|
{
|
|
if (CurrentMode == newMode)
|
|
{
|
|
return;
|
|
}
|
|
|
|
CurrentMode = newMode;
|
|
if (_isRunning)
|
|
{
|
|
RestartCaptureNoLock(clearBuffer: true);
|
|
}
|
|
else
|
|
{
|
|
RingBuffer.Clear();
|
|
}
|
|
}
|
|
|
|
ModeChanged?.Invoke(CurrentMode);
|
|
}
|
|
|
|
private void HandleDefaultDeviceChanged(DataFlow flow, Role role)
|
|
{
|
|
if (role != Role.Multimedia)
|
|
{
|
|
return;
|
|
}
|
|
|
|
lock (_stateSync)
|
|
{
|
|
if (!_isRunning || _isRestarting)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (CurrentMode == AudioInputMode.Microphone && flow == DataFlow.Capture)
|
|
{
|
|
RestartCaptureNoLock(clearBuffer: false);
|
|
return;
|
|
}
|
|
|
|
if (CurrentMode == AudioInputMode.SystemOutput && flow == DataFlow.Render)
|
|
{
|
|
RestartCaptureNoLock(clearBuffer: false);
|
|
}
|
|
}
|
|
}
|
|
|
|
private void RestartCaptureNoLock(bool clearBuffer)
|
|
{
|
|
_isRestarting = true;
|
|
try
|
|
{
|
|
StopAndDisposeCaptureNoLock();
|
|
StartCaptureForCurrentModeNoLock(clearBuffer);
|
|
}
|
|
finally
|
|
{
|
|
_isRestarting = false;
|
|
}
|
|
}
|
|
|
|
private void StartCaptureForCurrentModeNoLock(bool clearBuffer)
|
|
{
|
|
if (clearBuffer)
|
|
{
|
|
RingBuffer.Clear();
|
|
}
|
|
|
|
_capture = CurrentMode switch
|
|
{
|
|
AudioInputMode.Microphone => CreateMicrophoneCapture(),
|
|
AudioInputMode.SystemOutput => CreateLoopbackCapture(),
|
|
_ => throw new ArgumentOutOfRangeException(nameof(CurrentMode), CurrentMode, "Unknown audio input mode.")
|
|
};
|
|
|
|
_capture.DataAvailable += OnDataAvailable;
|
|
_capture.RecordingStopped += OnRecordingStopped;
|
|
_capture.StartRecording();
|
|
|
|
SampleRate = _capture.WaveFormat.SampleRate;
|
|
SampleRateChanged?.Invoke(SampleRate);
|
|
}
|
|
|
|
private IWaveIn CreateMicrophoneCapture()
|
|
{
|
|
try
|
|
{
|
|
MMDevice defaultInput = _deviceEnumerator.GetDefaultAudioEndpoint(DataFlow.Capture, Role.Multimedia);
|
|
return new WasapiCapture(defaultInput);
|
|
}
|
|
catch
|
|
{
|
|
return new WaveInEvent
|
|
{
|
|
WaveFormat = new WaveFormat(44100, 16, 1),
|
|
BufferMilliseconds = 25
|
|
};
|
|
}
|
|
}
|
|
|
|
private IWaveIn CreateLoopbackCapture()
|
|
{
|
|
try
|
|
{
|
|
MMDevice defaultOutput = _deviceEnumerator.GetDefaultAudioEndpoint(DataFlow.Render, Role.Multimedia);
|
|
return new WasapiLoopbackCapture(defaultOutput);
|
|
}
|
|
catch
|
|
{
|
|
return new WasapiLoopbackCapture();
|
|
}
|
|
}
|
|
|
|
private void OnDataAvailable(object? sender, WaveInEventArgs e)
|
|
{
|
|
IWaveIn? activeCapture = _capture;
|
|
if (activeCapture is null || e.BytesRecorded <= 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
float[] samples = ConvertToMonoFloatSamples(e.Buffer, e.BytesRecorded, activeCapture.WaveFormat);
|
|
if (samples.Length == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
RingBuffer.Write(samples);
|
|
}
|
|
|
|
private static float[] ConvertToMonoFloatSamples(byte[] buffer, int bytesRecorded, WaveFormat format)
|
|
{
|
|
int channels = Math.Max(1, format.Channels);
|
|
if (format.Encoding == WaveFormatEncoding.IeeeFloat && format.BitsPerSample == 32)
|
|
{
|
|
int totalSamples = bytesRecorded / sizeof(float);
|
|
int frames = totalSamples / channels;
|
|
if (frames <= 0)
|
|
{
|
|
return Array.Empty<float>();
|
|
}
|
|
|
|
float[] mono = new float[frames];
|
|
WaveBuffer waveBuffer = new(buffer);
|
|
for (int frame = 0; frame < frames; frame++)
|
|
{
|
|
float sum = 0f;
|
|
int offset = frame * channels;
|
|
for (int ch = 0; ch < channels; ch++)
|
|
{
|
|
sum += waveBuffer.FloatBuffer[offset + ch];
|
|
}
|
|
|
|
mono[frame] = sum / channels;
|
|
}
|
|
|
|
return mono;
|
|
}
|
|
|
|
if (format.Encoding == WaveFormatEncoding.Pcm && format.BitsPerSample == 16)
|
|
{
|
|
int totalSamples = bytesRecorded / sizeof(short);
|
|
int frames = totalSamples / channels;
|
|
if (frames <= 0)
|
|
{
|
|
return Array.Empty<float>();
|
|
}
|
|
|
|
float[] mono = new float[frames];
|
|
WaveBuffer waveBuffer = new(buffer);
|
|
for (int frame = 0; frame < frames; frame++)
|
|
{
|
|
float sum = 0f;
|
|
int offset = frame * channels;
|
|
for (int ch = 0; ch < channels; ch++)
|
|
{
|
|
sum += waveBuffer.ShortBuffer[offset + ch] / 32768f;
|
|
}
|
|
|
|
mono[frame] = sum / channels;
|
|
}
|
|
|
|
return mono;
|
|
}
|
|
|
|
return Array.Empty<float>();
|
|
}
|
|
|
|
private void OnRecordingStopped(object? sender, StoppedEventArgs e)
|
|
{
|
|
if (_disposed)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (e.Exception is null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
Task.Run(() =>
|
|
{
|
|
lock (_stateSync)
|
|
{
|
|
if (!_isRunning || _isRestarting)
|
|
{
|
|
return;
|
|
}
|
|
|
|
RestartCaptureNoLock(clearBuffer: false);
|
|
}
|
|
});
|
|
}
|
|
|
|
private void StopAndDisposeCaptureNoLock()
|
|
{
|
|
if (_capture is null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
IWaveIn toDispose = _capture;
|
|
_capture = null;
|
|
|
|
toDispose.DataAvailable -= OnDataAvailable;
|
|
toDispose.RecordingStopped -= OnRecordingStopped;
|
|
|
|
try
|
|
{
|
|
toDispose.StopRecording();
|
|
}
|
|
catch
|
|
{
|
|
// Device may already be removed/disposed.
|
|
}
|
|
|
|
toDispose.Dispose();
|
|
}
|
|
|
|
private void ThrowIfDisposed()
|
|
{
|
|
if (_disposed)
|
|
{
|
|
throw new ObjectDisposedException(nameof(AudioCapture));
|
|
}
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
if (_disposed)
|
|
{
|
|
return;
|
|
}
|
|
|
|
Stop();
|
|
_deviceEnumerator.Dispose();
|
|
_disposed = true;
|
|
}
|
|
|
|
private sealed class EndpointNotificationClient(AudioCapture owner) : IMMNotificationClient
|
|
{
|
|
private readonly AudioCapture _owner = owner;
|
|
|
|
public void OnDefaultDeviceChanged(DataFlow flow, Role role, string defaultDeviceId)
|
|
{
|
|
_owner.HandleDefaultDeviceChanged(flow, role);
|
|
}
|
|
|
|
public void OnDeviceAdded(string pwstrDeviceId)
|
|
{
|
|
}
|
|
|
|
public void OnDeviceRemoved(string deviceId)
|
|
{
|
|
}
|
|
|
|
public void OnDeviceStateChanged(string deviceId, DeviceState newState)
|
|
{
|
|
}
|
|
|
|
public void OnPropertyValueChanged(string pwstrDeviceId, PropertyKey key)
|
|
{
|
|
}
|
|
}
|
|
}
|