Joining voice on the voice-chat preset was unreliable: audio arrived after several seconds of the route flipping back and forth, sometimes not at all, and VoiceOver went quiet while it happened. Device logs show why. A graph with voice processing enabled reports a successful start and is then torn down within a second, roughly three times in four; every configuration without voice processing — both microphone presets, and voice chat with processing off — comes up first time and runs indefinitely. With voice processing the input and output are one IO unit, and it only stays up while the input is part of the render chain. The input node carried a tap and no connection, which leaves it out of that chain. Route the input through a silent mixer so it is genuinely rendered. The rest of this is the amplifier rather than the cause, and each part of it turned one failed start into a storm: The stall watchdog rebuilt on every missed tick, without bound. That converted a graph that could not start into endless session reconfiguration, which is what the user heard and what hid the reason from the log. It now backs off after each failed attempt and stops after four, logging VC_WATCHDOG exhausted, so a transient freeze still recovers and a graph that will not start fails visibly. Nothing waited for a graph to start before judging it dead. Enabling voice processing rebuilds both halves of the IO, which posts a configuration change and reads as not running for several hundred milliseconds, so the configuration-change handler and the watchdog both tore down graphs that were about to run. A settling window holds them off for two seconds. A route change forced a full rebuild, and every rebuild moves the route, so one notification produced the next. Route changes now take the non-forcing path, which rebuilds a stopped graph and leaves a healthy one alone; the hardware test it uses reads the input node's format, not AVAudioSession, whose reported rate and channel count do not settle until after the graph has started. The input side is built once per session instead of being added when voice is joined, so joining and leaving voice set a stream id rather than replacing the graph, and a mono voice-chat apply no longer clears a stereo capsule configuration it never applied. Every rebuild now logs its cause, and VC_START/VC_START_CHECK record whether the graph survived its start. The first-attempt failure is not fixed and is recorded in PROGRESS.md as a release gate: capture still comes up on a watchdog rebuild rather than immediately. The changed logic sits on AVAudioSession and AVAudioEngine, which the net10.0 test project cannot reference, so the behaviour is covered by the existing source assertions; verification is on device.
380 lines
26 KiB
C#
380 lines
26 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 bool speakerIsExplicit;
|
|
// 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;
|
|
// 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;
|
|
private System.Threading.Timer? watchdog;
|
|
private long lastRenderCallbacks = -1;
|
|
private int watchdogMisses;
|
|
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;
|
|
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;
|
|
// 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");
|
|
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;
|
|
}
|
|
SaveAndReconfigure();
|
|
}
|
|
|
|
// 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.
|
|
// 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(); }
|
|
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"}");
|
|
// 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
|
|
// 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 _);
|
|
}
|
|
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 && stereoApplied) ClearStereo(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.");
|
|
stereoApplied = true;
|
|
}
|
|
AVAudioSessionDataSourceDescription? source = CaptureChannels == 2
|
|
? port.DataSources?.FirstOrDefault(SupportsStereoPolarPattern)
|
|
: port.DataSources?.FirstOrDefault(value => value.DataSourceID.ToString() == SelectedDataSourceId);
|
|
if (CaptureChannels == 1 && source is null && stereoApplied) ClearStereo(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 void ClearStereo(AVAudioSession session) { stereoApplied = false; ClearStereoPolarPattern(session); }
|
|
|
|
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(speakerIsExplicit, "cat.voice.audio.speakerIsExplicit");
|
|
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();
|
|
ResetWatchdogAttempts();
|
|
if (IosAudioEngine.Shared.IsConnected) IosAudioEngine.Shared.Reconfigure(true, "settings"); Changed?.Invoke();
|
|
}
|
|
|
|
private void Set(string key, string? value) { if (value is null) defaults.RemoveObject(key); else defaults.SetString(value, key); }
|
|
internal void Deactivate()
|
|
{
|
|
// A released session has no route the next graph can be compared against.
|
|
watchdog?.Dispose(); watchdog = null; ResetWatchdog(); ResetWatchdogAttempts();
|
|
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, reason); }
|
|
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; }
|
|
private void ResetWatchdogAttempts() { watchdogAttempts = 0; nextWatchdogAttempt = 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
|
|
{
|
|
// 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; }
|
|
long callbacks = IosAudioEngine.Shared.RenderCallbacks;
|
|
bool stalled = !IosAudioEngine.Shared.IsRunning || callbacks == lastRenderCallbacks;
|
|
lastRenderCallbacks = callbacks;
|
|
if (!stalled) { watchdogMisses = 0; ResetWatchdogAttempts(); return; }
|
|
// One missed tick can be a route change already rebuilding the graph.
|
|
if (++watchdogMisses < 2) return;
|
|
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;
|
|
}
|
|
ResetWatchdog();
|
|
watchdogAttempts++;
|
|
nextWatchdogAttempt = Environment.TickCount64 + Math.Min(2_000 * (1 << watchdogAttempts), 30_000);
|
|
try { IosAudioEngine.Shared.Reconfigure(true, $"stall watchdog {watchdogAttempts}"); }
|
|
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;
|
|
// Apply is mid-flight: this notification describes the change Apply is itself making.
|
|
if (applying) return;
|
|
// 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);
|
|
}
|
|
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");
|
|
}
|
|
}
|