Skip to content

Commit

Permalink
For #18: Separate sound outputs and passthrough outputs
Browse files Browse the repository at this point in the history
  • Loading branch information
micahmo committed Apr 17, 2023
1 parent 838ba40 commit 498cf0d
Show file tree
Hide file tree
Showing 4 changed files with 209 additions and 39 deletions.
37 changes: 37 additions & 0 deletions SoundBoard/GlobalSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,43 @@ public static class GlobalSettings

#endregion

#region Passthrough output device

/// <summary>
/// Add an output device to the current list
/// </summary>
public static void AddPassthroughOutputDeviceGuid(Guid guid) => PassthroughOutputDeviceGuids.Add(guid);

/// <summary>
/// Remove an output device from the current list
/// </summary>
public static void RemovePassthroughOutputDeviceGuid(Guid guid) => PassthroughOutputDeviceGuids.Remove(guid);

/// <summary>
/// Removes all current output devices
/// </summary>
public static void RemoveAllPassthroughOutputDeviceGuids() => PassthroughOutputDeviceGuids.Clear();

/// <summary>
/// Get the current list of output devices
/// </summary>
public static List<Guid> GetPassthroughOutputDeviceGuids() => (PassthroughOutputDeviceGuids.Any() ? PassthroughOutputDeviceGuids : Enumerable.Empty<Guid>()).ToList();

/// <summary>
/// The name of the OutputDeviceGuid setting name.
/// </summary>
public static string PassthroughOutputDeviceGuidSettingName = "PassthroughOutputDeviceGuid";

/// <summary>
/// Defines the ID(s) of the audio output device(s) to use when playing sounds
/// </summary>
/// <remarks>
/// HashSet to prevent duplicate GUIDs.
/// </remarks>
private static HashSet<Guid> PassthroughOutputDeviceGuids { get; } = new HashSet<Guid>();

#endregion

/// <summary>
/// The latency to use when chaining input to outputs
/// </summary>
Expand Down
187 changes: 148 additions & 39 deletions SoundBoard/MainWindow.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ public MainWindow()
DownloadIdentifier = "portable"
};

HandleInputOutputChange();
HandleAudioPassthroughChange();

Tabs.SelectionChanged += (_, __) =>
{
Expand Down Expand Up @@ -373,6 +373,17 @@ private void LoadSettings(string configFilePath)
});
}

if (globalSettings.Attributes?[GlobalSettings.PassthroughOutputDeviceGuidSettingName] is XmlAttribute passthroughOutputDeviceGuidAttribute)
{
passthroughOutputDeviceGuidAttribute.Value.Split(',').ToList().ForEach(guid =>
{
if (Guid.TryParse(guid, out var passthroughOutputDeviceGuid))
{
GlobalSettings.AddPassthroughOutputDeviceGuid(passthroughOutputDeviceGuid);
}
});
}

