Fix stereo capture paths

This commit is contained in:
2026-09-21 02:11:33 +02:00
parent 08e6c5930a
commit 9be33f357d
6 changed files with 100 additions and 27 deletions
+34 -18
View File
@@ -14,9 +14,8 @@ internal sealed class IosAudioEngine
private AVAudioSourceNode? source;
private AVAudioFormat? outputFormat;
private VoiceCatClient? client;
private uint microphoneStream;
private int microphoneChannels = 1;
private readonly PcmRing microphoneRing = new(131_072);
private sealed record MicrophoneRoute(uint StreamId, int Channels, PcmRing Ring);
private MicrophoneRoute? microphone;
private readonly short[] microphoneFrame = new short[960 * 2];
private readonly CancellationTokenSource microphoneStop = new();
private readonly Task microphoneWorker;
@@ -39,11 +38,25 @@ internal sealed class IosAudioEngine
internal void StartMicrophone(uint streamId, int channels)
{
microphoneStream = streamId; microphoneChannels = Math.Clamp(channels, 1, 2); Rebuild();
Volatile.Write(ref microphone, new(streamId, Math.Clamp(channels, 1, 2), new(131_072))); Rebuild();
}
internal void StopMicrophone() { microphoneStream = 0; Rebuild(); }
internal void Reconfigure() { if (IsConnected) Rebuild(); }
internal void StopMicrophone() { Volatile.Write(ref microphone, null); Rebuild(); }
internal void Reconfigure()
{
if (!IsConnected) return;
MicrophoneRoute? current = Volatile.Read(ref microphone);
int channels = IosAudioRouter.Shared.CaptureChannels;
if (current is not null && current.Channels != channels)
{
// Stop the old tap before publishing a route with a different sample width. Otherwise
// an in-flight callback could interpret its old converter buffer using the new width.
DestroyGraph();
client!.Audio.SetCaptureChannels(current.StreamId, channels);
Volatile.Write(ref microphone, new(current.StreamId, channels, new(131_072)));
}
Rebuild();
}
internal bool EnsureRunning()
{
if (!IsConnected || engine?.Running == true) return true;
@@ -52,7 +65,7 @@ internal sealed class IosAudioEngine
private void Rebuild()
{
DestroyGraph(); IosAudioRouter.Shared.Apply();
DestroyGraph(); MicrophoneRoute? route = Volatile.Read(ref microphone); IosAudioRouter.Shared.Apply(route is not null);
var next = new AVAudioEngine();
outputFormat = new(AVAudioCommonFormat.PCMFloat32, 48_000, 2, false);
source = new(outputFormat, Render);
@@ -61,13 +74,13 @@ internal sealed class IosAudioEngine
if (OperatingSystem.IsIOSVersionAtLeast(27)) next.Connect(source, next.MainMixerNode, outputFormat, out connectionError);
else next.Connect(source, next.MainMixerNode, outputFormat);
if (connectionError is not null) throw new InvalidOperationException(connectionError.LocalizedDescription);
if (microphoneStream != 0)
if (route is not null)
{
AVAudioInputNode input = next.InputNode;
input.SetVoiceProcessingEnabled(IosAudioRouter.Shared.UsesVoiceProcessing, out _);
if (IosAudioRouter.Shared.UsesVoiceProcessing) input.VoiceProcessingAgcEnabled = IosAudioRouter.Shared.AutomaticGainControl;
AVAudioFormat inputFormat = input.GetBusOutputFormat(0);
microphoneFormat = new(AVAudioCommonFormat.PCMInt16, 48_000, (uint)microphoneChannels, true);
microphoneFormat = new(AVAudioCommonFormat.PCMInt16, 48_000, (uint)route.Channels, true);
microphoneConverter = new(inputFormat, microphoneFormat);
uint capacity = checked((uint)Math.Ceiling(4_096 * 48_000 / inputFormat.SampleRate) + 64);
convertedMicrophone = new(microphoneFormat, capacity);
@@ -85,8 +98,8 @@ internal sealed class IosAudioEngine
private unsafe void Capture(AVAudioPcmBuffer buffer, AVAudioTime time)
{
VoiceCatClient? owner = client; uint stream = microphoneStream;
if (owner is null || stream == 0 || buffer.FrameLength == 0) return;
VoiceCatClient? owner = client; MicrophoneRoute? route = Volatile.Read(ref microphone);
if (owner is null || route is null || buffer.FrameLength == 0) return;
AVAudioConverter? converter = microphoneConverter;
AVAudioPcmBuffer? converted = convertedMicrophone;
AVAudioConverterInputHandler? provider = inputProvider;
@@ -95,7 +108,7 @@ internal sealed class IosAudioEngine
converter.ConvertToBuffer(converted, out _, provider);
if (converted.FrameLength == 0) return;
nint samples = Marshal.ReadIntPtr(converted.Int16ChannelData);
if (samples != 0) microphoneRing.TryWrite(new ReadOnlySpan<short>((void*)samples, checked((int)converted.FrameLength * microphoneChannels)));
if (samples != 0) route.Ring.TryWrite(new ReadOnlySpan<short>((void*)samples, checked((int)converted.FrameLength * route.Channels)));
pendingInput = null;
}
@@ -110,12 +123,15 @@ internal sealed class IosAudioEngine
using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(10));
while (await timer.WaitForNextTickAsync(token).ConfigureAwait(false))
{
int channels = microphoneChannels, required = 960 * channels;
while (microphoneRing.Count >= required)
MicrophoneRoute? route = Volatile.Read(ref microphone);
if (route is null) continue;
int required = 960 * route.Channels;
while (route.Ring.Count >= required)
{
int read = microphoneRing.Read(microphoneFrame.AsSpan(0, required));
VoiceCatClient? owner = client; uint stream = microphoneStream;
if (read == required && owner is not null && stream != 0) owner.Audio.FeedPcm(stream, microphoneFrame.AsSpan(0, required), channels);
int read = route.Ring.Read(microphoneFrame.AsSpan(0, required));
VoiceCatClient? owner = client;
if (read == required && owner is not null && ReferenceEquals(route, Volatile.Read(ref microphone)))
owner.Audio.FeedPcm(route.StreamId, microphoneFrame.AsSpan(0, required), route.Channels);
}
}
}
@@ -142,7 +158,7 @@ internal sealed class IosAudioEngine
internal void Stop()
{
IsConnected = false; microphoneStream = 0;
IsConnected = false; Volatile.Write(ref microphone, null);
if (client is { } owner) owner.Audio.MixedPcm -= ReceiveMixedPcm;
DestroyGraph(); client = null; IosAudioRouter.Shared.Deactivate();
}