From 1003bbfdf207a5a495baebf8191b02218c6095cc Mon Sep 17 00:00:00 2001 From: Ednunp <29843396+Ednunp@users.noreply.github.com> Date: Thu, 16 Jul 2026 22:50:38 +0100 Subject: [PATCH] Per-app send fixes + apps-mode UI rework (no send-all) + truly-global remembered lists Per-app capture (the "foobar alone = no sound" report): - ProcessLoopbackCapture: take ActivateAudioInterfaceAsync's out operation as a raw IntPtr (released after completion) instead of a typed interface. The eager RCW cast of the not-yet-realised operation object threw InvalidCastException / E_NOINTERFACE and killed EVERY specific-app capture at start. - CompositeCaptureBackend: single IsPushEligible authority shared by Start, UpdateSources and the coalesced rebuild. The update paths were missing the ProcessLoopback exclusion, so switching whole-device -> one app kept the push backend and fed it "proc:" (GetDevice ArgumentException). - PushModeWasapiBackend: loud backstop rejecting process-loopback specs. - Self-test: lifecycle test now FAILS on an activation error (it previously passed green with the feature completely dead) + pure routing-rule checks. Apps-mode UI (Ed 2026-07-16): the "Send all applications" checkbox is GONE from the main window - applications mode always means picking specific apps; whole-system audio is devices mode's job. Active list = running apps + any ticked app that is not running ("(not running)" so it can be unticked); Remembered list = global address book minus whatever is ticked. In-place list reconcile (no Clear+rebuild) kills the NVDA double-read of the toggled row. Profile.SendAllApplications stays for the SERVICE (deliberate divergence - a headless lock-screen sender wants system audio). Remembered lists now genuinely machine-wide (AppConfig-backed): the settings store is an intra-process cache, so remembered applications were forgotten on every exit and remembered peers were per-profile in practice. Both books moved to AppConfig; legacy per-profile peers are unioned in on profile load; profile save snapshots the global book back for old-build compat. Cross-instance persistence pinned by self-test. Service (issue #23): 15s capture pulse (callbacks/bytes/pre-encode peak/frames sent) while sending - distinguishes "endpoint mix is genuinely silent at the lock screen" from a pipeline fault, which callbacks alone cannot. Gate: 38/38. Co-Authored-By: Claude Opus 4.8 --- src/RemSound.App/MainForm.cs | 236 +++++++++--------- src/RemSound.App/SelfTest.cs | 84 ++++++- src/RemSound.App/ServiceProfileDialog.cs | 3 + src/RemSound.App/ServiceSendHost.cs | 21 ++ src/RemSound.Core/AppConfig.cs | 13 + src/RemSound.Core/Profile.cs | 12 +- src/RemSound.Core/RemSoundSettingsStore.cs | 60 +++-- .../CompositeCaptureBackend.cs | 23 +- src/RemSound.Sender/ProcessLoopbackCapture.cs | 27 +- src/RemSound.Sender/PushModeWasapiBackend.cs | 8 + 10 files changed, 337 insertions(+), 150 deletions(-) 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) {