fix(windows): list all user-session apps in the app-audio picker

The picker gated its list on visible-window processes plus audio sessions
on only the default render device. That both showed non-audio apps (any
window) and missed real ones (windowless or routed to a secondary device).
Process loopback targets a PID and its child tree regardless of whether the
app is currently playing, so the gate fought the capture layer.

Now enumerate every process in the user's interactive session (windowed or
not), deduped by executable with the windowed tree-root as the capture PID;
scan all active render endpoints to flag currently-playing apps with a > and
sort them first. Adds a filter box and persists checks across filtering.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-24 00:24:20 +02:00
parent 04bdb70d47
commit 47124b15a2
2 changed files with 233 additions and 124 deletions

View File

@@ -13,87 +13,154 @@ public sealed record EntireDesktop(bool ExcludeSelf = false) : AppAudioScope;
public sealed record OnlyApps(IReadOnlyList<int> Pids, IReadOnlyList<string> Names) : AppAudioScope; public sealed record OnlyApps(IReadOnlyList<int> Pids, IReadOnlyList<string> Names) : AppAudioScope;
public sealed record AllExceptApps(IReadOnlyList<int> Pids, IReadOnlyList<string> Names) : AppAudioScope; public sealed record AllExceptApps(IReadOnlyList<int> Pids, IReadOnlyList<string> Names) : AppAudioScope;
public sealed record AudioAppInfo(int Pid, string DisplayName); // IsPlaying = a process in this app's executable group currently has an *active* audio
// session on some render endpoint. Purely informational for the picker — capture works
// on any PID regardless (process-loopback yields silence until the app plays).
public sealed record AudioAppInfo(int Pid, string DisplayName, bool IsPlaying);
// ── Enumerator ───────────────────────────────────────────────────────────────── // ── Enumerator ─────────────────────────────────────────────────────────────────
public static class AudioSessionEnumerator public static class AudioSessionEnumerator
{ {
// Returns all user-facing apps: visible-window processes (primary, like macOS // Returns every app in the user's interactive session — windowed or not — so any of
// SCShareableContent.current) plus any background audio-session-only processes // them can be picked for capture even before it starts producing audio. Process
// (e.g. Spotify in mini-player). Excludes VoiceCat itself and system processes. // loopback (see ProcessLoopbackCapture) targets a PID and its child tree, so a silent
// selection simply starts working the moment that app plays.
//
// Apps are deduped by executable (multiple PIDs of the same program collapse to one
// row whose PID is the process-tree root). Session-0 services and VoiceCat itself are
// excluded. Apps currently producing audio are flagged IsPlaying and sorted first.
public static IReadOnlyList<AudioAppInfo> GetAudioApps() public static IReadOnlyList<AudioAppInfo> GetAudioApps()
{ {
var seen = new HashSet<int>();
var result = new List<AudioAppInfo>();
int selfPid = Environment.ProcessId; int selfPid = Environment.ProcessId;
int sessionId = SafeCurrentSessionId();
var playingPids = GetActiveAudioPids();
// ── 1. Visible-window processes (EnumWindows) ───────────────────────── // Group by executable name; keep the best representative PID per group.
// Same set macOS ScreenCaptureKit exposes: all apps with at least one var groups = new Dictionary<string, AppGroup>(StringComparer.OrdinalIgnoreCase);
// visible top-level window. Shows apps even when not currently producing audio.
EnumWindows((hWnd, _) => foreach (var proc in Process.GetProcesses())
{ {
if (!IsWindowVisible(hWnd)) return true;
GetWindowThreadProcessId(hWnd, out uint pid);
if (pid == 0 || pid == (uint)selfPid || !seen.Add((int)pid)) return true;
try try
{ {
var proc = Process.GetProcessById((int)pid); if (proc.Id == selfPid) continue;
string name = proc.MainWindowTitle.Length > 0 if (proc.SessionId != sessionId) continue; // drop session-0 services
? $"{proc.ProcessName} — {proc.MainWindowTitle}" string name = proc.ProcessName;
: proc.ProcessName; if (string.IsNullOrEmpty(name)) continue;
if (!string.IsNullOrEmpty(proc.ProcessName))
result.Add(new AudioAppInfo((int)pid, name));
}
catch { /* process exited between EnumWindows and GetProcessById */ }
return true; // continue enumeration bool hasWindow = proc.MainWindowHandle != IntPtr.Zero;
}, IntPtr.Zero); bool playing = playingPids.Contains(proc.Id);
string title = hasWindow ? SafeWindowTitle(proc) : "";
string display = title.Length > 0 ? $"{name} — {title}" : name;
// ── 2. Background audio-session processes (WASAPI, supplement) ──────── if (groups.TryGetValue(name, out var g))
// Catches apps that produce audio but have no visible window (screen reader,
// background music player, etc.). Silently skipped if WASAPI is unavailable.
AppendAudioSessionApps(seen, selfPid, result);
return result.OrderBy(a => a.DisplayName, StringComparer.OrdinalIgnoreCase).ToList();
}
// ── EnumWindows P/Invoke ──────────────────────────────────────────────────
private delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam);
[DllImport("user32.dll")]
private static extern bool EnumWindows(EnumWindowsProc lpEnumFunc, IntPtr lParam);
[DllImport("user32.dll")]
private static extern bool IsWindowVisible(IntPtr hWnd);
[DllImport("user32.dll")]
private static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId);
// ── WASAPI audio session supplement ──────────────────────────────────────
private static void AppendAudioSessionApps(HashSet<int> seen, int selfPid,
List<AudioAppInfo> result)
{ {
g.AnyPlaying |= playing;
// Prefer a windowed PID (the process-tree root) as the capture target;
// among non-windowed, prefer a currently-playing PID.
bool better = (hasWindow && !g.HasWindow)
|| (!g.HasWindow && !hasWindow && playing && !g.RepPlaying);
if (better)
{
g.Pid = proc.Id;
g.Display = display;
g.HasWindow = hasWindow;
g.RepPlaying = playing;
}
}
else
{
groups[name] = new AppGroup
{
Pid = proc.Id,
Display = display,
HasWindow = hasWindow,
RepPlaying = playing,
AnyPlaying = playing,
};
}
}
catch { /* protected/exited process — skip */ }
finally { proc.Dispose(); }
}
return groups.Values
.Select(g => new AudioAppInfo(g.Pid, g.Display, g.AnyPlaying))
.OrderByDescending(a => a.IsPlaying)
.ThenBy(a => a.DisplayName, StringComparer.OrdinalIgnoreCase)
.ToList();
}
private sealed class AppGroup
{
public int Pid;
public string Display = "";
public bool HasWindow;
public bool RepPlaying; // representative PID is playing
public bool AnyPlaying; // any PID in the group is playing
}
private static int SafeCurrentSessionId()
{
try { using var me = Process.GetCurrentProcess(); return me.SessionId; }
catch { return 1; } // typical interactive session fallback
}
private static string SafeWindowTitle(Process proc)
{
try { return proc.MainWindowTitle; }
catch { return ""; }
}
// ── WASAPI: PIDs with an active render session (any endpoint) ─────────────
// Scans ALL active render endpoints, not just the default — an app routed to a
// secondary device still counts as "playing now".
private static HashSet<int> GetActiveAudioPids()
{
var pids = new HashSet<int>();
IMMDeviceEnumerator? enumerator = null; IMMDeviceEnumerator? enumerator = null;
IMMDevice? device = null; IMMDeviceCollection? devices = null;
IAudioSessionManager2? manager = null;
IAudioSessionEnumerator? sessions = null;
try try
{ {
enumerator = (IMMDeviceEnumerator)Activator.CreateInstance( enumerator = (IMMDeviceEnumerator)Activator.CreateInstance(
Type.GetTypeFromCLSID(new Guid("BCDE0395-E52F-467C-8E3D-C4579291692E"))!)!; Type.GetTypeFromCLSID(new Guid("BCDE0395-E52F-467C-8E3D-C4579291692E"))!)!;
enumerator.GetDefaultAudioEndpoint(0 /*eRender*/, 1 /*eMultimedia*/, out device); if (enumerator.EnumAudioEndpoints(0 /*eRender*/, 0x1 /*DEVICE_STATE_ACTIVE*/,
out devices) < 0 || devices == null)
return pids;
devices.GetCount(out int devCount);
for (int d = 0; d < devCount; d++)
CollectActivePids(devices, d, pids);
}
catch { /* no audio device or WASAPI unavailable — ignore */ }
finally
{
if (devices != null) Marshal.ReleaseComObject(devices);
if (enumerator != null) Marshal.ReleaseComObject(enumerator);
}
return pids;
}
private static void CollectActivePids(IMMDeviceCollection devices, int index, HashSet<int> pids)
{
IMMDevice? device = null;
IAudioSessionManager2? manager = null;
IAudioSessionEnumerator? sessions = null;
try
{
if (devices.Item(index, out device) < 0 || device == null) return;
var mgr2Iid = new Guid("77AA99A0-1BD6-484F-8BC7-2C654C9A9B6F"); var mgr2Iid = new Guid("77AA99A0-1BD6-484F-8BC7-2C654C9A9B6F");
device.Activate(ref mgr2Iid, 0x17 /*CLSCTX_ALL*/, IntPtr.Zero, out object mgr); if (device.Activate(ref mgr2Iid, 0x17 /*CLSCTX_ALL*/, IntPtr.Zero, out object mgr) < 0)
return;
manager = (IAudioSessionManager2)mgr; manager = (IAudioSessionManager2)mgr;
manager.GetSessionEnumerator(out sessions); if (manager.GetSessionEnumerator(out sessions) < 0 || sessions == null) return;
sessions.GetCount(out int count); sessions.GetCount(out int count);
for (int i = 0; i < count; i++) for (int i = 0; i < count; i++)
@@ -101,32 +168,24 @@ public static class AudioSessionEnumerator
IAudioSessionControl? ctrl = null; IAudioSessionControl? ctrl = null;
try try
{ {
sessions.GetSession(i, out ctrl); if (sessions.GetSession(i, out ctrl) < 0 || ctrl == null) continue;
ctrl.GetState(out int state);
if (state != 1 /*AudioSessionStateActive*/) continue;
var ctrl2 = (IAudioSessionControl2)ctrl; var ctrl2 = (IAudioSessionControl2)ctrl;
ctrl2.GetProcessId(out uint pid); ctrl2.GetProcessId(out uint pid);
if (pid != 0) pids.Add((int)pid);
int ipid = (int)pid;
if (pid == 0 || ipid == selfPid || !seen.Add(ipid)) continue;
try
{
var proc = Process.GetProcessById(ipid);
if (!string.IsNullOrEmpty(proc.ProcessName))
result.Add(new AudioAppInfo(ipid, proc.ProcessName));
}
catch { /* exited */ }
} }
catch { /* stale session */ } catch { /* stale session */ }
finally { if (ctrl != null) Marshal.ReleaseComObject(ctrl); } finally { if (ctrl != null) Marshal.ReleaseComObject(ctrl); }
} }
} }
catch { /* no audio device or WASAPI unavailable — ignore */ } catch { /* device went away */ }
finally finally
{ {
if (sessions != null) Marshal.ReleaseComObject(sessions); if (sessions != null) Marshal.ReleaseComObject(sessions);
if (manager != null) Marshal.ReleaseComObject(manager); if (manager != null) Marshal.ReleaseComObject(manager);
if (device != null) Marshal.ReleaseComObject(device); if (device != null) Marshal.ReleaseComObject(device);
if (enumerator != null) Marshal.ReleaseComObject(enumerator);
} }
} }
@@ -136,13 +195,21 @@ public static class AudioSessionEnumerator
InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
internal interface IMMDeviceEnumerator internal interface IMMDeviceEnumerator
{ {
[PreserveSig] int EnumAudioEndpoints(int dataFlow, int stateMask, out IntPtr devices); [PreserveSig] int EnumAudioEndpoints(int dataFlow, int stateMask, out IMMDeviceCollection devices);
[PreserveSig] int GetDefaultAudioEndpoint(int dataFlow, int role, out IMMDevice endpoint); [PreserveSig] int GetDefaultAudioEndpoint(int dataFlow, int role, out IMMDevice endpoint);
[PreserveSig] int GetDevice([MarshalAs(UnmanagedType.LPWStr)] string id, out IMMDevice device); [PreserveSig] int GetDevice([MarshalAs(UnmanagedType.LPWStr)] string id, out IMMDevice device);
[PreserveSig] int RegisterEndpointNotificationCallback(IntPtr client); [PreserveSig] int RegisterEndpointNotificationCallback(IntPtr client);
[PreserveSig] int UnregisterEndpointNotificationCallback(IntPtr client); [PreserveSig] int UnregisterEndpointNotificationCallback(IntPtr client);
} }
[ComImport, Guid("0BD7A1BE-7A1A-44DB-8397-CC5392387B5E"),
InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
internal interface IMMDeviceCollection
{
[PreserveSig] int GetCount(out int count);
[PreserveSig] int Item(int index, out IMMDevice device);
}
[ComImport, Guid("D666063F-1587-4E43-81F1-B948E807363F"), [ComImport, Guid("D666063F-1587-4E43-81F1-B948E807363F"),
InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
internal interface IMMDevice internal interface IMMDevice

View File

@@ -13,12 +13,17 @@ public sealed class AppAudioPickerDialog : Form
private readonly RadioButton _rdoOnly; private readonly RadioButton _rdoOnly;
private readonly RadioButton _rdoExcept; private readonly RadioButton _rdoExcept;
private readonly CheckBox _chkExcludeSelf; private readonly CheckBox _chkExcludeSelf;
private readonly TextBox _txtFilter;
private readonly ListView _appList; private readonly ListView _appList;
private readonly Label _lblApps; private readonly Label _lblApps;
// Snapshot taken when the dialog opens (refresh on open, not on every check change). // Snapshot taken when the dialog opens (refresh on open, not on every check change).
private IReadOnlyList<AudioAppInfo> _apps = []; private IReadOnlyList<AudioAppInfo> _apps = [];
// Checked apps survive filtering (a filtered-out row keeps its check here).
// pid → clean display name (used for the shared scope's Names).
private readonly Dictionary<int, string> _checked = new();
public AppAudioScope? ChosenScope { get; private set; } public AppAudioScope? ChosenScope { get; private set; }
public AppAudioPickerDialog() public AppAudioPickerDialog()
@@ -67,21 +72,31 @@ public sealed class AppAudioPickerDialog : Form
// ── App list ──────────────────────────────────────────────────────── // ── App list ────────────────────────────────────────────────────────
_lblApps = new Label _lblApps = new Label
{ {
Text = "Apps with active audio sessions:", Text = "Apps (▶ = currently playing):",
AutoSize = true, AutoSize = true,
Location = new Point(12, 112), Location = new Point(12, 112),
Visible = false, Visible = false,
TabIndex = 4, TabIndex = 4,
}; };
_txtFilter = new TextBox
{
Location = new Point(12, 132),
Size = new Size(360, 23),
PlaceholderText = "Filter apps…",
Visible = false,
TabIndex = 5,
};
_txtFilter.TextChanged += (_, _) => PopulateList();
_appList = new ListView _appList = new ListView
{ {
Location = new Point(12, 134), Location = new Point(12, 160),
Size = new Size(360, 158), Size = new Size(360, 150),
CheckBoxes = true, CheckBoxes = true,
View = View.List, View = View.List,
Visible = false, Visible = false,
TabIndex = 5, TabIndex = 6,
FullRowSelect = true, FullRowSelect = true,
}; };
// Exclude mode supports only one target process tree (WASAPI EXCLUDE takes a single // Exclude mode supports only one target process tree (WASAPI EXCLUDE takes a single
@@ -93,26 +108,26 @@ public sealed class AppAudioPickerDialog : Form
{ {
Text = "&Share", Text = "&Share",
DialogResult = DialogResult.OK, DialogResult = DialogResult.OK,
Location = new Point(216, 308), Location = new Point(216, 320),
Size = new Size(75, 27), Size = new Size(75, 27),
TabIndex = 6, TabIndex = 7,
}; };
var btnCancel = new Button var btnCancel = new Button
{ {
Text = "&Cancel", Text = "&Cancel",
DialogResult = DialogResult.Cancel, DialogResult = DialogResult.Cancel,
Location = new Point(297, 308), Location = new Point(297, 320),
Size = new Size(75, 27), Size = new Size(75, 27),
TabIndex = 7, TabIndex = 8,
}; };
btnShare.Click += OnShareClick; btnShare.Click += OnShareClick;
AcceptButton = btnShare; AcceptButton = btnShare;
CancelButton = btnCancel; CancelButton = btnCancel;
AutoScaleMode = AutoScaleMode.Font; AutoScaleMode = AutoScaleMode.Font;
ClientSize = new Size(384, 348); ClientSize = new Size(384, 360);
Controls.AddRange([_rdoAll, _rdoOnly, _rdoExcept, _chkExcludeSelf, _lblApps, _appList, Controls.AddRange([_rdoAll, _rdoOnly, _rdoExcept, _chkExcludeSelf, _lblApps, _txtFilter,
btnShare, btnCancel]); _appList, btnShare, btnCancel]);
FormBorderStyle = FormBorderStyle.FixedDialog; FormBorderStyle = FormBorderStyle.FixedDialog;
MaximizeBox = false; MaximizeBox = false;
MinimizeBox = false; MinimizeBox = false;
@@ -129,30 +144,57 @@ public sealed class AppAudioPickerDialog : Form
private void RefreshAppList() private void RefreshAppList()
{ {
_apps = AudioSessionEnumerator.GetAudioApps(); _apps = AudioSessionEnumerator.GetAudioApps();
PopulateList();
}
// Rebuild the visible rows from _apps + the current filter text, restoring check
// state from _checked so a selection persists while the user filters.
private void PopulateList()
{
string filter = _txtFilter.Text.Trim();
_suppressItemCheck = true;
_appList.BeginUpdate();
_appList.Items.Clear(); _appList.Items.Clear();
foreach (var app in _apps) foreach (var app in _apps)
_appList.Items.Add(new ListViewItem($"{app.DisplayName} (PID {app.Pid})") { Tag = app.Pid }); {
if (filter.Length > 0 &&
app.DisplayName.IndexOf(filter, StringComparison.OrdinalIgnoreCase) < 0)
continue;
string marker = app.IsPlaying ? "▶ " : "";
_appList.Items.Add(new ListViewItem($"{marker}{app.DisplayName} (PID {app.Pid})")
{
Tag = app,
Checked = _checked.ContainsKey(app.Pid),
});
}
_appList.EndUpdate();
_suppressItemCheck = false;
} }
private void OnModeChanged(object? sender, EventArgs e) private void OnModeChanged(object? sender, EventArgs e)
{ {
bool showList = _rdoOnly.Checked || _rdoExcept.Checked; bool showList = _rdoOnly.Checked || _rdoExcept.Checked;
_lblApps.Visible = showList; _lblApps.Visible = showList;
_txtFilter.Visible = showList;
_appList.Visible = showList; _appList.Visible = showList;
_lblApps.Text = _rdoExcept.Checked _lblApps.Text = _rdoExcept.Checked
? "App to exclude (everything else is shared):" ? "App to exclude (everything else is shared):"
: "Apps with active audio sessions:"; : "Apps (▶ = currently playing):";
// Self-exclude only applies to entire-desktop; the per-app modes already exclude // Self-exclude only applies to entire-desktop; the per-app modes already exclude
// this app's own tree (Include) or spend the single EXCLUDE slot on the chosen app. // this app's own tree (Include) or spend the single EXCLUDE slot on the chosen app.
_chkExcludeSelf.Enabled = _rdoAll.Checked; _chkExcludeSelf.Enabled = _rdoAll.Checked;
if (showList && _appList.Items.Count == 0)
RefreshAppList();
// Switching into exclude mode: collapse any multi-selection down to a single item. // Switching into exclude mode: collapse any multi-selection down to a single item.
if (_rdoExcept.Checked) if (_rdoExcept.Checked)
KeepSingleCheck(firstCheckedOnly: true); TrimCheckedToOne();
if (showList && _appList.Items.Count == 0)
RefreshAppList();
else
PopulateList(); // re-render markers/labels and restore check state
} }
// In exclude mode the list behaves like radio buttons: checking one item clears the rest. // In exclude mode the list behaves like radio buttons: checking one item clears the rest.
@@ -160,28 +202,36 @@ public sealed class AppAudioPickerDialog : Form
private void OnAppItemCheck(object? sender, ItemCheckEventArgs e) private void OnAppItemCheck(object? sender, ItemCheckEventArgs e)
{ {
if (_suppressItemCheck || !_rdoExcept.Checked || e.NewValue != CheckState.Checked) if (_suppressItemCheck) return;
return; if (_appList.Items[e.Index].Tag is not AudioAppInfo app) return;
if (e.NewValue == CheckState.Checked)
{
if (_rdoExcept.Checked)
{
// Radio behavior: clear every other visible check and the persisted set.
_suppressItemCheck = true; _suppressItemCheck = true;
foreach (ListViewItem item in _appList.Items) foreach (ListViewItem item in _appList.Items)
if (item.Index != e.Index && item.Checked) if (item.Index != e.Index && item.Checked) item.Checked = false;
item.Checked = false;
_suppressItemCheck = false; _suppressItemCheck = false;
_checked.Clear();
}
_checked[app.Pid] = app.DisplayName;
}
else
{
_checked.Remove(app.Pid);
}
} }
// Leave at most one item checked (the first), unchecking the rest. // Reduce the persisted selection to a single (first) entry — used when entering
private void KeepSingleCheck(bool firstCheckedOnly) // exclude mode, whose single EXCLUDE slot can only target one process tree.
private void TrimCheckedToOne()
{ {
_suppressItemCheck = true; if (_checked.Count <= 1) return;
bool kept = false; var first = _checked.First();
foreach (ListViewItem item in _appList.Items) _checked.Clear();
{ _checked[first.Key] = first.Value;
if (!item.Checked) continue;
if (firstCheckedOnly && !kept) { kept = true; continue; }
item.Checked = false;
}
_suppressItemCheck = false;
} }
private void OnShareClick(object? sender, EventArgs e) private void OnShareClick(object? sender, EventArgs e)
@@ -192,18 +242,7 @@ public sealed class AppAudioPickerDialog : Form
return; return;
} }
var checkedPids = new List<int>(); if (_checked.Count == 0)
var checkedNames = new List<string>();
foreach (ListViewItem item in _appList.CheckedItems)
{
if (item.Tag is int pid)
{
checkedPids.Add(pid);
checkedNames.Add(item.Text);
}
}
if (checkedPids.Count == 0)
{ {
MessageBox.Show( MessageBox.Show(
"Select at least one app, or choose 'Entire desktop'.", "Select at least one app, or choose 'Entire desktop'.",
@@ -214,8 +253,11 @@ public sealed class AppAudioPickerDialog : Form
return; return;
} }
var pids = _checked.Keys.ToList();
var names = _checked.Values.ToList();
ChosenScope = _rdoOnly.Checked ChosenScope = _rdoOnly.Checked
? new OnlyApps(checkedPids, checkedNames) ? new OnlyApps(pids, names)
: new AllExceptApps(checkedPids, checkedNames); : new AllExceptApps(pids, names);
} }
} }