fix(windows): real native exclude + self-echo removal for screen audio

"All apps except selected" previously captured the complement of a frozen
app snapshot in INCLUDE mode (missed late-launched apps and system sounds,
wasted captures on silent windows). It now opens a single ProcessLoopbackCapture
in EXCLUDE mode (AUDIOCLIENT_PROCESS_LOOPBACK_MODE_EXCLUDE_TARGET_PROCESS_TREE)
of the one chosen app — true system-mix-minus-one, dynamic so apps launched
after sharing starts are included. The picker enforces single-selection in
exclude mode (the activation params take one target PID).

Adds an "Exclude VoiceCat's own audio (prevents echo)" checkbox (default on,
entire-desktop only) that routes the desktop capture through the same EXCLUDE
path targeting our own process id, killing the whole-device self-echo loop.

No C++/ABI changes. Updates voice.md and PROGRESS.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-22 12:28:53 +02:00
parent fb73b694d0
commit 5e18dfa1c9
6 changed files with 131 additions and 34 deletions

View File

@@ -22,15 +22,14 @@ public sealed class ProcessAudioMixer : IDisposable
private VoiceCatClient? _client;
private uint _streamId;
public void Start(AppAudioScope scope, VoiceCatClient client, uint streamId,
IReadOnlyList<AudioAppInfo> allApps)
public void Start(AppAudioScope scope, VoiceCatClient client, uint streamId)
{
if (_running) return;
_client = client;
_streamId = streamId;
var pids = ResolvePids(scope, allApps);
if (pids.Count == 0)
var specs = ResolveCaptures(scope);
if (specs.Count == 0)
{
// nothing to capture — scope resolved to empty set
return;
@@ -38,14 +37,15 @@ public sealed class ProcessAudioMixer : IDisposable
lock (_frameLock)
{
_latestFrames = new List<short[]>(new short[pids.Count][]);
_latestFrames = new List<short[]>(new short[specs.Count][]);
_activeChannels = Channels;
}
for (int i = 0; i < pids.Count; i++)
for (int i = 0; i < specs.Count; i++)
{
int captureIndex = i;
var cap = new ProcessLoopbackCapture(pids[i], ProcessLoopbackCapture.Mode.Include);
var (pid, mode) = specs[i];
var cap = new ProcessLoopbackCapture(pid, mode);
cap.PcmFrameReady += (pcm, spc, ch) => OnCaptureFrame(captureIndex, pcm, ch);
_captures.Add(cap);
}
@@ -117,18 +117,21 @@ public sealed class ProcessAudioMixer : IDisposable
// ── 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)
// Resolve the scope to the WASAPI captures to open:
// OnlyApps → one INCLUDE capture per selected process tree.
// AllExceptApps → one EXCLUDE capture of the single selected process tree, which
// natively captures the whole system render mix minus that tree
// (dynamic — apps launched later are included automatically).
private static List<(int pid, ProcessLoopbackCapture.Mode mode)> ResolveCaptures(AppAudioScope scope)
{
return scope switch
{
OnlyApps o => [.. o.Pids],
AllExceptApps a =>
allApps
.Where(app => !a.Pids.Contains(app.Pid))
.Select(app => app.Pid)
.ToList(),
OnlyApps o => o.Pids.Select(p => (p, ProcessLoopbackCapture.Mode.Include)).ToList(),
AllExceptApps a when a.Pids.Count > 0 =>
[(a.Pids[0], ProcessLoopbackCapture.Mode.Exclude)],
// Entire desktop minus VoiceCat itself: EXCLUDE our own process tree.
EntireDesktop { ExcludeSelf: true } =>
[(Environment.ProcessId, ProcessLoopbackCapture.Mode.Exclude)],
_ => [],
};
}