From 498cf0d4f7cd92eddffb5c7c763d2326271b6ccd Mon Sep 17 00:00:00 2001 From: Micah Morrison Date: Mon, 17 Apr 2023 19:57:28 -0400 Subject: [PATCH] For #18: Separate sound outputs and passthrough outputs --- SoundBoard/GlobalSettings.cs | 37 ++++ SoundBoard/MainWindow.xaml.cs | 187 ++++++++++++++++---- SoundBoard/Properties/Resources.Designer.cs | 18 ++ SoundBoard/Properties/Resources.resx | 6 + 4 files changed, 209 insertions(+), 39 deletions(-) diff --git a/SoundBoard/GlobalSettings.cs b/SoundBoard/GlobalSettings.cs index 717b903..5715950 100644 --- a/SoundBoard/GlobalSettings.cs +++ b/SoundBoard/GlobalSettings.cs @@ -97,6 +97,43 @@ public static class GlobalSettings #endregion + #region Passthrough output device + + /// + /// Add an output device to the current list + /// + public static void AddPassthroughOutputDeviceGuid(Guid guid) => PassthroughOutputDeviceGuids.Add(guid); + + /// + /// Remove an output device from the current list + /// + public static void RemovePassthroughOutputDeviceGuid(Guid guid) => PassthroughOutputDeviceGuids.Remove(guid); + + /// + /// Removes all current output devices + /// + public static void RemoveAllPassthroughOutputDeviceGuids() => PassthroughOutputDeviceGuids.Clear(); + + /// + /// Get the current list of output devices + /// + public static List GetPassthroughOutputDeviceGuids() => (PassthroughOutputDeviceGuids.Any() ? PassthroughOutputDeviceGuids : Enumerable.Empty()).ToList(); + + /// + /// The name of the OutputDeviceGuid setting name. + /// + public static string PassthroughOutputDeviceGuidSettingName = "PassthroughOutputDeviceGuid"; + + /// + /// Defines the ID(s) of the audio output device(s) to use when playing sounds + /// + /// + /// HashSet to prevent duplicate GUIDs. + /// + private static HashSet PassthroughOutputDeviceGuids { get; } = new HashSet(); + + #endregion + /// /// The latency to use when chaining input to outputs /// diff --git a/SoundBoard/MainWindow.xaml.cs b/SoundBoard/MainWindow.xaml.cs index c7213e9..9fefdb9 100644 --- a/SoundBoard/MainWindow.xaml.cs +++ b/SoundBoard/MainWindow.xaml.cs @@ -139,7 +139,7 @@ public MainWindow() DownloadIdentifier = "portable" }; - HandleInputOutputChange(); + HandleAudioPassthroughChange(); Tabs.SelectionChanged += (_, __) => { @@ -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)) { @@ -844,6 +855,7 @@ private void SaveSettings(string configFilePath) textWriter.WriteStartElement(nameof(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()); @@ -1217,10 +1229,10 @@ 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 @@ -1228,16 +1240,16 @@ private void overflow_Click(object sender, RoutedEventArgs e) 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(); @@ -1423,17 +1435,19 @@ 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 itemsToRemove = inputDeviceMenu.Items.OfType().ToList(); + // Use Control instead of MenuItem to capture the Separator. + List itemsToRemove = audioPassthroughMenu.Items.OfType().ToList(); + + #region Output // Create a menu item for each output device using (MMDeviceEnumerator deviceEnumerator = new MMDeviceEnumerator()) @@ -1441,37 +1455,75 @@ private void InputDeviceMenuOpened(object sender, RoutedEventArgs e) // 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().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); } } } @@ -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)) { @@ -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(); @@ -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 @@ -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); @@ -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 () => { @@ -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(); }); @@ -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) @@ -1911,7 +2020,7 @@ internal void OnAnySoundRenamed() private readonly Dictionary _tabContextMenus = new Dictionary(); private readonly WpfUpdateChecker _updateChecker; private MenuItem _newPageDefaultMenu; - private MenuItem _inputDeviceMenu; + private MenuItem _audioPassthroughMenu; private MenuItem _outputDeviceMenu; #endregion diff --git a/SoundBoard/Properties/Resources.Designer.cs b/SoundBoard/Properties/Resources.Designer.cs index 2711d1a..ea8b7ba 100644 --- a/SoundBoard/Properties/Resources.Designer.cs +++ b/SoundBoard/Properties/Resources.Designer.cs @@ -87,6 +87,15 @@ internal static string AllSoundsClearedFromTab { } } + /// + /// Looks up a localized string similar to Audio passthrough. + /// + internal static string AudioPassthrough { + get { + return ResourceManager.GetString("AudioPassthrough", resourceCulture); + } + } + /// /// Looks up a localized string similar to There was an error opening the input ({0}) or output ({1}) audio device for audio passthrough.. /// @@ -684,6 +693,15 @@ internal static string SoundBoardExplanation5 { } } + /// + /// Looks up a localized string similar to Sound output device. + /// + internal static string SoundOutputDevice { + get { + return ResourceManager.GetString("SoundOutputDevice", resourceCulture); + } + } + /// /// Looks up a localized string similar to This sound is set to loop. /// diff --git a/SoundBoard/Properties/Resources.resx b/SoundBoard/Properties/Resources.resx index 07151fb..de744a3 100644 --- a/SoundBoard/Properties/Resources.resx +++ b/SoundBoard/Properties/Resources.resx @@ -271,6 +271,12 @@ Input device + + Audio passthrough + + + Sound output device + Output device