fix(ios-audio): unify iOS audio onto one always-external AVAudioEngine

The iOS audio path was a hybrid: Voice-Chat-class presets ran a native
VPIO AVAudioEngine (core external) while Stereo/Studio/A2DP presets ran
the core's miniaudio devices. Nearly every "no input / no output / both"
bug lived in the seam between the two paths — the lingering miniaudio
capture unit fighting VPIO, the audioRestart ordering dance, the
route-change "glitching" loop, stereo<->mono stickiness, and
"can't hear anyone". Switching presets/routes mid-call routinely dropped
a direction.

Drive ALL iOS audio through one AVAudioEngine with the core fully
external at all times: setExternalPlayback(1) once at connect, every MIC
stream external_feed=1, mic via vc_stream_feed_pcm, playback via
vc_set_mixed_output_sink (drained by an always-on AVAudioSourceNode so
remote audio plays before joining voice). VPIO + AGC toggle per preset.
Every preset/route/interruption change funnels through one deterministic
Swift-only reconfigure (stop -> apply session config -> rebuild -> start)
— no second path to hand off to, so a change can't drop a direction.

- IOSVoiceProcessingEngine.swift -> IOSAudioEngine: always-on source-node
  playback, conditional mic tap, VPIO/AGC; one rebuild() backing
  startListening/stop/startMic/stopMic/reconfigure/setCaptureChannels.
- IOSAudioRouter: 7 presets -> 4 (Voice Chat / Stereo Mic / Mono Mic /
  Advanced); persisted voiceProcessingEnabled + agcEnabled; setters call
  IOSAudioEngine.reconfigure() instead of audioRestart/reconcileVoicePath.
- AudioSessionManager slimmed; SessionState mic lifecycle collapsed;
  AppState wires external playback + listening at connect, stop at
  disconnect; SettingsView shows 4 presets + Advanced VPIO/AGC toggles.

No core/ABI/test changes — relies on the already-shipped external API
(test_external_pcm, test_external_playback). xcodebuild iOS device Debug
BUILD SUCCEEDED. Updates docs/voice.md §8 and PROGRESS.md.
This commit is contained in:
2026-06-23 02:45:53 +02:00
parent 7547b8e140
commit d30c4ee2f5
8 changed files with 438 additions and 414 deletions

View File

@@ -91,6 +91,7 @@ final class AppState {
func disconnect() {
session?.stopMicStream()
session?.client.disconnect()
IOSAudioEngine.shared.stop()
AudioSessionManager.shared.deactivateSession()
session = nil
connectingClient?.disconnect()
@@ -160,17 +161,18 @@ final class AppState {
self.session = newSession
EventFeedback.shared.play(.login)
EventFeedback.shared.speak("Connected")
// Activate the audio session now, while connected NOT lazily when the first
// remote stream arrives. The core opens its miniaudio playback device the moment
// a remote stream starts and only THEN emits .streamStarted; if we waited for
// that event to activate, the playback device would open against an inactive
// AVAudioSession and produce no sound (the "can't hear anyone" bug). Activating
// here guarantees the session is live before any device opens.
// Put the core into external-playback mode ONCE, now, before the session is
// activated or any remote stream can arrive so the core never opens a miniaudio
// device on iOS (the single ordering rule of the unified audio path). Then activate
// the session and start the engine in listening mode so remote audio plays the
// moment someone talks, even before we join voice (no "can't hear anyone").
client.setExternalPlayback(true)
do {
try AudioSessionManager.shared.ensureSessionActive()
} catch {
print("Audio session activate on connect failed: \(error)")
}
IOSAudioEngine.shared.startListening(client: client)
} else {
connectStatus = "Auth failed: \(ev.result.description)"
showPasswordPrompt = true
@@ -178,6 +180,7 @@ final class AppState {
case .disconnected:
if session == nil { cancelConnect() }
else {
IOSAudioEngine.shared.stop()
AudioSessionManager.shared.deactivateSession()
session = nil; isConnecting = false
}