fix(ios): keep the voice-processing graph up instead of rebuilding it
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

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.
This commit is contained in:
2026-09-25 20:56:31 +02:00
parent 1a0ff957ae
commit 01bae734b8
6 changed files with 229 additions and 135 deletions
@@ -1,41 +0,0 @@
using VoiceCat.Core;
namespace VoiceCat.Tests;
public class AudioRouteWatcherTests
{
// Reconfiguring a session moves the route, and moving the route is reported back as a change.
// Acting on that echo rebuilds the graph that caused it, which is the audio flipping between
// two routes without end rather than one reconfiguration.
[Fact]
public void ARouteThatMatchesTheLiveGraphIsNotRebuiltFor()
{
var watcher = new AudioRouteWatcher();
// Nothing is known about a graph built before anything was recorded.
Assert.True(watcher.ShouldRebuild("builtInMic|builtInSpeaker"));
watcher.Built("builtInMic|builtInSpeaker");
// The echo of the Apply that produced this route, however often it is reported.
Assert.False(watcher.ShouldRebuild("builtInMic|builtInSpeaker"));
Assert.False(watcher.ShouldRebuild("builtInMic|builtInSpeaker"));
// A headset arriving is hardware the graph is not built on: rebuild, then settle again.
Assert.True(watcher.ShouldRebuild("headsetMic|headset"));
watcher.Built("headsetMic|headset");
Assert.False(watcher.ShouldRebuild("headsetMic|headset"));
// Forcing the speaker takes the headset out of the route. The rebuild that does it records
// the result, so the change it is reported as does not drive a second rebuild.
watcher.Built("builtInMic|builtInSpeaker");
Assert.False(watcher.ShouldRebuild("builtInMic|builtInSpeaker"));
// And releasing it, which brings the headset back, is one rebuild and no more.
Assert.True(watcher.ShouldRebuild("headsetMic|headset"));
watcher.Built("headsetMic|headset");
Assert.False(watcher.ShouldRebuild("headsetMic|headset"));
// A released session has no route left to compare against.
watcher.Reset();
Assert.True(watcher.ShouldRebuild("headsetMic|headset"));
}
}
@@ -180,7 +180,11 @@ public class PublishServerScriptTests
Assert.Contains("private void TickWatchdog()", router);
Assert.Contains("IosAudioEngine.Shared.RenderCallbacks", router);
Assert.Contains("IosAudioEngine.Shared.IsRunning", router);
Assert.Contains("IosAudioEngine.Shared.Reconfigure()", router);
// Bounded: a rebuild that does not restore the callbacks backs off and then stops, so a
// graph that cannot start fails visibly instead of churning the session forever.
Assert.Contains("IosAudioEngine.Shared.Reconfigure(true, $\"stall watchdog {watchdogAttempts}\")", router);
Assert.Contains("watchdogAttempts >= MaximumWatchdogAttempts", router);
Assert.Contains("VC_WATCHDOG exhausted", router);
// SetActive fails for as long as an interruption is in force, so Began suppresses
// recovery and the watchdog retries the rebuild that Ended asks for.
@@ -218,7 +222,15 @@ public class PublishServerScriptTests
Assert.True(engine.IndexOf("SetVoiceProcessingEnabled", StringComparison.Ordinal) <
engine.IndexOf("next.Connect(source", StringComparison.Ordinal));
Assert.Contains("AVAudioEngine.ConfigurationChangeNotification", engine);
Assert.Contains("ReferenceEquals(engine, next) && !next.Running", engine);
Assert.Contains("if (!IsConnected || !ReferenceEquals(engine, next)) return;", engine);
Assert.Contains("if (!next.Running || HardwareChanged()) Rebuild(\"configuration change\");", engine);
// With voice processing the input and output are one IO unit, and it only stays up while the
// input is rendered. A tap alone leaves it out of the chain and the unit dies seconds later.
Assert.Contains("next.Connect(input, captureSink, inputFormat", engine);
Assert.Contains("captureSink.OutputVolume = 0f;", engine);
// Capture exists for the session, so joining voice names a stream instead of rebuilding.
Assert.Contains("internal void StopMicrophone() { Volatile.Write(ref microphone, null); }", engine);
Assert.Contains("bool captures = IsConnected;", engine);
}
[Fact]
@@ -239,11 +251,35 @@ public class PublishServerScriptTests
// means fighting the system for the route, which the user sees as audio flipping.
Assert.Contains("if (overridePending)", router);
Assert.Contains("overridePending = false;", router);
// A route change that reports the route the live graph was built on is the echo of the
// Apply that produced it, and answering it rebuilds without end.
// Every reconfiguration moves the route and moving the route notifies the handler, so a
// route change must never force a rebuild: that is one rebuild per notification, without
// end. The graph is rebuilt only when it stopped or when the hardware it was built around
// moved, which is the one thing AVAudioEngine cannot absorb by itself.
Assert.Contains("if (applying) return;", router);
Assert.Contains("!route.ShouldRebuild(RouteSignature(AVAudioSession.SharedInstance()))", router);
Assert.Contains("route.Built(RouteSignature(session))", router);
Assert.Contains("Recover($\"route change ({reason})\", force: false)", router);
Assert.DoesNotContain("Recover($\"route change ({reason})\");", router);
string engine = await File.ReadAllTextAsync(Path.Combine(
FindRoot(), "clients", "apple", "VoiceCat.iOS", "IosAudioEngine.cs"));
// Asked of the graph's own input, not of AVAudioSession and not remembered from a route:
// the session's reported rate and channel count do not settle until after the graph has
// started, and CurrentRoute still names the previous route for a while, so either one
// reports a change that has not happened and every rebuild reports it again.
Assert.Contains("live.InputNode.GetBusOutputFormat(0)", engine);
Assert.Contains("format.SampleRate != builtInputRate || format.ChannelCount != builtInputChannels", engine);
Assert.DoesNotContain("AVAudioSession.SharedInstance().SampleRate", engine);
// Every rebuild names what asked for it, so a storm can be read from a log.
Assert.Contains("VC_REBUILD cause=", engine);
// Building a graph is itself what posts most configuration changes, and the engine reads as
// stopped until voice processing has rebuilt the IO. Nothing may judge a graph dead inside
// that window, or it replaces the graph it just built with one that reports the same thing.
Assert.Contains("internal bool Settling =>", engine);
Assert.Contains("if (!force && Settling) return;", engine);
Assert.Contains("if (Settling) return;", engine);
Assert.Contains("if (Settling) return true;", engine);
Assert.Contains("|| IosAudioEngine.Shared.Settling) { ResetWatchdog(); return; }", router);
Assert.Contains("&& !HardwareChanged()) return;", engine);
Assert.Contains("(!next.Running || HardwareChanged())", engine);
Assert.Contains("if (Preset == IosAudioPreset.VoiceChat) { SelectedInputId = null; SelectedDataSourceId = null; }", router);
Assert.DoesNotContain("SelectedInputId ??= session.PreferredInput", router);
}