2026-09-19 15:43:37 +02:00
|
|
|
using System.Runtime.InteropServices;
|
|
|
|
|
using AVFoundation;
|
|
|
|
|
using VoiceCat.Audio;
|
|
|
|
|
using VoiceCat.Core;
|
|
|
|
|
|
|
|
|
|
namespace VoiceCat.iOS;
|
|
|
|
|
|
|
|
|
|
internal sealed class IosAudioEngine
|
|
|
|
|
{
|
|
|
|
|
internal static IosAudioEngine Shared { get; } = new();
|
2026-09-20 16:25:30 +02:00
|
|
|
private readonly AdaptivePcmBuffer playbackRing = new(2, capacityFrames: 65_536);
|
2026-09-19 15:43:37 +02:00
|
|
|
private readonly short[] renderScratch = new short[16_384];
|
|
|
|
|
private AVAudioEngine? engine;
|
|
|
|
|
private AVAudioSourceNode? source;
|
|
|
|
|
private AVAudioFormat? outputFormat;
|
|
|
|
|
private VoiceCatClient? client;
|
2026-09-21 02:11:33 +02:00
|
|
|
private sealed record MicrophoneRoute(uint StreamId, int Channels, PcmRing Ring);
|
|
|
|
|
private MicrophoneRoute? microphone;
|
2026-09-19 15:43:37 +02:00
|
|
|
private readonly short[] microphoneFrame = new short[960 * 2];
|
|
|
|
|
private readonly CancellationTokenSource microphoneStop = new();
|
|
|
|
|
private readonly Task microphoneWorker;
|
|
|
|
|
private AVAudioFormat? microphoneFormat;
|
|
|
|
|
private AVAudioConverter? microphoneConverter;
|
|
|
|
|
private AVAudioPcmBuffer? convertedMicrophone;
|
|
|
|
|
private AVAudioPcmBuffer? pendingInput;
|
|
|
|
|
private AVAudioConverterInputHandler? inputProvider;
|
|
|
|
|
private bool inputProvided;
|
|
|
|
|
private bool tapInstalled;
|
|
|
|
|
internal bool IsConnected { get; private set; }
|
2026-09-20 16:25:30 +02:00
|
|
|
internal int BufferMilliseconds { get => playbackRing.BufferMilliseconds; set => playbackRing.BufferMilliseconds = value; }
|
2026-09-19 15:43:37 +02:00
|
|
|
|
|
|
|
|
private IosAudioEngine() { microphoneWorker = PumpMicrophoneAsync(microphoneStop.Token); }
|
|
|
|
|
|
|
|
|
|
internal void StartListening(VoiceCatClient owner)
|
|
|
|
|
{
|
|
|
|
|
Stop(); client = owner; IsConnected = true; owner.Audio.MixedPcm += ReceiveMixedPcm; Rebuild();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
internal void StartMicrophone(uint streamId, int channels)
|
|
|
|
|
{
|
2026-09-21 02:11:33 +02:00
|
|
|
Volatile.Write(ref microphone, new(streamId, Math.Clamp(channels, 1, 2), new(131_072))); Rebuild();
|
2026-09-19 15:43:37 +02:00
|
|
|
}
|
|
|
|
|
|
2026-09-21 02:11:33 +02:00
|
|
|
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();
|
|
|
|
|
}
|
2026-09-19 20:09:00 +02:00
|
|
|
internal bool EnsureRunning()
|
|
|
|
|
{
|
|
|
|
|
if (!IsConnected || engine?.Running == true) return true;
|
|
|
|
|
Rebuild(); return engine?.Running == true;
|
|
|
|
|
}
|
2026-09-19 15:43:37 +02:00
|
|
|
|
|
|
|
|
private void Rebuild()
|
|
|
|
|
{
|
2026-09-21 02:11:33 +02:00
|
|
|
DestroyGraph(); MicrophoneRoute? route = Volatile.Read(ref microphone); IosAudioRouter.Shared.Apply(route is not null);
|
2026-09-19 15:43:37 +02:00
|
|
|
var next = new AVAudioEngine();
|
|
|
|
|
outputFormat = new(AVAudioCommonFormat.PCMFloat32, 48_000, 2, false);
|
|
|
|
|
source = new(outputFormat, Render);
|
|
|
|
|
next.AttachNode(source);
|
|
|
|
|
NSError? connectionError = null;
|
|
|
|
|
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);
|
2026-09-21 02:11:33 +02:00
|
|
|
if (route is not null)
|
2026-09-19 15:43:37 +02:00
|
|
|
{
|
|
|
|
|
AVAudioInputNode input = next.InputNode;
|
|
|
|
|
input.SetVoiceProcessingEnabled(IosAudioRouter.Shared.UsesVoiceProcessing, out _);
|
|
|
|
|
if (IosAudioRouter.Shared.UsesVoiceProcessing) input.VoiceProcessingAgcEnabled = IosAudioRouter.Shared.AutomaticGainControl;
|
|
|
|
|
AVAudioFormat inputFormat = input.GetBusOutputFormat(0);
|
2026-09-21 02:11:33 +02:00
|
|
|
microphoneFormat = new(AVAudioCommonFormat.PCMInt16, 48_000, (uint)route.Channels, true);
|
2026-09-19 15:43:37 +02:00
|
|
|
microphoneConverter = new(inputFormat, microphoneFormat);
|
|
|
|
|
uint capacity = checked((uint)Math.Ceiling(4_096 * 48_000 / inputFormat.SampleRate) + 64);
|
|
|
|
|
convertedMicrophone = new(microphoneFormat, capacity);
|
|
|
|
|
inputProvider = ProvideInput;
|
|
|
|
|
NSError? tapError = null;
|
|
|
|
|
if (OperatingSystem.IsIOSVersionAtLeast(27)) input.InstallTapOnBus(0, 960, inputFormat, out tapError, Capture);
|
|
|
|
|
else input.InstallTapOnBus(0, 960, inputFormat, Capture);
|
|
|
|
|
if (tapError is not null) throw new InvalidOperationException(tapError.LocalizedDescription);
|
|
|
|
|
tapInstalled = true;
|
|
|
|
|
}
|
|
|
|
|
next.Prepare();
|
|
|
|
|
if (!next.StartAndReturnError(out NSError? error)) { next.Dispose(); throw new InvalidOperationException(error.LocalizedDescription); }
|
|
|
|
|
engine = next;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private unsafe void Capture(AVAudioPcmBuffer buffer, AVAudioTime time)
|
|
|
|
|
{
|
2026-09-21 02:11:33 +02:00
|
|
|
VoiceCatClient? owner = client; MicrophoneRoute? route = Volatile.Read(ref microphone);
|
|
|
|
|
if (owner is null || route is null || buffer.FrameLength == 0) return;
|
2026-09-19 15:43:37 +02:00
|
|
|
AVAudioConverter? converter = microphoneConverter;
|
|
|
|
|
AVAudioPcmBuffer? converted = convertedMicrophone;
|
|
|
|
|
AVAudioConverterInputHandler? provider = inputProvider;
|
|
|
|
|
if (converter is null || converted is null || provider is null) return;
|
|
|
|
|
pendingInput = buffer; inputProvided = false; converted.FrameLength = 0;
|
|
|
|
|
converter.ConvertToBuffer(converted, out _, provider);
|
|
|
|
|
if (converted.FrameLength == 0) return;
|
|
|
|
|
nint samples = Marshal.ReadIntPtr(converted.Int16ChannelData);
|
2026-09-21 02:11:33 +02:00
|
|
|
if (samples != 0) route.Ring.TryWrite(new ReadOnlySpan<short>((void*)samples, checked((int)converted.FrameLength * route.Channels)));
|
2026-09-19 15:43:37 +02:00
|
|
|
pendingInput = null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private AVAudioBuffer ProvideInput(uint _, out AVAudioConverterInputStatus status)
|
|
|
|
|
{
|
|
|
|
|
if (!inputProvided && pendingInput is { } input) { inputProvided = true; status = AVAudioConverterInputStatus.HaveData; return input; }
|
|
|
|
|
status = AVAudioConverterInputStatus.NoDataNow; return null!;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async Task PumpMicrophoneAsync(CancellationToken token)
|
|
|
|
|
{
|
|
|
|
|
using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(10));
|
|
|
|
|
while (await timer.WaitForNextTickAsync(token).ConfigureAwait(false))
|
|
|
|
|
{
|
2026-09-21 02:11:33 +02:00
|
|
|
MicrophoneRoute? route = Volatile.Read(ref microphone);
|
|
|
|
|
if (route is null) continue;
|
|
|
|
|
int required = 960 * route.Channels;
|
|
|
|
|
while (route.Ring.Count >= required)
|
2026-09-19 15:43:37 +02:00
|
|
|
{
|
2026-09-21 02:11:33 +02:00
|
|
|
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);
|
2026-09-19 15:43:37 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void ReceiveMixedPcm(ReadOnlySpan<short> pcm) => playbackRing.TryWrite(pcm);
|
|
|
|
|
|
|
|
|
|
private unsafe int Render(IntPtr isSilence, IntPtr timestamp, uint frameCount, IntPtr outputData)
|
|
|
|
|
{
|
|
|
|
|
int frames = checked((int)frameCount), requested = checked(frames * 2);
|
|
|
|
|
if (requested > renderScratch.Length) return -1;
|
|
|
|
|
Span<short> input = renderScratch.AsSpan(0, requested);
|
|
|
|
|
int read = playbackRing.Read(input); input[read..].Clear();
|
|
|
|
|
int count = Marshal.ReadInt32(outputData), first = IntPtr.Size == 8 ? 8 : 4, stride = IntPtr.Size == 8 ? 16 : 12;
|
|
|
|
|
if (count != 2) return -1;
|
|
|
|
|
for (int channel = 0; channel < 2; channel++)
|
|
|
|
|
{
|
|
|
|
|
nint data = Marshal.ReadIntPtr(outputData, first + channel * stride + 8);
|
|
|
|
|
var output = new Span<float>((void*)data, frames);
|
|
|
|
|
for (int frame = 0; frame < frames; frame++) output[frame] = input[frame * 2 + channel] / 32768f;
|
|
|
|
|
}
|
|
|
|
|
if (isSilence != IntPtr.Zero) Marshal.WriteByte(isSilence, read == 0 ? (byte)1 : (byte)0);
|
|
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
internal void Stop()
|
|
|
|
|
{
|
2026-09-21 02:11:33 +02:00
|
|
|
IsConnected = false; Volatile.Write(ref microphone, null);
|
2026-09-19 15:43:37 +02:00
|
|
|
if (client is { } owner) owner.Audio.MixedPcm -= ReceiveMixedPcm;
|
|
|
|
|
DestroyGraph(); client = null; IosAudioRouter.Shared.Deactivate();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void DestroyGraph()
|
|
|
|
|
{
|
|
|
|
|
if (engine is { } old)
|
|
|
|
|
{
|
|
|
|
|
if (tapInstalled) old.InputNode.RemoveTapOnBus(0);
|
|
|
|
|
old.Stop(); if (source is not null) old.DetachNode(source); old.Dispose();
|
|
|
|
|
}
|
|
|
|
|
tapInstalled = false; pendingInput = null; inputProvider = null;
|
|
|
|
|
convertedMicrophone?.Dispose(); convertedMicrophone = null;
|
|
|
|
|
microphoneConverter?.Dispose(); microphoneConverter = null;
|
|
|
|
|
microphoneFormat?.Dispose(); microphoneFormat = null;
|
|
|
|
|
source?.Dispose(); source = null; outputFormat?.Dispose(); outputFormat = null; engine = null;
|
|
|
|
|
}
|
|
|
|
|
}
|