if (globalSettings.Attributes?[nameof(GlobalSettings.AudioPassthroughLatency)] is XmlAttribute audioPassthroughLatencyAttribute
&& int.TryParse(audioPassthroughLatencyAttribute.Value, out int audioPassthroughLatency))
{
Expand Down Expand Up @@ -844,6 +855,7 @@ private void SaveSettings(string configFilePath)
textWriter.WriteStartElement(nameof(GlobalSettings)); // <GlobalSettings>
textWriter.WriteAttributeString(GlobalSettings.OutputDeviceGuidSettingName, string.Join(@",", GlobalSettings.GetOutputDeviceGuids()));
textWriter.WriteAttributeString(GlobalSettings.InputDeviceGuidSettingName, string.Join(@",", GlobalSettings.GetInputDeviceGuids()));
textWriter.WriteAttributeString(GlobalSettings.PassthroughOutputDeviceGuidSettingName, string.Join(@",", GlobalSettings.GetPassthroughOutputDeviceGuids()));
textWriter.WriteAttributeString(nameof(GlobalSettings.AudioPassthroughLatency), GlobalSettings.AudioPassthroughLatency.ToString());
textWriter.WriteAttributeString(nameof(GlobalSettings.NewPageDefaultRows), GlobalSettings.NewPageDefaultRows.ToString());
textWriter.WriteAttributeString(nameof(GlobalSettings.NewPageDefaultColumns), GlobalSettings.NewPageDefaultColumns.ToString());
Expand Down Expand Up @@ -1217,27 +1229,27 @@ private void overflow_Click(object sender, RoutedEventArgs e)
_newPageDefaultMenu.Click += NewPageDefault_Click;
_newPageDefaultMenu.SetSeparator(true);

_inputDeviceMenu = new MenuItem { Header = Properties.Resources.InputDevice };
_inputDeviceMenu.SubmenuOpened += InputDeviceMenuOpened;
_audioPassthroughMenu = new MenuItem { Header = Properties.Resources.AudioPassthrough };
_audioPassthroughMenu.SubmenuOpened += AudioPassthroughMenuOpened;

_outputDeviceMenu = new MenuItem {Header = Properties.Resources.OutputDevice};
_outputDeviceMenu = new MenuItem {Header = Properties.Resources.SoundOutputDevice};
_outputDeviceMenu.SubmenuOpened += OutputDeviceMenuOpened;

// Add a placeholder menu item so that "Output device" will have a submenu
// even before we have evaluated the audio devices to add to the menu
MenuItem placeholder = new MenuItem();
_outputDeviceMenu.Items.Add(placeholder);

// Add a placeholder menu item so that "Input device" will have a submenu
// Add a placeholder menu item so that "Audio Passthrough" will have a submenu
// even before we have evaluated the audio devices to add to the menu
placeholder = new MenuItem();
_inputDeviceMenu.Items.Add(placeholder);
_audioPassthroughMenu.Items.Add(placeholder);

overflowMenu.Items.Add(importConfig);
overflowMenu.Items.Add(exportConfig);
overflowMenu.Items.Add(clearConfig);
overflowMenu.Items.Add(_newPageDefaultMenu);
overflowMenu.Items.Add(_inputDeviceMenu);
overflowMenu.Items.Add(_audioPassthroughMenu);
overflowMenu.Items.Add(_outputDeviceMenu);

overflowMenu.AddSeparators();
Expand Down Expand Up @@ -1423,55 +1435,95 @@ private async void NewPageDefault_Click(object sender, RoutedEventArgs e)
}
}

