feat(windows): per-app screen-audio sharing (include/exclude)
Some checks failed
Build Linux Binaries / linux/amd64 (push) Has been cancelled
Build Linux Binaries / linux/arm64 (push) Has been cancelled

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>
This commit is contained in:
2026-06-22 00:19:38 +02:00
parent 4f89d2d32d
commit e806b698ec
11 changed files with 1064 additions and 24 deletions

View File

@@ -0,0 +1,205 @@
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);
}
}