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);
}
}

View File

@@ -0,0 +1,146 @@
using VoiceCat.Interop;
// Owns N ProcessLoopbackCapture instances, mixes their PCM every 20 ms, and feeds
// the result to the core via vc_stream_feed_pcm. Used for per-app audio sharing.
namespace VoiceCat.App.Audio;
public sealed class ProcessAudioMixer : IDisposable
{
private const int SampleRate = 48000;
private const int FrameSamples = 960;
private const int Channels = 2; // stereo; captures fall back to mono if needed
private readonly List<ProcessLoopbackCapture> _captures = [];
// Per-capture latest frame, protected by _frameLock.
private readonly object _frameLock = new();
private List<short[]> _latestFrames = [];
private int _activeChannels = Channels;
private Thread? _mixThread;
private volatile bool _running;
private VoiceCatClient? _client;
private uint _streamId;
public void Start(AppAudioScope scope, VoiceCatClient client, uint streamId,
IReadOnlyList<AudioAppInfo> allApps)
{
if (_running) return;
_client = client;
_streamId = streamId;
var pids = ResolvePids(scope, allApps);
if (pids.Count == 0)
{
// nothing to capture — scope resolved to empty set
return;
}
lock (_frameLock)
{
_latestFrames = new List<short[]>(new short[pids.Count][]);
_activeChannels = Channels;
}
for (int i = 0; i < pids.Count; i++)
{
int captureIndex = i;
var cap = new ProcessLoopbackCapture(pids[i], ProcessLoopbackCapture.Mode.Include);
cap.PcmFrameReady += (pcm, spc, ch) => OnCaptureFrame(captureIndex, pcm, ch);
_captures.Add(cap);
}
foreach (var c in _captures) c.Start();
_running = true;
_mixThread = new Thread(MixLoop) { IsBackground = true, Name = "ProcessAudioMixer" };
_mixThread.Start();
}
public void Stop()
{
_running = false;
_mixThread?.Join(500);
foreach (var c in _captures) { c.Stop(); c.Dispose(); }
_captures.Clear();
}
public void Dispose() => Stop();
// ── Capture callback ──────────────────────────────────────────────────────
private void OnCaptureFrame(int index, short[] pcm, int channels)
{
lock (_frameLock)
{
// Upmix mono → stereo interleave if the capture fell back to mono.
if (channels == 1 && _activeChannels == 2)
pcm = MonoToStereo(pcm);
if (index < _latestFrames.Count)
_latestFrames[index] = pcm;
}
}
// ── Mix loop (20 ms timer) ────────────────────────────────────────────────
private void MixLoop()
{
// Use a target period close to 20 ms; small under-shoot avoids accumulating drift.
const int periodMs = 19;
while (_running)
{
Thread.Sleep(periodMs);
if (!_running) break;
short[] mix;
lock (_frameLock)
{
int len = FrameSamples * _activeChannels;
mix = new short[len];
foreach (var frame in _latestFrames)
{
if (frame == null) continue;
int frameLen = Math.Min(frame.Length, len);
for (int i = 0; i < frameLen; i++)
{
int sum = mix[i] + frame[i];
// Saturating clamp
mix[i] = (short)Math.Clamp(sum, short.MinValue, short.MaxValue);
}
}
}
_client?.StreamFeedPcm(_streamId, mix, FrameSamples, (uint)_activeChannels);
}
}
// ── Helpers ───────────────────────────────────────────────────────────────
// For "AllExcept": enumerate all running audio apps and exclude the specified ones.
// For "OnlyApps": use their PIDs directly.
private static List<int> ResolvePids(AppAudioScope scope, IReadOnlyList<AudioAppInfo> allApps)
{
return scope switch
{
OnlyApps o => [.. o.Pids],
AllExceptApps a =>
allApps
.Where(app => !a.Pids.Contains(app.Pid))
.Select(app => app.Pid)
.ToList(),
_ => [],
};
}
private static short[] MonoToStereo(short[] mono)
{
var stereo = new short[mono.Length * 2];
for (int i = 0; i < mono.Length; i++)
{
stereo[i * 2] = mono[i];
stereo[i * 2 + 1] = mono[i];
}
return stereo;
}
}

View File

