2026-09-16 16:48:06 +02:00
|
|
|
using VoiceCat.Audio;
|
2026-06-22 00:19:38 +02:00
|
|
|
using VoiceCat.Interop;
|
|
|
|
|
|
|
|
|
|
namespace VoiceCat.App.Audio;
|
|
|
|
|
|
2026-09-16 16:48:06 +02:00
|
|
|
// Capture threads own their ring producers; the mix thread alone consumes them.
|
2026-06-22 00:19:38 +02:00
|
|
|
public sealed class ProcessAudioMixer : IDisposable
|
|
|
|
|
{
|
2026-09-16 16:48:06 +02:00
|
|
|
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<ProcessLoopbackCapture> captures = [];
|
|
|
|
|
private Input[] inputs = [];
|
|
|
|
|
private Thread? thread;
|
|
|
|
|
private volatile bool running;
|
|
|
|
|
private VoiceCatClient? client;
|
|
|
|
|
private uint streamId;
|
2026-06-22 12:28:53 +02:00
|
|
|
public void Start(AppAudioScope scope, VoiceCatClient client, uint streamId)
|
2026-06-22 00:19:38 +02:00
|
|
|
{
|
2026-09-16 16:48:06 +02:00
|
|
|
if (running) return;
|
|
|
|
|
this.client = client; this.streamId = streamId;
|
2026-06-22 12:28:53 +02:00
|
|
|
var specs = ResolveCaptures(scope);
|
2026-09-16 16:48:06 +02:00
|
|
|
inputs = specs.Select(_ => new Input()).ToArray();
|
|
|
|
|
try
|
2026-06-22 00:19:38 +02:00
|
|
|
{
|
2026-09-16 16:48:06 +02:00
|
|
|
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();
|
2026-06-22 00:19:38 +02:00
|
|
|
}
|
2026-09-16 16:48:06 +02:00
|
|
|
catch { Stop(); throw; }
|
2026-06-22 00:19:38 +02:00
|
|
|
}
|
|
|
|
|
public void Stop()
|
|
|
|
|
{
|
2026-09-16 16:48:06 +02:00
|
|
|
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;
|
2026-06-22 00:19:38 +02:00
|
|
|
}
|
|
|
|
|
public void Dispose() => Stop();
|
|
|
|
|
private void MixLoop()
|
|
|
|
|
{
|
2026-09-16 16:48:06 +02:00
|
|
|
var frame = new short[1920]; var mix = new short[1920]; var sums = new int[1920];
|
|
|
|
|
long deadline = System.Diagnostics.Stopwatch.GetTimestamp();
|
|
|
|
|
while (running)
|
2026-06-22 00:19:38 +02:00
|
|
|
{
|
2026-09-16 16:48:06 +02:00
|
|
|
sums.AsSpan().Clear();
|
|
|
|
|
foreach (Input input in inputs)
|
2026-06-22 00:19:38 +02:00
|
|
|
{
|
2026-09-16 16:48:06 +02:00
|
|
|
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];
|
2026-06-22 00:19:38 +02:00
|
|
|
}
|
2026-09-16 16:48:06 +02:00
|
|
|
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();
|
2026-06-22 00:19:38 +02:00
|
|
|
}
|
|
|
|
|
}
|
2026-09-16 16:48:06 +02:00
|
|
|
private static List<(int pid, ProcessLoopbackCapture.Mode mode)> ResolveCaptures(AppAudioScope scope) => scope switch
|
2026-06-22 00:19:38 +02:00
|
|
|
{
|
2026-09-16 16:48:06 +02:00
|
|
|
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)],
|
|
|
|
|
_ => [],
|
|
|
|
|
};
|
2026-06-22 00:19:38 +02:00
|
|
|
}
|