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>
147 lines
5.0 KiB
C#
147 lines
5.0 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,
|
|
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;
|
|
}
|
|
}
|