fix(ios): stop miniaudio from clobbering AVAudioSession (stereo->A2DP output death)
The real root cause of "selecting Stereo Mic kills headphone/A2DP output on Join Voice." Every prior fix worked on the Swift IOSAudioRouter under the false premise that "miniaudio does NOT touch AVAudioSession on iOS." It does: the core opened devices via ma_device_init(nullptr, ...), and with a NULL context miniaudio 0.11.25 runs an iOS "hack" that sets the session category by device type, then ma_context_init__coreaudio calls setCategory()+setActive() on every device open -- capture -> AVAudioSessionCategoryRecord with zero options. That wipes the .playAndRecord category, the mode, and .allowBluetoothA2DP / .mixWithOthers / .allowAirPlay that IOSAudioRouter had just configured, killing headphone/A2DP (and even wired) output. Stereo presets break worst because they rely on the A2DP output route the wipe removes. TeamTalk avoids this by opening RemoteIO/VPIO AudioUnits directly and leaving the session entirely to the app. Fix (core, cross-platform safe): AudioEngine now owns a ma_context built by make_context_config() with coreaudio.sessionCategory = ma_ios_session_category_none and noAudioSessionActivate/Deactivate = MA_TRUE, and routes all ma_device_init calls (playback, capture, loopback) plus enumerate_devices through it. miniaudio no longer touches AVAudioSession; IOSAudioRouter is the sole owner (the session is already activated on connect in AppState before any device opens). Context is lazily inited in start(), reused across restarts, uninited in ~AudioEngine. Adds TEMP AudioSessionManager.logSessionState() diagnostics (after activate, on route change, on .streamStarted) to verify on-device that the category stays PlayAndRecord+allowBluetoothA2DP instead of flipping to Record. Remove once confirmed. Windows: cmake --build --preset dev clean; ctest --preset dev 21/21. iOS build + on-device verification pending on Mac. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -62,6 +62,7 @@ final class AudioSessionManager {
|
||||
let outputNames = route.outputs.map { $0.portName }.joined(separator: ", ")
|
||||
let inputNames = route.inputs.map { $0.portName }.joined(separator: ", ")
|
||||
logger.info("session activated — outputs: [\(outputNames)], inputs: [\(inputNames)]")
|
||||
logSessionState("after activate")
|
||||
}
|
||||
|
||||
/// Deactivate the AVAudioSession. Call ONLY when disconnecting from the server — not
|
||||
@@ -77,6 +78,34 @@ final class AudioSessionManager {
|
||||
logger.info("session deactivated")
|
||||
}
|
||||
|
||||
/// TEMP DIAGNOSTIC (stereo-A2DP fix verification): log the full AVAudioSession state —
|
||||
/// category, mode, options, and active route. The bug was miniaudio resetting the category
|
||||
/// to `Record` (no output) on device open; with the core's ma_context now configured to
|
||||
/// leave the session alone, this should report `AVAudioSessionCategoryPlayAndRecord` with
|
||||
/// `allowBluetoothA2DP` set, and the output route should be the headphones/A2DP device —
|
||||
/// even after the mic engine starts. Remove once verified on-device.
|
||||
func logSessionState(_ when: String) {
|
||||
let s = AVAudioSession.sharedInstance()
|
||||
var opts: [String] = []
|
||||
let o = s.categoryOptions
|
||||
if o.contains(.mixWithOthers) { opts.append("mixWithOthers") }
|
||||
if o.contains(.duckOthers) { opts.append("duckOthers") }
|
||||
if o.contains(.allowBluetoothHFP) { opts.append("allowBluetoothHFP") }
|
||||
if o.contains(.allowBluetoothA2DP) { opts.append("allowBluetoothA2DP") }
|
||||
if o.contains(.allowAirPlay) { opts.append("allowAirPlay") }
|
||||
if o.contains(.defaultToSpeaker) { opts.append("defaultToSpeaker") }
|
||||
let outs = s.currentRoute.outputs.map { "\($0.portName)[\($0.portType.rawValue)]" }
|
||||
.joined(separator: ", ")
|
||||
let ins = s.currentRoute.inputs.map { "\($0.portName)[\($0.portType.rawValue)]" }
|
||||
.joined(separator: ", ")
|
||||
logger.info("""
|
||||
[SESSION @ \(when, privacy: .public)] category=\(s.category.rawValue, privacy: .public) \
|
||||
mode=\(s.mode.rawValue, privacy: .public) options=[\(opts.joined(separator: ","), privacy: .public)] \
|
||||
inputs=[\(ins, privacy: .public)] outputs=[\(outs, privacy: .public)] \
|
||||
inputCh=\(s.inputNumberOfChannels) outputCh=\(s.outputNumberOfChannels)
|
||||
""")
|
||||
}
|
||||
|
||||
@objc private func handleInterruption(_ notification: Notification) {
|
||||
guard let info = notification.userInfo,
|
||||
let typeValue = info[AVAudioSessionInterruptionTypeKey] as? UInt,
|
||||
@@ -133,6 +162,7 @@ final class AudioSessionManager {
|
||||
|
||||
IOSAudioRouter.shared.refreshRoutes()
|
||||
NotificationCenter.default.post(name: .voiceCatDeviceListChanged, object: nil)
|
||||
logSessionState("route change (\(reasonLabel(reason)))")
|
||||
}
|
||||
|
||||
private func reasonLabel(_ reason: AVAudioSession.RouteChangeReason) -> String {
|
||||
|
||||
@@ -6,8 +6,14 @@ private let logger = Logger(subsystem: "cat.voice.VoiceCatiOS", category: "IOSAu
|
||||
|
||||
/// iOS audio routing layer — drives all iOS audio route selection via `AVAudioSession`
|
||||
/// *before* the core (miniaudio) opens its device. miniaudio does NOT touch
|
||||
/// `AVAudioSession` on iOS; it opens the current default route via CoreAudio and that's
|
||||
/// it. All iOS audio routing (input port selection, mic orientation/polar patterns,
|
||||
/// `AVAudioSession` on iOS — but ONLY because the core deliberately opens its devices
|
||||
/// through a `ma_context` configured with `sessionCategory = none` +
|
||||
/// `noAudioSessionActivate/Deactivate` (see `AudioEngine::make_context_config` in
|
||||
/// `core/src/audio/audio_engine.cpp`). With miniaudio's default NULL-context path it WOULD
|
||||
/// reset the category to `Record`/`Playback` with no options on every device open, wiping
|
||||
/// `.allowBluetoothA2DP`/`.playAndRecord` and killing headphone/A2DP output (the long-standing
|
||||
/// "stereo mic kills output" bug). With that disabled, this class is the sole owner of the
|
||||
/// session. All iOS audio routing (input port selection, mic orientation/polar patterns,
|
||||
/// HFP vs A2DP, measurement/raw mode, stereo capture) must be driven from here.
|
||||
///
|
||||
/// The three user-facing choices:
|
||||
|
||||
@@ -110,6 +110,9 @@ final class SessionState {
|
||||
addActivity("Audio session activate failed: \(error)")
|
||||
}
|
||||
}
|
||||
// TEMP DIAGNOSTIC: the core opens its miniaudio devices around now — log the
|
||||
// session state to confirm miniaudio is no longer resetting the category to Record.
|
||||
AudioSessionManager.shared.logSessionState("stream started (user \(ev.userId))")
|
||||
addActivity("Stream started (user \(ev.userId))")
|
||||
case .streamStopped:
|
||||
addActivity("Stream stopped (user \(ev.userId))")
|
||||
|
||||
Reference in New Issue
Block a user