Adds "only selected apps" and "all apps except selected" screen-audio modes to the Windows client, alongside the existing entire-desktop path. Per-app capture uses WASAPI process loopback (AUDCLNT_ACTIVATIONTYPE_ PROCESS_LOOPBACK) via ProcessLoopbackCapture, mixed by ProcessAudioMixer and fed to the core through vc_stream_feed_pcm (external_feed=1 so the core skips its own loopback device). Init must pass AUDCLNT_STREAMFLAGS_LOOPBACK | EVENTCALLBACK | AUTOCONVERTPCM; the LOOPBACK flag is what makes the virtual endpoint deliver rendered audio (without it every buffer is flagged SILENT) and AUTOCONVERTPCM resamples the app's native format to 48k s16. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
206 lines
9.8 KiB
C#
206 lines
9.8 KiB
C#
using System.Diagnostics;
|
|
using System.Runtime.InteropServices;
|
|
|
|
namespace VoiceCat.App.Audio;
|
|
|
|
// ── Scope types (mirror macOS ScreenAudioScope / ScreenAudioSelection) ────────
|
|
|
|
public abstract record AppAudioScope;
|
|
public sealed record EntireDesktop : 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 AudioAppInfo(int Pid, string DisplayName);
|
|
|
|
// ── 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.
|
|
public static IReadOnlyList<AudioAppInfo> GetAudioApps()
|
|
{
|
|
var seen = new HashSet<int>();
|
|
var result = new List<AudioAppInfo>();
|
|
int selfPid = Environment.ProcessId;
|
|
|
|
// ── 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, _) =>
|
|
{
|
|
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));
|
|
}
|
|
catch { /* process exited between EnumWindows and GetProcessById */ }
|
|
|
|
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();
|
|
}
|
|
|
|
// ── 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)
|
|
{
|
|
IMMDeviceEnumerator? enumerator = null;
|
|
IMMDevice? device = null;
|
|
IAudioSessionManager2? manager = null;
|
|
IAudioSessionEnumerator? sessions = null;
|
|
|
|
try
|
|
{
|
|
enumerator = (IMMDeviceEnumerator)Activator.CreateInstance(
|
|
Type.GetTypeFromCLSID(new Guid("BCDE0395-E52F-467C-8E3D-C4579291692E"))!)!;
|
|
|
|
enumerator.GetDefaultAudioEndpoint(0 /*eRender*/, 1 /*eMultimedia*/, out device);
|
|
|
|
var mgr2Iid = new Guid("77AA99A0-1BD6-484F-8BC7-2C654C9A9B6F");
|
|
device.Activate(ref mgr2Iid, 0x17 /*CLSCTX_ALL*/, IntPtr.Zero, out object mgr);
|
|
manager = (IAudioSessionManager2)mgr;
|
|
|
|
manager.GetSessionEnumerator(out sessions);
|
|
sessions.GetCount(out int count);
|
|
|
|
for (int i = 0; i < count; i++)
|
|
{
|
|
IAudioSessionControl? ctrl = null;
|
|
try
|
|
{
|
|
sessions.GetSession(i, out ctrl);
|
|
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 */ }
|
|
}
|
|
catch { /* stale session */ }
|
|
finally { if (ctrl != null) Marshal.ReleaseComObject(ctrl); }
|
|
}
|
|
}
|
|
catch { /* no audio device or WASAPI unavailable — ignore */ }
|
|
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);
|
|
}
|
|
}
|
|
|
|
// ── COM interface declarations ─────────────────────────────────────────────
|
|
|
|
[ComImport, Guid("A95664D2-9614-4F35-A746-DE8DB63617E6"),
|
|
InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
|
internal interface IMMDeviceEnumerator
|
|
{
|
|
[PreserveSig] int EnumAudioEndpoints(int dataFlow, int stateMask, out IntPtr 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("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);
|
|
}
|
|
}
|