Skip to content

Commit

Permalink
[WIP] Audio Device Selection
Browse files Browse the repository at this point in the history
Allow users to select their listening device. This is to support users using VoiceMeeter and controlling the audio output streams from different game clients.
  • Loading branch information
Soapwood committed Aug 12, 2024
1 parent 6abf725 commit 2bec28e
Show file tree
Hide file tree
Showing 11 changed files with 787 additions and 482 deletions.
14 changes: 14 additions & 0 deletions VXMusic/Audio/Device/AudioDevice.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
namespace VXMusic.Audio.Device;

public class AudioDevice
{
public string DeviceFriendlyName { get; set; }
public bool IsDefaultAudioDevice { get; set; }

public override string ToString()
{
return $"Device Name: {DeviceFriendlyName} | IsDefault: {IsDefaultAudioDevice}";
}

public static AudioDevice Default = new AudioDevice() { DeviceFriendlyName = "Default (Everything)", IsDefaultAudioDevice = true };
}
205 changes: 171 additions & 34 deletions VXMusic/Audio/Recording/WindowsAudioDeviceListener.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
using System.Collections.ObjectModel;
using NAudio.Wave;
using NAudio.CoreAudioApi;
using System.Diagnostics;
using Microsoft.Extensions.Logging;
using VXMusic.Audio;
using VXMusic.Audio.Device;

namespace VXMusic.Audio.Recording;

Expand All @@ -12,28 +14,82 @@ public class WindowsAudioDeviceListener : IAudioRecordingClient
private ILogger<WindowsAudioDeviceListener> _logger;

private WasapiLoopbackCapture? _capture;
private WasapiCapture? _audioDeviceCapture;
private WaveFileWriter? _writer;

private int _recordingRate = 44100;
private int _recordingBits = 16;
private int _recordingChannels = 1;

static string BufferFile = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "VirtualXtensions", "VXMusic", "Cache", "output.wav");
static string BufferFile = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"VirtualXtensions", "VXMusic", "Cache", "output.wav");

private int _recordingTimeSeconds;

private CaptureState CurrentCaptureState => _capture.CaptureState;
//private CaptureState CurrentCaptureState => SelectedAudioDevice.IsDefaultAudioDevice ? _capture.CaptureState : _audioDeviceCapture.CaptureState;

private CaptureState CurrentCaptureState
{
get
{
if (IsSelectedAudioDeviceDefault)
{
return _capture.CaptureState;
}
else
{
return _audioDeviceCapture.CaptureState;
}
}
}


//private static ObservableCollection<MMDevice> AvailableMMDevices;
public static List<AudioDevice> AvailableAudioDevices = new List<AudioDevice>();

//public static MMDeviceCollection AvailableAudioDevices;
public static AudioDevice SelectedAudioDevice;

public static bool IsSelectedAudioDeviceDefault => SelectedAudioDevice.IsDefaultAudioDevice;

public WindowsAudioDeviceListener(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
_logger = _serviceProvider.GetService(typeof(ILogger<WindowsAudioDeviceListener>))
as ILogger<WindowsAudioDeviceListener> ?? throw new ApplicationException("A logger must be created in service provider.");

_logger = _serviceProvider.GetService(typeof(ILogger<WindowsAudioDeviceListener>))
as ILogger<WindowsAudioDeviceListener> ??
throw new ApplicationException("A logger must be created in service provider.");

_logger.LogTrace("Creating WindowsAudioDeviceListener");

CreateCaptureInstance();
InitialiseAudioDevices();

SelectedAudioDevice = AudioDevice.Default;

_recordingTimeSeconds = 5;

CreateCacheDirectoryIfDoesNotExist();
}


public void InitialiseAudioDevices()
{
_logger.LogInformation("Initialising Audio Devices.");

MMDeviceCollection availableMMDevices = GetWindowsRenderDevices();

AvailableAudioDevices.Add(AudioDevice.Default);

AvailableAudioDevices.AddRange(availableMMDevices.Cast<MMDevice>()
.Select(device => new AudioDevice
{
DeviceFriendlyName = device.FriendlyName,
IsDefaultAudioDevice = false
})
.ToList());

_logger.LogDebug($"Found the following Audio Devices:");
AvailableAudioDevices.ForEach(device => _logger.LogDebug($"{device}"));
}

public int GetRecordingTimeSeconds()
{
return _recordingTimeSeconds;
Expand All @@ -44,64 +100,137 @@ public bool IsCaptureStateStopped()
// Wait for the capture to complete by monitoring the capture state
return CurrentCaptureState == CaptureState.Stopped;
}
private void CreateCaptureInstance()

private void CreateDefaultWasapiLoopbackCaptureInstance()
{
// WasapiLoopbackCapture allows to capture all audio on default audio device
// https://github.com/naudio/NAudio/blob/master/Docs/WasapiLoopbackCapture.md
_capture = new WasapiLoopbackCapture();
_capture.ShareMode = AudioClientShareMode.Shared; // Set the share mode
_capture.WaveFormat = new WaveFormat(44100, 16, 1); // Shazam needs 1 channel
_capture.WaveFormat = new WaveFormat(_recordingRate, _recordingBits, _recordingChannels); // Shazam needs 1 channel

_capture.DataAvailable += OnDataAvailable;

_writer = new WaveFileWriter(BufferFile, _capture.WaveFormat);

_capture.StartRecording();
}

