From f8a42f2806d35511a9449dfbe2fa148db34ca459 Mon Sep 17 00:00:00 2001 From: Ednunp <29843396+Ednunp@users.noreply.github.com> Date: Sun, 12 Jul 2026 10:53:58 +0100 Subject: [PATCH] Per-app send: UI on the Input/Output tab, saved per profile Local checkpoint - NOT for public release. Builds on the engine core commit. Ed's revisions to the plan: the send-mode chooser lives on the Input/Output tab (not Preferences), right after "Send my audio", and the setting saves per profile. Input/Output tab: - New "How to send WASAPI audio" listbox (Alt+6) after the send checkbox, with two rows: send whole sound devices (classic) or send specific applications. Switching it swaps the tab live between the loopback-outputs list and the app section. Hidden entirely on Windows too old for process loopback (mode pinned to devices), so nothing changes for Win7. - Applications section: "Send all applications" master checkbox (Alt+7, ticked by default = whole system audio, same as today) and an "Applications to send" checked list (Alt+8) shown only when the master is unticked. - All house controls (AccessibleCheckBox, MnemonicLabel, WireCheckedListAccessibility), accessible names + Alt-shortcut suffixes, tab order slotted in. Behaviour: - App list reconciles on a 3s timer while visible: apps appear/disappear as they open and close, ticks preserved by process NAME, and a ticked app that closes stays in the list marked "(not running)" and resumes when it reappears. - ApplySendSources: devices mode unchanged; applications mode sends either the default-render loopback (send all) or one process-loopback spec per running process of each ticked app. WASAPI mics and ASIO run alongside in both modes. - Process-loopback sources are excluded from the single-source push-mode fast path (it opens an MMDevice; a "proc:" id has none) - they go via MixingEngine. - "Is anything being sent" status/tray gates account for applications mode. Persistence moved from AppConfig to Profile: WasapiSendMode / SendAllApplications / SelectedSendApplications. Profile round-trip self-test extended. Gate: 15/15. Co-Authored-By: Claude Opus 4.8 --- src/RemSound.App/MainForm.cs | 363 +++++++++++++++++- src/RemSound.App/SelfTest.cs | 10 + src/RemSound.Core/AppConfig.cs | 14 - src/RemSound.Core/Profile.cs | 20 + .../CompositeCaptureBackend.cs | 6 +- 5 files changed, 380 insertions(+), 33 deletions(-) diff --git a/src/RemSound.App/MainForm.cs b/src/RemSound.App/MainForm.cs index bcedf73..4fef095 100644 --- a/src/RemSound.App/MainForm.cs +++ b/src/RemSound.App/MainForm.cs @@ -87,6 +87,35 @@ public sealed class MainForm : Form private readonly Label sendOutputDevicesStatusLabel = new() { AutoSize = true, Text = "No output device selected." }; private readonly CheckedListBox sendInputDevicesList = new() { CheckOnClick = true, Width = 430, Height = 90 }; private readonly Label sendInputDevicesStatusLabel = new() { AutoSize = true, Text = "No input device selected." }; + // Per-application WASAPI send (issue #20). The WASAPI send side captures EITHER whole output + // devices (sendOutputDevicesList above — the classic behaviour) OR the audio of specific running + // applications, chosen by sendModeList on the Input/Output tab right after the "Send my audio" + // checkbox. Windows 10 build 19041+ only: on older Windows the chooser row is hidden and the mode + // is forced to "devices". The app list is tracked by process NAME (so a selection survives an app + // restart) and reconciled on sendAppsReconcileTimer so apps dropping in and out don't pile up. + // Unlike the device lists this selection IS persisted — per profile (WasapiSendMode / + // SendAllApplications / SelectedSendApplications) — matching how Ed wants a profile to remember its + // whole send setup. + private readonly ListBox sendModeList = new() { Width = 430, Height = 38, IntegralHeight = false }; + private MnemonicLabel? sendModeLabel; + private readonly AccessibleCheckBox sendAllApplicationsCheckbox = new() + { + Text = "Send all applications (Alt+&7)", + AccessibleName = "Send all applications", + AutoSize = true, + Checked = true, + }; + private readonly CheckedListBox sendAppsList = new() { CheckOnClick = true, Width = 430, Height = 90 }; + private readonly Label sendAppsStatusLabel = new() { AutoSize = true, Text = "No application selected." }; + private MnemonicLabel? sendAppsLabel; + private System.Windows.Forms.Timer? sendAppsReconcileTimer; + // Guards the sendModeList / sendAllApplicationsCheckbox / sendAppsList handlers while we + // programmatically repopulate them (mode switch, profile apply, reconcile) so those handlers + // don't fire MarkProfileDirty or trigger re-entrant rebuilds on our own writes. + private bool suppressSendAppEvents; + // The two sendModeList rows, in order. Index 0 = whole sound devices (classic), 1 = applications. + private const int SendModeDevicesIndex = 0; + private const int SendModeApplicationsIndex = 1; // ASIO-side lists. Always present in the form but hidden when ASIO is disabled. The two // lists are independent of the WASAPI ones — the user can tick any combination across all // five lists. Sender mixes WASAPI capture + ASIO capture into one outgoing stream; @@ -906,6 +935,11 @@ public sealed class MainForm : Form asioReceiveOutputDevicesStatusLabel.AccessibleName = "Selected ASIO receive channel status"; asioSendDevicesList.AccessibleName = "ASIO audio inputs to send (Alt+2)"; asioSendDevicesStatusLabel.AccessibleName = "Selected ASIO send channel status"; + // Per-application send controls (issue #20). Listbox + list carry the "(Alt+N)" suffix like the + // other non-CheckBox controls; the master checkbox owns its own &-mnemonic via its Text. + sendModeList.AccessibleName = "How to send WASAPI audio (Alt+6)"; + sendAppsList.AccessibleName = "Applications to send (Alt+8)"; + sendAppsStatusLabel.AccessibleName = "Selected application status"; // Keyboard shortcuts / Minimise to tray / Save / Save as buttons retired 2026-05-08 // (now File menu items in BuildFileMenu). asioDriverBox.AccessibleName = "ASIO driver (Alt+D)"; @@ -1360,6 +1394,7 @@ public sealed class MainForm : Form continuousTuneTimer.Stop(); continuousTuneTimer.Dispose(); updateCheckTimer.Stop(); updateCheckTimer.Dispose(); asioDriverChangeDebounce.Stop(); asioDriverChangeDebounce.Dispose(); + try { sendAppsReconcileTimer?.Stop(); sendAppsReconcileTimer?.Dispose(); } catch { } try { processSelfMeter.Dispose(); } catch { } try { deviceChangeNotifier?.Dispose(); } catch { } try { powerResumeHandler?.Dispose(); } catch { } @@ -3194,6 +3229,14 @@ public sealed class MainForm : Form if (list.Parent is not null) list.Parent.Visible = visible; // the FlowLayoutPanel wrapper } + /// Shows/hides a control together with its FlowLayoutPanel row wrapper so the whole + /// table row collapses (rather than leaving an empty gap). Used by the send-mode switch. + private static void SetRowControlVisible(Control control, bool visible) + { + control.Visible = visible; + if (control.Parent is not null) control.Parent.Visible = visible; + } + /// Audio I/O tab — full content. All the existing main-form audio controls /// (mode, ASIO driver, send/receive checkboxes, device lists, volume) live here. private void BuildAudioIOTab() @@ -3203,7 +3246,7 @@ public sealed class MainForm : Form Dock = DockStyle.Fill, Padding = new Padding(12), ColumnCount = 2, - RowCount = 11, + RowCount = 13, AutoScroll = true, }; panel.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize)); @@ -3255,13 +3298,247 @@ public sealed class MainForm : Form var sendCheckboxPanel = new FlowLayoutPanel { AutoSize = true, Dock = DockStyle.Fill }; sendCheckboxPanel.Controls.Add(sendMyAudioCheckbox); panel.Controls.Add(sendCheckboxPanel, 1, 6); - sendOutputDevicesLabel = FormLayoutRows.AddCheckedListRow(panel, 7, "WASAPI audio outputs to send (Alt+&4)", sendOutputDevicesList, sendOutputDevicesStatusLabel, FocusListControl); - sendInputDevicesLabel = FormLayoutRows.AddCheckedListRow(panel, 8, "WASAPI audio inputs to send (Alt+&5)", sendInputDevicesList, sendInputDevicesStatusLabel, FocusListControl); - asioSendDevicesLabel = FormLayoutRows.AddCheckedListRow(panel, 9, "ASIO audio inputs to send (Alt+&2)", asioSendDevicesList, asioSendDevicesStatusLabel, FocusListControl); + + // Row 7: "how to send WASAPI audio" chooser — sound devices (the classic loopback list) or + // specific applications. Sits right after "Send my audio". Built manually (like the ASIO + // driver row) so we keep the label reference to collapse the whole row on old Windows. + BuildSendModeRow(panel, 7); + + // Row 8 (devices mode): the classic WASAPI outputs-to-send loopback list. + sendOutputDevicesLabel = FormLayoutRows.AddCheckedListRow(panel, 8, "WASAPI audio outputs to send (Alt+&4)", sendOutputDevicesList, sendOutputDevicesStatusLabel, FocusListControl); + + // Rows 9-10 (applications mode): the "Send all applications" master checkbox and the app list. + var sendAllAppsPanel = new FlowLayoutPanel { AutoSize = true, Dock = DockStyle.Fill }; + sendAllAppsPanel.Controls.Add(sendAllApplicationsCheckbox); + panel.Controls.Add(sendAllAppsPanel, 1, 9); + sendAppsLabel = FormLayoutRows.AddCheckedListRow(panel, 10, "Applications to send (Alt+&8)", sendAppsList, sendAppsStatusLabel, FocusListControl); + + // Rows 11-12: the remaining send lists (unchanged, just shifted down two rows). + sendInputDevicesLabel = FormLayoutRows.AddCheckedListRow(panel, 11, "WASAPI audio inputs to send (Alt+&5)", sendInputDevicesList, sendInputDevicesStatusLabel, FocusListControl); + asioSendDevicesLabel = FormLayoutRows.AddCheckedListRow(panel, 12, "ASIO audio inputs to send (Alt+&2)", asioSendDevicesList, asioSendDevicesStatusLabel, FocusListControl); + + WireSendModeControls(); audioIOTabPage.Controls.Add(panel); } + /// Builds the "how to send WASAPI audio" chooser row: a two-item listbox (whole sound + /// devices vs specific applications) preceded by its mnemonic label. Built by hand rather than via + /// FormLayoutRows so we retain to collapse the whole row on Windows too + /// old for per-application capture. + private void BuildSendModeRow(TableLayoutPanel panel, int row) + { + sendModeLabel = new MnemonicLabel + { + Text = "How to send WASAPI audio (Alt+&6)", + AutoSize = true, + Anchor = AnchorStyles.Left, + MnemonicTarget = sendModeList, + }; + sendModeLabel.Click += (_, _) => FocusControl(sendModeList); + sendModeList.AccessibleName = "How to send WASAPI audio (Alt+6)"; + sendModeList.Items.Clear(); + sendModeList.Items.Add("Send whole sound devices"); // SendModeDevicesIndex + sendModeList.Items.Add("Send specific applications"); // SendModeApplicationsIndex + sendModeList.SelectedIndex = SendModeDevicesIndex; + panel.Controls.Add(sendModeLabel, 0, row); + var wrapper = new FlowLayoutPanel + { + AutoSize = true, + Dock = DockStyle.Fill, + FlowDirection = FlowDirection.TopDown, + WrapContents = false, + TabStop = false, + }; + wrapper.Controls.Add(sendModeList); + panel.Controls.Add(wrapper, 1, row); + } + + /// Wires the send-mode chooser, the "Send all applications" master checkbox and the app + /// list, sets up the reconcile timer, and applies the initial visibility. On Windows older than the + /// process-loopback API the whole applications path is hidden and the mode is pinned to devices. + private void WireSendModeControls() + { + WireCheckedListAccessibility(sendAppsList, sendAppsStatusLabel, "application"); + + sendModeList.SelectedIndexChanged += (_, _) => + { + if (suppressSendAppEvents) return; + ApplySendModeVisibility(); + if (sendModeList.SelectedIndex == SendModeApplicationsIndex) ReconcileSendAppsList(); + MarkProfileDirty(); + ApplySendSources(); + }; + + sendAllApplicationsCheckbox.CheckedChanged += (_, _) => + { + if (suppressSendAppEvents) return; + ApplySendModeVisibility(); + MarkProfileDirty(); + ApplySendSources(); + }; + + sendAppsList.ItemCheck += (_, args) => + { + if (suppressSendAppEvents) return; + // Defer to after the check state settles, then persist-dirty and re-apply the sources. + BeginInvoke(() => + { + if (suppressSendAppEvents) return; + MarkProfileDirty(); + ApplySendSources(); + }); + }; + + // Reconcile the app list on a slow timer so entries appear/disappear as apps open and close, + // without ever piling up (each pass releases every session object — see AudioAppEnumerator). + // Only ticks while the applications list is actually visible. + sendAppsReconcileTimer = new System.Windows.Forms.Timer { Interval = 3000 }; + sendAppsReconcileTimer.Tick += (_, _) => + { + if (sendModeList.SelectedIndex == SendModeApplicationsIndex && sendAppsList.Visible) + ReconcileSendAppsList(); + }; + + ApplySendModeVisibility(); + } + + /// Shows the device list in devices mode and the app checkbox + list in applications mode, + /// collapsing the other. On unsupported Windows the chooser itself is hidden and devices mode is + /// forced. Also starts/stops the reconcile timer with the applications view. + private void ApplySendModeVisibility() + { + var supported = ProcessLoopbackCapture.IsSupported; + + // The chooser row only makes sense where applications mode is possible. + if (sendModeLabel is not null) sendModeLabel.Visible = supported; + SetRowControlVisible(sendModeList, supported); + if (!supported && sendModeList.SelectedIndex != SendModeDevicesIndex) + { + suppressSendAppEvents = true; + sendModeList.SelectedIndex = SendModeDevicesIndex; + suppressSendAppEvents = false; + } + + var appsMode = supported && sendModeList.SelectedIndex == SendModeApplicationsIndex; + + // Devices mode → show the loopback outputs list; applications mode → hide it. + if (sendOutputDevicesLabel is not null) sendOutputDevicesLabel.Visible = !appsMode; + SetRowControlVisible(sendOutputDevicesList, !appsMode); + + // Applications mode → show the master checkbox; the app list (and its label) show only when + // "Send all applications" is unticked (with it ticked there's nothing to pick). + SetRowControlVisible(sendAllApplicationsCheckbox, appsMode); + var showAppList = appsMode && !sendAllApplicationsCheckbox.Checked; + if (sendAppsLabel is not null) sendAppsLabel.Visible = showAppList; + SetRowControlVisible(sendAppsList, showAppList); + + if (appsMode) + { + if (sendAppsList.Items.Count == 0) ReconcileSendAppsList(); + sendAppsReconcileTimer?.Start(); + } + else + { + sendAppsReconcileTimer?.Stop(); + } + } + + /// Refreshes from the current audio apps, preserving the user's + /// ticks by process NAME and keeping any ticked-but-not-currently-running apps in the list (so a + /// selection survives an app closing and reopening). Tagged items carry the process name; the + /// display shows the friendly name and a "(not running)" hint when the app isn't live. + private void ReconcileSendAppsList() + { + if (!ProcessLoopbackCapture.IsSupported) return; + + // Names the user currently has ticked (keep them even if the app has since closed). + var tickedNames = new HashSet( + sendAppsList.CheckedItems.OfType().Select(c => c.ProcessName), + StringComparer.OrdinalIgnoreCase); + + var running = AudioAppEnumerator.Snapshot(); + var runningNames = new HashSet(running.Select(a => a.ProcessName), StringComparer.OrdinalIgnoreCase); + + // Build the merged set: every running app, plus any ticked app that isn't running right now. + var choices = new List(); + foreach (var a in running) + choices.Add(new AudioAppChoice(a.ProcessName, a.DisplayName, running: true)); + foreach (var name in tickedNames) + if (!runningNames.Contains(name)) + choices.Add(new AudioAppChoice(name, name, running: false)); + choices.Sort((x, y) => string.Compare(x.ToString(), y.ToString(), StringComparison.CurrentCultureIgnoreCase)); + + suppressSendAppEvents = true; + try + { + sendAppsList.BeginUpdate(); + sendAppsList.Items.Clear(); + foreach (var c in choices) + { + var index = sendAppsList.Items.Add(c); + if (tickedNames.Contains(c.ProcessName)) sendAppsList.SetItemChecked(index, true); + } + sendAppsList.EndUpdate(); + } + finally + { + suppressSendAppEvents = false; + } + UpdateCheckedListStatus(sendAppsList, sendAppsStatusLabel, "application"); + } + + /// Restores the WASAPI send mode, the "Send all applications" master toggle and the ticked + /// app names from a loaded profile. Remembered apps that aren't running right now are seeded into the + /// list (ticked, marked "not running") so they resume capture the moment they reappear. On Windows + /// too old for process loopback the mode is forced back to devices. + private void RestoreSendModeFromProfile(Profile p) + { + suppressSendAppEvents = true; + try + { + var wantApps = ProcessLoopbackCapture.IsSupported + && string.Equals(p.WasapiSendMode, "applications", StringComparison.OrdinalIgnoreCase); + sendModeList.SelectedIndex = wantApps ? SendModeApplicationsIndex : SendModeDevicesIndex; + sendAllApplicationsCheckbox.Checked = p.SendAllApplications; + + sendAppsList.Items.Clear(); + foreach (var name in (p.SelectedSendApplications ?? new()).Distinct(StringComparer.OrdinalIgnoreCase)) + { + if (string.IsNullOrWhiteSpace(name)) continue; + var idx = sendAppsList.Items.Add(new AudioAppChoice(name, name, running: false)); + sendAppsList.SetItemChecked(idx, true); + } + } + finally + { + suppressSendAppEvents = false; + } + + ApplySendModeVisibility(); + if (sendModeList.SelectedIndex == SendModeApplicationsIndex) ReconcileSendAppsList(); + } + + /// The process names the user has ticked in the app list (lower-case, no extension). + private List CheckedSendApplicationNames() => + sendAppsList.CheckedItems.OfType().Select(c => c.ProcessName).Distinct().ToList(); + + /// One row in the "Applications to send" list. Identity is the process NAME; the display + /// adds a "(not running)" hint for a remembered-but-closed app. + private sealed class AudioAppChoice + { + public string ProcessName { get; } + private readonly string display; + private readonly bool running; + public AudioAppChoice(string processName, string displayName, bool running) + { + ProcessName = processName; + display = displayName; + this.running = running; + } + public override string ToString() => running ? display : $"{display} (not running)"; + } + /// Audio profile tab — split into two GroupBox sections so NVDA announces the /// section name when focus first crosses into it. Send-side group: codec, packet size, /// lock to audio clock. Receive-side group: latency + auto-tune controls, buffer @@ -4873,9 +5150,12 @@ public sealed class MainForm : Form asioReceiveOutputDevicesList.TabIndex = 3; volumeBar.TabIndex = 4; sendMyAudioCheckbox.TabIndex = 5; - sendOutputDevicesList.TabIndex = 6; - sendInputDevicesList.TabIndex = 7; - asioSendDevicesList.TabIndex = 8; + sendModeList.TabIndex = 6; // how-to-send chooser, right after "Send my audio" + sendOutputDevicesList.TabIndex = 7; // devices mode + sendAllApplicationsCheckbox.TabIndex = 8; // applications mode + sendAppsList.TabIndex = 9; // applications mode + sendInputDevicesList.TabIndex = 10; + asioSendDevicesList.TabIndex = 11; // Profiles & preferences tab retired 2026-05-08 — the controls that used to live // there have moved to the File menu (Open/Save/Save as/Rename/etc.) and the // Preferences dialog (Mute cues / Accept remote vol / Startup behaviour). @@ -5152,10 +5432,21 @@ public sealed class MainForm : Form } } + /// True when the WASAPI send side is in "specific applications" mode (and the OS supports + /// it). In that mode the loopback-outputs list is ignored in favour of the app selection. + private bool AppsModeActive() => + ProcessLoopbackCapture.IsSupported && sendModeList.SelectedIndex == SendModeApplicationsIndex; + + /// True when applications mode will actually send something: "send all applications" is on, + /// or at least one specific app is ticked. + private bool HasAppModeSend() => + AppsModeActive() && (sendAllApplicationsCheckbox.Checked || sendAppsList.CheckedItems.Count > 0); + private bool HasCheckedSendDevice() => - sendOutputDevicesList.CheckedItems.OfType().Any(c => c.DeviceId is not null) + (!AppsModeActive() && sendOutputDevicesList.CheckedItems.OfType().Any(c => c.DeviceId is not null)) || sendInputDevicesList.CheckedItems.OfType().Any(c => c.DeviceId is not null) - || asioSendDevicesList.CheckedItems.OfType().Any(c => c.DeviceId is not null); + || asioSendDevicesList.CheckedItems.OfType().Any(c => c.DeviceId is not null) + || HasAppModeSend(); private void ApplySendSources() { @@ -5165,20 +5456,49 @@ public sealed class MainForm : Form var specs = new List(); var addedLoopbackIds = new HashSet(StringComparer.OrdinalIgnoreCase); string? followedDefaultLoopback = null; - foreach (var item in sendOutputDevicesList.CheckedItems.OfType()) + var appsMode = ProcessLoopbackCapture.IsSupported && sendModeList.SelectedIndex == SendModeApplicationsIndex; + if (!appsMode) { - if (item.IsDefaultFollower) + // Devices mode (classic): loopback-capture whole output devices from the ticked list. + foreach (var item in sendOutputDevicesList.CheckedItems.OfType()) { - // Loopback-capture whatever Windows currently uses as the default OUTPUT, and follow it. - followedDefaultLoopback = ResolveDefaultDeviceId(NAudio.CoreAudioApi.DataFlow.Render); - if (!string.IsNullOrEmpty(followedDefaultLoopback) && addedLoopbackIds.Add(followedDefaultLoopback)) + if (item.IsDefaultFollower) { - specs.Add(new CaptureSourceSpec(followedDefaultLoopback, CaptureKind.Loopback, "Windows default audio device")); + // Loopback-capture whatever Windows currently uses as the default OUTPUT, and follow it. + followedDefaultLoopback = ResolveDefaultDeviceId(NAudio.CoreAudioApi.DataFlow.Render); + if (!string.IsNullOrEmpty(followedDefaultLoopback) && addedLoopbackIds.Add(followedDefaultLoopback)) + { + specs.Add(new CaptureSourceSpec(followedDefaultLoopback, CaptureKind.Loopback, "Windows default audio device")); + } + } + else if (item.DeviceId is { } id && addedLoopbackIds.Add(id)) + { + specs.Add(new CaptureSourceSpec(id, CaptureKind.Loopback, item.Name)); } } - else if (item.DeviceId is { } id && addedLoopbackIds.Add(id)) + } + else if (sendAllApplicationsCheckbox.Checked) + { + // Applications mode, "send all applications" = the same result as sending the whole + // system output: loopback the current default render device (and follow it if it changes). + followedDefaultLoopback = ResolveDefaultDeviceId(NAudio.CoreAudioApi.DataFlow.Render); + if (!string.IsNullOrEmpty(followedDefaultLoopback) && addedLoopbackIds.Add(followedDefaultLoopback)) { - specs.Add(new CaptureSourceSpec(id, CaptureKind.Loopback, item.Name)); + specs.Add(new CaptureSourceSpec(followedDefaultLoopback, CaptureKind.Loopback, "All applications (system audio)")); + } + } + else + { + // Applications mode, specific apps: one process-loopback spec per running process of each + // ticked app name. Apps not currently running contribute nothing until they reappear (the + // reconcile timer keeps the list fresh and re-applies). Child processes are captured too + // (the process-loopback include-tree mode), so a browser's audio renderers are covered. + foreach (var name in CheckedSendApplicationNames()) + { + foreach (var pid in AudioAppEnumerator.PidsForProcessName(name)) + { + specs.Add(new CaptureSourceSpec(ProcessLoopbackId.Format(pid), CaptureKind.ProcessLoopback, name)); + } } } lastFollowedDefaultLoopbackId = followedDefaultLoopback; @@ -7528,6 +7848,7 @@ public sealed class MainForm : Form ApplyTicksToList(sendOutputDevicesList, p.SelectedWasapiSendOutputs); ApplyTicksToList(sendInputDevicesList, p.SelectedWasapiSendInputs); ApplyTicksToList(asioSendDevicesList, p.SelectedAsioSendInputs); + RestoreSendModeFromProfile(p); receiveAudioCheckbox.Checked = p.ReceiveAudioOn; sendMyAudioCheckbox.Checked = p.SendAudioOn; @@ -7720,6 +8041,10 @@ public sealed class MainForm : Form profile.SelectedWasapiSendOutputs = ExtractCheckedDeviceIds(sendOutputDevicesList); profile.SelectedWasapiSendInputs = ExtractCheckedDeviceIds(sendInputDevicesList); profile.SelectedAsioSendInputs = ExtractCheckedDeviceIds(asioSendDevicesList); + // WASAPI send mode (whole devices vs specific applications) — persisted per profile. + profile.WasapiSendMode = sendModeList.SelectedIndex == SendModeApplicationsIndex ? "applications" : "devices"; + profile.SendAllApplications = sendAllApplicationsCheckbox.Checked; + profile.SelectedSendApplications = CheckedSendApplicationNames(); profile.SelectedConnectedPeers = GatherSelectedPeerEntries(); profile.EnableAllPeerShaping = enableAllPeerShapingBox.Checked; profile.PeerShaping = peerShaping; @@ -8236,7 +8561,9 @@ public sealed class MainForm : Form // "WASAPI" rather than "WASAPI + ASIO". if (sendMyAudioCheckbox.Checked) { - var hasWasapiSend = AnyChecked(sendInputDevicesList) || AnyChecked(sendOutputDevicesList); + var hasWasapiSend = AnyChecked(sendInputDevicesList) + || (!AppsModeActive() && AnyChecked(sendOutputDevicesList)) + || HasAppModeSend(); var hasAsioSend = AnyChecked(asioSendDevicesList); parts.Add($"sending ({DescribeLanes(hasWasapiSend, hasAsioSend)})"); } diff --git a/src/RemSound.App/SelfTest.cs b/src/RemSound.App/SelfTest.cs index c178f80..c806418 100644 --- a/src/RemSound.App/SelfTest.cs +++ b/src/RemSound.App/SelfTest.cs @@ -370,6 +370,11 @@ internal static class SelfTest p.AudioPort = 47830; p.AsioDriverName = "Some ASIO Driver"; p.SelectedWasapiSendInputs.Add("device-id-abc"); + // Per-application send mode (issue #20) is per-profile — round-trip it too. + p.WasapiSendMode = "applications"; + p.SendAllApplications = false; + p.SelectedSendApplications.Add("vlc"); + p.SelectedSendApplications.Add("firefox"); store.Save(p); var back = store.Load("selftest roundtrip"); @@ -381,6 +386,11 @@ internal static class SelfTest && back.AsioDriverName == "Some ASIO Driver" && back.SelectedWasapiSendInputs.Contains("device-id-abc"), "profile fields must survive a save/reload"); + Check(back.WasapiSendMode == "applications" + && !back.SendAllApplications + && back.SelectedSendApplications.Contains("vlc") + && back.SelectedSendApplications.Contains("firefox"), + "per-application send settings must survive a save/reload"); return null; } finally diff --git a/src/RemSound.Core/AppConfig.cs b/src/RemSound.Core/AppConfig.cs index 2bd0fea..0b4bfcd 100644 --- a/src/RemSound.Core/AppConfig.cs +++ b/src/RemSound.Core/AppConfig.cs @@ -230,20 +230,6 @@ public sealed class AppConfig /// device, following it if the default changes. Machine-wide, opt-in, default false. public bool UseDefaultLoopbackSend { get; set; } - /// How the WASAPI send side captures system audio: "devices" (the WASAPI outputs-to-send - /// loopback list — the classic behaviour, default) or "applications" (per-application process - /// loopback — Windows 10 2004+ only). Chosen in Preferences; the send tab shows the matching list. - /// ASIO capture runs alongside either way. - public string WasapiSendMode { get; set; } = "devices"; - /// In "applications" send mode: when true (the default) every app's audio is sent (i.e. the - /// whole system audio); when false only the apps in are sent. - /// Mirrors the "Send all applications" master checkbox. - public bool SendAllApplications { get; set; } = true; - /// In "applications" send mode with off: the process names - /// (lower-case, no path/extension, e.g. "vlc", "firefox") whose audio to send. Tracked by NAME not - /// PID so the selection survives an app restart. - public List SelectedSendApplications { get; set; } = new(); - /// Remembered answer to the "untick the other outputs/inputs when you turn on Use Windows /// default?" prompt. Null = ask each time; true = always untick; false = never untick. Set when the /// user ticks "Don't ask again" on that prompt. Machine-wide. diff --git a/src/RemSound.Core/Profile.cs b/src/RemSound.Core/Profile.cs index c3ab717..b3604e5 100644 --- a/src/RemSound.Core/Profile.cs +++ b/src/RemSound.Core/Profile.cs @@ -67,6 +67,26 @@ public sealed class Profile public List SelectedWasapiSendInputs { get; set; } = []; // microphones / line-ins public List SelectedAsioSendInputs { get; set; } = []; + // === WASAPI send mode: whole devices vs specific applications (issue #20) === + /// How the WASAPI send side captures system audio: "devices" (the classic + /// "WASAPI audio outputs to send" loopback list — default) or "applications" + /// (per-application process loopback — Windows 10 build 19041+ only). Chosen by the listbox on + /// the Input/Output tab, right after the send section; the tab shows the matching list. Saved per + /// profile so one setup can send whole devices and another send just a couple of apps. ASIO capture + /// runs alongside either way. On a machine too old for process loopback this is forced back to + /// "devices" on load. + public string WasapiSendMode { get; set; } = "devices"; + /// In "applications" send mode: when true (the default) every app's audio is sent — i.e. + /// exactly the same result as sending the whole system's default output. When false, only the apps + /// named in are sent. Mirrors the "Send all applications" + /// master checkbox. + public bool SendAllApplications { get; set; } = true; + /// In "applications" send mode with off: the process + /// names (lower-case, no path/extension, e.g. "vlc", "firefox") whose audio to send. Tracked by + /// NAME not PID so the selection survives an app restarting. Apps not currently running stay in the + /// list (remembered) and start being captured again the moment they reappear. + public List SelectedSendApplications { get; set; } = []; + // === Connectivity & transport === public int AudioPort { get; set; } = 47830; public int CodecRaw { get; set; } = (int)AudioTransportCodec.Pcm; diff --git a/src/RemSound.Sender/CompositeCaptureBackend.cs b/src/RemSound.Sender/CompositeCaptureBackend.cs index cc1d9e1..628af36 100644 --- a/src/RemSound.Sender/CompositeCaptureBackend.cs +++ b/src/RemSound.Sender/CompositeCaptureBackend.cs @@ -179,7 +179,11 @@ internal sealed class CompositeCaptureBackend : ICaptureBackend // (multi-source needs the rendezvous logic in MixingEngine). Applies equally in // WasapiOnly and BothIndependent — in either, the WASAPI lane is single-source // when the user has ticked one input. - var wantPushMode = useTightLatencyWasapi && wasapiSpecs.Count == 1; + // Process-loopback (per-application) sources are never eligible for push mode — that fast + // path opens an MMDevice by id, which a synthetic "proc:" id has no counterpart for. + // They always go through MixingEngine, which knows how to open a process-loopback capture. + var wantPushMode = useTightLatencyWasapi && wasapiSpecs.Count == 1 + && wasapiSpecs[0].Kind != CaptureKind.ProcessLoopback; var currentIsPush = wasapi is PushModeWasapiBackend; if (wantPushMode != currentIsPush) {