fix(ios): stop the speaker toggle rebuilding the audio graph in a loop
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

Toggling speaker output flipped the route back and forth indefinitely. Two
loops, both of which made a rebuild produce the condition for the next one.

Reconfiguring the session moves the route, and moving the route is reported
back through RouteChangeNotification. Forcing the speaker takes a headset out
of the route, which arrives as OldDeviceUnavailable, and releasing it brings
the headset back as NewDeviceAvailable; neither is among the reasons the
handler filters, so each rebuild answered its own echo with another rebuild.
Nothing compared the reported route against the route the live graph was
actually built on.

Record that route at the end of Apply, once the session is configured, and
rebuild only when a reported change differs from it; notifications that arrive
while Apply is still running describe the change Apply is itself making and are
ignored outright. The decision is AudioRouteWatcher in VoiceCat.Core, which is
platform-agnostic and tested, following ControlPathWatcher; the route identity
it compares is supplied by the caller, on iOS the UIDs of the current route's
ports. A graph whose route is unchanged but broken is still the stall
watchdog's to catch.

The port override was also re-asserted on every Apply, so where the system
wanted to hand output back to a connected headset each rebuild forced it to the
speaker again and the resulting route change drove the next rebuild. It is now
the one-shot request it should always have been, issued by the toggle alone;
the DefaultToSpeaker category option is the part that persists across rebuilds.

Also updates the route test from 724f7e9, which asserted the voice-chat preset
clearing the speaker flag and the absence of the port override. Both were
deliberately removed when speaker output became orthogonal to the preset, and
the test should have been updated with them.
This commit is contained in:
2026-09-25 19:05:14 +02:00
parent 938b23d4c0
commit 1a0ff957ae
4 changed files with 133 additions and 11 deletions
+36
View File
@@ -0,0 +1,36 @@
namespace VoiceCat.Core;
/// Decides whether a reported audio-route change is a reason to rebuild the audio graph, or the
/// echo of the rebuild that produced it.
///
/// The distinction matters because reconfiguring a session moves the route, and moving the route
/// is reported back as a change. Taking a headset out of the route to force the speaker is
/// reported as a device becoming unavailable, and releasing the speaker is reported as one
/// becoming available: act on either and the rebuild answers its own echo, which is an endless
/// flip between two routes rather than one reconfiguration.
///
/// The rule is that only the hardware carrying audio decides. A graph must be rebuilt when that
/// changes, because the input format changes with it; when the route still matches the one the
/// live graph was built on, there is nothing to rebuild for.
///
/// Platform-agnostic on purpose: the caller supplies whatever stable route identity its OS
/// reports (on iOS, the UIDs of the current route's ports).
public sealed class AudioRouteWatcher
{
private readonly object gate = new();
private string built = "";
/// Records the route a graph has just been built on. Called after the session is configured,
/// so it describes the new route rather than the one it replaced.
public void Built(string signature) { lock (gate) built = signature ?? ""; }
/// Forgets the recorded route, for a session that has been released and has no route left.
public void Reset() { lock (gate) built = ""; }
/// Returns true when the reported route differs from the one the live graph was built on.
/// A graph built before anything was recorded is rebuilt, since nothing is known about it.
public bool ShouldRebuild(string signature)
{
lock (gate) return built.Length == 0 || built != (signature ?? "");
}
}