2026-06-22 00:19:38 +02:00
|
|
|
using System.Diagnostics;
|
|
|
|
|
using System.Runtime.InteropServices;
|
|
|
|
|
|
|
|
|
|
namespace VoiceCat.App.Audio;
|
|
|
|
|
|
|
|
|
|
// ── Scope types (mirror macOS ScreenAudioScope / ScreenAudioSelection) ────────
|
|
|
|
|
|
|
|
|
|
public abstract record AppAudioScope;
|
2026-06-22 12:28:53 +02:00
|
|
|
// 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;
|
2026-06-22 00:19:38 +02:00
|
|
|
public sealed record OnlyApps(IReadOnlyList<int> Pids, IReadOnlyList<string> Names) : AppAudioScope;
|
|
|
|
|
public sealed record AllExceptApps(IReadOnlyList<int> Pids, IReadOnlyList<string> Names) : AppAudioScope;
|
|
|
|
|
|
2026-06-24 00:24:20 +02:00
|
|
|
// 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);
|
2026-06-22 00:19:38 +02:00
|
|
|
|
|
|
|
|
// ── Enumerator ─────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
public static class AudioSessionEnumerator
|
|
|
|
|
{
|
2026-06-24 00:24:20 +02:00
|
|
|
// 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.
|
2026-06-22 00:19:38 +02:00
|
|
|
public static IReadOnlyList<AudioAppInfo> GetAudioApps()
|
|
|
|
|
{
|
2026-06-24 00:24:20 +02:00
|
|
|
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<string, AppGroup>(StringComparer.OrdinalIgnoreCase);
|
2026-06-22 00:19:38 +02:00
|
|
|
|
2026-06-24 00:24:20 +02:00
|
|
|
foreach (var proc in Process.GetProcesses())
|
|
|
|
|
{
|
2026-06-22 00:19:38 +02:00
|
|
|
try
|
|
|
|
|
{
|
2026-06-24 00:24:20 +02:00
|
|
|
if (proc.Id == selfPid) continue;
|
|
|
|
|
if (proc.SessionId != sessionId) continue; // drop session-0 services
|
|
|
|
|
string name = proc.ProcessName;
|
|
|
|
|
if (string.IsNullOrEmpty(name)) continue;
|
2026-06-22 00:19:38 +02:00
|
|
|
|
2026-06-24 00:24:20 +02:00
|
|
|
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;
|
2026-06-22 00:19:38 +02:00
|
|
|
|
2026-06-24 00:24:20 +02:00
|
|
|
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(); }
|
|
|
|
|
}
|
2026-06-22 00:19:38 +02:00
|
|
|
|
2026-06-24 00:24:20 +02:00
|
|
|
return groups.Values
|
|
|
|
|
.Select(g => new AudioAppInfo(g.Pid, g.Display, g.AnyPlaying))
|
|
|
|
|
.OrderByDescending(a => a.IsPlaying)
|
|
|
|
|
.ThenBy(a => a.DisplayName, StringComparer.OrdinalIgnoreCase)
|
|
|
|
|
.ToList();
|
2026-06-22 00:19:38 +02:00
|
|
|
}
|
|
|
|
|
|
2026-06-24 00:24:20 +02:00
|
|
|
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
|
|
|
|
|
}
|
2026-06-22 00:19:38 +02:00
|
|
|
|
2026-06-24 00:24:20 +02:00
|
|
|
private static int SafeCurrentSessionId()
|
|
|
|
|
{
|
|
|
|
|
try { using var me = Process.GetCurrentProcess(); return me.SessionId; }
|
|
|
|
|
catch { return 1; } // typical interactive session fallback
|
|
|
|
|
}
|
2026-06-22 00:19:38 +02:00
|
|
|
|
2026-06-24 00:24:20 +02:00
|
|
|
private static string SafeWindowTitle(Process proc)
|
|
|
|
|
{
|
|
|
|
|
try { return proc.MainWindowTitle; }
|
|
|
|
|
catch { return ""; }
|
|
|
|
|
}
|
2026-06-22 00:19:38 +02:00
|
|
|
|
2026-06-24 00:24:20 +02:00
|
|
|
// ── 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".
|
2026-06-22 00:19:38 +02:00
|
|
|
|
2026-06-24 00:24:20 +02:00
|
|
|
private static HashSet<int> GetActiveAudioPids()
|
2026-06-22 00:19:38 +02:00
|
|
|
{
|
2026-06-24 00:24:20 +02:00
|
|
|
var pids = new HashSet<int>();
|
2026-06-22 00:19:38 +02:00
|
|
|
IMMDeviceEnumerator? enumerator = null;
|
2026-06-24 00:24:20 +02:00
|
|
|
IMMDeviceCollection? devices = null;
|
2026-06-22 00:19:38 +02:00
|
|
|
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
enumerator = (IMMDeviceEnumerator)Activator.CreateInstance(
|
|
|
|
|
Type.GetTypeFromCLSID(new Guid("BCDE0395-E52F-467C-8E3D-C4579291692E"))!)!;
|
|
|
|
|
|
2026-06-24 00:24:20 +02:00
|
|
|
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;
|
2026-06-22 00:19:38 +02:00
|
|
|
|
|
|
|
|
var mgr2Iid = new Guid("77AA99A0-1BD6-484F-8BC7-2C654C9A9B6F");
|
2026-06-24 00:24:20 +02:00
|
|
|
if (device.Activate(ref mgr2Iid, 0x17 /*CLSCTX_ALL*/, IntPtr.Zero, out object mgr) < 0)
|
|
|
|
|
return;
|
2026-06-22 00:19:38 +02:00
|
|
|
manager = (IAudioSessionManager2)mgr;
|
|
|
|
|
|
2026-06-24 00:24:20 +02:00
|
|
|
if (manager.GetSessionEnumerator(out sessions) < 0 || sessions == null) return;
|
2026-06-22 00:19:38 +02:00
|
|
|
sessions.GetCount(out int count);
|
|
|
|
|
|
|
|
|
|
for (int i = 0; i < count; i++)
|
|
|
|
|
{
|
|
|
|
|
IAudioSessionControl? ctrl = null;
|
|
|
|
|
try
|
|
|
|
|
{
|
2026-06-24 00:24:20 +02:00
|
|
|
if (sessions.GetSession(i, out ctrl) < 0 || ctrl == null) continue;
|
|
|
|
|
ctrl.GetState(out int state);
|
|
|
|
|
if (state != 1 /*AudioSessionStateActive*/) continue;
|
|
|
|
|
|
2026-06-22 00:19:38 +02:00
|
|
|
var ctrl2 = (IAudioSessionControl2)ctrl;
|
|
|
|
|
ctrl2.GetProcessId(out uint pid);
|
2026-06-24 00:24:20 +02:00
|
|
|
if (pid != 0) pids.Add((int)pid);
|
2026-06-22 00:19:38 +02:00
|
|
|
}
|
|
|
|
|
catch { /* stale session */ }
|
|
|
|
|
finally { if (ctrl != null) Marshal.ReleaseComObject(ctrl); }
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-06-24 00:24:20 +02:00
|
|
|
catch { /* device went away */ }
|
2026-06-22 00:19:38 +02:00
|
|
|
finally
|
|
|
|
|
{
|
2026-06-24 00:24:20 +02:00
|
|
|
if (sessions != null) Marshal.ReleaseComObject(sessions);
|
|
|
|
|
if (manager != null) Marshal.ReleaseComObject(manager);
|
|
|
|
|
if (device != null) Marshal.ReleaseComObject(device);
|
2026-06-22 00:19:38 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── COM interface declarations ─────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
[ComImport, Guid("A95664D2-9614-4F35-A746-DE8DB63617E6"),
|
|
|
|
|
InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
|
|
|
|
internal interface IMMDeviceEnumerator
|
|
|
|
|
{
|
2026-06-24 00:24:20 +02:00
|
|
|
[PreserveSig] int EnumAudioEndpoints(int dataFlow, int stateMask, out IMMDeviceCollection devices);
|
2026-06-22 00:19:38 +02:00
|
|
|
[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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-24 00:24:20 +02:00
|
|
|
[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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-22 00:19:38 +02:00
|
|
|
[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);
|
|
|
|
|
}
|
|
|
|
|
}
|