public void StartRecording()
private void CreateCaptureInstanceWithSelectedAudioDevice(AudioDevice audioDevice)
{
// Recreate WasapiLoopbackCapture object if it has been disposed.
if (_capture == null)
CreateCaptureInstance();
// Capture specific device
var availableRenderDevices = GetWindowsRenderDevices();

MMDevice? mmDevice;

// Set the selected device if there are any available devices
try
{
mmDevice = availableRenderDevices.FirstOrDefault(d =>
d.FriendlyName.Equals(SelectedAudioDevice.DeviceFriendlyName, StringComparison.OrdinalIgnoreCase));
}
catch (Exception e)
{
_logger.LogError("Could not find available Audio Device from Friendly Name. Resorting to Default.");
SelectedAudioDevice = AudioDevice.Default;
CreateDefaultWasapiLoopbackCaptureInstance();
return;
}

if (mmDevice == null)
{
_logger.LogError(
"Could not find available Audio Device, returned MMDevice was null. Resorting to Default.");
SelectedAudioDevice = AudioDevice.Default;
CreateDefaultWasapiLoopbackCaptureInstance();
return;
}

_audioDeviceCapture = new WasapiCapture(mmDevice);

_audioDeviceCapture.ShareMode = AudioClientShareMode.Shared; // Set the share mode
_audioDeviceCapture.WaveFormat =
new WaveFormat(_recordingRate, _recordingBits, _recordingChannels); // Shazam needs 1 channel

_audioDeviceCapture.DataAvailable += OnDataAvailable;

_writer = new WaveFileWriter(BufferFile, _audioDeviceCapture.WaveFormat);

try
{
_audioDeviceCapture.StartRecording();
}
catch (Exception e)
{
_logger.LogError($"Failed to start recording: {e.Message}");
}

}

static MMDeviceCollection GetWindowsRenderDevices()
{
// Create an MMDeviceEnumerator to get the audio devices
var enumerator = new MMDeviceEnumerator();
// Get the audio endpoint collection for both render and capture devices
return enumerator.EnumerateAudioEndPoints(DataFlow.Capture, DeviceState.Active);
}

public void StartRecording()
{
_logger.LogTrace("Starting Recording... setting up devices.");
try
{
_capture.DataAvailable += OnDataAvailable;

// We want to write as WAV for lossless recording. Will need to convert to other formats from there.
_writer = new WaveFileWriter(BufferFile, _capture.WaveFormat);
if (IsSelectedAudioDeviceDefault)
{
_logger.LogTrace("Default Audio Device is selected. Creating WasapiLoopbackCapture.");
CreateDefaultWasapiLoopbackCaptureInstance();
}
else
{
_logger.LogTrace($"Selected Audio Device {SelectedAudioDevice}.");
CreateCaptureInstanceWithSelectedAudioDevice(SelectedAudioDevice);
}

_capture.StartRecording();
_logger.LogTrace("Recording Started.");
}
catch (Exception ex)
{
_logger.LogError("Error starting recording: " + Environment.NewLine + ex.Message);
}
_logger.LogTrace("Recording Started.");

}

public void StopRecording()
{
try
{
_capture?.StopRecording();
if (IsSelectedAudioDeviceDefault)
{
_capture?.StopRecording();
_capture?.Dispose();
_capture = null;
}
else
{
_audioDeviceCapture?.StopRecording();
_audioDeviceCapture.Dispose();
_audioDeviceCapture = null;
}

_writer?.Dispose();
_capture?.Dispose();

// Now that we're creating the Audio Listener as a service, we want to keep the service alive in memory.
// However, we can't purge the audioBuffer publically. We need to dispose of it here and recreate it again.
_capture = null;
}
catch (Exception ex)
{
_logger.LogError("Error stopping recording: " + Environment.NewLine + ex.Message);
}

_logger.LogTrace("Recording stopped.");
}

private void CreateCacheDirectoryIfDoesNotExist()
{
var bufferFileRoot = Directory.GetParent(BufferFile).ToString();
if(!Directory.Exists(bufferFileRoot))

if (!Directory.Exists(bufferFileRoot))
{
Directory.CreateDirectory(bufferFileRoot);
}
Expand All @@ -112,15 +241,23 @@ private void OnDataAvailable(object sender, WaveInEventArgs e)
try
{
_writer?.Write(e.Buffer, 0, e.BytesRecorded);

var averageBytesPerSecond = IsSelectedAudioDeviceDefault
? _capture.WaveFormat.AverageBytesPerSecond
: _audioDeviceCapture.WaveFormat.AverageBytesPerSecond;

// Limits to 10 second of recording. TODO Inject this time limit
if (_writer.Position > _capture.WaveFormat.AverageBytesPerSecond * _recordingTimeSeconds)
_capture.StopRecording();
if (_writer.Position > averageBytesPerSecond * _recordingTimeSeconds)
{
if (IsSelectedAudioDeviceDefault)
_capture.StopRecording();
else
_audioDeviceCapture.StopRecording();
}

}
catch (Exception ex)
{
_logger.LogError("Error writing to file:" + Environment.NewLine + ex.Message);
}
}

}
3 changes: 3 additions & 0 deletions VXMusicDesktop/App.config
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,9 @@
<setting name="HasLaunched" serializeAs="String">
<value>False</value>
</setting>
<setting name="SelectedAudioDevice" serializeAs="String">
<value />
</setting>
</VXMusicDesktop.Properties.Settings>
</userSettings>
</configuration>
10 changes: 5 additions & 5 deletions VXMusicDesktop/MVVM/View/OverlayView.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -235,11 +235,11 @@
</StackPanel>
</Border>

<Border Width="300"
Height="60"
CornerRadius="10"
HorizontalAlignment="Left"
Margin="20,10,0,0">
<Border Width="300"
Height="60"
CornerRadius="10"
HorizontalAlignment="Left"
Margin="20,10,0,0">

<Border.Background>
<LinearGradientBrush x:Name="OverlayMenuOption3BoxBorderGradientBrush" StartPoint="0,0"
Expand Down
Loading

0 comments on commit 2bec28e

Please sign in to comment.