private void InputDeviceMenuOpened(object sender, RoutedEventArgs e)
private void AudioPassthroughMenuOpened(object sender, RoutedEventArgs e)
{
if (sender is MenuItem inputDeviceMenu)
if (sender is MenuItem audioPassthroughMenu)
{
// Clear the current items, whether they are the placeholder
// or the previously evaluated audio devices.
// We're marking them for removal instead of removing them immediately so that the
// menu doesn't resize and decide to close because our mouse is no longer over it.
// Instead we'll add all the new items, then remove the old ones at the very end.
// Use Control istead of MenuItem to capture the Separator.
List<Control> itemsToRemove = inputDeviceMenu.Items.OfType<Control>().ToList();
// Use Control instead of MenuItem to capture the Separator.
List<Control> itemsToRemove = audioPassthroughMenu.Items.OfType<Control>().ToList();

#region Output

// Create a menu item for each output device
using (MMDeviceEnumerator deviceEnumerator = new MMDeviceEnumerator())
{
// Note: We're going in reverse order to preserve the separator and "Close" item at the bottom

// Now add the rest
foreach (MMDevice device in deviceEnumerator.EnumerateAudioEndPoints(DataFlow.Capture, DeviceState.Active).Reverse())
foreach (MMDevice device in deviceEnumerator.EnumerateAudioEndPoints(DataFlow.Render, DeviceState.Active).Reverse())
{
MenuItem menuItem = new MenuItem
{
Header = string.Format(Properties.Resources.SingleSpecifier, device.FriendlyName),
Icon = GlobalSettings.GetInputDeviceGuids().Contains(device.GetGuid()) ? ImageHelper.GetImage(ImageHelper.CheckIconPath) : null,
Icon = GlobalSettings.GetPassthroughOutputDeviceGuids().Contains(device.GetGuid()) ? ImageHelper.GetImage(ImageHelper.CheckIconPath) : null,
StaysOpenOnClick = true
};
menuItem.PreviewMouseUp += (_, args) => HandleInputDeviceSelection(device.GetGuid());
inputDeviceMenu.Items.Insert(0, menuItem);
menuItem.PreviewMouseUp += (_, args) => HandlePassthroughOutputDeviceSelection(device.GetGuid(), args.ChangedButton);
audioPassthroughMenu.Items.Insert(0, menuItem);
}

MenuItem closeDeviceMenuMenuItem = new MenuItem
// First, add the default device
var defaultDevice = deviceEnumerator.GetDefaultAudioEndpoint(DataFlow.Render, Role.Multimedia);
var defaultDeviceMenuItem = new MenuItem
{
Header = Properties.Resources.Close
Header = string.Format(Properties.Resources.DefaultDevice, defaultDevice.FriendlyName),
Icon = GlobalSettings.GetPassthroughOutputDeviceGuids().Contains(Guid.Empty) ? ImageHelper.GetImage(ImageHelper.CheckIconPath) : null,
StaysOpenOnClick = true
};
inputDeviceMenu.Items.Add(new Separator());
inputDeviceMenu.Items.Add(closeDeviceMenuMenuItem);
defaultDeviceMenuItem.PreviewMouseUp += (_, args) => HandlePassthroughOutputDeviceSelection(Guid.Empty, args.ChangedButton);
audioPassthroughMenu.Items.Insert(0, defaultDeviceMenuItem);
}

// Finally, remove the items marked for removal
foreach (Control control in itemsToRemove)
// Add an item for output heading
audioPassthroughMenu.Items.Insert(0, new MenuItem { Header = Properties.Resources.OutputDevice, IsEnabled = false });

audioPassthroughMenu.Items.Insert(0, new Separator());

#endregion

#region Input

// Create a menu item for each output device
using (MMDeviceEnumerator deviceEnumerator = new MMDeviceEnumerator())
{
inputDeviceMenu.Items.Remove(control);
// Note: We're going in reverse order to preserve the separator and "Close" item at the bottom

// Now add the rest
foreach (MMDevice device in deviceEnumerator.EnumerateAudioEndPoints(DataFlow.Capture, DeviceState.Active).Reverse())
{
MenuItem menuItem = new MenuItem
{
Header = string.Format(Properties.Resources.SingleSpecifier, device.FriendlyName),
Icon = GlobalSettings.GetInputDeviceGuids().Contains(device.GetGuid()) ? ImageHelper.GetImage(ImageHelper.CheckIconPath) : null,
StaysOpenOnClick = true
};
menuItem.PreviewMouseUp += (_, args) => HandlePassthroughInputDeviceSelection(device.GetGuid());
audioPassthroughMenu.Items.Insert(0, menuItem);
}
}

// We only have close and separator, which looks funny, so remove the separator
if (inputDeviceMenu.Items.Count == 2
&& inputDeviceMenu.Items.OfType<Separator>().FirstOrDefault() is Separator separator)
// Add an item for input heading
audioPassthroughMenu.Items.Insert(0, new MenuItem { Header = Properties.Resources.InputDevice, IsEnabled = false });

#endregion

// Add close
MenuItem closeDeviceMenuMenuItem = new MenuItem
{
inputDeviceMenu.Items.Remove(separator);
Header = Properties.Resources.Close
};
audioPassthroughMenu.Items.Add(new Separator());
audioPassthroughMenu.Items.Add(closeDeviceMenuMenuItem);

// Finally, remove the items marked for removal
foreach (Control control in itemsToRemove)
{
audioPassthroughMenu.Items.Remove(control);
}
}
}
Expand Down Expand Up @@ -1549,7 +1601,7 @@ private void OutputDeviceMenuOpened(object sender, RoutedEventArgs e)

// This behavior is different from the output devices in that we can only select one.
// However, all of the pieces are in place to allow multiple selection if needed.
private void HandleInputDeviceSelection(Guid deviceGuid)
private void HandlePassthroughInputDeviceSelection(Guid deviceGuid)
{
if (GlobalSettings.GetInputDeviceGuids().Contains(deviceGuid))
{
Expand All @@ -1564,12 +1616,58 @@ private void HandleInputDeviceSelection(Guid deviceGuid)
}

// Refresh the menu
InputDeviceMenuOpened(_inputDeviceMenu, new RoutedEventArgs());
AudioPassthroughMenuOpened(_audioPassthroughMenu, new RoutedEventArgs());

HandleInputOutputChange();
HandleAudioPassthroughChange();
}

private void HandleInputOutputChange()
// This behavior is different from both in put selection (which only allows one) and output selection (which requires at least one)
private void HandlePassthroughOutputDeviceSelection(Guid guid, MouseButton mouseButton)
{
if (mouseButton == MouseButton.Right)
{
// This is a toggle. Do not clear the list, and add or removing depending on existence.
if (GlobalSettings.GetPassthroughOutputDeviceGuids().Contains(guid))
{
if (GlobalSettings.GetPassthroughOutputDeviceGuids().All(g => g == guid))
{
// This is the only device in the list, so we can't really toggle it. Do nothing.
}
else
{
// This is in the list, and it's being toggled off, so remove it.
GlobalSettings.RemovePassthroughOutputDeviceGuid(guid);
}
}
else
{
// This is not the list, and it's being togged on, so add it.
GlobalSettings.AddPassthroughOutputDeviceGuid(guid);
}
}
else
{
// A single click will toggle this one
if (GlobalSettings.GetPassthroughOutputDeviceGuids().Any() && GlobalSettings.GetPassthroughOutputDeviceGuids().All(g => g == guid))
{
// Toggle it off
GlobalSettings.RemovePassthroughOutputDeviceGuid(guid);
}
else
{
// Toggle it on and remove others
GlobalSettings.RemoveAllPassthroughOutputDeviceGuids();
GlobalSettings.AddPassthroughOutputDeviceGuid(guid);
}
}

// Refresh the menu
AudioPassthroughMenuOpened(_audioPassthroughMenu, new RoutedEventArgs());

HandleAudioPassthroughChange();
}

