Two iOS bugs with the same shape: unconditional rebuilds where a conditional check belongs. The audio graph was torn down on every unintentional disconnect and every foreground transition. A lost connection ran the same teardown as an explicit disconnect, deactivating the AVAudioSession and so dropping the Bluetooth HFP link for a transport blip, and foregrounding always called Reconfigure even though the `audio` background mode keeps the graph live. Both cost seconds of dead audio on a headset. Split "session ended" from "transport blipped". Detach unbinds the client but keeps the session, graph, and route, so a reconnect rebinds to a live HFP link; the route is parked on stream id 0 so capture cannot feed the next connection a stream it never announced. StartListening reuses a running graph, StartMicrophone reuses a running tap of the same width, and Reconfigure gained a non-forcing mode that no-ops when tap presence, channel width, and voice processing all still match. Foregrounding now ensures the graph is running and only reconfigures if it actually stopped. Route changes, media-services resets, and the stall watchdog still force a full rebuild. Every list also reloaded on a model event raised 20 times a second by the microphone level timer. ReloadData recreates the accessibility element tree, so VoiceOver explore mode re-announced the row under a dragging finger and a double tap landed on an element that no longer existed. No controller ever unsubscribed, so popped controllers kept reloading too. Move the level to its own LevelChanged event, and reload lists through ListRefresher, which subscribes only while on screen and only reloads when the rendered content signature changed. The voice bar publishes its accessibility value on 5% steps, MoveUserController reloads just its two checkmark rows, and the chat transcripts skip reassigning identical text. The changed logic sits on UIKit and AVFoundation types the net10.0 test project cannot reference, so this carries no tests; the Bluetooth reconnect and foreground paths need device verification.
318 lines
21 KiB
C#
318 lines
21 KiB
C#
using AVFoundation;
|
|
using Foundation;
|
|
using ObjCRuntime;
|
|
using UIKit;
|
|
|
|
namespace VoiceCat.iOS;
|
|
|
|
internal enum IosAudioPreset { VoiceChat, StereoMicrophone, MonoMicrophone, Advanced }
|
|
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);
|
|
|
|
internal sealed class IosAudioRouter
|
|
{
|
|
private static readonly NativeHandle SupportedPolarPatternsSelector = Selector.GetHandle("supportedPolarPatterns");
|
|
private static readonly NativeHandle SetPreferredPolarPatternSelector = Selector.GetHandle("setPreferredPolarPattern:error:");
|
|
internal static IosAudioRouter Shared { get; } = new();
|
|
private readonly NSUserDefaults defaults = NSUserDefaults.StandardUserDefaults;
|
|
private bool applying;
|
|
private System.Threading.Timer? watchdog;
|
|
private long lastRenderCallbacks = -1;
|
|
private int watchdogMisses;
|
|
private bool watchdogTicking;
|
|
private bool interrupted;
|
|
internal event Action? Changed;
|
|
internal IosAudioPreset Preset { get; private set; } = IosAudioPreset.VoiceChat;
|
|
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;
|
|
|
|
private IosAudioRouter()
|
|
{
|
|
NSNotificationCenter.DefaultCenter.AddObserver(AVAudioSession.RouteChangeNotification, HandleRouteChange);
|
|
NSNotificationCenter.DefaultCenter.AddObserver(AVAudioSession.InterruptionNotification, HandleInterruption);
|
|
NSNotificationCenter.DefaultCenter.AddObserver(AVAudioSession.MediaServicesWereResetNotification, _ => Recover("media services reset"));
|
|
}
|
|
|
|
internal void Load()
|
|
{
|
|
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;
|
|
ForceSpeaker = defaults.BoolForKey("cat.voice.audio.forceSpeaker");
|
|
// Older voice-chat selections saved an automatic speaker override. Drop that
|
|
// preference for the named preset so an existing install can follow HFP again.
|
|
if (Preset == IosAudioPreset.VoiceChat) ForceSpeaker = false;
|
|
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");
|
|
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");
|
|
if (Preset == IosAudioPreset.VoiceChat) { SelectedInputId = null; SelectedDataSourceId = null; }
|
|
if (Enum.TryParse(defaults.StringForKey("cat.voice.audio.polarPattern"), true, out AVAudioDataSourcePolarPattern pattern)) SelectedPolarPattern = pattern;
|
|
RefreshRoutes();
|
|
}
|
|
|
|
internal void SelectPreset(IosAudioPreset preset)
|
|
{
|
|
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;
|
|
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;
|
|
// HFP needs to remain free to follow a connected headset. A speaker route can
|
|
// still be requested explicitly with the Speaker output control.
|
|
if (preset == IosAudioPreset.VoiceChat) ForceSpeaker = false;
|
|
}
|
|
SaveAndReconfigure();
|
|
}
|
|
|
|
internal void SetForceSpeaker(bool value) { ForceSpeaker = value; Preset = IosAudioPreset.Advanced; SaveAndReconfigure(); }
|
|
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() ?? [];
|
|
Outputs = session.CurrentRoute.Outputs.Select(value => new IosAudioPort(value.UID, value.PortName, value.PortType.ToString())).ToArray();
|
|
// 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();
|
|
}
|
|
|
|
internal void Apply(bool configureInput)
|
|
{
|
|
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;
|
|
if (!session.SetCategory(AVAudioSessionCategory.PlayAndRecord, mode, options, out NSError? categoryError)) throw new InvalidOperationException(categoryError.LocalizedDescription);
|
|
session.SetPreferredSampleRate(48_000, out _); session.SetPreferredIOBufferDuration(0.02, out _);
|
|
if (configureInput) ApplyInputSelection(session);
|
|
if (!session.SetActive(true, AVAudioSessionSetActiveOptions.NotifyOthersOnDeactivation, out NSError? activeError)) throw new InvalidOperationException(activeError.LocalizedDescription);
|
|
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"}");
|
|
// The speaker port override forces both input and output to built-in hardware,
|
|
// even with a headset connected. DefaultToSpeaker is an explicit speaker choice
|
|
// only; the voice-chat preset leaves it off so HFP can follow the headset.
|
|
RefreshRoutes();
|
|
ResetWatchdog(); EnsureWatchdog();
|
|
}
|
|
finally { applying = false; }
|
|
}
|
|
|
|
private void ApplyInputSelection(AVAudioSession session)
|
|
{
|
|
AVAudioSessionPortDescription? port = session.AvailableInputs?.FirstOrDefault(value => value.UID == SelectedInputId);
|
|
if (CaptureChannels == 2) port ??= session.AvailableInputs?.FirstOrDefault(value => value.PortType == AVAudioSession.PortBuiltInMic);
|
|
if (port is null) { if (CaptureChannels == 1) ClearStereoPolarPattern(session); return; }
|
|
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.");
|
|
}
|
|
AVAudioSessionDataSourceDescription? source = CaptureChannels == 2
|
|
? port.DataSources?.FirstOrDefault(SupportsStereoPolarPattern)
|
|
: port.DataSources?.FirstOrDefault(value => value.DataSourceID.ToString() == SelectedDataSourceId);
|
|
if (CaptureChannels == 1 && source is null) ClearStereoPolarPattern(session);
|
|
if (source is not null)
|
|
{
|
|
if (!port.SetPreferredDataSource(source, out NSError? portError)) throw new InvalidOperationException(portError.LocalizedDescription);
|
|
if (CaptureChannels == 2) SetStereoPolarPattern(source);
|
|
else if (SelectedPolarPattern != AVAudioDataSourcePolarPattern.Unknown &&
|
|
!source.SetPreferredPolarPattern(SelectedPolarPattern, out NSError? patternError))
|
|
throw new InvalidOperationException(patternError.LocalizedDescription);
|
|
}
|
|
if (!session.SetPreferredInput(port, out NSError? inputError)) throw new InvalidOperationException(inputError.LocalizedDescription);
|
|
// 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.
|
|
if (CaptureChannels == 2 && source is not null &&
|
|
!session.SetInputDataSource(source, out NSError? sourceError))
|
|
throw new InvalidOperationException(sourceError.LocalizedDescription);
|
|
Console.Error.WriteLine($"VC_ROUTE_SELECT port={port.PortName} source={source?.DataSourceName ?? "none"} " +
|
|
$"stereoPattern={source is not null && SupportsStereoPolarPattern(source)} " +
|
|
$"patterns={string.Join(',', source?.SupportedPolarPatterns?.Select(value => value.ToString()) ?? [])}");
|
|
}
|
|
|
|
// 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);
|
|
}
|
|
|
|
private static void ClearStereoPolarPattern(AVAudioSession session)
|
|
{
|
|
session.SetPreferredInputOrientation(AVAudioStereoOrientation.None, out _);
|
|
AVAudioSessionPortDescription? builtIn = session.AvailableInputs?.FirstOrDefault(value => value.PortType == AVAudioSession.PortBuiltInMic);
|
|
if (builtIn is null) return;
|
|
foreach (AVAudioSessionDataSourceDescription source in builtIn.DataSources ?? [])
|
|
if (source.SelectedPolarPattern == AVAudioDataSourcePolarPattern.Unknown && SupportsStereoPolarPattern(source))
|
|
source.SetPreferredPolarPattern(AVAudioDataSourcePolarPattern.Unknown, out _);
|
|
}
|
|
|
|
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");
|
|
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();
|
|
if (IosAudioEngine.Shared.IsConnected) IosAudioEngine.Shared.Reconfigure(); Changed?.Invoke();
|
|
}
|
|
|
|
private void Set(string key, string? value) { if (value is null) defaults.RemoveObject(key); else defaults.SetString(value, key); }
|
|
internal void Deactivate()
|
|
{
|
|
watchdog?.Dispose(); watchdog = null; ResetWatchdog();
|
|
AVAudioSession.SharedInstance().SetActive(false, AVAudioSessionSetActiveOptions.NotifyOthersOnDeactivation, out _);
|
|
}
|
|
internal void EnsureAudio(string reason)
|
|
{
|
|
if (!IosAudioEngine.Shared.IsConnected || interrupted) return;
|
|
try { IosAudioEngine.Shared.EnsureRunning(); }
|
|
catch (Exception exception) { System.Diagnostics.Debug.WriteLine($"Audio recovery ({reason}) failed: {exception}"); }
|
|
}
|
|
|
|
internal void Recover(string reason, bool force = true)
|
|
{
|
|
RefreshRoutes();
|
|
if (!IosAudioEngine.Shared.IsConnected || interrupted) return;
|
|
UIApplication.SharedApplication.BeginInvokeOnMainThread(() =>
|
|
{
|
|
try { IosAudioEngine.Shared.Reconfigure(force); }
|
|
catch (Exception exception) { System.Diagnostics.Debug.WriteLine($"Audio recovery ({reason}) failed: {exception}"); }
|
|
});
|
|
}
|
|
|
|
// 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; }
|
|
|
|
// 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");
|
|
}
|
|
|
|
private void TickWatchdog()
|
|
{
|
|
if (watchdogTicking) return;
|
|
watchdogTicking = true;
|
|
try
|
|
{
|
|
if (!IosAudioEngine.Shared.IsConnected || interrupted || applying) { ResetWatchdog(); return; }
|
|
long callbacks = IosAudioEngine.Shared.RenderCallbacks;
|
|
bool stalled = !IosAudioEngine.Shared.IsRunning || callbacks == lastRenderCallbacks;
|
|
lastRenderCallbacks = callbacks;
|
|
if (!stalled) { watchdogMisses = 0; return; }
|
|
// One missed tick can be a route change already rebuilding the graph.
|
|
if (++watchdogMisses < 2) return;
|
|
ResetWatchdog();
|
|
try { IosAudioEngine.Shared.Reconfigure(); }
|
|
catch (Exception exception) { System.Diagnostics.Debug.WriteLine($"Audio watchdog rebuild failed: {exception}"); }
|
|
}
|
|
finally { watchdogTicking = false; }
|
|
}
|
|
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;
|
|
Recover($"route change ({reason})");
|
|
}
|
|
private void HandleInterruption(NSNotification note)
|
|
{
|
|
NSNumber? type = note.UserInfo?[new NSString("AVAudioSessionInterruptionTypeKey")] as NSNumber;
|
|
AVAudioSessionInterruptionType interruption = (AVAudioSessionInterruptionType)(type?.UInt32Value ?? 0);
|
|
// 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");
|
|
}
|
|
}
|