Files
voice-cat/clients/apple/dotnet/VoiceCat.iOS/IosAudioEngine.cs
T
Talon c6715028c1
.NET port / test (macos-latest) (push) Canceled after 0s
.NET port / test (ubuntu-24.04) (push) Canceled after 0s
.NET port / test (windows-latest) (push) Canceled after 0s
.NET port / apple-client (push) Canceled after 0s
.NET port / cpp-conformance (push) Canceled after 0s
Add managed UIKit iOS client
2026-09-19 15:43:37 +02:00

158 lines
7.5 KiB
C#

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();
private readonly PcmRing playbackRing = new(131_072);
private readonly short[] renderScratch = new short[16_384];
private AVAudioEngine? engine;
private AVAudioSourceNode? source;
private AVAudioFormat? outputFormat;
private VoiceCatClient? client;
private uint microphoneStream;
private int microphoneChannels = 1;
private readonly PcmRing microphoneRing = new(131_072);
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; }
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)
{
microphoneStream = streamId; microphoneChannels = Math.Clamp(channels, 1, 2); Rebuild();
}
internal void StopMicrophone() { microphoneStream = 0; Rebuild(); }
internal void Reconfigure() { if (IsConnected) Rebuild(); }
private void Rebuild()
{
DestroyGraph(); IosAudioRouter.Shared.Apply();
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);
if (microphoneStream != 0)
{
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);
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)
{
VoiceCatClient? owner = client; uint stream = microphoneStream;
if (owner is null || stream == 0 || buffer.FrameLength == 0) return;
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);
if (samples != 0) microphoneRing.TryWrite(new ReadOnlySpan<short>((void*)samples, checked((int)converted.FrameLength * microphoneChannels)));
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))
{
int channels = microphoneChannels, required = 960 * channels;
while (microphoneRing.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);
}
}
}
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()
{
IsConnected = false; microphoneStream = 0;
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;
}
}