private void HandleAudioPassthroughChange()
{
// Clear any existing chaining
CleanUpAudioPassthrough();
Expand All @@ -1578,14 +1676,14 @@ private void HandleInputOutputChange()
{
Guid inputDeviceId = GlobalSettings.GetInputDeviceGuids().First();

foreach (var outputDeviceId in GlobalSettings.GetOutputDeviceGuids())
foreach (var outputDeviceId in GlobalSettings.GetPassthroughOutputDeviceGuids())
{
try
{
// Create the input
MMDevice inputDevice = Utilities.GetDevice(inputDeviceId, DataFlow.Capture);
WasapiCapture inputCapture = new WasapiCapture(inputDevice);
inputCapture.RecordingStopped += HandleRecordingStopped;
inputCapture.RecordingStopped += HandlePassthroughRecordingStopped;
_inputCaptures.Add(inputCapture);

// Create the buffer
Expand All @@ -1602,6 +1700,7 @@ private void HandleInputOutputChange()

// Create the outputs
WasapiOut output = new WasapiOut(Utilities.GetDevice(outputDeviceId, DataFlow.Render), AudioClientShareMode.Shared, true, GlobalSettings.AudioPassthroughLatency);
output.PlaybackStopped += HandlePassthroughPlaybackStopped;
_outputCaptures.Add(output);

output.Init(bufferedWaveProvider);
Expand All @@ -1611,7 +1710,8 @@ private void HandleInputOutputChange()
}
catch (Exception ex)
{
HandleRecordingStopped(this, new StoppedEventArgs());
HandlePassthroughRecordingStopped(this, new StoppedEventArgs());
HandlePassthroughPlaybackStopped(this, new StoppedEventArgs());

Dispatcher.Invoke(async () =>
{
Expand Down Expand Up @@ -1663,20 +1763,31 @@ private void HandleInputOutputChange()
}
}

private void HandleRecordingStopped(object sender, StoppedEventArgs args)
private void HandlePassthroughRecordingStopped(object sender, StoppedEventArgs args)
{
// Handle the device being disabled/disconnected/etc.
// Handle the input device being disabled/disconnected/etc.
GlobalSettings.RemoveAllInputDeviceGuids();
CleanUpAudioPassthrough();
}

private void HandlePassthroughPlaybackStopped(object sender, StoppedEventArgs args)
{
// This fires even for normal stoppages, so we only want to clean things up if there was an exception
if (args.Exception != null)
{
// Handle the output device being disabled/disconnected/etc.
GlobalSettings.RemoveAllPassthroughOutputDeviceGuids();
CleanUpAudioPassthrough();
}
}

private void CleanUpAudioPassthrough()
{
try
{
_inputCaptures.ForEach(ic =>
{
ic.RecordingStopped -= HandleRecordingStopped;
ic.RecordingStopped -= HandlePassthroughRecordingStopped;
ic.StopRecording();
ic.Dispose();
});
Expand Down Expand Up @@ -1745,8 +1856,6 @@ private void HandleOutputDeviceSelection(Guid deviceGuid, MouseButton mouseButto

// Refresh the menu
OutputDeviceMenuOpened(_outputDeviceMenu, new RoutedEventArgs());

HandleInputOutputChange();
}

private void CloseSnackbarButton_Click(object sender, RoutedEventArgs e)
Expand Down Expand Up @@ -1911,7 +2020,7 @@ internal void OnAnySoundRenamed()
private readonly Dictionary<MetroTabItem, ContextMenu> _tabContextMenus = new Dictionary<MetroTabItem, ContextMenu>();
private readonly WpfUpdateChecker _updateChecker;
private MenuItem _newPageDefaultMenu;
private MenuItem _inputDeviceMenu;
private MenuItem _audioPassthroughMenu;
private MenuItem _outputDeviceMenu;

#endregion
Expand Down
Loading

0 comments on commit 498cf0d

Please sign in to comment.