@@ -0,0 +1,434 @@
using System.Runtime.InteropServices;
// Single-process WASAPI loopback capture via AUDIOCLIENT_ACTIVATION_PARAMS
// (Windows 10 2004+ / Build 19041+).
//
// Threading: ALL WASAPI init runs on the capture thread (MTA). If called from the
// WinForms UI thread (STA), ActivateAudioInterfaceAsync fires ActivateCompleted on
// an MTA pool thread; COM marshals that back to the STA pump — but the STA thread is
// blocked on CompletionEvent.Wait → deadlock. MTA capture thread avoids this.
//
// COM QI policy: the COM objects returned by the process-loopback activation path
// reject QueryInterface for their own IIDs under .NET's RCW mechanism. Every call
// to IAudioClient and IAudioCaptureClient is therefore dispatched via raw vtable
// pointer arithmetic, bypassing .NET COM interop entirely.
namespace VoiceCat.App.Audio;
public sealed class ProcessLoopbackCapture : IDisposable
{
public enum Mode { Include, Exclude }
// Fired on the capture thread every 20 ms (960 samples/channel @ 48 kHz, interleaved s16).
public event Action<short[], int /*samplesPerChannel*/, int /*channels*/>? PcmFrameReady;
private const int SampleRate = 48000;
private const int FrameSamples = 960; // 20 ms
private const string LoopbackDevicePath = "VAD\\Process_Loopback";
private readonly int _pid;
private readonly Mode _mode;
// Raw COM pointers — managed via explicit AddRef/Release, no RCW wrapping.
private IntPtr _audioClientPtr; // IAudioClient*
private IntPtr _captureClientPtr; // IAudioCaptureClient*
private AutoResetEvent? _bufferEvent;
private Thread? _captureThread;
private volatile bool _running;
private int _channels;
// Accumulator: assembles driver-callback-sized fragments into FrameSamples chunks.
private short[] _accumBuf = [];
private int _accumCount;
// Init-done signal: Set() by the capture thread after ActivateClient() completes.
private readonly ManualResetEventSlim _initDone = new(false);
private bool _initOk;
public ProcessLoopbackCapture(int pid, Mode mode)
{
_pid = pid;
_mode = mode;
}
/// <summary>Starts capture. Blocks until WASAPI activation completes (typically &lt;100 ms).
/// Returns false if the process cannot be captured.</summary>
public bool Start()
{
if (_running) return false;
_running = true;
_captureThread = new Thread(CaptureThreadProc)
{
IsBackground = true,
Name = $"ProcLoopback:{_pid}",
};
_captureThread.Start();
bool ok = _initDone.Wait(5000) && _initOk;
if (!ok) _running = false;
return ok;
}
public void Stop()
{
_running = false;
_bufferEvent?.Set();
_captureThread?.Join(500);
if (_audioClientPtr != IntPtr.Zero) AC_Stop(_audioClientPtr);
}
public void Dispose()
{
Stop();
ComRelease(ref _captureClientPtr);
ComRelease(ref _audioClientPtr);
_bufferEvent?.Dispose();
_initDone.Dispose();
}
// ── Capture thread (MTA) ──────────────────────────────────────────────────
private void CaptureThreadProc()
{
_initOk = ActivateAndStart();
_initDone.Set();
if (!_initOk) return;
CaptureLoop();
}
private bool ActivateAndStart()
{
if (!ActivateClient()) return false;
_bufferEvent = new AutoResetEvent(false);
if (AC_SetEventHandle(_audioClientPtr, _bufferEvent.SafeWaitHandle.DangerousGetHandle()) < 0)
return false;
return AC_Start(_audioClientPtr) >= 0;
}
// ── Activation ───────────────────────────────────────────────────────────
private unsafe bool ActivateClient()
{
var activationParams = new AudioClientActivationParams
{
ActivationType = 1, // AUDCLNT_ACTIVATIONTYPE_PROCESS_LOOPBACK
TargetProcessId = (uint)_pid,
ProcessLoopbackMode = _mode == Mode.Include ? 0u : 1u,
};
var handler = new ActivationCompletionHandler();
var audioClientIid = new Guid("1CB9AD4C-DBFA-4c32-B178-C2F568A703B2");
IntPtr opPtr;
{
AudioClientActivationParams* pParams = &activationParams;
// PROPVARIANT (VT_BLOB) x64: vt(2)+res(6)+cbSize(4)+pad(4)+pBlobData(8) = 24 B.
var pv = stackalloc byte[24];
*(ushort*)(pv + 0) = 65;
*(uint*) (pv + 8) = (uint)sizeof(AudioClientActivationParams);
*(nint*) (pv + 16) = (nint)pParams;
int hr = ActivateAudioInterfaceAsync(
LoopbackDevicePath, ref audioClientIid,
(IntPtr)pv, handler, out opPtr);
if (hr < 0) return false;
}
if (!handler.CompletionEvent.Wait(3000))
{
ComRelease(ref opPtr);
return false;
}
// Call GetActivateResult via vtable (slot 3) — avoids QI on the async-op object.
if (!Vtable_GetActivateResult(handler.OperationPtr, out int activateHr, out IntPtr activatedPtr))
{
handler.ReleaseOp();
ComRelease(ref opPtr);
return false;
}
handler.ReleaseOp();
ComRelease(ref opPtr);
if (activateHr < 0 || activatedPtr == IntPtr.Zero) return false;
// Store the raw IAudioClient* — do NOT create an RCW; use vtable dispatch instead.
_audioClientPtr = activatedPtr;
// activatedPtr already has ref count from GetActivateResult; don't double-release.
return InitializeStream();
}
// IActivateAudioInterfaceAsyncOperation vtable slot 3: GetActivateResult(HRESULT*, IUnknown**)
private static unsafe bool Vtable_GetActivateResult(IntPtr op,
out int activateHr, out IntPtr activatedPtr)
{
activateHr = unchecked((int)0x80004005);
activatedPtr = IntPtr.Zero;
if (op == IntPtr.Zero) return false;
void** vtable = *(void***)op.ToPointer();
var fn = (delegate* unmanaged[Stdcall]<IntPtr, int*, IntPtr*, int>)vtable[3];
fixed (int* pHr = &activateHr)
fixed (IntPtr* pPtr = &activatedPtr)
return fn(op, pHr, pPtr) >= 0;
}
private unsafe bool InitializeStream()
{
// Try s16 stereo first; fall back to s16 mono.
foreach (int ch in new[] { 2, 1 })
{
var fmt = new WaveFormatEx
{
wFormatTag = 1, // WAVE_FORMAT_PCM
nChannels = (ushort)ch,
nSamplesPerSec = SampleRate,
wBitsPerSample = 16,
nBlockAlign = (ushort)(ch * 2),
nAvgBytesPerSec = (uint)(SampleRate * ch * 2),
cbSize = 0,
};
// Process-loopback requires LOOPBACK (deliver rendered audio) + EVENTCALLBACK +
// AUTOCONVERTPCM (resample the app's native format to our requested s16/48k). Without
// LOOPBACK every buffer comes back AUDCLNT_BUFFERFLAGS_SILENT; without AUTOCONVERTPCM
// the requested format is rejected. Matches the MS ApplicationLoopback sample.
// AUDCLNT_SHAREMODE_SHARED = 0
// AUDCLNT_STREAMFLAGS_LOOPBACK = 0x00020000
// AUDCLNT_STREAMFLAGS_EVENTCALLBACK = 0x00040000
// AUDCLNT_STREAMFLAGS_AUTOCONVERTPCM = 0x80000000
const uint streamFlags = 0x00020000u | 0x00040000u | 0x80000000u;
int hr = AC_Initialize(_audioClientPtr, 0, streamFlags,
2_000_000 /*200ms hns*/, 0, &fmt, null);
if (hr >= 0)
{
_channels = ch;
_accumBuf = new short[FrameSamples * ch];
_accumCount = 0;
break;
}
if (ch == 1) return false;
}
var captureIid = new Guid("C8ADBD64-E71E-48a0-A4DE-185C395CD317");
int getHr = AC_GetService(_audioClientPtr, ref captureIid, out _captureClientPtr);
return getHr >= 0 && _captureClientPtr != IntPtr.Zero;
}
// ── Capture loop ─────────────────────────────────────────────────────────
private void CaptureLoop()
{
while (_running)
{
_bufferEvent!.WaitOne(100);
if (!_running) break;
while (_running)
{
int hr = CC_GetNextPacketSize(_captureClientPtr, out uint packetSize);
if (hr < 0 || packetSize == 0) break;
hr = CC_GetBuffer(_captureClientPtr, out IntPtr dataPtr, out uint framesAvailable,
out uint flags);
if (hr < 0) break;
bool silent = (flags & 2) != 0; // AUDCLNT_BUFFERFLAGS_SILENT
if (framesAvailable > 0)
{
if (silent) AccumulateSilence((int)framesAvailable);
else AccumulatePcm(dataPtr, (int)framesAvailable);
}
CC_ReleaseBuffer(_captureClientPtr, framesAvailable);
}
}
}
private unsafe void AccumulatePcm(IntPtr data, int frames)
{
var src = (short*)data.ToPointer();
int total = frames * _channels;
int idx = 0;
while (idx < total)
{
int space = _accumBuf.Length - _accumCount;
int copy = Math.Min(total - idx, space);
fixed (short* dst = _accumBuf)
Buffer.MemoryCopy(src + idx, dst + _accumCount, copy * 2L, copy * 2L);
_accumCount += copy;
idx += copy;
if (_accumCount == _accumBuf.Length)
FlushFrame();
}
}
private void AccumulateSilence(int frames)
{
int total = frames * _channels;
int idx = 0;
while (idx < total)
{
int space = _accumBuf.Length - _accumCount;
int fill = Math.Min(total - idx, space);
Array.Clear(_accumBuf, _accumCount, fill);
_accumCount += fill;
idx += fill;
if (_accumCount == _accumBuf.Length)
FlushFrame();
}
}
private void FlushFrame()
{
var copy = new short[_accumBuf.Length];
_accumBuf.AsSpan().CopyTo(copy);
PcmFrameReady?.Invoke(copy, FrameSamples, _channels);
_accumCount = 0;
}
// ── IAudioClient vtable helpers (raw dispatch, no RCW / no QI) ───────────
//
// Vtable layout (IUnknown base: 0=QI 1=AddRef 2=Release; then IAudioClient methods):
// 3=Initialize 4=GetBufferSize 5=GetStreamLatency 6=GetCurrentPadding
// 7=IsFormatSupported 8=GetMixFormat 9=GetDevicePeriod
// 10=Start 11=Stop 12=Reset 13=SetEventHandle 14=GetService
private static unsafe int AC_Initialize(IntPtr ac, int shareMode, uint streamFlags,
long hnsBufferDuration, long hnsPeriodicity, WaveFormatEx* pFormat, Guid* pSession)
{
var fn = (delegate* unmanaged[Stdcall]<IntPtr, int, uint, long, long, WaveFormatEx*, Guid*, int>)
(*(void***)ac)[3];
return fn(ac, shareMode, streamFlags, hnsBufferDuration, hnsPeriodicity, pFormat, pSession);
}
private static unsafe int AC_Start(IntPtr ac)
{
var fn = (delegate* unmanaged[Stdcall]<IntPtr, int>)(*(void***)ac)[10];
return fn(ac);
}
private static unsafe int AC_Stop(IntPtr ac)
{
var fn = (delegate* unmanaged[Stdcall]<IntPtr, int>)(*(void***)ac)[11];
return fn(ac);
}
private static unsafe int AC_SetEventHandle(IntPtr ac, IntPtr eventHandle)
{
var fn = (delegate* unmanaged[Stdcall]<IntPtr, IntPtr, int>)(*(void***)ac)[13];
return fn(ac, eventHandle);
}
private static unsafe int AC_GetService(IntPtr ac, ref Guid riid, out IntPtr ppv)
{
var fn = (delegate* unmanaged[Stdcall]<IntPtr, Guid*, IntPtr*, int>)(*(void***)ac)[14];
fixed (Guid* pIid = &riid)
fixed (IntPtr* pPpv = &ppv)
return fn(ac, pIid, pPpv);
}
// ── IAudioCaptureClient vtable helpers ────────────────────────────────────
//
// Vtable (IUnknown: 0-2; then): 3=GetBuffer 4=ReleaseBuffer 5=GetNextPacketSize
private static unsafe int CC_GetBuffer(IntPtr cc, out IntPtr ppData,
out uint pNumFrames, out uint pdwFlags)
{
var fn = (delegate* unmanaged[Stdcall]<IntPtr, IntPtr*, uint*, uint*, ulong*, ulong*, int>)
(*(void***)cc)[3];
ulong devPos = 0, qpcPos = 0;
fixed (IntPtr* p0 = &ppData)
fixed (uint* p1 = &pNumFrames)
fixed (uint* p2 = &pdwFlags)
return fn(cc, p0, p1, p2, &devPos, &qpcPos);
}
private static unsafe int CC_ReleaseBuffer(IntPtr cc, uint numFrames)
{
var fn = (delegate* unmanaged[Stdcall]<IntPtr, uint, int>)(*(void***)cc)[4];
return fn(cc, numFrames);
}
private static unsafe int CC_GetNextPacketSize(IntPtr cc, out uint pNumFrames)
{
var fn = (delegate* unmanaged[Stdcall]<IntPtr, uint*, int>)(*(void***)cc)[5];
fixed (uint* p = &pNumFrames)
return fn(cc, p);
}
// ── COM utilities ─────────────────────────────────────────────────────────
private static unsafe void ComRelease(ref IntPtr ptr)
{
if (ptr == IntPtr.Zero) return;
var fn = (delegate* unmanaged[Stdcall]<IntPtr, uint>)(*(void***)ptr)[2]; // IUnknown::Release
fn(ptr);
ptr = IntPtr.Zero;
}
// ── P/Invoke & structs ────────────────────────────────────────────────────
[DllImport("Mmdevapi.dll", CharSet = CharSet.Unicode)]
private static extern int ActivateAudioInterfaceAsync(
string deviceInterfacePath,
ref Guid riid,
IntPtr activationParams,
[MarshalAs(UnmanagedType.Interface)] IActivateAudioInterfaceCompletionHandler completionHandler,
out IntPtr activationOperation);
[StructLayout(LayoutKind.Sequential)]
private struct AudioClientActivationParams
{
public int ActivationType; // AUDCLNT_ACTIVATIONTYPE_PROCESS_LOOPBACK = 1
public uint TargetProcessId;
public uint ProcessLoopbackMode; // INCLUDE=0, EXCLUDE=1
}
[StructLayout(LayoutKind.Sequential, Pack = 2)]
private struct WaveFormatEx
{
public ushort wFormatTag;
public ushort nChannels;
public uint nSamplesPerSec;
public uint nAvgBytesPerSec;
public ushort nBlockAlign;
public ushort wBitsPerSample;
public ushort cbSize;
}
// ── Completion handler CCW ────────────────────────────────────────────────
//
// Only this object still uses .NET COM interop (as a CCW). The activateOperation
// parameter is IntPtr to avoid QI on the incoming async-op pointer.
[ComVisible(true), ClassInterface(ClassInterfaceType.None)]
private sealed class ActivationCompletionHandler : IActivateAudioInterfaceCompletionHandler
{
public readonly ManualResetEventSlim CompletionEvent = new(false);
public IntPtr OperationPtr { get; private set; }
public void ActivateCompleted(IntPtr activateOperation)
{
OperationPtr = activateOperation;
if (OperationPtr != IntPtr.Zero) Marshal.AddRef(OperationPtr);
CompletionEvent.Set();
}
public void ReleaseOp()
{
if (OperationPtr == IntPtr.Zero) return;
Marshal.Release(OperationPtr);
OperationPtr = IntPtr.Zero;
}
}
[ComImport, Guid("41D949AB-9862-444A-80F6-C261334DA5EB"),
InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
private interface IActivateAudioInterfaceCompletionHandler
{
void ActivateCompleted(IntPtr activateOperation);
}
}

View File

@@ -0,0 +1,166 @@
using VoiceCat.App.Audio;
namespace VoiceCat.App.Forms;
/// <summary>
/// Modal dialog for selecting which apps' audio to share.
/// Returns the chosen <see cref="AppAudioScope"/> via <see cref="ChosenScope"/>,
/// or <see cref="DialogResult.Cancel"/> if the user dismissed without sharing.
/// </summary>
public sealed class AppAudioPickerDialog : Form
{
private readonly RadioButton _rdoAll;
private readonly RadioButton _rdoOnly;
private readonly RadioButton _rdoExcept;
private readonly ListView _appList;
private readonly Label _lblApps;
// Snapshot taken when the dialog opens (refresh on open, not on every check change).
private IReadOnlyList<AudioAppInfo> _apps = [];
public AppAudioScope? ChosenScope { get; private set; }
public AppAudioPickerDialog()
{
// ── Radio buttons ───────────────────────────────────────────────────
_rdoAll = new RadioButton
{
Text = "&Entire desktop",
Checked = true,
Location = new Point(12, 12),
Size = new Size(360, 20),
TabIndex = 0,
};
_rdoOnly = new RadioButton
{
Text = "&Only selected apps",
Location = new Point(12, 36),
Size = new Size(360, 20),
TabIndex = 1,
};
_rdoExcept = new RadioButton
{
Text = "All apps e&xcept selected",
Location = new Point(12, 60),
Size = new Size(360, 20),
TabIndex = 2,
};
_rdoAll.CheckedChanged += OnModeChanged;
_rdoOnly.CheckedChanged += OnModeChanged;
_rdoExcept.CheckedChanged += OnModeChanged;
// ── App list ────────────────────────────────────────────────────────
_lblApps = new Label
{
Text = "Apps with active audio sessions:",
AutoSize = true,
Location = new Point(12, 90),
Visible = false,
TabIndex = 3,
};
_appList = new ListView
{
Location = new Point(12, 112),
Size = new Size(360, 180),
CheckBoxes = true,
View = View.List,
Visible = false,
TabIndex = 4,
FullRowSelect = true,
};
// ── Buttons ─────────────────────────────────────────────────────────
var btnShare = new Button
{
Text = "&Share",
DialogResult = DialogResult.OK,
Location = new Point(216, 308),
Size = new Size(75, 27),
TabIndex = 5,
};
var btnCancel = new Button
{
Text = "&Cancel",
DialogResult = DialogResult.Cancel,
Location = new Point(297, 308),
Size = new Size(75, 27),
TabIndex = 6,
};
btnShare.Click += OnShareClick;
AcceptButton = btnShare;
CancelButton = btnCancel;
AutoScaleMode = AutoScaleMode.Font;
ClientSize = new Size(384, 348);
Controls.AddRange([_rdoAll, _rdoOnly, _rdoExcept, _lblApps, _appList, btnShare, btnCancel]);
FormBorderStyle = FormBorderStyle.FixedDialog;
MaximizeBox = false;
MinimizeBox = false;
StartPosition = FormStartPosition.CenterParent;
Text = "Share App Audio";
}
protected override void OnLoad(EventArgs e)
{
base.OnLoad(e);
RefreshAppList();
}
private void RefreshAppList()
{
_apps = AudioSessionEnumerator.GetAudioApps();
_appList.Items.Clear();
foreach (var app in _apps)
_appList.Items.Add(new ListViewItem($"{app.DisplayName} (PID {app.Pid})") { Tag = app.Pid });
}
private void OnModeChanged(object? sender, EventArgs e)
{
bool showList = _rdoOnly.Checked || _rdoExcept.Checked;
_lblApps.Visible = showList;
_appList.Visible = showList;
if (showList && _appList.Items.Count == 0)
RefreshAppList();
}
private void OnShareClick(object? sender, EventArgs e)
{
if (_rdoAll.Checked)
{
ChosenScope = new EntireDesktop();
return;
}
var checkedPids = new List<int>();
var checkedNames = new List<string>();
foreach (ListViewItem item in _appList.CheckedItems)
{
if (item.Tag is int pid)
{
checkedPids.Add(pid);
checkedNames.Add(item.Text);
}
}
if (checkedPids.Count == 0)
{
MessageBox.Show(
"Select at least one app, or choose 'Entire desktop'.",
"No apps selected",
MessageBoxButtons.OK,
MessageBoxIcon.Warning);
DialogResult = DialogResult.None; // prevent close
return;
}
ChosenScope = _rdoOnly.Checked
? new OnlyApps(checkedPids, checkedNames)
: new AllExceptApps(checkedPids, checkedNames);
}
/// <summary>All apps that were visible in the list when the user clicked Share.</summary>
public IReadOnlyList<AudioAppInfo> VisibleApps => _apps;
}

View File

@@ -1,3 +1,4 @@
using VoiceCat.App.Audio;
using VoiceCat.Interop; using VoiceCat.Interop;
namespace VoiceCat.App.Forms; namespace VoiceCat.App.Forms;
@@ -22,6 +23,7 @@ public partial class MainForm : Form
// Voice state // Voice state
private uint _micStreamId; // 0 = not started private uint _micStreamId; // 0 = not started
private uint _screenStreamId; // 0 = not sharing screen audio private uint _screenStreamId; // 0 = not sharing screen audio
private ProcessAudioMixer? _screenMixer; // non-null only in per-app capture mode
private Keys _pttKey = Keys.F8; private Keys _pttKey = Keys.F8;
private bool _serverMuted; private bool _serverMuted;
private bool _serverDeafened; private bool _serverDeafened;
@@ -442,6 +444,7 @@ public partial class MainForm : Form
_currentChannelId = 0; _currentChannelId = 0;
_micStreamId = 0; _micStreamId = 0;
_screenStreamId = 0; _screenStreamId = 0;
_screenMixer?.Stop(); _screenMixer?.Dispose(); _screenMixer = null;
txtCompose.Enabled = false; txtCompose.Enabled = false;
btnSend.Enabled = false; btnSend.Enabled = false;
tsbJoinVoice.Enabled = false; tsbJoinVoice.Enabled = false;
@@ -622,28 +625,72 @@ public partial class MainForm : Form
{ {
if (_screenStreamId == 0) if (_screenStreamId == 0)
{ {
var (result, streamId) = _client.StartStream(VcStreamKind.ScreenAudio, "Desktop audio"); StartScreenAudio();
if (result == VcResult.Ok)
{
_screenStreamId = streamId;
tsbScreenShare.Text = "Stop Screen Audio";
_miScreenShare.Text = "Stop Screen &Audio";
AddActivity("Started sharing screen audio");
} }
else else
{
StopScreenAudio();
}
}
private void StartScreenAudio()
{
using var picker = new AppAudioPickerDialog();
if (picker.ShowDialog(this) != DialogResult.OK || picker.ChosenScope == null) return;
var scope = picker.ChosenScope;
if (scope is EntireDesktop)
{
// Existing whole-device WASAPI loopback path — core handles it.
var (result, streamId) = _client.StartStream(VcStreamKind.ScreenAudio, "Desktop audio");
if (result != VcResult.Ok)
{ {
AddActivity($"Failed to start screen audio: {result}"); AddActivity($"Failed to start screen audio: {result}");
return;
} }
_screenStreamId = streamId;
AddActivity("Sharing screen audio: entire desktop");
} }
else else
{ {
// Per-app path: suppress core loopback, C# mixer feeds PCM.
var (result, streamId) = _client.StartStreamExternalFeed(VcStreamKind.ScreenAudio, "App audio");
if (result != VcResult.Ok)
{
AddActivity($"Failed to start screen audio: {result}");
return;
}
_screenStreamId = streamId;
_screenMixer = new ProcessAudioMixer();
_screenMixer.Start(scope, _client, streamId, picker.VisibleApps);
string desc = scope switch
{
OnlyApps o => $"only {o.Pids.Count} app(s)",
AllExceptApps a => $"all except {a.Pids.Count} app(s)",
_ => "apps",
};
AddActivity($"Sharing screen audio: {desc}");
}
tsbScreenShare.Text = "Stop Screen Audio";
_miScreenShare.Text = "Stop Screen &Audio";
}
private void StopScreenAudio()
{
_screenMixer?.Stop();
_screenMixer?.Dispose();
_screenMixer = null;
_client.StopStream(_screenStreamId); _client.StopStream(_screenStreamId);
_screenStreamId = 0; _screenStreamId = 0;
tsbScreenShare.Text = "Share Screen Audio"; tsbScreenShare.Text = "Share Screen Audio";
_miScreenShare.Text = "Share Screen &Audio"; _miScreenShare.Text = "Share Screen &Audio";
AddActivity("Stopped sharing screen audio"); AddActivity("Stopped sharing screen audio");
} }
}
private void TrkOutputVolume_Scroll(object? sender, EventArgs e) => private void TrkOutputVolume_Scroll(object? sender, EventArgs e) =>
_client.SetOutputVolume(trkOutputVolume.Value / 100f); _client.SetOutputVolume(trkOutputVolume.Value / 100f);
@@ -1019,7 +1066,7 @@ public partial class MainForm : Form
_client.EventReceived -= OnEvent; _client.EventReceived -= OnEvent;
foreach (var win in _pmWindows.Values.ToList()) win.Close(); foreach (var win in _pmWindows.Values.ToList()) win.Close();
_pmWindows.Clear(); _pmWindows.Clear();
if (_screenStreamId != 0) _client.StopStream(_screenStreamId); if (_screenStreamId != 0) { _screenMixer?.Stop(); _screenMixer?.Dispose(); _screenMixer = null; _client.StopStream(_screenStreamId); }
if (_micStreamId != 0) _client.StopStream(_micStreamId); if (_micStreamId != 0) _client.StopStream(_micStreamId);
_client.Disconnect(); _client.Disconnect();
_client.Dispose(); _client.Dispose();

View File

@@ -18,6 +18,9 @@
WinForms' own analyzer (WFO0003) flags manifest-based DPI settings as superseded by WinForms' own analyzer (WFO0003) flags manifest-based DPI settings as superseded by
this property in modern .NET. --> this property in modern .NET. -->
<ApplicationHighDpiMode>PerMonitorV2</ApplicationHighDpiMode> <ApplicationHighDpiMode>PerMonitorV2</ApplicationHighDpiMode>
<!-- WASAPI process-loopback COM interop in Audio/ProcessLoopbackCapture.cs uses unsafe
pointer arithmetic for PROPVARIANT/AUDIOCLIENT_ACTIVATION_PARAMS marshaling. -->
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup> </PropertyGroup>
<!-- System.Security.Cryptography.ProtectedData (PasswordProtector.cs's DPAPI wrapper) ships <!-- System.Security.Cryptography.ProtectedData (PasswordProtector.cs's DPAPI wrapper) ships

View File

@@ -46,6 +46,9 @@ internal struct VcStreamDescNative
public IntPtr DeviceId; // unused by vc_stream_start today — device selection is a public IntPtr DeviceId; // unused by vc_stream_start today — device selection is a
// separate vc_set_input_device call; always IntPtr.Zero here. // separate vc_set_input_device call; always IntPtr.Zero here.
public IntPtr Label; public IntPtr Label;
// Mirrors vc_stream_desc::external_feed. When 1, the core skips its own WASAPI loopback
// and the caller feeds PCM via StreamFeedPcm (per-app capture path on Windows).
public int ExternalFeed;
} }
[StructLayout(LayoutKind.Sequential)] [StructLayout(LayoutKind.Sequential)]

