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:<pid>" (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 <noreply@anthropic.com>
This commit is contained in:
Ednunp
2026-07-16 22:50:38 +01:00
co-authored by Claude Opus 4.8
parent 92ad77ff52
commit 1003bbfdf2
10 changed files with 337 additions and 150 deletions
+121 -115
View File
@@ -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);
}
/// <summary>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.</summary>
/// <summary>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.</summary>
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
}
/// <summary>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 <see cref="selectedSendApps"/>, so the two
/// lists stay in lock-step. A "(not running)" hint marks a remembered app that isn't live.</summary>
/// <summary>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
/// <see cref="selectedSendApps"/>.</summary>
private void ReconcileSendAppsList()
{
if (!ProcessLoopbackCapture.IsSupported) return;
@@ -3760,30 +3735,31 @@ public sealed class MainForm : Form
var running = AudioAppEnumerator.Snapshot();
var runningNames = new HashSet<string>(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<AudioAppChoice> { 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");
}
/// <summary>Rebuild a CheckedListBox from a set of app choices, ticking each one that's in the shared
/// send set. Caller must hold <see cref="suppressSendAppEvents"/>.</summary>
private void FillAppList(CheckedListBox list, IEnumerable<AudioAppChoice> choices)
/// <summary>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.</summary>
internal (string[] ActiveRows, string[] ActiveChecked, string[] RememberedRows) SnapshotAppListsForTest()
{
ReconcileSendAppsList();
return (
sendAppsList.Items.OfType<AudioAppChoice>().Select(c => c.ProcessName).ToArray(),
sendAppsList.CheckedItems.OfType<AudioAppChoice>().Select(c => c.ProcessName).ToArray(),
rememberedAppsList.Items.OfType<AudioAppChoice>().Select(c => c.ProcessName).ToArray());
}
/// <summary>Reconcile a CheckedListBox to exactly <paramref name="choices"/> 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 <see cref="suppressSendAppEvents"/> (SetItemChecked would otherwise re-enter ItemCheck).</summary>
private void ReconcileAppListInPlace(CheckedListBox list, IReadOnlyList<AudioAppChoice> 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();
}
/// <summary>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;
/// <summary>True when applications mode will actually send something: "send all applications" is on,
/// or at least one specific app is ticked.</summary>
/// <summary>True when applications mode will actually send something: at least one app is ticked.</summary>
private bool HasAppModeSend() =>
AppsModeActive() && (sendAllApplicationsCheckbox.Checked || selectedSendApps.Count > 0);
AppsModeActive() && selectedSendApps.Count > 0;
private bool HasCheckedSendDevice() =>
(!AppsModeActive() && sendOutputDevicesList.CheckedItems.OfType<AudioDeviceChoice>().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;
+80 -4
View File
@@ -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:<pid>" 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";
}
/// <summary>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<string>());
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); }
}
/// <summary>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.</summary>
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); }
}
+3
View File
@@ -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." };
+21
View File
@@ -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);
+13
View File
@@ -96,6 +96,19 @@ public sealed class AppConfig
/// and the Options → Manage named peers dialog. Empty by default.</summary>
public Dictionary<string, NamedPeer> NamedPeers { get; set; } = new();
/// <summary>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.</summary>
public List<string>? RememberedPeers { get; set; }
/// <summary>Machine-wide remembered APPLICATION process names (lower-case) — the shared "apps I
/// send" address book, companion to <see cref="RememberedPeers"/>. 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.</summary>
public List<string>? RememberedApplications { get; set; }
/// <summary>Legacy flat name map (friendly name only). Kept so pre-registry configs still deserialise;
/// migrated into <see cref="NamedPeers"/> on load, then no longer written.</summary>
public Dictionary<string, string> PeerFriendlyNames { get; set; } = new();
+7 -5
View File
@@ -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.</summary>
public string WasapiSendMode { get; set; } = "devices";
/// <summary>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 <see cref="SelectedSendApplications"/> are sent. Mirrors the "Send all applications"
/// master checkbox.</summary>
/// <summary>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
/// <see cref="SelectedSendApplications"/> 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.</summary>
public bool SendAllApplications { get; set; } = true;
/// <summary>In "applications" send mode with <see cref="SendAllApplications"/> off: the process
/// <summary>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.</summary>
+45 -15
View File
@@ -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<string> LoadRememberedPeers() =>
Try(() => Load()?.RememberedPeers?
Try(() => AppConfig.Load().RememberedPeers?
.Where(static value => !string.IsNullOrWhiteSpace(value))
.Distinct(StringComparer.OrdinalIgnoreCase).ToList())
?? [];
public void SaveRememberedPeers(IEnumerable<string> 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 */ }
}
/// <summary>GLOBAL remembered application names (lower-case process names) — the shared "apps I send"
/// list, machine-wide like <see cref="LoadRememberedPeers"/>, not per-profile.</summary>
public IReadOnlyList<string> LoadRememberedApplications() =>
Try(() => Load()?.RememberedApplications?
Try(() => AppConfig.Load().RememberedApplications?
.Where(static value => !string.IsNullOrWhiteSpace(value))
.Distinct(StringComparer.OrdinalIgnoreCase).ToList())
?? [];
public void SaveRememberedApplications(IEnumerable<string> 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<string>(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<string, string>(profile.CustomCuePaths),
RecordingSettings = profile.RecordingSettings?.Clone() ?? new RecordingSettings(),
};
MigrateRememberedPeersToGlobal(profile);
}
/// <summary>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).</summary>
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 */ }
}
/// <summary>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<string>(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<string>(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<string>? 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<string>? 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.
+19 -4
View File
@@ -166,6 +166,22 @@ internal sealed class CompositeCaptureBackend : ICaptureBackend
return w + a;
}
/// <summary>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:&lt;pid&gt;") spec — push mode opens an MMDevice by id, which a synthetic
/// process id has no counterpart for (feeding one to <see cref="PushModeWasapiBackend"/> 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.</summary>
private bool IsPushEligible(IReadOnlyList<CaptureSourceSpec> wasapiOnlySpecs) =>
IsPushEligibleFor(wasapiOnlySpecs, useTightLatencyWasapi);
/// <summary>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.</summary>
internal static bool IsPushEligibleFor(IReadOnlyList<CaptureSourceSpec> wasapiOnlySpecs, bool tightLatency) =>
tightLatency && wasapiOnlySpecs.Count == 1
&& wasapiOnlySpecs[0].Kind != CaptureKind.ProcessLoopback;
public void Start(IReadOnlyList<CaptureSourceSpec> 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:<pid>" 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)
{
+20 -7
View File
@@ -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 }
@@ -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)
{