fix(ios): stop the speaker toggle rebuilding the audio graph in a loop
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:
@@ -0,0 +1,41 @@
|
||||
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"));
|
||||
}
|
||||
}
|
||||
@@ -222,16 +222,29 @@ public class PublishServerScriptTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IosVoiceChatLeavesBluetoothHeadsetRouteToSystem()
|
||||
public async Task IosSpeakerOutputIsAnOrthogonalOneShotChoice()
|
||||
{
|
||||
string router = await File.ReadAllTextAsync(Path.Combine(
|
||||
FindRoot(), "clients", "apple", "VoiceCat.iOS", "IosAudioRouter.cs"));
|
||||
|
||||
Assert.Contains("if (preset == IosAudioPreset.VoiceChat) ForceSpeaker = false", router);
|
||||
Assert.Contains("if (Preset == IosAudioPreset.VoiceChat) ForceSpeaker = false", router);
|
||||
// Speaker output decides where audio goes, not how it is captured, so it must not take the
|
||||
// preset with it, and no preset may clear it back.
|
||||
Assert.Contains("ForceSpeaker = value; speakerIsExplicit = overridePending = true", router);
|
||||
Assert.DoesNotContain("ForceSpeaker = value; Preset = IosAudioPreset.Advanced", router);
|
||||
Assert.DoesNotContain("IosAudioPreset.VoiceChat) ForceSpeaker = false", router);
|
||||
// The stored flag is honoured only once the user has actually chosen, so the value older
|
||||
// installs inherited from the voice-chat preset cannot pin a headset user to the speaker.
|
||||
Assert.Contains("cat.voice.audio.speakerIsExplicit", router);
|
||||
// The port override is the toggle's one-shot request. Re-asserting it on every rebuild
|
||||
// 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.
|
||||
Assert.Contains("if (applying) return;", router);
|
||||
Assert.Contains("!route.ShouldRebuild(RouteSignature(AVAudioSession.SharedInstance()))", router);
|
||||
Assert.Contains("route.Built(RouteSignature(session))", router);
|
||||
Assert.Contains("if (Preset == IosAudioPreset.VoiceChat) { SelectedInputId = null; SelectedDataSourceId = null; }", router);
|
||||
Assert.Contains("ForceSpeaker = value; Preset = IosAudioPreset.Advanced", router);
|
||||
Assert.DoesNotContain("OverrideOutputAudioPort(", router);
|
||||
Assert.DoesNotContain("SelectedInputId ??= session.PreferredInput", router);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user