Port managed client audio and Windows application
This commit is contained in:
@@ -1,148 +1,85 @@
|
||||
using VoiceCat.Audio;
|
||||
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;
|
||||
|
||||
// Capture threads own their ring producers; the mix thread alone consumes them.
|
||||
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;
|
||||
|
||||
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;
|
||||
public void Start(AppAudioScope scope, VoiceCatClient client, uint streamId)
|
||||
{
|
||||
if (_running) return;
|
||||
_client = client;
|
||||
_streamId = streamId;
|
||||
|
||||
if (running) return;
|
||||
this.client = client; this.streamId = streamId;
|
||||
var specs = ResolveCaptures(scope);
|
||||
if (specs.Count == 0)
|
||||
inputs = specs.Select(_ => new Input()).ToArray();
|
||||
try
|
||||
{
|
||||
// nothing to capture — scope resolved to empty set
|
||||
return;
|
||||
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();
|
||||
}
|
||||
|
||||
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();
|
||||
catch { Stop(); throw; }
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
_running = false;
|
||||
_mixThread?.Join(500);
|
||||
foreach (var c in _captures) { c.Stop(); c.Dispose(); }
|
||||
_captures.Clear();
|
||||
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();
|
||||
|
||||
// ── 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)
|
||||
var frame = new short[1920]; var mix = new short[1920]; var sums = new int[1920];
|
||||
long deadline = System.Diagnostics.Stopwatch.GetTimestamp();
|
||||
while (running)
|
||||
{
|
||||
Thread.Sleep(periodMs);
|
||||
if (!_running) break;
|
||||
|
||||
short[] mix;
|
||||
lock (_frameLock)
|
||||
sums.AsSpan().Clear();
|
||||
foreach (Input input in inputs)
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
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];
|
||||
}
|
||||
|
||||
_client?.StreamFeedPcm(_streamId, mix, FrameSamples, (uint)_activeChannels);
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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)
|
||||
private static List<(int pid, ProcessLoopbackCapture.Mode mode)> ResolveCaptures(AppAudioScope scope) => scope switch
|
||||
{
|
||||
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;
|
||||
}
|
||||
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)],
|
||||
_ => [],
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user