using VoiceCat.Audio; using VoiceCat.Interop; namespace VoiceCat.App.Audio; // Capture threads own their ring producers; the mix thread alone consumes them. public sealed class ProcessAudioMixer : IDisposable { private sealed class Input { internal readonly PcmRing Ring = new(16384); private readonly short[] stereo = new short[1920]; internal void Feed(short[] pcm, int channels) { if (channels == 2) { Ring.TryWrite(pcm); return; } if (channels != 1 || pcm.Length > 960) return; for (int i = 0; i < pcm.Length; i++) stereo[2 * i] = stereo[2 * i + 1] = pcm[i]; Ring.TryWrite(stereo.AsSpan(0, pcm.Length * 2)); } } private readonly List captures = []; private Input[] inputs = []; private Thread? thread; private volatile bool running; private VoiceCatClient? client; private uint streamId; public void Start(AppAudioScope scope, VoiceCatClient client, uint streamId) { if (running) return; this.client = client; this.streamId = streamId; var specs = ResolveCaptures(scope); inputs = specs.Select(_ => new Input()).ToArray(); try { for (int i = 0; i < specs.Count; i++) { Input input = inputs[i]; var (pid, mode) = specs[i]; var capture = new ProcessLoopbackCapture(pid, mode); capture.PcmFrameReady += (pcm, _, channels) => input.Feed(pcm, channels); captures.Add(capture); if (!capture.Start()) throw new InvalidOperationException("Process audio capture could not start."); } if (inputs.Length == 0) return; running = true; thread = new Thread(MixLoop) { IsBackground = true, Name = "ProcessAudioMixer" }; thread.Start(); } catch { Stop(); throw; } } public void Stop() { running = false; if (thread is not null && !thread.Join(5000)) throw new TimeoutException("Process audio mixer did not stop."); foreach (var capture in captures) capture.Dispose(); captures.Clear(); inputs = []; thread = null; } public void Dispose() => Stop(); private void MixLoop() { var frame = new short[1920]; var mix = new short[1920]; var sums = new int[1920]; long deadline = System.Diagnostics.Stopwatch.GetTimestamp(); while (running) { sums.AsSpan().Clear(); foreach (Input input in inputs) { while (input.Ring.Count > 11520) input.Ring.Read(frame); frame.AsSpan().Clear(); input.Ring.Read(frame); for (int i = 0; i < frame.Length; i++) sums[i] += frame[i]; } for (int i = 0; i < mix.Length; i++) mix[i] = (short)Math.Clamp(sums[i], short.MinValue, short.MaxValue); client?.StreamFeedPcm(streamId, mix, 960, 2); deadline += System.Diagnostics.Stopwatch.Frequency / 50; double wait = (deadline - System.Diagnostics.Stopwatch.GetTimestamp()) * 1000.0 / System.Diagnostics.Stopwatch.Frequency; if (wait > 0) Thread.Sleep((int)Math.Ceiling(wait)); else if (wait < -100) deadline = System.Diagnostics.Stopwatch.GetTimestamp(); } } private static List<(int pid, ProcessLoopbackCapture.Mode mode)> ResolveCaptures(AppAudioScope scope) => 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)], EntireDesktop { ExcludeSelf: true } => [(Environment.ProcessId, ProcessLoopbackCapture.Mode.Exclude)], _ => [], }; }