diff --git a/src/RemSound.App/MainForm.cs b/src/RemSound.App/MainForm.cs
index cff1b47..bfb7787 100644
--- a/src/RemSound.App/MainForm.cs
+++ b/src/RemSound.App/MainForm.cs
@@ -94,18 +94,15 @@ public sealed class MainForm : Form
// 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.
+ // SelectedSendApplications) — matching how Ed wants a profile to remember its whole send setup.
+ // There is deliberately NO "send all applications" option here (Ed removed it 2026-07-16): picking
+ // the applications mode means picking specific apps; whole-system audio is what devices mode is for.
+ // (The SERVICE still has its own send-all concept — a headless lock-screen sender wants system audio —
+ // so Profile.SendAllApplications stays for ServiceProfileDialog/ServiceSendHost.)
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,
- };
- // "Currently active applications" — the apps running right now (like Discovered peers).
+ // "Currently active applications" — the apps running right now (like Discovered peers), PLUS any
+ // ticked app that isn't running (shown "(not running)") so the user can always find and untick it.
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;
@@ -124,12 +121,7 @@ public sealed class MainForm : Form
// when the ticked apps' actual process ids change (an app opened or closed).
private RemSound.Sender.AudioSessionStartWatcher? sessionStartWatcher;
private string? lastSendAppPidSignature;
- // The active-apps list's FIRST ROW is a synthetic "Send all applications" toggle (Ed's design): ticked =
- // send the whole system, and the individual apps below collapse away; unticked reveals them. It's backed
- // by sendAllApplicationsCheckbox (kept as hidden state). This reserved process-name marks that row so the
- // handlers can tell it apart from a real app.
- private const string SendAllAppsSentinel = " __send_all_apps__";
- // Guards the sendModeList / sendAllApplicationsCheckbox / sendAppsList handlers while we
+ // Guards the sendModeList / 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;
@@ -3499,7 +3491,7 @@ public sealed class MainForm : Form
Dock = DockStyle.Fill,
Padding = new Padding(12),
ColumnCount = 2,
- RowCount = 14,
+ RowCount = 13,
AutoScroll = true,
};
panel.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize));
@@ -3560,17 +3552,16 @@ public sealed class MainForm : Form
// 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-11 (applications mode): the "Send all applications" master checkbox, then TWO app lists —
- // currently-active (running now) and remembered (the global address book), mirroring the peers lists.
- var sendAllAppsPanel = new FlowLayoutPanel { AutoSize = true, Dock = DockStyle.Fill };
- sendAllAppsPanel.Controls.Add(sendAllApplicationsCheckbox);
- panel.Controls.Add(sendAllAppsPanel, 1, 9);
- sendAppsLabel = FormLayoutRows.AddCheckedListRow(panel, 10, "Currently active applications (Alt+&8)", sendAppsList, sendAppsStatusLabel, FocusListControl);
- rememberedAppsLabel = FormLayoutRows.AddCheckedListRow(panel, 11, "Remembered applications (Alt+&9)", rememberedAppsList, rememberedAppsStatusLabel, FocusListControl);
+ // Rows 9-10 (applications mode): TWO app lists — currently-active (running now, plus any ticked
+ // app that isn't, so it can always be unticked) and remembered (the global address book, minus
+ // whatever is ticked), mirroring the peers lists. No "send all applications" toggle — picking this
+ // mode means picking specific apps; whole-system audio is devices mode's job (Ed, 2026-07-16).
+ sendAppsLabel = FormLayoutRows.AddCheckedListRow(panel, 9, "Currently active applications (Alt+&8)", sendAppsList, sendAppsStatusLabel, FocusListControl);
+ rememberedAppsLabel = FormLayoutRows.AddCheckedListRow(panel, 10, "Remembered applications (Alt+&9)", rememberedAppsList, rememberedAppsStatusLabel, FocusListControl);
- // Rows 12-13: the remaining send lists (shifted down one row for the remembered-apps list above).
- sendInputDevicesLabel = FormLayoutRows.AddCheckedListRow(panel, 12, "WASAPI audio inputs to send (Alt+&5)", sendInputDevicesList, sendInputDevicesStatusLabel, FocusListControl);
- asioSendDevicesLabel = FormLayoutRows.AddCheckedListRow(panel, 13, "ASIO audio inputs to send (Alt+&2)", asioSendDevicesList, asioSendDevicesStatusLabel, FocusListControl);
+ // Rows 11-12: the remaining send lists.
+ 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();
@@ -3609,9 +3600,9 @@ public sealed class MainForm : Form
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.
+ /// Wires the send-mode chooser and the two app lists, 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");
@@ -3626,14 +3617,6 @@ public sealed class MainForm : Form
ApplySendSources();
};
- sendAllApplicationsCheckbox.CheckedChanged += (_, _) =>
- {
- if (suppressSendAppEvents) return;
- ApplySendModeVisibility(); // re-renders the active list (show/hide apps under the "send all" row)
- MarkProfileDirty();
- ApplySendSources();
- };
-
// Both app lists toggle the SAME send set: ticking an app in either the active or the remembered
// list adds it; unticking removes it. The other list re-renders to match, exactly like the peers
// lists. Defer to after the check state settles.
@@ -3644,12 +3627,7 @@ public sealed class MainForm : Form
if (suppressSendAppEvents) return;
if (list.Items[args.Index] is not AudioAppChoice choice) return;
var nowChecked = args.NewValue == CheckState.Checked;
- // The "Send all applications" first row drives the backing checkbox (which re-applies the
- // mode and rebuilds the list to show/hide the individual apps); real rows toggle the send set.
- if (choice.ProcessName == SendAllAppsSentinel)
- BeginInvoke(() => sendAllApplicationsCheckbox.Checked = nowChecked);
- else
- BeginInvoke(() => OnSendAppToggled(choice.ProcessName, nowChecked));
+ BeginInvoke(() => OnSendAppToggled(choice.ProcessName, nowChecked));
};
}
WireAppList(sendAppsList);
@@ -3701,23 +3679,18 @@ public sealed class MainForm : Form
if (sendOutputDevicesLabel is not null) sendOutputDevicesLabel.Visible = !appsMode;
SetRowControlVisible(sendOutputDevicesList, !appsMode);
- // The "Send all applications" toggle is now the first ROW of the active list, so the standalone
- // checkbox is hidden (kept only as backing state). The active list (with that row on top) shows
- // whenever we're in applications mode. The remembered list only makes sense when picking specific
- // apps — i.e. when "send all" is off.
- SetRowControlVisible(sendAllApplicationsCheckbox, false);
+ // Applications mode shows both app lists (active + remembered); devices mode collapses them.
if (sendAppsLabel is not null) sendAppsLabel.Visible = appsMode;
SetRowControlVisible(sendAppsList, appsMode);
- var showRemembered = appsMode && !sendAllApplicationsCheckbox.Checked;
- if (rememberedAppsLabel is not null) rememberedAppsLabel.Visible = showRemembered;
- SetRowControlVisible(rememberedAppsList, showRemembered);
+ if (rememberedAppsLabel is not null) rememberedAppsLabel.Visible = appsMode;
+ SetRowControlVisible(rememberedAppsList, appsMode);
- // Populate the active list whenever it's on screen (so the "Send all applications" row — and the
- // apps beneath it, when send-all is off — are shown). Only the specific-apps case needs the ongoing
- // reconcile poll + the instant session-start watcher; "send all" just loopbacks the whole device.
- if (appsMode) ReconcileSendAppsList();
- if (appsMode && !sendAllApplicationsCheckbox.Checked)
+ // Populate the lists whenever they're on screen, and keep the reconcile poll + the instant
+ // session-start watcher running the whole time we're in applications mode — a ticked app must be
+ // caught from its very start even when the user is looking at another tab.
+ if (appsMode)
{
+ ReconcileSendAppsList();
sendAppsReconcileTimer?.Start();
EnsureSessionStartWatcher();
}
@@ -3749,10 +3722,12 @@ public sealed class MainForm : Form
ReconcileSendAppsList(); // re-render both lists to reflect the new shared selection
}
- /// Refreshes BOTH send-app lists from the current state: the "currently active" list = apps
- /// running right now; the "remembered" list = the global remembered-apps address book (plus anything in
- /// the active selection). Every item is ticked iff it's in , so the two
- /// lists stay in lock-step. A "(not running)" hint marks a remembered app that isn't live.
+ /// Refreshes BOTH send-app lists from the current state. Active list = apps running right
+ /// now PLUS every ticked app (a ticked app that closed stays visible, marked "(not running)", so the
+ /// user can always find and untick it). Remembered list = the global remembered-apps address book
+ /// MINUS whatever is ticked — ticking an app "moves" it to the active list; unticking drops it back
+ /// into remembered (Ed's design, 2026-07-16). Every item is ticked iff it's in
+ /// .
private void ReconcileSendAppsList()
{
if (!ProcessLoopbackCapture.IsSupported) return;
@@ -3760,30 +3735,31 @@ public sealed class MainForm : Form
var running = AudioAppEnumerator.Snapshot();
var runningNames = new HashSet(running.Select(a => a.ProcessName), StringComparer.OrdinalIgnoreCase);
- // Remembered = the global address book, unioned with the current selection (so a just-ticked app
- // always appears) — sorted by display name.
- var remembered = settings.LoadRememberedApplications()
- .Concat(selectedSendApps)
+ // Active = running apps (alphabetical), then any ticked app that ISN'T running appended after.
+ var activeChoices = running
+ .Select(a => new AudioAppChoice(a.ProcessName, a.DisplayName, running: true))
+ .ToList();
+ activeChoices.AddRange(selectedSendApps
+ .Where(n => !runningNames.Contains(n))
+ .OrderBy(n => n, StringComparer.CurrentCultureIgnoreCase)
+ .Select(n => new AudioAppChoice(n, n, running: false)));
+
+ // Remembered = the global address book minus the ticked apps, sorted by name.
+ var rememberedChoices = settings.LoadRememberedApplications()
+ .Where(n => !selectedSendApps.Contains(n))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(n => n, StringComparer.CurrentCultureIgnoreCase)
- .ToList();
-
- var sendAll = sendAllApplicationsCheckbox.Checked;
- // Active list: the "Send all applications" toggle is always the first row. Ticked, the individual
- // apps below collapse away (there's nothing to pick); unticked, the running apps appear under it.
- var activeChoices = new List { new(SendAllAppsSentinel, "Send all applications", running: true) };
- if (!sendAll)
- activeChoices.AddRange(running.Select(a => new AudioAppChoice(a.ProcessName, a.DisplayName, running: true)));
+ .Select(name =>
+ {
+ var live = running.FirstOrDefault(a => string.Equals(a.ProcessName, name, StringComparison.OrdinalIgnoreCase));
+ return new AudioAppChoice(name, live?.DisplayName ?? name, running: runningNames.Contains(name));
+ }).ToList();
suppressSendAppEvents = true;
try
{
- FillAppList(sendAppsList, activeChoices);
- FillAppList(rememberedAppsList, remembered.Select(name =>
- {
- var live = running.FirstOrDefault(a => string.Equals(a.ProcessName, name, StringComparison.OrdinalIgnoreCase));
- return new AudioAppChoice(name, live?.DisplayName ?? name, running: runningNames.Contains(name));
- }));
+ ReconcileAppListInPlace(sendAppsList, activeChoices);
+ ReconcileAppListInPlace(rememberedAppsList, rememberedChoices);
}
finally
{
@@ -3793,21 +3769,62 @@ public sealed class MainForm : Form
UpdateCheckedListStatus(rememberedAppsList, rememberedAppsStatusLabel, "remembered application");
}
- /// Rebuild a CheckedListBox from a set of app choices, ticking each one that's in the shared
- /// send set. Caller must hold .
- private void FillAppList(CheckedListBox list, IEnumerable choices)
+ /// Test seam: reconcile now and return the two send-app lists' rows — the active list's
+ /// process names, which of them are ticked, and the remembered list's process names. Lets the
+ /// self-test pin the list semantics (ticked apps leave Remembered; a ticked app that isn't running
+ /// still shows in Active so it can be unticked) against the REAL reconcile logic.
+ internal (string[] ActiveRows, string[] ActiveChecked, string[] RememberedRows) SnapshotAppListsForTest()
+ {
+ ReconcileSendAppsList();
+ return (
+ sendAppsList.Items.OfType().Select(c => c.ProcessName).ToArray(),
+ sendAppsList.CheckedItems.OfType().Select(c => c.ProcessName).ToArray(),
+ rememberedAppsList.Items.OfType().Select(c => c.ProcessName).ToArray());
+ }
+
+ /// Reconcile a CheckedListBox to exactly with MINIMAL mutation:
+ /// a row already showing the right app with the right tick state is left completely untouched, so the
+ /// row the user is sitting on is not destroyed and re-announced by NVDA when the reconcile timer fires
+ /// or a toggle rebuilds the list. Only differing rows are replaced, and only
+ /// surplus rows past the end are removed. This is what killed the "double read the top checkbox" glitch
+ /// — a full Clear()+re-add used to recreate the very row that had just been toggled. Every app row is
+ /// ticked from the shared send set. Caller must
+ /// hold (SetItemChecked would otherwise re-enter ItemCheck).
+ private void ReconcileAppListInPlace(CheckedListBox list, IReadOnlyList choices)
{
list.BeginUpdate();
- list.Items.Clear();
- foreach (var c in choices)
+ try
{
- var i = list.Items.Add(c);
- // The "Send all applications" sentinel row is ticked from the send-all state; every real app row
- // is ticked from the shared send set.
- var ticked = c.ProcessName == SendAllAppsSentinel ? sendAllApplicationsCheckbox.Checked : selectedSendApps.Contains(c.ProcessName);
- if (ticked) list.SetItemChecked(i, true);
+ // Trim rows that no longer have a counterpart (from the end, so surviving indices don't shift).
+ while (list.Items.Count > choices.Count) list.Items.RemoveAt(list.Items.Count - 1);
+
+ for (var i = 0; i < choices.Count; i++)
+ {
+ var c = choices[i];
+ var wantChecked = selectedSendApps.Contains(c.ProcessName);
+
+ if (i < list.Items.Count)
+ {
+ // Replace only when the row's identity or visible label actually changed — leaving a
+ // matching row in place is what prevents the spurious re-read.
+ var existing = list.Items[i] as AudioAppChoice;
+ var sameRow = existing is not null
+ && string.Equals(existing.ProcessName, c.ProcessName, StringComparison.OrdinalIgnoreCase)
+ && string.Equals(existing.ToString(), c.ToString(), StringComparison.Ordinal);
+ if (!sameRow) list.Items[i] = c;
+ if (list.GetItemChecked(i) != wantChecked) list.SetItemChecked(i, wantChecked);
+ }
+ else
+ {
+ var idx = list.Items.Add(c);
+ if (wantChecked) list.SetItemChecked(idx, true);
+ }
+ }
+ }
+ finally
+ {
+ list.EndUpdate();
}
- list.EndUpdate();
}
/// Re-resolve the ticked apps' current process ids and, if they changed (an app opened or
@@ -3819,7 +3836,7 @@ public sealed class MainForm : Form
{
if (!connected || suppressSendAppEvents) return;
if (!ProcessLoopbackCapture.IsSupported) return;
- if (sendModeList.SelectedIndex != SendModeApplicationsIndex || sendAllApplicationsCheckbox.Checked) return;
+ if (sendModeList.SelectedIndex != SendModeApplicationsIndex) return;
var sig = ComputeSendAppPidSignature(CheckedSendApplicationNames(), AudioAppEnumerator.PidsForProcessName);
if (sig == lastSendAppPidSignature) return;
lastSendAppPidSignature = sig;
@@ -3868,7 +3885,6 @@ public sealed class MainForm : Form
var wantApps = ProcessLoopbackCapture.IsSupported
&& string.Equals(p.WasapiSendMode, "applications", StringComparison.OrdinalIgnoreCase);
sendModeList.SelectedIndex = wantApps ? SendModeApplicationsIndex : SendModeDevicesIndex;
- sendAllApplicationsCheckbox.Checked = p.SendAllApplications;
// The profile's active apps become the shared send set; ReconcileSendAppsList (below) then
// renders both lists (active + remembered) from it.
@@ -5533,11 +5549,10 @@ public sealed class MainForm : Form
sendMyAudioCheckbox.TabIndex = 5;
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 — currently active
- rememberedAppsList.TabIndex = 10; // applications mode — remembered
- sendInputDevicesList.TabIndex = 11;
- asioSendDevicesList.TabIndex = 12;
+ sendAppsList.TabIndex = 8; // applications mode — currently active
+ rememberedAppsList.TabIndex = 9; // applications mode — remembered
+ 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).
@@ -5819,10 +5834,9 @@ public sealed class MainForm : Form
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.
+ /// True when applications mode will actually send something: at least one app is ticked.
private bool HasAppModeSend() =>
- AppsModeActive() && (sendAllApplicationsCheckbox.Checked || selectedSendApps.Count > 0);
+ AppsModeActive() && selectedSendApps.Count > 0;
private bool HasCheckedSendDevice() =>
(!AppsModeActive() && sendOutputDevicesList.CheckedItems.OfType().Any(c => c.DeviceId is not null))
@@ -5859,22 +5873,12 @@ public sealed class MainForm : Form
}
}
}
- 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(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.
+ // Applications mode: one process-loopback spec per running process of each ticked app name.
+ // Apps not currently running contribute nothing until they reappear (the reconcile timer and
+ // the session-start watcher keep the list fresh and re-apply). 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))
@@ -8437,7 +8441,9 @@ public sealed class MainForm : Form
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;
+ // SendAllApplications is deliberately NOT written here — the main window no longer has that
+ // concept (Ed removed it 2026-07-16). The field itself stays on Profile for the SERVICE, whose
+ // headless lock-screen use case is exactly whole-system audio.
profile.SelectedSendApplications = CheckedSendApplicationNames();
profile.SelectedConnectedPeers = GatherSelectedPeerEntries();
profile.EnableAllPeerShaping = enableAllPeerShapingBox.Checked;
diff --git a/src/RemSound.App/SelfTest.cs b/src/RemSound.App/SelfTest.cs
index 0a929b2..1b1ac1c 100644
--- a/src/RemSound.App/SelfTest.cs
+++ b/src/RemSound.App/SelfTest.cs
@@ -70,6 +70,7 @@ internal static class SelfTest
RunStep(results, "Service reachability-gated sending (drop dead peers, re-arm recovered)", ServiceReachabilityGating);
RunStep(results, "Send-app capture change-detection (catch an app the instant it opens)", SendAppCaptureChangeDetection);
RunStep(results, "Remembered applications list is global + clearable", RememberedApplicationsGlobal);
+ RunStep(results, "Send-app lists semantics (ticked → Active, out of Remembered)", SendAppListSemantics);
RunStep(results, "Service registration args", ServiceRegistrationArgs);
RunStep(results, "Recording engine (all formats + source gate + mono)", RecordingEngine);
RunStep(results, "Recording split tracks (per-peer + own)", RecordingSplitTracks);
@@ -303,6 +304,21 @@ internal static class SelfTest
var supported = RemSound.Sender.ProcessLoopbackCapture.IsSupported;
Check(supported == OperatingSystem.IsWindowsVersionAtLeast(10, 0, 19041),
"support gate disagrees with the OS build check");
+
+ // Push-mode routing rule: a single whole-device loopback source IS push-eligible under tight
+ // latency, but a single per-app process-loopback source must NEVER be — the push backend opens an
+ // MMDevice by id and a synthetic "proc:" id makes GetDevice throw ArgumentException. This is
+ // the regression guard for "I only heard foobar with 'all applications' ticked": switching from the
+ // whole-device spec to a per-app spec used to keep the push backend and feed it the proc id.
+ var oneLoopback = new[] { new CaptureSourceSpec("dev-x", CaptureKind.Loopback, "device") };
+ var oneProc = new[] { new CaptureSourceSpec(ProcessLoopbackId.Format(1234), CaptureKind.ProcessLoopback, "app") };
+ Check(RemSound.Sender.CompositeCaptureBackend.IsPushEligibleFor(oneLoopback, tightLatency: true),
+ "a single whole-device loopback source should be push-eligible under tight latency");
+ Check(!RemSound.Sender.CompositeCaptureBackend.IsPushEligibleFor(oneProc, tightLatency: true),
+ "a per-app process-loopback source must never be routed to the push backend");
+ Check(!RemSound.Sender.CompositeCaptureBackend.IsPushEligibleFor(oneLoopback, tightLatency: false),
+ "nothing is push-eligible when tight latency is off");
+
return $"enumerated {apps.Count} app(s); process-loopback supported={supported}";
}
@@ -322,13 +338,24 @@ internal static class SelfTest
{
var capture = new RemSound.Sender.ProcessLoopbackCapture(pid);
var frames = 0L;
+ Exception? stopError = null;
capture.DataAvailable += (_, e) => Interlocked.Add(ref frames, e.BytesRecorded);
+ capture.RecordingStopped += (_, e) => stopError = e.Exception;
capture.StartRecording();
Thread.Sleep(150); // let activation + the capture loop run and then be torn down
capture.Dispose(); // teardown while the capture thread is live — the crash scenario
+
+ // Activation MUST have succeeded. This is the regression guard for the E_NOINTERFACE cast on
+ // IActivateAudioInterfaceAsyncOperation that silently killed every per-app capture: it was
+ // caught and reported via RecordingStopped, so "no crash" alone passed green while the feature
+ // was completely dead. A clean process-loopback teardown carries no exception (a silent process
+ // still activates fine — it just yields silence). Anything surfaced here is a real activation
+ // failure, so fail the gate on it.
+ if (stopError is not null)
+ return $"process-loopback activation failed: {stopError.GetType().Name}: {stopError.Message}";
cycles++;
}
- return $"ran {cycles} start/stop/dispose cycles on pid {pid} with no crash";
+ return $"ran {cycles} start/stop/dispose cycles on pid {pid}; activation clean, no crash";
}
/// Soak test for runtime lifecycle transitions — the class of bug that hard-crashed when Ed
@@ -1196,7 +1223,6 @@ internal static class SelfTest
SendAudioOn = true,
EnableAllPeerShaping = true,
WasapiSendMode = "applications",
- SendAllApplications = false,
};
input.SelectedSendApplications.Add("vlc");
input.SelectedSendApplications.Add("firefox");
@@ -1212,7 +1238,8 @@ internal static class SelfTest
if (RemSound.Sender.ProcessLoopbackCapture.IsSupported)
{
Check(back.WasapiSendMode == "applications", $"send mode must round-trip (got {back.WasapiSendMode})");
- Check(!back.SendAllApplications, "the send-all-applications toggle must round-trip");
+ // No send-all check: the main window has no "send all applications" concept any more
+ // (removed 2026-07-16) — Profile.SendAllApplications is service-only and untouched here.
Check(back.SelectedSendApplications.Contains("vlc") && back.SelectedSendApplications.Contains("firefox"),
"the selected applications must round-trip through the app list");
covered += ", send-mode, apps";
@@ -1566,9 +1593,58 @@ internal static class SelfTest
Check(loaded.Count == 2, $"remembered apps must dedupe case-insensitively (got {loaded.Count})");
Check(loaded.All(a => a == a.ToLowerInvariant()), "remembered app names must be stored lower-case");
+ // Cross-instance read: the list must be MACHINE-WIDE (AppConfig-backed). The old backing was
+ // the per-instance in-memory settings cache, so a second instance — or the next launch of the
+ // app — saw an empty list and every remembered application was silently forgotten on exit.
+ var second = new RemSoundSettingsStore("RemSound");
+ Check(second.LoadRememberedApplications().Count == 2,
+ "a separate store instance must see the same remembered applications (machine-wide persistence)");
+
store.SaveRememberedApplications(Array.Empty());
Check(store.LoadRememberedApplications().Count == 0, "clearing must empty the remembered applications list");
- return "global remembered applications: round-trip, case-insensitive dedupe, and clear";
+ return "global remembered applications: round-trip, case-insensitive dedupe, cross-instance, and clear";
+ }
+ finally { store.SaveRememberedApplications(original); }
+ }
+
+ /// Pins the two-list semantics Ed specified 2026-07-16 (no send-all option): a TICKED app
+ /// must live in the Active list — even when it isn't running, marked "(not running)", so it can
+ /// always be found and unticked — and must NOT appear in Remembered; an UNTICKED remembered app
+ /// stays in Remembered. Runs the REAL reconcile against a headless main window, seeding the global
+ /// remembered store with fake names (restored afterwards) so nothing on the machine can interfere.
+ private static string? SendAppListSemantics()
+ {
+ if (!RemSound.Sender.ProcessLoopbackCapture.IsSupported)
+ return Skip("process loopback needs Windows 10 build 19041+");
+
+ const string ticked = "zzremsound_selftest_ticked"; // never a real process name
+ const string unticked = "zzremsound_selftest_unticked";
+ var store = new RemSoundSettingsStore("RemSound");
+ var original = store.LoadRememberedApplications().ToList();
+ try
+ {
+ store.SaveRememberedApplications(original.Concat(new[] { ticked, unticked }).ToList());
+
+ MainForm mf;
+ try { mf = new MainForm(null, RemSound.Core.Profile.NewBlank(), null, null, headless: true); }
+ catch (Exception ex) { return Skip($"headless MainForm could not be constructed: {ex.GetType().Name}: {ex.Message}"); }
+ using (mf)
+ {
+ var p = new Profile { Title = "app list semantics", WasapiSendMode = "applications" };
+ p.SelectedSendApplications.Add(ticked);
+ mf.ApplyThenCaptureForTest(p);
+
+ var (activeRows, activeChecked, rememberedRows) = mf.SnapshotAppListsForTest();
+ Check(activeRows.Contains(ticked, StringComparer.OrdinalIgnoreCase),
+ "a ticked app that isn't running must still appear in the Active list (so it can be unticked)");
+ Check(activeChecked.Contains(ticked, StringComparer.OrdinalIgnoreCase),
+ "the ticked app must actually be ticked in the Active list");
+ Check(!rememberedRows.Contains(ticked, StringComparer.OrdinalIgnoreCase),
+ "a ticked app must NOT appear in the Remembered list");
+ Check(rememberedRows.Contains(unticked, StringComparer.OrdinalIgnoreCase),
+ "an unticked remembered app must stay in the Remembered list");
+ }
+ return "ticked app: in Active (not running) + out of Remembered; unticked app: stays in Remembered";
}
finally { store.SaveRememberedApplications(original); }
}
diff --git a/src/RemSound.App/ServiceProfileDialog.cs b/src/RemSound.App/ServiceProfileDialog.cs
index c396a4c..99da079 100644
--- a/src/RemSound.App/ServiceProfileDialog.cs
+++ b/src/RemSound.App/ServiceProfileDialog.cs
@@ -27,6 +27,9 @@ internal sealed class ServiceProfileDialog : Form
private readonly ListBox sendModeList = new() { Width = 460, Height = 40, IntegralHeight = false, AccessibleName = "How to send WASAPI audio (Alt+1)" };
private readonly CheckedListBox outputsList = new() { CheckOnClick = true, Width = 460, Height = 110, AccessibleName = "WASAPI audio outputs to send (Alt+2)" };
private readonly Label outputsStatus = new() { AutoSize = true, Text = "No output device selected." };
+ // DELIBERATE divergence from the main window (which dropped its send-all option 2026-07-16): the
+ // headless service's whole point is streaming the machine's system audio from the lock screen, so
+ // "send all applications" stays here as the sensible default.
private readonly AccessibleCheckBox sendAllAppsBox = new() { Text = "Send all applications (Alt+&3)", AccessibleName = "Send all applications", AutoSize = true, Checked = true };
private readonly CheckedListBox appsList = new() { CheckOnClick = true, Width = 460, Height = 110, AccessibleName = "Applications to send (Alt+4)" };
private readonly Label appsStatus = new() { AutoSize = true, Text = "No application selected." };
diff --git a/src/RemSound.App/ServiceSendHost.cs b/src/RemSound.App/ServiceSendHost.cs
index 8f21d23..443e615 100644
--- a/src/RemSound.App/ServiceSendHost.cs
+++ b/src/RemSound.App/ServiceSendHost.cs
@@ -71,9 +71,29 @@ public sealed class ServiceSendHost : IDisposable
private long captureWatchStartTick;
private bool loggedFirstCallback;
private bool loggedZeroCallbacks;
+ private long lastCapturePulseTick;
+
+ // How often the periodic capture pulse is written while the service is sending. 15s keeps a
+ // boot-to-login window (typically 30s+) covered by at least two readings without bloating the log.
+ private const int CapturePulseIntervalMs = 15_000;
private void WatchCaptureHealth()
{
+ // Jonathan's follow-up log proved callbacks CAN flow at the lock screen while peers still hear
+ // nothing — callbacks alone can't distinguish real sound from the silence keepalive feeding back.
+ // So while sending, also pulse the loudest pre-encode sample + frames actually sent every 15s:
+ // peak≈0.000 pre-login flipping to real values at login = the captured endpoint mix is genuinely
+ // silent on the lock screen (device/session routing), NOT a service pipeline fault.
+ var now = Environment.TickCount64;
+ if (now - lastCapturePulseTick >= CapturePulseIntervalMs)
+ {
+ lastCapturePulseTick = now;
+ var peak = sender.TakeMaxSenderPreEncodePeak();
+ var frames = sender.TakeSenderAudioFramesSent();
+ log?.Invoke($"service: capture pulse — callbacks={sender.CaptureCallbacks} bytes={sender.CaptureBytes} peak={peak:F3} framesSent={frames}"
+ + (peak < 0.001f ? " (capturing SILENCE — nothing audible in the endpoint mix)" : ""));
+ }
+
if (loggedFirstCallback) return;
if (sender.CaptureCallbacks > 0)
{
@@ -139,6 +159,7 @@ public sealed class ServiceSendHost : IDisposable
captureWatchStartTick = Environment.TickCount64;
loggedFirstCallback = false;
loggedZeroCallbacks = false;
+ lastCapturePulseTick = Environment.TickCount64; // first pulse lands one interval after start
// Come up on the network too, so the peers can discover and connect to us — not just receive a
// blind push. Same well-known audio port and the same components the interactive app uses.
presence.Start(RemPacket.DefaultPort, endpoints);
diff --git a/src/RemSound.Core/AppConfig.cs b/src/RemSound.Core/AppConfig.cs
index 1f3fc5e..0924a36 100644
--- a/src/RemSound.Core/AppConfig.cs
+++ b/src/RemSound.Core/AppConfig.cs
@@ -96,6 +96,19 @@ public sealed class AppConfig
/// and the Options → Manage named peers dialog. Empty by default.
public Dictionary NamedPeers { get; set; } = new();
+ /// Machine-wide remembered PEER entries — ONE shared address book across all profiles
+ /// (Ed, 2026-07: both remembered lists live in global, not the profile). Before this the list rode
+ /// in each profile's JSON, so it was per-profile in practice; each old profile's legacy list is
+ /// unioned in here the first time it's opened (RemSoundSettingsStore.ApplyProfile). Null = none yet.
+ /// Cleared from Preferences → General.
+ public List? RememberedPeers { get; set; }
+
+ /// Machine-wide remembered APPLICATION process names (lower-case) — the shared "apps I
+ /// send" address book, companion to . Before 2026-07-16 this only
+ /// lived in the in-memory settings cache, which silently forgot the list on every app exit. Null =
+ /// none yet. Cleared from Preferences → General.
+ public List? RememberedApplications { get; set; }
+
/// Legacy flat name map (friendly name only). Kept so pre-registry configs still deserialise;
/// migrated into on load, then no longer written.
public Dictionary PeerFriendlyNames { get; set; } = new();
diff --git a/src/RemSound.Core/Profile.cs b/src/RemSound.Core/Profile.cs
index 01239c3..b3fee49 100644
--- a/src/RemSound.Core/Profile.cs
+++ b/src/RemSound.Core/Profile.cs
@@ -76,12 +76,14 @@ public sealed class Profile
/// 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.
+ /// SERVICE profiles only (ServiceProfileDialog / ServiceSendHost): when true (the default)
+ /// in "applications" send mode 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. The MAIN window no longer has a "send all
+ /// applications" option (removed 2026-07-16): there, applications mode always means specific ticked
+ /// apps, and whole-system audio is devices mode's job. The main window neither reads nor writes this.
public bool SendAllApplications { get; set; } = true;
- /// In "applications" send mode with off: the process
+ /// In "applications" send mode: 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.
diff --git a/src/RemSound.Core/RemSoundSettingsStore.cs b/src/RemSound.Core/RemSoundSettingsStore.cs
index 3a80501..e86ce56 100644
--- a/src/RemSound.Core/RemSoundSettingsStore.cs
+++ b/src/RemSound.Core/RemSoundSettingsStore.cs
@@ -206,38 +206,44 @@ public sealed class RemSoundSettingsStore
Save(s);
}
+ // Remembered peers + remembered applications are MACHINE-WIDE, backed by AppConfig (the same
+ // persistent per-machine file the global hotkeys use) — NOT the in-memory settings cache. They used
+ // to go through the cache: peers survived only because each profile's JSON carried a copy (making
+ // them per-profile in practice, against Ed's "both lists are global" ask), and applications didn't
+ // survive an app exit AT ALL — the cache is intra-process only. 2026-07-16 fix.
+
public IReadOnlyList LoadRememberedPeers() =>
- Try(() => Load()?.RememberedPeers?
+ Try(() => AppConfig.Load().RememberedPeers?
.Where(static value => !string.IsNullOrWhiteSpace(value))
.Distinct(StringComparer.OrdinalIgnoreCase).ToList())
?? [];
public void SaveRememberedPeers(IEnumerable peers)
{
- var s = Load() ?? new Settings();
- s.RememberedPeers = peers
+ var c = AppConfig.Load();
+ c.RememberedPeers = peers
.Where(static value => !string.IsNullOrWhiteSpace(value))
.Select(static value => value.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase).ToList();
- Save(s);
+ try { c.Save(); } catch { /* best-effort, like the app's other AppConfig writes */ }
}
/// GLOBAL remembered application names (lower-case process names) — the shared "apps I send"
/// list, machine-wide like , not per-profile.
public IReadOnlyList LoadRememberedApplications() =>
- Try(() => Load()?.RememberedApplications?
+ Try(() => AppConfig.Load().RememberedApplications?
.Where(static value => !string.IsNullOrWhiteSpace(value))
.Distinct(StringComparer.OrdinalIgnoreCase).ToList())
?? [];
public void SaveRememberedApplications(IEnumerable apps)
{
- var s = Load() ?? new Settings();
- s.RememberedApplications = apps
+ var c = AppConfig.Load();
+ c.RememberedApplications = apps
.Where(static value => !string.IsNullOrWhiteSpace(value))
.Select(static value => value.Trim().ToLowerInvariant())
.Distinct(StringComparer.OrdinalIgnoreCase).ToList();
- Save(s);
+ try { c.Save(); } catch { /* best-effort, like the app's other AppConfig writes */ }
}
// LoggingEnabled lives in AppConfig now — it's a machine-local debug knob, not a
@@ -564,7 +570,8 @@ public sealed class RemSoundSettingsStore
ContinuousAutoTuneIntervalSec = profile.ContinuousAutoTuneIntervalSec,
MaxLatencyMsAsio = profile.MaxLatencyMsAsio,
ContinuousAutoTuneAsioEnabled = profile.ContinuousAutoTuneAsioEnabled,
- RememberedPeers = profile.RememberedPeers is null ? null : new List(profile.RememberedPeers),
+ // RememberedPeers no longer rides the cache — it's machine-wide in AppConfig now; the
+ // profile's legacy copy is unioned in below (migration) instead of replacing anything.
AsioDriverName = profile.AsioDriverName,
// Profile.AudioModeRaw and Profile.BothModeWarningSuppressed are no longer carried
// through the settings cache. Both fields are retired (2026-05-07 / 2026-05-11);
@@ -590,6 +597,28 @@ public sealed class RemSoundSettingsStore
CustomCuePaths = profile.CustomCuePaths is null ? new() : new Dictionary(profile.CustomCuePaths),
RecordingSettings = profile.RecordingSettings?.Clone() ?? new RecordingSettings(),
};
+ MigrateRememberedPeersToGlobal(profile);
+ }
+
+ /// Migration for the peers list going machine-wide (2026-07-16): profiles written by older
+ /// builds carry their own remembered-peers list, so the first time each one is opened its entries
+ /// are UNIONED into the AppConfig book — nothing is lost, nothing is overwritten. Once the sets
+ /// match this is a no-op (no file write).
+ private static void MigrateRememberedPeersToGlobal(Profile profile)
+ {
+ if (profile.RememberedPeers is not { Count: > 0 } legacy) return;
+ try
+ {
+ var c = AppConfig.Load();
+ var current = c.RememberedPeers ?? [];
+ var merged = current
+ .Concat(legacy.Where(static v => !string.IsNullOrWhiteSpace(v)).Select(static v => v.Trim()))
+ .Distinct(StringComparer.OrdinalIgnoreCase).ToList();
+ if (merged.Count == current.Count) return; // nothing new — skip the write
+ c.RememberedPeers = merged;
+ c.Save();
+ }
+ catch { /* best-effort, like the app's other AppConfig writes */ }
}
/// Copies the current in-memory settings cache into a Profile. Note: this only
@@ -609,7 +638,10 @@ public sealed class RemSoundSettingsStore
if (s.ContinuousAutoTuneIntervalSec is int cai) profile.ContinuousAutoTuneIntervalSec = cai;
if (s.MaxLatencyMsAsio is int mla) profile.MaxLatencyMsAsio = mla;
if (s.ContinuousAutoTuneAsioEnabled is bool cata) profile.ContinuousAutoTuneAsioEnabled = cata;
- if (s.RememberedPeers is { } rp) profile.RememberedPeers = new List(rp);
+ // Write the CURRENT machine-wide peers book into the profile on save. The global AppConfig copy
+ // is the authority now; this snapshot just keeps older builds (which read the profile's list)
+ // working against the same profile file, and makes the load-time migration union a no-op.
+ profile.RememberedPeers = new List(LoadRememberedPeers());
profile.AsioDriverName = s.AsioDriverName;
// AudioMode and BothModeWarningSuppressed are not copied — both Profile fields were
// retired in the 2026-05-11 cleanup. Mode is derived from AsioDriverName and the
@@ -656,11 +688,9 @@ public sealed class RemSoundSettingsStore
// the user opt either lane in or out independently.
public int? MaxLatencyMsAsio { get; set; }
public bool? ContinuousAutoTuneAsioEnabled { get; set; }
- public List? RememberedPeers { get; set; }
- // GLOBAL (machine-wide) remembered application names, like RememberedPeers — deliberately NOT
- // per-profile and NOT copied into profile files (see ExportProfile/ImportProfile, which don't
- // touch it), so one shared "apps I send" address book serves every profile. 2026-07-15.
- public List? RememberedApplications { get; set; }
+ // RememberedPeers / RememberedApplications retired from this cache 2026-07-16 — both books are
+ // machine-wide in AppConfig now (this cache is intra-process only, so applications were being
+ // forgotten on every exit and peers were per-profile in practice).
public string? AsioDriverName { get; set; }
// AudioMode and BothModeWarningSuppressed both retired from this cache. Mode is
// derived from AsioDriverName via LoadAudioMode; the Both-mode warning popup is gone.
diff --git a/src/RemSound.Sender/CompositeCaptureBackend.cs b/src/RemSound.Sender/CompositeCaptureBackend.cs
index 628af36..8bf221c 100644
--- a/src/RemSound.Sender/CompositeCaptureBackend.cs
+++ b/src/RemSound.Sender/CompositeCaptureBackend.cs
@@ -166,6 +166,22 @@ internal sealed class CompositeCaptureBackend : ICaptureBackend
return w + a;
}
+ /// Whether the WASAPI lane should run the single-source push-mode fast path for this spec
+ /// set. Requires tight-latency mode and exactly one WASAPI source, and that source must NOT be a
+ /// process-loopback ("proc:<pid>") spec — push mode opens an MMDevice by id, which a synthetic
+ /// process id has no counterpart for (feeding one to throws
+ /// ArgumentException from MMDeviceEnumerator.GetDevice). Per-app sources always go through
+ /// MixingEngine. This is the single authority for the decision; Start, UpdateSources and the
+ /// coalesced rebuild all defer to it so they can never disagree about which backend a spec set needs.
+ private bool IsPushEligible(IReadOnlyList wasapiOnlySpecs) =>
+ IsPushEligibleFor(wasapiOnlySpecs, useTightLatencyWasapi);
+
+ /// Pure decision core (no instance state) so the self-test can pin the routing rule that a
+ /// per-app process-loopback source must never be handed to the push-mode backend. Exposed for testing.
+ internal static bool IsPushEligibleFor(IReadOnlyList wasapiOnlySpecs, bool tightLatency) =>
+ tightLatency && wasapiOnlySpecs.Count == 1
+ && wasapiOnlySpecs[0].Kind != CaptureKind.ProcessLoopback;
+
public void Start(IReadOnlyList specs)
{
lock (gate)
@@ -182,8 +198,7 @@ internal sealed class CompositeCaptureBackend : ICaptureBackend
// 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 wantPushMode = IsPushEligible(wasapiSpecs);
var currentIsPush = wasapi is PushModeWasapiBackend;
if (wantPushMode != currentIsPush)
{
@@ -232,7 +247,7 @@ internal sealed class CompositeCaptureBackend : ICaptureBackend
// burst of restarts. Coalesce instead: stash the target and (re)arm a short timer so a run
// of changes collapses into ONE rebuild to the final state. PushModeWasapiBackend still
// supports only one source; this just defers the swap, it doesn't change the end result.
- var wouldBePush = useTightLatencyWasapi && newWasapi.Count == 1;
+ var wouldBePush = IsPushEligible(newWasapi);
var isPush = wasapi is PushModeWasapiBackend;
if (wouldBePush != isPush)
{
@@ -274,7 +289,7 @@ internal sealed class CompositeCaptureBackend : ICaptureBackend
pendingRebuildSpecs = null;
if (specs is null || !started) return;
var (newWasapi, newAsio) = SplitSpecs(specs);
- var wouldBePush = useTightLatencyWasapi && newWasapi.Count == 1;
+ var wouldBePush = IsPushEligible(newWasapi);
var isPush = wasapi is PushModeWasapiBackend;
if (wouldBePush != isPush)
{
diff --git a/src/RemSound.Sender/ProcessLoopbackCapture.cs b/src/RemSound.Sender/ProcessLoopbackCapture.cs
index d4b63b1..d48b34a 100644
--- a/src/RemSound.Sender/ProcessLoopbackCapture.cs
+++ b/src/RemSound.Sender/ProcessLoopbackCapture.cs
@@ -140,14 +140,27 @@ public sealed class ProcessLoopbackCapture : IWaveIn
var handler = new ActivationHandler();
var iidAudioClient = typeof(IAudioClient).GUID;
- var hr = ActivateAudioInterfaceAsync(VirtualDevicePath, ref iidAudioClient, ref propVariant, handler, out _);
- if (hr != 0) Marshal.ThrowExceptionForHR(hr);
+ // The out operation is taken as a raw pointer, NOT the typed interface: the eager RCW cast of a
+ // context-bound operation object is exactly what threw InvalidCastException / E_NOINTERFACE in
+ // the field and killed every specific-app capture ("foobar2000 sends no audio"). We don't need
+ // the operation object — the completion handler carries the result — so just hold and release
+ // the reference.
+ var opPtr = IntPtr.Zero;
+ try
+ {
+ var hr = ActivateAudioInterfaceAsync(VirtualDevicePath, ref iidAudioClient, ref propVariant, handler, out opPtr);
+ if (hr != 0) Marshal.ThrowExceptionForHR(hr);
- if (!handler.Completed.WaitOne(3000))
- throw new TimeoutException("Process-loopback activation timed out.");
- if (handler.ActivateResult != 0) Marshal.ThrowExceptionForHR(handler.ActivateResult);
+ if (!handler.Completed.WaitOne(3000))
+ throw new TimeoutException("Process-loopback activation timed out.");
+ if (handler.ActivateResult != 0) Marshal.ThrowExceptionForHR(handler.ActivateResult);
- audioClient = (IAudioClient)handler.Interface!;
+ audioClient = (IAudioClient)handler.Interface!;
+ }
+ finally
+ {
+ if (opPtr != IntPtr.Zero) Marshal.Release(opPtr);
+ }
}
finally
{
@@ -297,7 +310,7 @@ public sealed class ProcessLoopbackCapture : IWaveIn
ref Guid riid,
ref PROPVARIANT activationParams,
IActivateAudioInterfaceCompletionHandler completionHandler,
- out IActivateAudioInterfaceAsyncOperation activationOperation);
+ out IntPtr activationOperation);
private enum AUDIOCLIENT_ACTIVATION_TYPE { DEFAULT = 0, PROCESS_LOOPBACK = 1 }
diff --git a/src/RemSound.Sender/PushModeWasapiBackend.cs b/src/RemSound.Sender/PushModeWasapiBackend.cs
index c134918..b2ea04e 100644
--- a/src/RemSound.Sender/PushModeWasapiBackend.cs
+++ b/src/RemSound.Sender/PushModeWasapiBackend.cs
@@ -132,6 +132,14 @@ internal sealed class PushModeWasapiBackend : ICaptureBackend
throw new InvalidOperationException(
$"PushModeWasapiBackend supports only one source, got {specs.Count}. Caller must fall back to MixingEngine for multi-source.");
}
+ if (specs[0].Kind == CaptureKind.ProcessLoopback)
+ {
+ // Per-app process-loopback has no MMDevice to open — it must go through MixingEngine.
+ // CompositeCaptureBackend.IsPushEligible already excludes these, but backstop it here so a
+ // routing slip degrades to a clear diagnostic instead of GetDevice's opaque ArgumentException.
+ throw new InvalidOperationException(
+ $"PushModeWasapiBackend cannot capture a process-loopback source (\"{specs[0].Name}\"). Caller must route per-app sources to MixingEngine.");
+ }
lock (gate)
{