Files
voice-cat/clients/apple/VoiceCat.iOS/IosAudioEngine.cs
T

260 lines
13 KiB
C#
Raw Normal View History

2026-09-19 15:43:37 +02:00
using System.Runtime.InteropServices;
using AVFoundation;
2026-09-21 14:14:15 +02:00
using UIKit;
2026-09-19 15:43:37 +02:00
using VoiceCat.Audio;
using VoiceCat.Core;
namespace VoiceCat.iOS;
internal sealed class IosAudioEngine
{
2026-09-21 14:14:15 +02:00
// InstallTapOnBus bufferSize is only a request. Physical iOS hardware can deliver 4,800
// frames per callback (observed with the voice-processing graph), so conversion storage
// must cover substantially more than the requested 960 frames without allocating in Capture.
private const int MaximumCaptureCallbackFrames = 16_384;
2026-09-19 15:43:37 +02:00
internal static IosAudioEngine Shared { get; } = new();
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;
2026-09-21 14:14:15 +02:00
private Foundation.NSObject? engineConfigurationObserver;
2026-09-19 15:43:37 +02:00
private AVAudioSourceNode? source;
private AVAudioFormat? outputFormat;
private VoiceCatClient? client;
2026-09-21 14:14:15 +02:00
private sealed class MicrophoneRoute(uint streamId, int channels)
{
internal readonly uint StreamId = streamId;
internal readonly int Channels = channels;
// Capture hardware and the managed 20 ms sender have independent clocks. Correct
// their small rate difference before the queue eventually reaches its hard edge.
// RemoteIO can deliver capture in 100 ms bursts, so retain one burst of headroom.
internal readonly AdaptivePcmBuffer Ring = new(channels, 120, 65_536);
2026-09-21 14:14:15 +02:00
}
2026-09-21 02:11:33 +02:00
private MicrophoneRoute? microphone;
2026-09-19 15:43:37 +02:00
private readonly short[] microphoneFrame = new short[960 * 2];
2026-09-21 14:14:15 +02:00
private readonly Thread microphonePump;
2026-09-19 15:43:37 +02:00
private AVAudioFormat? microphoneFormat;
private AVAudioConverter? microphoneConverter;
private AVAudioPcmBuffer? convertedMicrophone;
private AVAudioPcmBuffer? pendingInput;
private AVAudioConverterInputHandler? inputProvider;
private bool inputProvided;
private bool tapInstalled;
2026-09-21 14:14:15 +02:00
private long captureCallbacks, capturedFrames, convertedFrames, rejectedFeeds, converterFailures, stereoFrames, stereoDifferentFrames;
2026-09-19 15:43:37 +02:00
internal bool IsConnected { get; private set; }
internal int BufferMilliseconds { get => playbackRing.BufferMilliseconds; set => playbackRing.BufferMilliseconds = value; }
2026-09-19 15:43:37 +02:00
2026-09-21 14:14:15 +02:00
private IosAudioEngine()
{
microphonePump = new Thread(PumpMicrophone) { IsBackground = true, Name = "VoiceCat iOS microphone pacer", Priority = ThreadPriority.Highest };
microphonePump.Start();
}
2026-09-19 15:43:37 +02:00
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 18:04:00 +02:00
Volatile.Write(ref microphone, CreateMicrophoneRoute(streamId, channels)); 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;
2026-09-21 14:14:15 +02:00
if (current is not null)
2026-09-21 02:11:33 +02:00
{
// 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();
2026-09-21 14:14:15 +02:00
if (current.Channels != channels) client!.Audio.SetCaptureChannels(current.StreamId, channels);
2026-09-21 18:04:00 +02:00
Volatile.Write(ref microphone, CreateMicrophoneRoute(current.StreamId, channels));
2026-09-21 02:11:33 +02:00
}
Rebuild();
}
2026-09-21 18:04:00 +02:00
private static MicrophoneRoute CreateMicrophoneRoute(uint streamId, int channels)
{
return new MicrophoneRoute(streamId, Math.Clamp(channels, 1, 2));
2026-09-21 18:04:00 +02:00
}
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();
2026-09-21 14:14:15 +02:00
AVAudioInputNode? input = null;
if (route is not null)
{
// Enabling voice processing rebuilds both sides of AVAudioEngine. Do it before any
// formats are queried or nodes are connected so the graph is built from the final IO.
input = next.InputNode;
if (!input.SetVoiceProcessingEnabled(IosAudioRouter.Shared.UsesVoiceProcessing, out NSError? processingError))
throw new InvalidOperationException(processingError.LocalizedDescription);
if (IosAudioRouter.Shared.UsesVoiceProcessing) input.VoiceProcessingAgcEnabled = IosAudioRouter.Shared.AutomaticGainControl;
}
2026-09-19 15:43:37 +02:00
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
{
2026-09-21 14:14:15 +02:00
AVAudioFormat inputFormat = input!.GetBusOutputFormat(0);
Console.Error.WriteLine($"VC_GRAPH vpio={IosAudioRouter.Shared.UsesVoiceProcessing} requestedCh={route.Channels} " +
$"inputCh={inputFormat.ChannelCount} inputRate={inputFormat.SampleRate}");
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);
2026-09-21 14:14:15 +02:00
uint capacity = checked((uint)Math.Ceiling(MaximumCaptureCallbackFrames * 48_000 / inputFormat.SampleRate) + 64);
2026-09-19 15:43:37 +02:00
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;
2026-09-21 14:14:15 +02:00
engineConfigurationObserver = Foundation.NSNotificationCenter.DefaultCenter.AddObserver(
AVAudioEngine.ConfigurationChangeNotification, notification =>
{
if (!ReferenceEquals(notification.Object, next)) return;
UIApplication.SharedApplication.BeginInvokeOnMainThread(() =>
{
if (IsConnected && ReferenceEquals(engine, next) && !next.Running) Rebuild();
});
}, next);
2026-09-19 15:43:37 +02:00
}
private unsafe void Capture(AVAudioPcmBuffer buffer, AVAudioTime time)
{
2026-09-21 14:14:15 +02:00
Interlocked.Increment(ref captureCallbacks);
Interlocked.Add(ref capturedFrames, buffer.FrameLength);
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;
2026-09-21 14:14:15 +02:00
converter.ConvertToBuffer(converted, out NSError? conversionError, provider);
if (conversionError is not null) Interlocked.Increment(ref converterFailures);
2026-09-19 15:43:37 +02:00
if (converted.FrameLength == 0) return;
2026-09-21 14:14:15 +02:00
Interlocked.Add(ref convertedFrames, converted.FrameLength);
2026-09-19 15:43:37 +02:00
nint samples = Marshal.ReadIntPtr(converted.Int16ChannelData);
2026-09-21 14:14:15 +02:00
if (samples != 0 && ReferenceEquals(route, Volatile.Read(ref microphone)))
{
var pcm = new ReadOnlySpan<short>((void*)samples, checked((int)converted.FrameLength * route.Channels));
if (route.Channels == 2)
{
Interlocked.Add(ref stereoFrames, converted.FrameLength);
long different = 0;
for (int frame = 0; frame < converted.FrameLength; frame++)
if (pcm[frame * 2] != pcm[frame * 2 + 1]) different++;
Interlocked.Add(ref stereoDifferentFrames, different);
}
if (!route.Ring.TryWrite(pcm)) Interlocked.Increment(ref rejectedFeeds);
}
2026-09-19 15:43:37 +02:00
pendingInput = null;
}
2026-09-21 14:14:15 +02:00
internal string CaptureDiagnostics()
{
return $"callbacks={Interlocked.Exchange(ref captureCallbacks, 0)} input={Interlocked.Exchange(ref capturedFrames, 0)} " +
$"converted={Interlocked.Exchange(ref convertedFrames, 0)} feedDrops={Interlocked.Exchange(ref rejectedFeeds, 0)} " +
$"converterErrors={Interlocked.Exchange(ref converterFailures, 0)} stereo={Interlocked.Exchange(ref stereoDifferentFrames, 0)}/" +
$"{Interlocked.Exchange(ref stereoFrames, 0)}";
}
2026-09-19 15:43:37 +02:00
private AVAudioBuffer ProvideInput(uint _, out AVAudioConverterInputStatus status)
{
if (!inputProvided && pendingInput is { } input) { inputProvided = true; status = AVAudioConverterInputStatus.HaveData; return input; }
status = AVAudioConverterInputStatus.NoDataNow; return null!;
}
2026-09-21 14:14:15 +02:00
private void PumpMicrophone()
2026-09-19 15:43:37 +02:00
{
2026-09-21 14:14:15 +02:00
long deadline = System.Diagnostics.Stopwatch.GetTimestamp();
while (true)
2026-09-19 15:43:37 +02:00
{
2026-09-21 02:11:33 +02:00
MicrophoneRoute? route = Volatile.Read(ref microphone);
2026-09-21 14:14:15 +02:00
VoiceCatClient? owner = client;
if (route is not null && owner is not null)
2026-09-19 15:43:37 +02:00
{
2026-09-21 14:14:15 +02:00
int required = 960 * route.Channels;
if (route.Ring.Read(microphoneFrame.AsSpan(0, required)) == required &&
ReferenceEquals(route, Volatile.Read(ref microphone)) &&
!owner.Audio.FeedPcm(route.StreamId, microphoneFrame.AsSpan(0, required), route.Channels))
Interlocked.Increment(ref rejectedFeeds);
2026-09-19 15:43:37 +02:00
}
2026-09-21 14:14:15 +02:00
deadline += System.Diagnostics.Stopwatch.Frequency / 50;
while (true)
{
long remaining = deadline - System.Diagnostics.Stopwatch.GetTimestamp();
if (remaining <= 0) break;
double milliseconds = remaining * 1000.0 / System.Diagnostics.Stopwatch.Frequency;
if (milliseconds > 2) Thread.Sleep(Math.Max(1, (int)milliseconds - 1)); else Thread.SpinWait(64);
}
if ((deadline - System.Diagnostics.Stopwatch.GetTimestamp()) * 1000.0 / System.Diagnostics.Stopwatch.Frequency < -100)
deadline = System.Diagnostics.Stopwatch.GetTimestamp();
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()
{
2026-09-21 14:14:15 +02:00
if (engineConfigurationObserver is { } observer)
{
Foundation.NSNotificationCenter.DefaultCenter.RemoveObserver(observer);
observer.Dispose(); engineConfigurationObserver = null;
}
2026-09-19 15:43:37 +02:00
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;
}
}