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:
@@ -59,7 +59,6 @@ final class SessionState {
|
||||
self.client = client
|
||||
self.selfUserId = selfUserId
|
||||
self.permissions = permissions
|
||||
AudioSessionManager.shared.client = client
|
||||
refreshChannels()
|
||||
refreshUsers()
|
||||
syncSelfChannel()
|
||||
@@ -73,16 +72,10 @@ final class SessionState {
|
||||
broadcastPump.onBroadcastStarted = { [weak self] in self?.startScreenShare() }
|
||||
broadcastPump.onBroadcastFinished = { [weak self] in self?.stopScreenShare() }
|
||||
broadcastPump.start()
|
||||
// When IOSAudioRouter changes the audio config, restart the voice path if needed so the
|
||||
// native VPIO engine (AEC/NS/AGC) engages or disengages to match the new preset/route.
|
||||
AudioSessionManager.shared.reconcileVoicePath = { [weak self] in self?.reconcileVoicePath() }
|
||||
}
|
||||
|
||||
deinit {
|
||||
broadcastPump.stop()
|
||||
MainActor.assumeIsolated {
|
||||
AudioSessionManager.shared.client = nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Event dispatch
|
||||
@@ -259,78 +252,39 @@ final class SessionState {
|
||||
return
|
||||
}
|
||||
|
||||
// VPIO path: on the AEC presets, the native AVAudioEngine does AEC/NS/AGC and the core
|
||||
// runs in external mode (no hardware mic/playback). The mic stream is started with
|
||||
// externalFeed so the core skips the hardware capture device; setExternalPlayback makes
|
||||
// it skip the hardware playback device and deliver the mix to IOSVoiceProcessingEngine.
|
||||
//
|
||||
// ORDER MATTERS: set the external-playback flag now, but defer audioRestart() until
|
||||
// AFTER startStream (below) so the MIC LocalStream — which carries external_feed=true —
|
||||
// already exists when ensure_audio_running() derives external_capture. Restarting before
|
||||
// the stream exists makes the core reopen a hardware capture device that is never dropped
|
||||
// (the announce-result restart early-returns because the engine is already running); that
|
||||
// lingering miniaudio capture unit then fights the AVAudioEngine VPIO unit on the same
|
||||
// .voiceChat session and silences VPIO playback.
|
||||
let useVPIO = IOSAudioRouter.shared.currentConfigUsesVoiceProcessing
|
||||
client.setExternalPlayback(useVPIO)
|
||||
|
||||
// Unified iOS path: the core is always external (set at connect via setExternalPlayback +
|
||||
// every MIC stream external_feed), and `IOSAudioEngine` drives capture + playback. So the
|
||||
// mic stream is just started with external_feed=true and the engine is told the mic is now
|
||||
// active — no setExternalPlayback toggle, no audioRestart ordering, no VPIO/miniaudio fork.
|
||||
let desc = StreamDescriptor(kind: .mic, deviceId: voiceState.currentDeviceId, label: "Mic",
|
||||
externalFeed: useVPIO)
|
||||
externalFeed: true)
|
||||
let (result, streamId) = client.startStream(desc)
|
||||
if result == .ok {
|
||||
voiceState.micActive = true
|
||||
voiceState.localStreamId = streamId
|
||||
EventFeedback.shared.play(.voiceOn)
|
||||
// Publish the active mic stream ID so IOSAudioRouter can reset the core's capture
|
||||
// channel count when the user switches mono↔stereo (selectCaptureChannels /
|
||||
// applyPreset). Without this, switching stereo→mono leaves the LocalStream's
|
||||
// capture_channels field at 2 and the next engine start still opens stereo.
|
||||
AudioSessionManager.shared.activeMicStreamId = streamId
|
||||
// Store the user's capture channel selection before the server acknowledges
|
||||
// the stream. The engine hasn't started yet at this point (it starts when
|
||||
// handle_stream_announce_result fires), so vc_set_capture_channels just
|
||||
// stores the value — no restart. ensure_audio_running() picks it up when
|
||||
// the stream is confirmed and opens the device with the right channel count.
|
||||
let channels = IOSAudioRouter.shared.captureChannels.channelCount
|
||||
if channels != 1 {
|
||||
client.setCaptureChannels(streamId: streamId, channels: channels)
|
||||
}
|
||||
if useVPIO {
|
||||
// The external-feed MIC stream now exists, so restart the core into full
|
||||
// external mode (no hardware capture/playback, mixer-timer only) — mic and
|
||||
// speaker are owned entirely by the VPIO engine, which we start right after.
|
||||
client.audioRestart()
|
||||
IOSVoiceProcessingEngine.shared.start(
|
||||
client: client, micStreamId: streamId, captureChannels: channels)
|
||||
}
|
||||
} else {
|
||||
guard result == .ok else {
|
||||
addActivity("Failed to start mic: \(result.description)")
|
||||
if useVPIO { // revert external-playback mode so remote audio still plays
|
||||
client.setExternalPlayback(false)
|
||||
client.audioRestart()
|
||||
}
|
||||
return
|
||||
}
|
||||
voiceState.micActive = true
|
||||
voiceState.localStreamId = streamId
|
||||
EventFeedback.shared.play(.voiceOn)
|
||||
|
||||
let channels = IOSAudioRouter.shared.captureChannels.channelCount
|
||||
if channels != 1 {
|
||||
client.setCaptureChannels(streamId: streamId, channels: channels)
|
||||
}
|
||||
// Engage the mic: installs the input tap and (per preset) VPIO, in one engine rebuild.
|
||||
IOSAudioEngine.shared.startMic(streamId: streamId, channels: channels)
|
||||
}
|
||||
|
||||
func stopMicStream() {
|
||||
// Tear down the VPIO engine first (removes the mic tap + unregisters the mixed sink),
|
||||
// then stop the mic stream, then restore the core's hardware playback for any remaining
|
||||
// remote audio. Order matters: the mic stream must be gone before audioRestart so the
|
||||
// core opens a normal playback device (and no capture device — there's no mic stream).
|
||||
let wasVPIO = IOSVoiceProcessingEngine.shared.isRunning
|
||||
if wasVPIO {
|
||||
IOSVoiceProcessingEngine.shared.stop()
|
||||
}
|
||||
// Disengage the mic (removes the tap + VPIO) but keep the engine running for any remaining
|
||||
// remote audio. Then stop the core's MIC stream. The core stays external throughout — no
|
||||
// setExternalPlayback toggle, no audioRestart.
|
||||
IOSAudioEngine.shared.stopMic()
|
||||
if voiceState.localStreamId != 0 {
|
||||
client.stopStream(voiceState.localStreamId)
|
||||
voiceState.localStreamId = 0
|
||||
AudioSessionManager.shared.activeMicStreamId = nil
|
||||
EventFeedback.shared.play(.voiceOff)
|
||||
}
|
||||
if wasVPIO {
|
||||
client.setExternalPlayback(false)
|
||||
client.audioRestart() // reopen hardware playback (no mic stream → no hw capture)
|
||||
}
|
||||
voiceState.micActive = false
|
||||
voiceState.level = 0
|
||||
// Do NOT deactivate the AVAudioSession here — the user may still want to hear
|
||||
@@ -338,19 +292,6 @@ final class SessionState {
|
||||
// disconnecting from the server (see AppState.disconnect / .disconnected event).
|
||||
}
|
||||
|
||||
/// Restart the voice path when the audio config changes mid-call (driven by IOSAudioRouter).
|
||||
/// If VPIO is involved on either the current or desired side, restart the mic so the native
|
||||
/// voice-processing engine engages/disengages and re-binds to the new route. Pure miniaudio
|
||||
/// config tweaks need no restart — the core's own audioRestart (already issued) handles them.
|
||||
private func reconcileVoicePath() {
|
||||
guard voiceState.micActive else { return }
|
||||
let want = IOSAudioRouter.shared.currentConfigUsesVoiceProcessing
|
||||
let have = IOSVoiceProcessingEngine.shared.isRunning
|
||||
guard want || have else { return }
|
||||
stopMicStream()
|
||||
doStartMicStream()
|
||||
}
|
||||
|
||||
// MARK: - Screen audio share
|
||||
|
||||
/// Called when the broadcast extension becomes active. Announces the SCREEN_AUDIO stream;
|
||||
|
||||
Reference in New Issue
Block a user