using System.Diagnostics; using System.Runtime.InteropServices; namespace VoiceCat.App.Audio; // ── Scope types (mirror macOS ScreenAudioScope / ScreenAudioSelection) ──────── public abstract record AppAudioScope; // ExcludeSelf=true captures the whole system render mix minus VoiceCat's own process tree // (kills the self-echo loop). Routed through the external-feed mixer as a single EXCLUDE // capture; ExcludeSelf=false keeps the core's whole-device loopback path. 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; // 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 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() { int selfPid = Environment.ProcessId; int sessionId = SafeCurrentSessionId(); var playingPids = GetActiveAudioPids(); // Group by executable name; keep the best representative PID per group. var groups = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var proc in Process.GetProcesses()) { try { 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 { /* 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 GetActiveAudioPids() { var pids = new HashSet(); IMMDeviceEnumerator? enumerator = null; IMMDeviceCollection? devices = null; try { enumerator = (IMMDeviceEnumerator)Activator.CreateInstance( Type.GetTypeFromCLSID(new Guid("BCDE0395-E52F-467C-8E3D-C4579291692E"))!)!; 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"); if (device.Activate(ref mgr2Iid, 0x17 /*CLSCTX_ALL*/, IntPtr.Zero, out object mgr) < 0) return; manager = (IAudioSessionManager2)mgr; if (manager.GetSessionEnumerator(out sessions) < 0 || sessions == null) return; sessions.GetCount(out int count); for (int i = 0; i < count; i++) { IAudioSessionControl? ctrl = null; try { 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); if (pid != 0) pids.Add((int)pid); } catch { /* stale session */ } finally { if (ctrl != null) Marshal.ReleaseComObject(ctrl); } } } catch { /* device went away */ } finally { if (sessions != null) Marshal.ReleaseComObject(sessions); if (manager != null) Marshal.ReleaseComObject(manager); if (device != null) Marshal.ReleaseComObject(device); } } // ── COM interface declarations ───────────────────────────────────────────── [ComImport, Guid("A95664D2-9614-4F35-A746-DE8DB63617E6"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] internal interface IMMDeviceEnumerator { [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 { [PreserveSig] int Activate(ref Guid iid, int clsCtx, IntPtr activationParams, [MarshalAs(UnmanagedType.IUnknown)] out object ppInterface); [PreserveSig] int OpenPropertyStore(int stgmAccess, out IntPtr propStore); [PreserveSig] int GetId([MarshalAs(UnmanagedType.LPWStr)] out string id); [PreserveSig] int GetState(out int state); } [ComImport, Guid("77AA99A0-1BD6-484F-8BC7-2C654C9A9B6F"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] internal interface IAudioSessionManager2 { [PreserveSig] int GetAudioSessionControl(ref Guid audioSessionGuid, int streamFlags, out IAudioSessionControl session); [PreserveSig] int GetSimpleAudioVolume(ref Guid audioSessionGuid, int streamFlags, out IntPtr audioVolume); [PreserveSig] int GetSessionEnumerator(out IAudioSessionEnumerator sessionEnum); [PreserveSig] int RegisterSessionNotification(IntPtr notification); [PreserveSig] int UnregisterSessionNotification(IntPtr notification); [PreserveSig] int RegisterDuckNotification([MarshalAs(UnmanagedType.LPWStr)] string sessionID, IntPtr notification); [PreserveSig] int UnregisterDuckNotification(IntPtr notification); } [ComImport, Guid("E2F5BB11-0570-40CA-ACDD-3AA01277DEE8"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] internal interface IAudioSessionEnumerator { [PreserveSig] int GetCount(out int sessionCount); [PreserveSig] int GetSession(int sessionIndex, out IAudioSessionControl session); } [ComImport, Guid("F4B1A599-7266-4319-A8CA-E70ACB11E8CD"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] internal interface IAudioSessionControl { [PreserveSig] int GetState(out int state); [PreserveSig] int GetDisplayName([MarshalAs(UnmanagedType.LPWStr)] out string name); [PreserveSig] int SetDisplayName([MarshalAs(UnmanagedType.LPWStr)] string name, ref Guid eventContext); [PreserveSig] int GetIconPath([MarshalAs(UnmanagedType.LPWStr)] out string iconPath); [PreserveSig] int SetIconPath([MarshalAs(UnmanagedType.LPWStr)] string iconPath, ref Guid eventContext); [PreserveSig] int GetGroupingParam(out Guid groupingParam); [PreserveSig] int SetGroupingParam(ref Guid groupingParam, ref Guid eventContext); [PreserveSig] int RegisterAudioSessionNotification(IntPtr notification); [PreserveSig] int UnregisterAudioSessionNotification(IntPtr notification); } [ComImport, Guid("BFB7FF88-7239-4FC9-8FA2-07C950BE9C6D"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] internal interface IAudioSessionControl2 : IAudioSessionControl { [PreserveSig] int GetSessionIdentifier([MarshalAs(UnmanagedType.LPWStr)] out string id); [PreserveSig] int GetSessionInstanceIdentifier([MarshalAs(UnmanagedType.LPWStr)] out string id); [PreserveSig] int GetProcessId(out uint pid); [PreserveSig] int IsSystemSoundsSession(); [PreserveSig] int SetDuckingPreference(bool optOut); } }