Files
voice-cat/clients/windows/VoiceCat.App/Audio/ProcessAudioMixer.cs
Talon 5e18dfa1c9 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>
2026-06-22 12:28:53 +02:00

150 lines
5.4 KiB
C#

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)
{
if (_running) return;
_client = client;
_streamId = streamId;
var specs = ResolveCaptures(scope);
if (specs.Count == 0)
{
// nothing to capture — scope resolved to empty set
return;
}
lock (_frameLock)
{
_latestFrames = new List<short[]>(new short[specs.Count][]);
_activeChannels = Channels;
}
for (int i = 0; i < specs.Count; i++)
{
int captureIndex = i;
var (pid, mode) = specs[i];
var cap = new ProcessLoopbackCapture(pid, mode);
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 ───────────────────────────────────────────────────────────────
// 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.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)],
_ => [],
};
}
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;
}
}