View File

@@ -186,6 +186,26 @@ public sealed class VoiceCatClient : IDisposable
} }
} }
/// <summary>
/// Like <see cref="StartStream"/> but sets <c>external_feed = 1</c> so the core skips its
/// own WASAPI loopback. The caller is responsible for feeding PCM via
/// <see cref="StreamFeedPcm"/>. Used by the Windows per-app capture path.
/// </summary>
public (VcResult Result, uint StreamId) StartStreamExternalFeed(VcStreamKind kind, string label)
{
nint labelPtr = Marshal.StringToCoTaskMemUTF8(label);
try
{
var desc = new VcStreamDescNative { Kind = kind, DeviceId = 0, Label = labelPtr, ExternalFeed = 1 };
var r = NativeMethods.vc_stream_start(_handle.DangerousGetHandle(), in desc, out uint streamId);
return (r, streamId);
}
finally
{
Marshal.FreeCoTaskMem(labelPtr);
}
}
public VcResult StopStream(uint streamId) => public VcResult StopStream(uint streamId) =>
NativeMethods.vc_stream_stop(_handle.DangerousGetHandle(), streamId); NativeMethods.vc_stream_stop(_handle.DangerousGetHandle(), streamId);

View File

@@ -207,6 +207,12 @@ typedef struct vc_stream_desc {
vc_stream_kind kind; vc_stream_kind kind;
const char* device_id; /* NULL = default device for this kind */ const char* device_id; /* NULL = default device for this kind */
const char* label; /* human label, e.g. "Microphone" */ const char* label; /* human label, e.g. "Microphone" */
/* If 1, the caller will feed PCM via vc_stream_feed_pcm; the core will NOT start its own
* WASAPI loopback capture. Only meaningful for VC_STREAM_SCREEN_AUDIO on Windows. Callers
* that brace-initialize this struct (tests, macOS) get 0 = auto-start loopback — no ABI
* break. See clients/windows/VoiceCat.App/Audio/ProcessAudioMixer.cs for the Windows
* per-app capture implementation that sets this. */
int external_feed;
} vc_stream_desc; } vc_stream_desc;
/* The effective Opus configuration in use for a stream — for a stream you own, this is /* The effective Opus configuration in use for a stream — for a stream you own, this is

View File

@@ -1162,6 +1162,7 @@ vc_result vc_client::stream_start(const vc_stream_desc& desc, uint32_t* out_stre
uint32_t sid = next_local_stream_id_++; uint32_t sid = next_local_stream_id_++;
ls.stream_id = sid; ls.stream_id = sid;
ls.pending = true; ls.pending = true;
ls.external_feed = (desc.external_feed != 0);
if (out_stream_id) *out_stream_id = sid; if (out_stream_id) *out_stream_id = sid;
req_id = next_req_id_++; req_id = next_req_id_++;
@@ -1196,6 +1197,7 @@ void vc_client::handle_stream_announce_result(uint64_t req_id,
bool ok_to_emit = false; bool ok_to_emit = false;
int kind; int kind;
int loopback_channels = 1; // only meaningful for SCREEN_AUDIO; set under the lock int loopback_channels = 1; // only meaningful for SCREEN_AUDIO; set under the lock
bool loopback_external = false;
{ {
std::lock_guard lk(local_streams_mu_); std::lock_guard lk(local_streams_mu_);
auto pit = pending_announce_kind_.find(req_id); auto pit = pending_announce_kind_.find(req_id);
@@ -1240,11 +1242,12 @@ void vc_client::handle_stream_announce_result(uint64_t req_id,
// the lock alongside the rest of the LocalStream setup; used below after unlock. // the lock alongside the rest of the LocalStream setup; used below after unlock.
if (kind == static_cast<int>(VC_STREAM_SCREEN_AUDIO)) { if (kind == static_cast<int>(VC_STREAM_SCREEN_AUDIO)) {
loopback_channels = ls.effective_params.stereo ? 2 : 1; loopback_channels = ls.effective_params.stereo ? 2 : 1;
loopback_external = ls.external_feed;
} }
} }
ensure_audio_running(); ensure_audio_running();
if (kind == static_cast<int>(VC_STREAM_SCREEN_AUDIO)) { if (kind == static_cast<int>(VC_STREAM_SCREEN_AUDIO) && !loopback_external) {
audio_engine_.start_loopback_capture(kind, loopback_channels); audio_engine_.start_loopback_capture(kind, loopback_channels);
} }
@@ -1261,6 +1264,7 @@ vc_result vc_client::stream_stop(uint32_t stream_id) {
if (state_net_.load(std::memory_order_acquire) != VC_STATE_CONNECTED) return VC_ERR_NOT_CONNECTED; if (state_net_.load(std::memory_order_acquire) != VC_STATE_CONNECTED) return VC_ERR_NOT_CONNECTED;
int stopped_kind = -1; int stopped_kind = -1;
bool stopped_external = false;
{ {
std::lock_guard lk(local_streams_mu_); std::lock_guard lk(local_streams_mu_);
for (auto& [k, ls] : local_streams_) { for (auto& [k, ls] : local_streams_) {
@@ -1268,11 +1272,12 @@ vc_result vc_client::stream_stop(uint32_t stream_id) {
} }
LocalStream* ls = find_local_stream_by_id(stream_id); LocalStream* ls = find_local_stream_by_id(stream_id);
if (!ls || !ls->active.load(std::memory_order_acquire)) return VC_ERR_INVALID_ARG; if (!ls || !ls->active.load(std::memory_order_acquire)) return VC_ERR_INVALID_ARG;
stopped_external = ls->external_feed;
ls->active.store(false, std::memory_order_release); ls->active.store(false, std::memory_order_release);
ls->encoder.destroy(); ls->encoder.destroy();
} }
if (stopped_kind == static_cast<int>(VC_STREAM_SCREEN_AUDIO)) { if (stopped_kind == static_cast<int>(VC_STREAM_SCREEN_AUDIO) && !stopped_external) {
audio_engine_.stop_loopback_capture(); audio_engine_.stop_loopback_capture();
} }

View File

@@ -246,6 +246,11 @@ struct vc_client {
// VC_STREAM_MIC. Set via vc_set_capture_channels; read by ensure_audio_running() to // VC_STREAM_MIC. Set via vc_set_capture_channels; read by ensure_audio_running() to
// configure AudioParams.capture_channels before the device opens. Defaults to 1 (mono). // configure AudioParams.capture_channels before the device opens. Defaults to 1 (mono).
uint32_t capture_channels = 1; uint32_t capture_channels = 1;
// If true, the caller is feeding PCM via vc_stream_feed_pcm — skip start/stop of the
// core's WASAPI loopback device. Set from vc_stream_desc::external_feed at stream_start
// time and checked in handle_stream_announce_result / stream_stop.
bool external_feed = false;
}; };
mutable std::mutex local_streams_mu_; mutable std::mutex local_streams_mu_;
std::unordered_map<int, LocalStream> local_streams_; // keyed by vc_stream_kind std::unordered_map<int, LocalStream> local_streams_; // keyed by vc_stream_kind