2026-09-19 15:43:37 +02:00
using AVFoundation ;
using Foundation ;
2026-09-21 18:04:00 +02:00
using ObjCRuntime ;
2026-09-19 19:33:10 +02:00
using UIKit ;
2026-09-19 15:43:37 +02:00
namespace VoiceCat.iOS ;
internal enum IosAudioPreset { VoiceChat , StereoMicrophone , MonoMicrophone , Advanced }
2026-09-19 19:33:10 +02:00
internal enum IosBluetoothMode { HfpVoice , BuiltInMicA2dp , BuiltInMicSpeaker }
internal enum IosMicMode { Standard , Raw }
internal sealed record IosAudioPort ( string Id , string Name , string Type );
internal sealed record IosAudioDataSource ( string Id , string Name , IReadOnlyList < AVAudioDataSourcePolarPattern > Patterns );
2026-09-19 15:43:37 +02:00
internal sealed class IosAudioRouter
{
2026-09-21 18:04:00 +02:00
private static readonly NativeHandle SupportedPolarPatternsSelector = Selector . GetHandle ( "supportedPolarPatterns" );
private static readonly NativeHandle SetPreferredPolarPatternSelector = Selector . GetHandle ( "setPreferredPolarPattern:error:" );
2026-09-19 15:43:37 +02:00
internal static IosAudioRouter Shared { get ; } = new ();
private readonly NSUserDefaults defaults = NSUserDefaults . StandardUserDefaults ;
2026-09-19 19:33:10 +02:00
private bool applying ;
2026-09-25 18:48:08 +02:00
private bool speakerIsExplicit ;
2026-09-25 20:56:31 +02:00
// Whether this session actually has a stereo capsule configuration to undo. Clearing one that
// was never applied is not free: SetPreferredPolarPattern(Unknown) drops the built-in array out
// of the beamformed mono configuration VoiceChat mode selects and exposes its four raw
// channels, which the voice-processing IO cannot start against.
private bool stereoApplied ;
2026-09-25 19:05:14 +02:00
// Set by an explicit speaker choice and consumed by the next Apply. The override is a one-shot
// request, never steady-state configuration; see SetForceSpeaker.
private bool overridePending ;
2026-09-24 13:31:16 +02:00
private System . Threading . Timer ? watchdog ;
private long lastRenderCallbacks = - 1 ;
private int watchdogMisses ;
2026-09-25 20:56:31 +02:00
private int watchdogAttempts ;
private long nextWatchdogAttempt ;
// A rebuild that does not restore the callbacks is not worth repeating at the same rate, and
// never worth repeating forever: an unbounded retry turns a graph that cannot start into a
// storm of session reconfiguration, which is both what the user hears and what hides the
// reason from the log. Back off, then stop and say so.
private const int MaximumWatchdogAttempts = 4 ;
2026-09-24 13:31:16 +02:00
private bool watchdogTicking ;
private bool interrupted ;
2026-09-19 19:33:10 +02:00
internal event Action ? Changed ;
2026-09-19 15:43:37 +02:00
internal IosAudioPreset Preset { get ; private set ; } = IosAudioPreset . VoiceChat ;
2026-09-19 19:33:10 +02:00
internal IosBluetoothMode BluetoothMode { get ; private set ; } = IosBluetoothMode . HfpVoice ;
internal IosMicMode MicMode { get ; private set ; } = IosMicMode . Standard ;
internal bool ForceSpeaker { get ; private set ; }
internal bool VoiceProcessing { get ; private set ; } = true ;
internal bool AutomaticGainControl { get ; private set ; } = true ;
internal int CaptureChannels { get ; private set ; } = 1 ;
internal string? SelectedInputId { get ; private set ; }
internal string? SelectedDataSourceId { get ; private set ; }
internal AVAudioDataSourcePolarPattern SelectedPolarPattern { get ; private set ; } = AVAudioDataSourcePolarPattern . Unknown ;
internal IReadOnlyList < IosAudioPort > Inputs { get ; private set ; } = [];
internal IReadOnlyList < IosAudioPort > Outputs { get ; private set ; } = [];
internal bool VoiceProcessingAvailable => CaptureChannels == 1 && MicMode == IosMicMode . Standard && BluetoothMode != IosBluetoothMode . BuiltInMicA2dp ;
internal bool UsesVoiceProcessing => VoiceProcessing && VoiceProcessingAvailable ;
2026-09-19 15:43:37 +02:00
private IosAudioRouter ()
{
2026-09-19 20:09:00 +02:00
NSNotificationCenter . DefaultCenter . AddObserver ( AVAudioSession . RouteChangeNotification , HandleRouteChange );
2026-09-19 19:33:10 +02:00
NSNotificationCenter . DefaultCenter . AddObserver ( AVAudioSession . InterruptionNotification , HandleInterruption );
2026-09-19 20:09:00 +02:00
NSNotificationCenter . DefaultCenter . AddObserver ( AVAudioSession . MediaServicesWereResetNotification , _ => Recover ( "media services reset" ));
2026-09-19 15:43:37 +02:00
}
internal void Load ()
{
2026-09-19 19:33:10 +02:00
if ( Enum . TryParse ( defaults . StringForKey ( "cat.voice.audio.preset" ), true , out IosAudioPreset preset )) Preset = preset ;
if ( Enum . TryParse ( defaults . StringForKey ( "cat.voice.audio.bluetoothMode" ), true , out IosBluetoothMode bluetooth )) BluetoothMode = bluetooth ;
if ( Enum . TryParse ( defaults . StringForKey ( "cat.voice.audio.micMode" ), true , out IosMicMode mic )) MicMode = mic ;
2026-09-25 18:48:08 +02:00
// Speaker output is an output-routing choice, orthogonal to the capture preset, which is
// why it sits outside Advanced audio. An install that predates the explicit choice carries
// a speaker flag the preset set on its behalf; drop that once so it cannot pin a headset
// user to the speaker, and honour the toggle from then on.
if ( defaults . BoolForKey ( "cat.voice.audio.speakerIsExplicit" )) ForceSpeaker = defaults . BoolForKey ( "cat.voice.audio.forceSpeaker" );
2026-09-19 15:43:37 +02:00
VoiceProcessing = defaults . ValueForKey ( new NSString ( "cat.voice.audio.voiceProcessing" )) is null || defaults . BoolForKey ( "cat.voice.audio.voiceProcessing" );
AutomaticGainControl = defaults . ValueForKey ( new NSString ( "cat.voice.audio.agc" )) is null || defaults . BoolForKey ( "cat.voice.audio.agc" );
2026-09-19 19:33:10 +02:00
CaptureChannels = defaults . IntForKey ( "cat.voice.audio.captureChannels" ) == 2 ? 2 : Preset == IosAudioPreset . StereoMicrophone ? 2 : 1 ;
SelectedInputId = defaults . StringForKey ( "cat.voice.audio.inputPortId" ); SelectedDataSourceId = defaults . StringForKey ( "cat.voice.audio.dataSourceId" );
2026-09-25 12:27:27 +02:00
if ( Preset == IosAudioPreset . VoiceChat ) { SelectedInputId = null ; SelectedDataSourceId = null ; }
2026-09-19 19:33:10 +02:00
if ( Enum . TryParse ( defaults . StringForKey ( "cat.voice.audio.polarPattern" ), true , out AVAudioDataSourcePolarPattern pattern )) SelectedPolarPattern = pattern ;
RefreshRoutes ();
2026-09-19 15:43:37 +02:00
}
internal void SelectPreset ( IosAudioPreset preset )
{
2026-09-19 19:33:10 +02:00
Preset = preset ;
if ( preset != IosAudioPreset . Advanced )
{
CaptureChannels = preset == IosAudioPreset . StereoMicrophone ? 2 : 1 ; MicMode = IosMicMode . Standard ;
BluetoothMode = preset == IosAudioPreset . VoiceChat ? IosBluetoothMode . HfpVoice : IosBluetoothMode . BuiltInMicA2dp ;
if ( preset is IosAudioPreset . StereoMicrophone or IosAudioPreset . MonoMicrophone )
SelectedInputId = AVAudioSession . SharedInstance (). AvailableInputs ?. FirstOrDefault ( value => value . PortType == AVAudioSession . PortBuiltInMic )?. UID ;
2026-09-21 14:14:15 +02:00
else SelectedInputId = null ;
// Named presets never carry an Advanced capsule selection across transitions.
// Stereo derives its data source below; mono/voice chat must clear a stale stereo one.
SelectedDataSourceId = null ; SelectedPolarPattern = AVAudioDataSourcePolarPattern . Unknown ;
2026-09-19 19:33:10 +02:00
}
SaveAndReconfigure ();
}
2026-09-25 18:48:08 +02:00
// Deliberately does not move the preset to Advanced: the speaker is an output choice every
// named preset supports, so a voice-chat user can take a call on the speaker without losing
// the capture configuration the preset stands for.
2026-09-25 19:05:14 +02:00
// The port override is issued once, on the toggle, and never re-issued by a later rebuild.
// Re-asserting it on every Apply means fighting iOS for the route: where the system wants to
// hand output back to a connected headset, each rebuild forces it back to the speaker, the
// route change that follows drives another rebuild, and the audio flips back and forth. The
// DefaultToSpeaker category option below is the part that does persist across rebuilds.
internal void SetForceSpeaker ( bool value ) { ForceSpeaker = value ; speakerIsExplicit = overridePending = true ; SaveAndReconfigure (); }
2026-09-19 19:33:10 +02:00
internal void SetVoiceProcessing ( bool value ) { VoiceProcessing = value ; SaveAndReconfigure (); }
internal void SetAutomaticGainControl ( bool value ) { AutomaticGainControl = value ; SaveAndReconfigure (); }
internal void SetCaptureChannels ( int value ) { CaptureChannels = value == 2 ? 2 : 1 ; Preset = IosAudioPreset . Advanced ; SaveAndReconfigure (); }
internal void SetBluetoothMode ( IosBluetoothMode value ) { BluetoothMode = value ; Preset = IosAudioPreset . Advanced ; SaveAndReconfigure (); }
internal void SetMicMode ( IosMicMode value ) { MicMode = value ; Preset = IosAudioPreset . Advanced ; SaveAndReconfigure (); }
internal void SelectInput ( string? id ) { SelectedInputId = string . IsNullOrEmpty ( id ) ? null : id ; SelectedDataSourceId = null ; Preset = IosAudioPreset . Advanced ; SaveAndReconfigure (); }
internal void SelectDataSource ( string? id ) { SelectedDataSourceId = string . IsNullOrEmpty ( id ) ? null : id ; Preset = IosAudioPreset . Advanced ; SaveAndReconfigure (); }
internal void SelectPolarPattern ( AVAudioDataSourcePolarPattern pattern ) { SelectedPolarPattern = pattern ; Preset = IosAudioPreset . Advanced ; SaveAndReconfigure (); }
internal IReadOnlyList < IosAudioDataSource > DataSources ()
{
AVAudioSessionPortDescription ? port = AVAudioSession . SharedInstance (). AvailableInputs ?. FirstOrDefault ( value => value . UID == SelectedInputId );
return port ?. DataSources ?. Select ( value => new IosAudioDataSource ( value . DataSourceID . ToString (), value . DataSourceName ,
value . SupportedPolarPatterns ?. ToArray () ?? [])). ToArray () ?? [];
}
internal void RefreshRoutes ()
{
AVAudioSession session = AVAudioSession . SharedInstance ();
Inputs = session . AvailableInputs ?. Select ( value => new IosAudioPort ( value . UID , value . PortName , value . PortType . ToString ())). ToArray () ?? [];
2026-09-26 20:30:24 +02:00
Outputs = session . CurrentRoute ?. Outputs ?. Select ( value => new IosAudioPort ( value . UID , value . PortName , value . PortType . ToString ())). ToArray () ?? [];
2026-09-25 12:27:27 +02:00
// A route reported by iOS is not an explicit user input choice. Capturing it here
// can pin the built-in mic after a speaker fallback and displace a Bluetooth HFP route
// on the next graph rebuild.
Changed ?. Invoke ();
2026-09-19 15:43:37 +02:00
}
2026-09-21 02:11:33 +02:00
internal void Apply ( bool configureInput )
2026-09-19 15:43:37 +02:00
{
2026-09-19 19:33:10 +02:00
if ( applying ) return ; applying = true ;
try
{
AVAudioSession session = AVAudioSession . SharedInstance (); AVAudioSessionCategoryOptions options = AVAudioSessionCategoryOptions . MixWithOthers ;
if ( BluetoothMode == IosBluetoothMode . HfpVoice ) options |= AVAudioSessionCategoryOptions . AllowBluetooth | AVAudioSessionCategoryOptions . AllowBluetoothA2DP | AVAudioSessionCategoryOptions . AllowAirPlay ;
if ( BluetoothMode == IosBluetoothMode . BuiltInMicA2dp ) options |= AVAudioSessionCategoryOptions . AllowBluetoothA2DP | AVAudioSessionCategoryOptions . AllowAirPlay ;
if ( BluetoothMode == IosBluetoothMode . BuiltInMicSpeaker || ForceSpeaker && BluetoothMode != IosBluetoothMode . BuiltInMicA2dp ) options |= AVAudioSessionCategoryOptions . DefaultToSpeaker ;
AVAudioSessionMode mode = CaptureChannels == 2 ? AVAudioSessionMode . Default : MicMode == IosMicMode . Raw ? AVAudioSessionMode . Measurement
: BluetoothMode == IosBluetoothMode . BuiltInMicA2dp ? AVAudioSessionMode . VideoRecording : AVAudioSessionMode . VoiceChat ;
2026-09-26 20:30:24 +02:00
if (! session . SetCategory ( AVAudioSessionCategory . PlayAndRecord , mode , options , out NSError ? categoryError )) throw new InvalidOperationException ( categoryError ?. LocalizedDescription ?? "Could not configure the iOS audio session." );
2026-09-21 02:11:33 +02:00
session . SetPreferredSampleRate ( 48_000 , out _ ); session . SetPreferredIOBufferDuration ( 0.02 , out _ );
2026-09-26 20:30:24 +02:00
if (! session . SetActive ( true , AVAudioSessionSetActiveOptions . NotifyOthersOnDeactivation , out NSError ? activeError )) throw new InvalidOperationException ( activeError ?. LocalizedDescription ?? "Could not activate the iOS audio session." );
2026-09-26 21:02:54 +02:00
// Apple requires an active session before selecting a preferred input or data source.
if ( configureInput ) ApplyInputSelection ( session );
2026-09-21 14:14:15 +02:00
Console . Error . WriteLine ( $"VC_ROUTE preset={Preset} requestedCh={CaptureChannels} sessionCh={session.InputNumberOfChannels} " +
$"preferred={session.PreferredInput?.PortName ?? " default "} dataSource={session.InputDataSource?.DataSourceName ?? " default "} " +
$"pattern={session.InputDataSource?.SelectedPolarPattern.ToString() ?? " default "}" );
2026-09-25 18:48:08 +02:00
// DefaultToSpeaker only decides where audio goes when nothing else is connected, so a
// user who asks for the speaker with a headset attached needs the port override as
2026-09-25 19:05:14 +02:00
// well. Only the toggle itself issues it, and only once.
if ( overridePending )
{
overridePending = false ;
session . OverrideOutputAudioPort ( ForceSpeaker && BluetoothMode != IosBluetoothMode . BuiltInMicA2dp
? AVAudioSessionPortOverride . Speaker : AVAudioSessionPortOverride . None , out _ );
}
2026-09-25 12:27:27 +02:00
RefreshRoutes ();
2026-09-24 13:31:16 +02:00
ResetWatchdog (); EnsureWatchdog ();
2026-09-19 19:33:10 +02:00
}
finally { applying = false ; }
2026-09-19 15:43:37 +02:00
}
2026-09-21 14:14:15 +02:00
private void ApplyInputSelection ( AVAudioSession session )
2026-09-19 19:33:10 +02:00
{
AVAudioSessionPortDescription ? port = session . AvailableInputs ?. FirstOrDefault ( value => value . UID == SelectedInputId );
if ( CaptureChannels == 2 ) port ??= session . AvailableInputs ?. FirstOrDefault ( value => value . PortType == AVAudioSession . PortBuiltInMic );
2026-09-25 20:56:31 +02:00
if ( port is null ) { if ( CaptureChannels == 1 && stereoApplied ) ClearStereo ( session ); return ; }
2026-09-26 21:02:54 +02:00
if (! session . SetPreferredInput ( port , out NSError ? inputError )) throw new InvalidOperationException ( inputError ?. LocalizedDescription ?? "Could not select the microphone input." );
2026-09-21 18:04:00 +02:00
if ( CaptureChannels == 2 )
{
// Polar-pattern discovery alone is insufficient on current iPhones: until a stereo
// orientation is requested, the built-in port can expose only its mono Bottom source
// and AVAudioEngine consequently binds a one-channel input node. This is Apple's
// dedicated switch for built-in stereo recording. Portrait matches this portrait UI;
// it also gives Core Audio an unambiguous left/right mapping before graph creation.
if (! session . SetPreferredInputOrientation ( AVAudioStereoOrientation . Portrait , out NSError ? orientationError ))
throw new InvalidOperationException ( orientationError ?. LocalizedDescription ?? "Could not set the stereo microphone orientation." );
2026-09-25 20:56:31 +02:00
stereoApplied = true ;
2026-09-21 18:04:00 +02:00
}
2026-09-21 14:14:15 +02:00
AVAudioSessionDataSourceDescription ? source = CaptureChannels == 2
2026-09-21 18:04:00 +02:00
? port . DataSources ?. FirstOrDefault ( SupportsStereoPolarPattern )
2026-09-21 14:14:15 +02:00
: port . DataSources ?. FirstOrDefault ( value => value . DataSourceID . ToString () == SelectedDataSourceId );
2026-09-25 20:56:31 +02:00
if ( CaptureChannels == 1 && source is null && stereoApplied ) ClearStereo ( session );
2026-09-21 02:11:33 +02:00
if ( source is not null )
{
2026-09-26 20:30:24 +02:00
if (! port . SetPreferredDataSource ( source , out NSError ? portError )) throw new InvalidOperationException ( portError ?. LocalizedDescription ?? "Could not select the microphone data source." );
2026-09-21 18:04:00 +02:00
if ( CaptureChannels == 2 ) SetStereoPolarPattern ( source );
else if ( SelectedPolarPattern != AVAudioDataSourcePolarPattern . Unknown &&
! source . SetPreferredPolarPattern ( SelectedPolarPattern , out NSError ? patternError ))
2026-09-26 20:30:24 +02:00
throw new InvalidOperationException ( patternError ?. LocalizedDescription ?? "Could not select the microphone polar pattern." );
2026-09-21 02:11:33 +02:00
}
2026-09-21 14:14:15 +02:00
// The working Swift client deliberately does not call
// SetPreferredInputNumberOfChannels: doing so disrupts stereo + A2DP routing. The stereo
// capsule and polar pattern above cause the input node to expose its two-channel format.
2026-09-26 21:02:54 +02:00
// A port preference is enough. SetInputDataSource only accepts a member of the *current*
// port's InputDataSources, which can still be a different route after SetPreferredInput.
2026-09-21 14:14:15 +02:00
Console . Error . WriteLine ( $"VC_ROUTE_SELECT port={port.PortName} source={source?.DataSourceName ?? " none "} " +
2026-09-21 18:04:00 +02:00
$"stereoPattern={source is not null && SupportsStereoPolarPattern(source)} " +
2026-09-21 14:14:15 +02:00
$"patterns={string.Join(',', source?.SupportedPolarPatterns?.Select(value => value.ToString()) ?? [])}" );
}
2026-09-21 18:04:00 +02:00
// Microsoft.iOS 27 exposes AVAudioSessionPolarPatternStereo as an NSString constant but its
// AVAudioDataSourcePolarPattern smart enum still has no Stereo member. Reading the native
// NSArray and setting the native NSString avoids silently mapping Stereo to Unknown.
private static bool SupportsStereoPolarPattern ( AVAudioSessionDataSourceDescription source )
{
NativeHandle handle = NativeMethods . GetObject ( source . Handle , SupportedPolarPatternsSelector );
NSArray ? patterns = Runtime . GetNSObject < NSArray >( handle );
if ( patterns is null ) return false ;
for ( nuint index = 0 ; index < patterns . Count ; index ++)
if ( patterns . GetItem < NSString >( index )?. IsEqualTo ( AVAudioSession . PolarPatternStereo . Handle ) == true ) return true ;
return false ;
}
private static void SetStereoPolarPattern ( AVAudioSessionDataSourceDescription source )
{
NativeHandle error = NativeHandle . Zero ;
if ( NativeMethods . SetObject ( source . Handle , SetPreferredPolarPatternSelector ,
AVAudioSession . PolarPatternStereo . Handle , ref error ) == 0 )
throw new InvalidOperationException ( Runtime . GetNSObject < NSError >( error )?. LocalizedDescription ?? "Could not enable stereo microphone capture." );
}
private static class NativeMethods
{
[System.Runtime.InteropServices.DllImport("/usr/lib/libobjc.dylib", EntryPoint = "objc_msgSend")]
internal static extern NativeHandle GetObject ( NativeHandle receiver , NativeHandle selector );
[System.Runtime.InteropServices.DllImport("/usr/lib/libobjc.dylib", EntryPoint = "objc_msgSend")]
internal static extern byte SetObject ( NativeHandle receiver , NativeHandle selector , NativeHandle value , ref NativeHandle error );
}
2026-09-25 20:56:31 +02:00
private void ClearStereo ( AVAudioSession session ) { stereoApplied = false ; ClearStereoPolarPattern ( session ); }
2026-09-21 14:14:15 +02:00
private static void ClearStereoPolarPattern ( AVAudioSession session )
{
2026-09-21 18:04:00 +02:00
session . SetPreferredInputOrientation ( AVAudioStereoOrientation . None , out _ );
2026-09-21 14:14:15 +02:00
AVAudioSessionPortDescription ? builtIn = session . AvailableInputs ?. FirstOrDefault ( value => value . PortType == AVAudioSession . PortBuiltInMic );
if ( builtIn is null ) return ;
foreach ( AVAudioSessionDataSourceDescription source in builtIn . DataSources ?? [])
2026-09-21 18:04:00 +02:00
if ( source . SelectedPolarPattern == AVAudioDataSourcePolarPattern . Unknown && SupportsStereoPolarPattern ( source ))
2026-09-21 14:14:15 +02:00
source . SetPreferredPolarPattern ( AVAudioDataSourcePolarPattern . Unknown , out _ );
2026-09-19 19:33:10 +02:00
}
private void SaveAndReconfigure ()
{
defaults . SetString ( Preset . ToString (), "cat.voice.audio.preset" ); defaults . SetString ( BluetoothMode . ToString (), "cat.voice.audio.bluetoothMode" );
defaults . SetString ( MicMode . ToString (), "cat.voice.audio.micMode" ); defaults . SetBool ( ForceSpeaker , "cat.voice.audio.forceSpeaker" );
2026-09-25 18:48:08 +02:00
defaults . SetBool ( speakerIsExplicit , "cat.voice.audio.speakerIsExplicit" );
2026-09-19 19:33:10 +02:00
defaults . SetBool ( VoiceProcessing , "cat.voice.audio.voiceProcessing" ); defaults . SetBool ( AutomaticGainControl , "cat.voice.audio.agc" ); defaults . SetInt ( CaptureChannels , "cat.voice.audio.captureChannels" );
Set ( "cat.voice.audio.inputPortId" , SelectedInputId ); Set ( "cat.voice.audio.dataSourceId" , SelectedDataSourceId ); defaults . SetString ( SelectedPolarPattern . ToString (), "cat.voice.audio.polarPattern" ); defaults . Synchronize ();
2026-09-25 20:56:31 +02:00
ResetWatchdogAttempts ();
if ( IosAudioEngine . Shared . IsConnected ) IosAudioEngine . Shared . Reconfigure ( true , "settings" ); Changed ?. Invoke ();
2026-09-19 19:33:10 +02:00
}
private void Set ( string key , string? value ) { if ( value is null ) defaults . RemoveObject ( key ); else defaults . SetString ( value , key ); }
2026-09-24 13:31:16 +02:00
internal void Deactivate ()
{
2026-09-25 19:05:14 +02:00
// A released session has no route the next graph can be compared against.
2026-09-25 20:56:31 +02:00
watchdog ?. Dispose (); watchdog = null ; ResetWatchdog (); ResetWatchdogAttempts ();
2026-09-26 20:30:24 +02:00
if (! AVAudioSession . SharedInstance (). SetActive ( false , AVAudioSessionSetActiveOptions . NotifyOthersOnDeactivation , out NSError ? error ))
Console . Error . WriteLine ( $"VC_DEACTIVATE failed: {error?.LocalizedDescription ?? " no system error "}" );
else Console . Error . WriteLine ( "VC_DEACTIVATE succeeded" );
2026-09-24 13:31:16 +02:00
}
2026-09-19 20:09:00 +02:00
internal void EnsureAudio ( string reason )
{
2026-09-24 13:31:16 +02:00
if (! IosAudioEngine . Shared . IsConnected || interrupted ) return ;
2026-09-19 20:09:00 +02:00
try { IosAudioEngine . Shared . EnsureRunning (); }
catch ( Exception exception ) { System . Diagnostics . Debug . WriteLine ( $"Audio recovery ({reason}) failed: {exception}" ); }
}
2026-09-25 17:00:25 +02:00
internal void Recover ( string reason , bool force = true )
2026-09-19 20:09:00 +02:00
{
RefreshRoutes ();
2026-09-24 13:31:16 +02:00
if (! IosAudioEngine . Shared . IsConnected || interrupted ) return ;
2026-09-19 20:09:00 +02:00
UIApplication . SharedApplication . BeginInvokeOnMainThread (() =>
{
2026-09-25 20:56:31 +02:00
try { IosAudioEngine . Shared . Reconfigure ( force , reason ); }
2026-09-19 20:09:00 +02:00
catch ( Exception exception ) { System . Diagnostics . Debug . WriteLine ( $"Audio recovery ({reason}) failed: {exception}" ); }
});
}
2026-09-24 13:31:16 +02:00
// The render callback is the only clock for the device-clocked pipeline, so a graph that
// stops while the app is backgrounded freezes capture, mix and send with no notification to
// recover from. Poll for that and rebuild; a failed rebuild is retried on the next tick.
private void EnsureWatchdog ()
{
watchdog ??= new System . Threading . Timer ( _ => UIApplication . SharedApplication . BeginInvokeOnMainThread ( TickWatchdog ),
null , TimeSpan . FromSeconds ( 1 ), TimeSpan . FromSeconds ( 1 ));
}
private void ResetWatchdog () { lastRenderCallbacks = - 1 ; watchdogMisses = 0 ; }
2026-09-25 20:56:31 +02:00
private void ResetWatchdogAttempts () { watchdogAttempts = 0 ; nextWatchdogAttempt = 0 ; }
2026-09-24 13:31:16 +02:00
2026-09-25 17:00:25 +02:00
// An interruption that ends while the app is suspended never delivers its Ended notification,
// so foregrounding still has to check. It must not rebuild unconditionally though: the `audio`
// background mode keeps the session and graph live across a backgrounding, so the graph is
// almost always healthy here and a rebuild costs a visible glitch plus, on Bluetooth, an HFP
// renegotiation. Ensure it is running, and only reconfigure when it actually stopped.
internal void ResumeForeground ()
{
interrupted = false ; ResetWatchdog (); RefreshRoutes ();
if ( IosAudioEngine . Shared . IsConnected && ! IosAudioEngine . Shared . IsRunning ) Recover ( "foreground" );
else EnsureAudio ( "foreground" );
}
2026-09-24 13:31:16 +02:00
private void TickWatchdog ()
{
if ( watchdogTicking ) return ;
watchdogTicking = true ;
try
{
2026-09-25 20:56:31 +02:00
// A graph still starting up reports no callbacks yet and reads as not running. Judging
// it there is how the watchdog ends up rebuilding a healthy graph on every tick.
if (! IosAudioEngine . Shared . IsConnected || interrupted || applying || IosAudioEngine . Shared . Settling ) { ResetWatchdog (); return ; }
2026-09-24 13:31:16 +02:00
long callbacks = IosAudioEngine . Shared . RenderCallbacks ;
bool stalled = ! IosAudioEngine . Shared . IsRunning || callbacks == lastRenderCallbacks ;
lastRenderCallbacks = callbacks ;
2026-09-25 20:56:31 +02:00
if (! stalled ) { watchdogMisses = 0 ; ResetWatchdogAttempts (); return ; }
2026-09-24 13:31:16 +02:00
// One missed tick can be a route change already rebuilding the graph.
if (++ watchdogMisses < 2 ) return ;
2026-09-25 20:56:31 +02:00
if ( Environment . TickCount64 < nextWatchdogAttempt ) return ;
if ( watchdogAttempts >= MaximumWatchdogAttempts )
{
if ( watchdogAttempts == MaximumWatchdogAttempts )
{
watchdogAttempts ++;
Console . Error . WriteLine ( "VC_WATCHDOG exhausted; the audio graph will not start and is no longer being rebuilt" );
}
return ;
}
2026-09-24 13:31:16 +02:00
ResetWatchdog ();
2026-09-25 20:56:31 +02:00
watchdogAttempts ++;
nextWatchdogAttempt = Environment . TickCount64 + Math . Min ( 2_000 * ( 1 << watchdogAttempts ), 30_000 );
try { IosAudioEngine . Shared . Reconfigure ( true , $"stall watchdog {watchdogAttempts}" ); }
2026-09-24 13:31:16 +02:00
catch ( Exception exception ) { System . Diagnostics . Debug . WriteLine ( $"Audio watchdog rebuild failed: {exception}" ); }
}
finally { watchdogTicking = false ; }
}
2026-09-19 20:09:00 +02:00
private void HandleRouteChange ( NSNotification note )
{
NSNumber ? value = note . UserInfo ?[ new NSString ( "AVAudioSessionRouteChangeReasonKey" )] as NSNumber ;
AVAudioSessionRouteChangeReason reason = ( AVAudioSessionRouteChangeReason )( value ?. UInt32Value ?? 0 );
RefreshRoutes ();
if ( reason is AVAudioSessionRouteChangeReason . CategoryChange
or AVAudioSessionRouteChangeReason . Override
or AVAudioSessionRouteChangeReason . RouteConfigurationChange )
return ;
2026-09-25 19:05:14 +02:00
// Apply is mid-flight: this notification describes the change Apply is itself making.
if ( applying ) return ;
2026-09-25 20:56:31 +02:00
// Not a forced rebuild. Every reconfiguration moves the route, and moving the route
// notifies here, so answering a route change with an unconditional rebuild is a loop with
// one iteration per notification: forcing the speaker takes a headset out of the route as
// OldDeviceUnavailable, releasing it brings the headset back as NewDeviceAvailable, and
// even joining voice moves the route through SetPreferredInput. AVAudioEngine follows a
// route change on its own; what it cannot absorb is the hardware under the tap changing,
// and Reconfigure tests for that, rebuilding a stopped graph and leaving a healthy
// unchanged one alone. Comparing routes instead cannot work: CurrentRoute still names the
// previous route for a while after a reconfiguration.
Console . Error . WriteLine ( $"VC_ROUTE_CHANGE reason={reason} running={IosAudioEngine.Shared.IsRunning} " +
$"hardwareChanged={IosAudioEngine.Shared.HardwareChanged()}" );
Recover ( $"route change ({reason})" , force : false );
2026-09-19 20:09:00 +02:00
}
2026-09-19 15:43:37 +02:00
private void HandleInterruption ( NSNotification note )
{
NSNumber ? type = note . UserInfo ?[ new NSString ( "AVAudioSessionInterruptionTypeKey" )] as NSNumber ;
2026-09-19 20:09:00 +02:00
AVAudioSessionInterruptionType interruption = ( AVAudioSessionInterruptionType )( type ?. UInt32Value ?? 0 );
2026-09-24 13:31:16 +02:00
// The system stops the graph on Began and SetActive fails until the interruption clears,
// so suppress recovery until Ended and let the watchdog retry if that rebuild fails.
if ( interruption == AVAudioSessionInterruptionType . Began ) { interrupted = true ; ResetWatchdog (); return ; }
if ( interruption != AVAudioSessionInterruptionType . Ended ) return ;
interrupted = false ; ResetWatchdog (); Recover ( "interruption ended" );
2026-09-19 15:43:37 +02:00
}
}