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

@@ -38,9 +38,9 @@ struct SettingsView: View {
.accessibilityLabel("Speaker output")
.accessibilityHint("Routes audio to the speaker instead of the earpiece when no headphones are connected.")
// Surface the voice-processing state. On the AEC presets the native iOS
// Surface the voice-processing state. On Voice Chat the native iOS
// Voice-Processing unit (VPIO) does echo cancellation, noise suppression and
// automatic gain control; the other presets (stereo/studio/A2DP) can't use it.
// automatic gain control; the stereo / mono-mic / A2DP configs can't use it.
if router.currentConfigUsesVoiceProcessing {
Label("Echo cancellation & noise suppression on (iOS voice processing)",
systemImage: "waveform.badge.mic")
@@ -48,18 +48,11 @@ struct SettingsView: View {
.foregroundStyle(.secondary)
.accessibilityLabel("Echo cancellation and noise suppression are on")
} else {
Label("No echo cancellation in this preset (stereo / studio / A2DP)",
Label("No echo cancellation in this configuration (stereo / A2DP / off)",
systemImage: "waveform.slash")
.font(.caption)
.foregroundStyle(.secondary)
.accessibilityLabel("Echo cancellation is off in this preset")
}
if !router.hasBluetoothDevice && !router.hasWiredHeadset {
Text("Connect Bluetooth headphones or a wired headset for more presets.")
.font(.caption)
.foregroundStyle(.secondary)
.accessibilityLabel("No external audio device connected")
.accessibilityLabel("Echo cancellation is off in this configuration")
}
}
@@ -127,6 +120,27 @@ struct SettingsView: View {
}
.accessibilityLabel("Microphone processing mode")
// Voice-processing (VPIO) controls only meaningful on a VPIO-capable
// config (mono + standard + non-A2DP). iOS bundles echo cancellation and
// noise suppression into one master switch (no per-stage toggle); AGC is
// the one sub-stage it lets us control independently.
if router.voiceProcessingAvailable {
Toggle("Voice Processing (AEC + noise suppression)", isOn: Binding(
get: { router.voiceProcessingEnabled },
set: { router.setVoiceProcessingEnabled($0) }
))
.accessibilityLabel("Voice processing")
.accessibilityHint("Echo cancellation and noise suppression, bundled together by iOS.")
if router.voiceProcessingEnabled {
Toggle("Automatic Gain Control", isOn: Binding(
get: { router.agcEnabled },
set: { router.setAgcEnabled($0) }
))
.accessibilityLabel("Automatic gain control")
}
}
if router.showsRawModeSpeakerWarning {
Label(
"Raw mode on speaker — echo risk (no AEC)",