fix(ios): rebuild audio only when needed and stop VoiceOver list churn
Build and test / test (macos-latest) (push) Waiting to run
Build and test / test (ubuntu-24.04) (push) Waiting to run
Build and test / test (windows-latest) (push) Waiting to run
Build and test / apple-client (push) Waiting to run

Two iOS bugs with the same shape: unconditional rebuilds where a
conditional check belongs.

The audio graph was torn down on every unintentional disconnect and every
foreground transition. A lost connection ran the same teardown as an
explicit disconnect, deactivating the AVAudioSession and so dropping the
Bluetooth HFP link for a transport blip, and foregrounding always called
Reconfigure even though the `audio` background mode keeps the graph live.
Both cost seconds of dead audio on a headset.

Split "session ended" from "transport blipped". Detach unbinds the client
but keeps the session, graph, and route, so a reconnect rebinds to a live
HFP link; the route is parked on stream id 0 so capture cannot feed the
next connection a stream it never announced. StartListening reuses a
running graph, StartMicrophone reuses a running tap of the same width, and
Reconfigure gained a non-forcing mode that no-ops when tap presence,
channel width, and voice processing all still match. Foregrounding now
ensures the graph is running and only reconfigures if it actually stopped.
Route changes, media-services resets, and the stall watchdog still force a
full rebuild.

Every list also reloaded on a model event raised 20 times a second by the
microphone level timer. ReloadData recreates the accessibility element
tree, so VoiceOver explore mode re-announced the row under a dragging
finger and a double tap landed on an element that no longer existed. No
controller ever unsubscribed, so popped controllers kept reloading too.

Move the level to its own LevelChanged event, and reload lists through
ListRefresher, which subscribes only while on screen and only reloads when
the rendered content signature changed. The voice bar publishes its
accessibility value on 5% steps, MoveUserController reloads just its two
checkmark rows, and the chat transcripts skip reassigning identical text.

The changed logic sits on UIKit and AVFoundation types the net10.0 test
project cannot reference, so this carries no tests; the Bluetooth
reconnect and foreground paths need device verification.
This commit is contained in:
2026-09-25 17:00:25 +02:00
parent 724f7e912d
commit 0b81b81c0c
9 changed files with 273 additions and 45 deletions
+17 -6
View File
@@ -36,6 +36,8 @@ internal sealed class AppModel
private int diagnosticPolls;
internal event Action? Changed;
// Raised by the 20 Hz level timer only. Subscribers must be cheap and must not reload a list.
internal event Action? LevelChanged;
internal event Action<ServerIdentityChallenge>? IdentityRequested;
internal IReadOnlyList<ServerProfile> Profiles => profiles;
internal IReadOnlyList<ChatEntry> Messages => messages;
@@ -137,7 +139,7 @@ internal sealed class AppModel
{
System.Diagnostics.Debug.WriteLine($"Connection failed: {exception}");
IsConnecting = false; Status = exception.Message;
if (ReferenceEquals(client, next)) { client = null; await StopSessionResourcesAsync(); }
if (ReferenceEquals(client, next)) { client = null; await StopSessionResourcesAsync(releaseAudio: !restoring); }
await next.DisposeAsync();
Notify();
if (restoring && !explicitDisconnect) ScheduleReconnect();
@@ -199,7 +201,7 @@ internal sealed class AppModel
// Handle that state change directly so a Wi-Fi transition cannot strand this session.
CaptureRestoreState(owner);
client = null; Status = "Connection lost";
try { await StopSessionResourcesAsync(); }
try { await StopSessionResourcesAsync(releaseAudio: false); }
catch (Exception exception) { System.Diagnostics.Debug.WriteLine($"Audio cleanup failed: {exception}"); }
try { await owner.DisposeAsync(); }
catch (Exception exception) { System.Diagnostics.Debug.WriteLine($"Connection cleanup failed: {exception}"); }
@@ -273,7 +275,7 @@ internal sealed class AppModel
internal async Task DisconnectAsync()
{
explicitDisconnect = true; lifetime?.Cancel(); await StopSessionResourcesAsync();
explicitDisconnect = true; lifetime?.Cancel(); await StopSessionResourcesAsync(releaseAudio: true);
VoiceCatClient? old = client; client = null; microphoneStream = 0; IsConnecting = false; Status = "Not connected"; Notify();
if (old is not null) await old.DisposeAsync(); feedback.Play(SoundEvent.Logout); feedback.Speak("Disconnected");
}
@@ -325,7 +327,10 @@ internal sealed class AppModel
catch (Exception exception) when (exception is IOException or InvalidOperationException or ObjectDisposedException) { return; }
lastTalking = talking; feedback.Play(talking ? SoundEvent.VoiceStart : SoundEvent.VoiceStop);
}
Notify();
// Only the voice bar renders the level. Raising the general Changed event at 20 Hz made
// every list reload itself that often, which tears down VoiceOver's element tree under an
// exploring finger; keep the fast signal on its own event.
LevelChanged?.Invoke();
}
private void HandleUserEvent(VoiceCatClient owner, UserEvent value)
@@ -367,9 +372,15 @@ internal sealed class AppModel
owner.SetSelfAudioState(restoreMuted, restoreDeafened); AddActivity($"Restored to channel {restoreChannel}{(restoringVoice ? " with voice" : "")}");
}
private async Task StopSessionResourcesAsync()
// `releaseAudio` distinguishes an ended session from an interrupted one. Ending releases the
// AVAudioSession, which on Bluetooth drops the HFP link and costs seconds of renegotiation on
// the way back; a lost connection is a transport event that changed nothing about the audio
// configuration, so it only unbinds the client and leaves the live route in place.
private async Task StopSessionResourcesAsync(bool releaseAudio)
{
levelTimer?.Dispose(); levelTimer = null; IosAudioEngine.Shared.Stop(); microphoneStream = 0; MicrophoneLevel = 0; lastTalking = false;
levelTimer?.Dispose(); levelTimer = null;
if (releaseAudio) IosAudioEngine.Shared.Stop(); else IosAudioEngine.Shared.Detach();
microphoneStream = 0; MicrophoneLevel = 0; lastTalking = false;
if (broadcast is { } pump) { broadcast = null; pump.Changed -= BroadcastChanged; await pump.DisposeAsync(); }
}
}