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:
@@ -8,19 +8,6 @@ private let logger = Logger(subsystem: "cat.voice.VoiceCatiOS", category: "Audio
|
||||
final class AudioSessionManager {
|
||||
static let shared = AudioSessionManager()
|
||||
|
||||
weak var client: VoiceCatClient?
|
||||
|
||||
/// The stream ID of the currently active local MIC stream, if any. Set by `SessionState`
|
||||
/// when the user joins/leaves voice so `IOSAudioRouter` can reset the core's capture
|
||||
/// channel count (e.g. when switching stereo → mono) without going through `SessionState`.
|
||||
var activeMicStreamId: UInt32?
|
||||
|
||||
/// Set by `SessionState`. Invoked by `IOSAudioRouter` after an audio-config change so the
|
||||
/// voice path (native VPIO vs the core's miniaudio path) can be restarted to match the new
|
||||
/// preset/route when the mic is active. No-op when not in voice. See
|
||||
/// `SessionState.reconcileVoicePath()` and `IOSVoiceProcessingEngine`.
|
||||
var reconcileVoicePath: (() -> Void)?
|
||||
|
||||
/// Tracks whether WE activated the session. The session must be active whenever the
|
||||
/// AudioEngine is running (for capture OR playback), so it is activated when any audio
|
||||
/// needs to play (a remote stream started OR the user joins voice) and only deactivated
|
||||
@@ -28,6 +15,10 @@ final class AudioSessionManager {
|
||||
/// want to hear remote audio.
|
||||
private var isSessionActive = false
|
||||
|
||||
/// Whether the AVAudioSession is currently active (we activated it). Read by `IOSAudioRouter`
|
||||
/// to decide whether the post-activation A2DP speaker fallback can be applied.
|
||||
var isActive: Bool { isSessionActive }
|
||||
|
||||
func configure() {
|
||||
// Load stored audio routing preferences and apply them before any audio session
|
||||
// activation. IOSAudioRouter drives all iOS audio route selection via AVAudioSession;
|
||||
@@ -114,18 +105,22 @@ final class AudioSessionManager {
|
||||
|
||||
switch type {
|
||||
case .began:
|
||||
// The system stops our AVAudioEngine and deactivates the session. Nothing to tear
|
||||
// down — `IOSAudioEngine` rebuilds on resume.
|
||||
logger.info("interruption began — session suspended by system")
|
||||
isSessionActive = false // system deactivated us
|
||||
client?.audioSuspend()
|
||||
isSessionActive = false
|
||||
case .ended:
|
||||
let optionsValue = info[AVAudioSessionInterruptionOptionKey] as? UInt ?? 0
|
||||
let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue)
|
||||
if options.contains(.shouldResume) {
|
||||
do {
|
||||
IOSAudioRouter.shared.applyConfiguration()
|
||||
try AVAudioSession.sharedInstance().setActive(true)
|
||||
isSessionActive = true
|
||||
logger.info("interruption ended — session reactivated")
|
||||
client?.audioResume()
|
||||
IOSAudioRouter.shared.applyA2dpSpeakerFallback()
|
||||
// Rebuild the engine graph against the restored route (both directions).
|
||||
IOSAudioEngine.shared.reconfigure()
|
||||
logger.info("interruption ended — session reactivated, engine rebuilt")
|
||||
} catch {
|
||||
logger.error("interruption ended — reactivation failed: \(error.localizedDescription)")
|
||||
}
|
||||
@@ -162,6 +157,9 @@ final class AudioSessionManager {
|
||||
// the loud speaker (not the earpiece), and a replug should hand output back to A2DP.
|
||||
if isSessionActive {
|
||||
IOSAudioRouter.shared.applyA2dpSpeakerFallback()
|
||||
// Rebind the engine (both directions) to the new route. The engine owns the route
|
||||
// now, so this is the single thing that re-establishes audio after a device change.
|
||||
IOSAudioEngine.shared.reconfigure()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user