diff --git a/clients/windows/VoiceCat.App/Audio/AudioSessionEnumerator.cs b/clients/windows/VoiceCat.App/Audio/AudioSessionEnumerator.cs index 137df88..3a7335f 100644 --- a/clients/windows/VoiceCat.App/Audio/AudioSessionEnumerator.cs +++ b/clients/windows/VoiceCat.App/Audio/AudioSessionEnumerator.cs @@ -13,87 +13,154 @@ public sealed record EntireDesktop(bool ExcludeSelf = false) : AppAudioScope; public sealed record OnlyApps(IReadOnlyList Pids, IReadOnlyList Names) : AppAudioScope; public sealed record AllExceptApps(IReadOnlyList Pids, IReadOnlyList 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 ───────────────────────────────────────────────────────────────── public static class AudioSessionEnumerator { - // Returns all user-facing apps: visible-window processes (primary, like macOS - // SCShareableContent.current) plus any background audio-session-only processes - // (e.g. Spotify in mini-player). Excludes VoiceCat itself and system processes. + // Returns every app in the user's interactive session — windowed or not — so any of + // them can be picked for capture even before it starts producing audio. Process + // 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 GetAudioApps() { - var seen = new HashSet(); - var result = new List(); - int selfPid = Environment.ProcessId; + int selfPid = Environment.ProcessId; + int sessionId = SafeCurrentSessionId(); + var playingPids = GetActiveAudioPids(); - // ── 1. Visible-window processes (EnumWindows) ───────────────────────── - // Same set macOS ScreenCaptureKit exposes: all apps with at least one - // visible top-level window. Shows apps even when not currently producing audio. - EnumWindows((hWnd, _) => + // Group by executable name; keep the best representative PID per group. + var groups = new Dictionary(StringComparer.OrdinalIgnoreCase); + + 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 { - var proc = Process.GetProcessById((int)pid); - string name = proc.MainWindowTitle.Length > 0 - ? $"{proc.ProcessName} — {proc.MainWindowTitle}" - : proc.ProcessName; - if (!string.IsNullOrEmpty(proc.ProcessName)) - result.Add(new AudioAppInfo((int)pid, name)); + if (proc.Id == selfPid) continue; + if (proc.SessionId != sessionId) continue; // drop session-0 services + string name = proc.ProcessName; + if (string.IsNullOrEmpty(name)) continue; + + bool hasWindow = proc.MainWindowHandle != IntPtr.Zero; + bool playing = playingPids.Contains(proc.Id); + string title = hasWindow ? SafeWindowTitle(proc) : ""; + string display = title.Length > 0 ? $"{name} — {title}" : name; + + if (groups.TryGetValue(name, out var g)) + { + 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 { /* process exited between EnumWindows and GetProcessById */ } + catch { /* protected/exited process — skip */ } + finally { proc.Dispose(); } + } - return true; // continue enumeration - }, IntPtr.Zero); - - // ── 2. Background audio-session processes (WASAPI, supplement) ──────── - // 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(); + return groups.Values + .Select(g => new AudioAppInfo(g.Pid, g.Display, g.AnyPlaying)) + .OrderByDescending(a => a.IsPlaying) + .ThenBy(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 seen, int selfPid, - List result) + 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 GetActiveAudioPids() + { + var pids = new HashSet(); IMMDeviceEnumerator? enumerator = null; - IMMDevice? device = null; - IAudioSessionManager2? manager = null; - IAudioSessionEnumerator? sessions = null; + IMMDeviceCollection? devices = null; try { enumerator = (IMMDeviceEnumerator)Activator.CreateInstance( 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 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"); - 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.GetSessionEnumerator(out sessions); + if (manager.GetSessionEnumerator(out sessions) < 0 || sessions == null) return; sessions.GetCount(out int count); for (int i = 0; i < count; i++) @@ -101,32 +168,24 @@ public static class AudioSessionEnumerator IAudioSessionControl? ctrl = null; 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; ctrl2.GetProcessId(out uint 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 */ } + if (pid != 0) pids.Add((int)pid); } catch { /* stale session */ } finally { if (ctrl != null) Marshal.ReleaseComObject(ctrl); } } } - catch { /* no audio device or WASAPI unavailable — ignore */ } + catch { /* device went away */ } finally { - if (sessions != null) Marshal.ReleaseComObject(sessions); - if (manager != null) Marshal.ReleaseComObject(manager); - if (device != null) Marshal.ReleaseComObject(device); - if (enumerator != null) Marshal.ReleaseComObject(enumerator); + if (sessions != null) Marshal.ReleaseComObject(sessions); + if (manager != null) Marshal.ReleaseComObject(manager); + if (device != null) Marshal.ReleaseComObject(device); } } @@ -136,13 +195,21 @@ public static class AudioSessionEnumerator InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] 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 GetDevice([MarshalAs(UnmanagedType.LPWStr)] string id, out IMMDevice device); [PreserveSig] int RegisterEndpointNotificationCallback(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"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] internal interface IMMDevice diff --git a/clients/windows/VoiceCat.App/Forms/AppAudioPickerDialog.cs b/clients/windows/VoiceCat.App/Forms/AppAudioPickerDialog.cs index 3b800f0..1901a19 100644 --- a/clients/windows/VoiceCat.App/Forms/AppAudioPickerDialog.cs +++ b/clients/windows/VoiceCat.App/Forms/AppAudioPickerDialog.cs @@ -12,13 +12,18 @@ public sealed class AppAudioPickerDialog : Form private readonly RadioButton _rdoAll; private readonly RadioButton _rdoOnly; private readonly RadioButton _rdoExcept; - private readonly CheckBox _chkExcludeSelf; + private readonly CheckBox _chkExcludeSelf; + private readonly TextBox _txtFilter; private readonly ListView _appList; private readonly Label _lblApps; // Snapshot taken when the dialog opens (refresh on open, not on every check change). private IReadOnlyList _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 _checked = new(); + public AppAudioScope? ChosenScope { get; private set; } public AppAudioPickerDialog() @@ -67,21 +72,31 @@ public sealed class AppAudioPickerDialog : Form // ── App list ──────────────────────────────────────────────────────── _lblApps = new Label { - Text = "Apps with active audio sessions:", + Text = "Apps (▶ = currently playing):", AutoSize = true, Location = new Point(12, 112), Visible = false, 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 { - Location = new Point(12, 134), - Size = new Size(360, 158), + Location = new Point(12, 160), + Size = new Size(360, 150), CheckBoxes = true, View = View.List, Visible = false, - TabIndex = 5, + TabIndex = 6, FullRowSelect = true, }; // Exclude mode supports only one target process tree (WASAPI EXCLUDE takes a single @@ -93,26 +108,26 @@ public sealed class AppAudioPickerDialog : Form { Text = "&Share", DialogResult = DialogResult.OK, - Location = new Point(216, 308), + Location = new Point(216, 320), Size = new Size(75, 27), - TabIndex = 6, + TabIndex = 7, }; var btnCancel = new Button { Text = "&Cancel", DialogResult = DialogResult.Cancel, - Location = new Point(297, 308), + Location = new Point(297, 320), Size = new Size(75, 27), - TabIndex = 7, + TabIndex = 8, }; btnShare.Click += OnShareClick; AcceptButton = btnShare; CancelButton = btnCancel; AutoScaleMode = AutoScaleMode.Font; - ClientSize = new Size(384, 348); - Controls.AddRange([_rdoAll, _rdoOnly, _rdoExcept, _chkExcludeSelf, _lblApps, _appList, - btnShare, btnCancel]); + ClientSize = new Size(384, 360); + Controls.AddRange([_rdoAll, _rdoOnly, _rdoExcept, _chkExcludeSelf, _lblApps, _txtFilter, + _appList, btnShare, btnCancel]); FormBorderStyle = FormBorderStyle.FixedDialog; MaximizeBox = false; MinimizeBox = false; @@ -129,30 +144,57 @@ public sealed class AppAudioPickerDialog : Form private void RefreshAppList() { _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(); 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) { bool showList = _rdoOnly.Checked || _rdoExcept.Checked; - _lblApps.Visible = showList; - _appList.Visible = showList; + _lblApps.Visible = showList; + _txtFilter.Visible = showList; + _appList.Visible = showList; _lblApps.Text = _rdoExcept.Checked ? "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 // this app's own tree (Include) or spend the single EXCLUDE slot on the chosen app. _chkExcludeSelf.Enabled = _rdoAll.Checked; - if (showList && _appList.Items.Count == 0) - RefreshAppList(); - // Switching into exclude mode: collapse any multi-selection down to a single item. 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. @@ -160,28 +202,36 @@ public sealed class AppAudioPickerDialog : Form private void OnAppItemCheck(object? sender, ItemCheckEventArgs e) { - if (_suppressItemCheck || !_rdoExcept.Checked || e.NewValue != CheckState.Checked) - return; + if (_suppressItemCheck) return; + if (_appList.Items[e.Index].Tag is not AudioAppInfo app) return; - _suppressItemCheck = true; - foreach (ListViewItem item in _appList.Items) - if (item.Index != e.Index && item.Checked) - item.Checked = false; - _suppressItemCheck = false; + if (e.NewValue == CheckState.Checked) + { + if (_rdoExcept.Checked) + { + // Radio behavior: clear every other visible check and the persisted set. + _suppressItemCheck = true; + foreach (ListViewItem item in _appList.Items) + if (item.Index != e.Index && item.Checked) item.Checked = 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. - private void KeepSingleCheck(bool firstCheckedOnly) + // Reduce the persisted selection to a single (first) entry — used when entering + // exclude mode, whose single EXCLUDE slot can only target one process tree. + private void TrimCheckedToOne() { - _suppressItemCheck = true; - bool kept = false; - foreach (ListViewItem item in _appList.Items) - { - if (!item.Checked) continue; - if (firstCheckedOnly && !kept) { kept = true; continue; } - item.Checked = false; - } - _suppressItemCheck = false; + if (_checked.Count <= 1) return; + var first = _checked.First(); + _checked.Clear(); + _checked[first.Key] = first.Value; } private void OnShareClick(object? sender, EventArgs e) @@ -192,18 +242,7 @@ public sealed class AppAudioPickerDialog : Form return; } - var checkedPids = new List(); - var checkedNames = new List(); - foreach (ListViewItem item in _appList.CheckedItems) - { - if (item.Tag is int pid) - { - checkedPids.Add(pid); - checkedNames.Add(item.Text); - } - } - - if (checkedPids.Count == 0) + if (_checked.Count == 0) { MessageBox.Show( "Select at least one app, or choose 'Entire desktop'.", @@ -214,8 +253,11 @@ public sealed class AppAudioPickerDialog : Form return; } + var pids = _checked.Keys.ToList(); + var names = _checked.Values.ToList(); + ChosenScope = _rdoOnly.Checked - ? new OnlyApps(checkedPids, checkedNames) - : new AllExceptApps(checkedPids, checkedNames); + ? new OnlyApps(pids, names) + : new AllExceptApps(pids, names); } }