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 _captures = []; // Per-capture latest frame, protected by _frameLock. private readonly object _frameLock = new(); private List _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(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]; 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; } }