Stabilize iOS reconnect and screen audio sessions
Build and test / test (macos-latest) (push) Canceled after 0s
Build and test / test (ubuntu-24.04) (push) Canceled after 0s
Build and test / test (windows-latest) (push) Canceled after 0s
Build and test / apple-client (push) Canceled after 0s

This commit is contained in:
2026-09-26 20:30:24 +02:00
parent 5a2dddba89
commit 4cc13a27a0
11 changed files with 327 additions and 36 deletions
+30 -5
View File
@@ -88,6 +88,7 @@ internal sealed class IosAudioEngine
// right hardware. Rebuilding it there would release an HFP headset and pay a Bluetooth
// profile renegotiation for a transport blip that changed no audio configuration.
if (engine?.Running == true) { playbackRing.Resynchronize(); return; }
if (ResumeStoppedGraph()) return;
Volatile.Write(ref microphone, null); Rebuild();
}
@@ -134,6 +135,7 @@ internal sealed class IosAudioEngine
int channels = IosAudioRouter.Shared.CaptureChannels;
if (!force && engine?.Running == true && tapInstalled && builtCaptureChannels == channels
&& voiceProcessing == IosAudioRouter.Shared.UsesVoiceProcessing && !HardwareChanged()) return;
if (!force && ResumeStoppedGraph()) return;
if (current is not null)
{
// Stop the old tap before publishing a route with a different sample width. Otherwise
@@ -155,9 +157,30 @@ internal sealed class IosAudioEngine
// Still starting: report it as running rather than replacing it, since that is what it is
// about to be, and a rebuild here would start the cycle over.
if (Settling) return true;
if (ResumeStoppedGraph()) return true;
Rebuild(); return engine?.Running == true;
}
private bool ResumeStoppedGraph()
{
if (engine is not { } paused || !tapInstalled ||
builtCaptureChannels != IosAudioRouter.Shared.CaptureChannels ||
voiceProcessing != IosAudioRouter.Shared.UsesVoiceProcessing || HardwareChanged()) return false;
IosAudioRouter.Shared.Apply(true);
bool started = paused.StartAndReturnError(out NSError? error);
if (started)
{
playbackRing.Resynchronize();
// Voice-processing IO may still be starting when Start returns. Give the existing
// graph its settling window before a watchdog judges it, just as a fresh graph gets.
Volatile.Write(ref lastRebuildAt, Environment.TickCount64);
Console.Error.WriteLine($"VC_RESUME running={paused.Running}");
return true;
}
Console.Error.WriteLine($"VC_RESUME running={paused.Running} error={error?.LocalizedDescription ?? "none"}");
return false;
}
private void Rebuild([System.Runtime.CompilerServices.CallerMemberName] string cause = "")
{
Console.Error.WriteLine($"VC_REBUILD cause={cause} running={engine?.Running == true} callbacks={RenderCallbacks}");
@@ -182,7 +205,7 @@ internal sealed class IosAudioEngine
// 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);
throw new InvalidOperationException(processingError?.LocalizedDescription ?? "Could not configure voice processing.");
if (IosAudioRouter.Shared.UsesVoiceProcessing) input.VoiceProcessingAgcEnabled = IosAudioRouter.Shared.AutomaticGainControl;
}
outputFormat = new(AVAudioCommonFormat.PCMFloat32, 48_000, 2, false);
@@ -195,6 +218,8 @@ internal sealed class IosAudioEngine
if (captures)
{
AVAudioFormat inputFormat = input!.GetBusOutputFormat(0);
if (inputFormat.SampleRate <= 0 || inputFormat.ChannelCount == 0)
throw new InvalidOperationException("No usable microphone input is available on the current audio route.");
Console.Error.WriteLine($"VC_GRAPH vpio={IosAudioRouter.Shared.UsesVoiceProcessing} requestedCh={captureChannels} " +
$"inputCh={inputFormat.ChannelCount} inputRate={inputFormat.SampleRate}");
builtInputRate = inputFormat.SampleRate; builtInputChannels = inputFormat.ChannelCount;
@@ -231,15 +256,15 @@ internal sealed class IosAudioEngine
captureSink.OutputVolume = 0f;
}
next.Prepare();
if (!next.StartAndReturnError(out NSError? error)) { next.Dispose(); throw new InvalidOperationException(error.LocalizedDescription); }
if (!next.StartAndReturnError(out NSError? error)) { next.Dispose(); throw new InvalidOperationException(error?.LocalizedDescription ?? "The iOS audio graph could not start."); }
engine = next;
{
AVAudioSession live = AVAudioSession.SharedInstance();
Console.Error.WriteLine($"VC_START running={next.Running} builtCh={builtInputChannels} sessionRate={live.SampleRate} " +
$"sessionInCh={live.InputNumberOfChannels} sessionOutCh={live.OutputNumberOfChannels} inputAvailable={live.InputAvailable} " +
$"other={live.OtherAudioPlaying} mode={live.Mode} options={live.CategoryOptions} io={live.IOBufferDuration:F4} " +
$"out={string.Join(',', live.CurrentRoute.Outputs.Select(value => value.PortType.ToString()))} " +
$"in={string.Join(',', live.CurrentRoute.Inputs.Select(value => value.PortType.ToString()))}");
$"out={string.Join(',', live.CurrentRoute?.Outputs?.Select(value => value.PortType.ToString()) ?? [])} " +
$"in={string.Join(',', live.CurrentRoute?.Inputs?.Select(value => value.PortType.ToString()) ?? [])}");
// A graph that starts and then stops on its own is the failure that matters, and it is
// invisible at start: check again once the IO has had time to come up.
AVAudioEngine started = next;
@@ -380,7 +405,7 @@ internal sealed class IosAudioEngine
{
IsConnected = false; Volatile.Write(ref microphone, null);
if (client is { } owner) owner.Audio.MixedPcm -= ReceiveMixedPcm;
DestroyGraph(); client = null; IosAudioRouter.Shared.Deactivate();
engine?.Pause(); client = null; IosAudioRouter.Shared.Deactivate();
}
private void DestroyGraph()