Files
voice-cat/clients/apple/iOS/VoiceCatiOS/AudioSessionManager.swift

190 lines
9.5 KiB
Swift
Raw Permalink Normal View History

import AVFoundation
import os
import VoiceCatCore
private let logger = Logger(subsystem: "cat.voice.VoiceCatiOS", category: "AudioSessionManager")
@MainActor
final class AudioSessionManager {
static let shared = AudioSessionManager()
weak var client: VoiceCatClient?
fix(ios): stereo mic + A2DP output, add vc_audio_restart ABI Diagnosed by comparing against TeamTalk5 (Client/iTeamTalk), which achieves stereo mic + A2DP output. Five fixes: 1. configureStereoCapture now calls setPreferredInput + setInputDataSource (mirroring TeamTalk5's SoundDevicesModel). Previously omitted based on incorrect diagnosis that setPreferredInput collapsed A2DP — the real culprit was setPreferredInputNumberOfChannels(2), which neither project uses. 2. New C ABI: vc_audio_restart (full stop + re-init, unlike suspend/resume which only stop/start). Swift wrapper added. The withAudioSuspend wrapper that used it was removed after on-device testing showed it killed all audio (including VoiceOver) when switching presets — the core's set_capture_channels handles engine restart internally. 3. Bluetooth options: Voice Chat preset now includes BOTH .allowBluetoothHFP AND .allowBluetoothA2DP (matching TeamTalk5's UtilSound.swift:228). Previously HFP-only blocked A2DP headphones. 4. Capture channels now reset when switching stereo→mono via selectCaptureChannels/applyPreset. AudioSessionManager tracks activeMicStreamId (set by SessionState on join/leave voice). 5. Docs synced: voice.md, tech-stack.md, architecture.md, PROGRESS.md. Removed stale setPreferredInputNumberOfChannels(2) references. Verified: ctest --preset dev 21/21 green, iOS client builds. Stereo mic + A2DP output still needs on-device debugging — the core recipe is correct but iOS 26 route behavior requires hands-on testing with a debugger.
2026-06-19 16:58:21 +02:00
/// 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)?
fix(ios): output muted with A2DP, session lifecycle, mic input issues Three bugs causing no audio output and no mic input: 1. .voiceChat mode + A2DP = output muted. The .voiceChat mode uses hardware AEC/AGC/HPF but requires HFP-compatible routes. A2DP is NOT HFP — iOS mutes the output because it can't set up the voice processing pipeline on an A2DP route. Fix: use .default mode for Standard+A2DP (no hardware AEC, but audio routes correctly). .voiceChat kept for HFP and speaker modes. Added info warning in Settings UI for A2DP no-AEC. 2. Session lifecycle broken. stopMicStream() called deactivateAfterStreaming() which deactivated the AVAudioSession — but the AudioEngine keeps running for remote audio playback, so leaving voice killed all remote audio. And the session was never activated when a remote user started talking (only on Join Voice), so you couldn't hear anyone before joining voice. Fix: - ensureSessionActive() replaces activateForStreaming() — idempotent, called on Join Voice AND on .streamStarted (remote user starts talking). - stopMicStream() no longer deactivates the session. - deactivateSession() called only on disconnect from server. - isSessionActive flag tracks state, updated by interruption handler. 3. setPreferredInputNumberOfChannels(1) called for mono — unnecessary (1 is the default) and may put the session in a bad state on some devices. Fix: only call it when stereo is explicitly selected. Also handle empty input port ID (selecting 'Default' in the picker) correctly. Added comprehensive route logging — after activation, logs the current output and input route names so issues can be diagnosed from Console.app.
2026-06-19 13:46:20 +02:00
/// 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
/// when disconnecting from the server not when leaving voice, since the user may still
/// want to hear remote audio.
fix(ios): output muted with A2DP, session lifecycle, mic input issues Three bugs causing no audio output and no mic input: 1. .voiceChat mode + A2DP = output muted. The .voiceChat mode uses hardware AEC/AGC/HPF but requires HFP-compatible routes. A2DP is NOT HFP — iOS mutes the output because it can't set up the voice processing pipeline on an A2DP route. Fix: use .default mode for Standard+A2DP (no hardware AEC, but audio routes correctly). .voiceChat kept for HFP and speaker modes. Added info warning in Settings UI for A2DP no-AEC. 2. Session lifecycle broken. stopMicStream() called deactivateAfterStreaming() which deactivated the AVAudioSession — but the AudioEngine keeps running for remote audio playback, so leaving voice killed all remote audio. And the session was never activated when a remote user started talking (only on Join Voice), so you couldn't hear anyone before joining voice. Fix: - ensureSessionActive() replaces activateForStreaming() — idempotent, called on Join Voice AND on .streamStarted (remote user starts talking). - stopMicStream() no longer deactivates the session. - deactivateSession() called only on disconnect from server. - isSessionActive flag tracks state, updated by interruption handler. 3. setPreferredInputNumberOfChannels(1) called for mono — unnecessary (1 is the default) and may put the session in a bad state on some devices. Fix: only call it when stereo is explicitly selected. Also handle empty input port ID (selecting 'Default' in the picker) correctly. Added comprehensive route logging — after activation, logs the current output and input route names so issues can be diagnosed from Console.app.
2026-06-19 13:46:20 +02:00
private var isSessionActive = false
func configure() {
feat(ios): audio overhaul, Join/Leave Voice, channel-id sync fix, stereo mic capture Three iOS client problems fixed plus a new core stereo-mic capture ABI: 1. Channel-id sync bug (mic button permanently dimmed): SessionState never synced currentChannelId from the self user's channelId on connect, so the mic button (gated on currentChannelId == 0) stayed dimmed. Added syncSelfChannel() (mirrors macOS MainWindowController.swift:461,491,522); called from init/.channelList/.userJoined/.userLeft/.userUpdated/.joinResult. Added applyServerMuteState() + serverMuted/serverDeafened to VoiceState. 2. Join/Leave Voice button: replaced icon-only mic toggle with explicit text button (parity with macOS). Mute/deafen disable when not in voice. 3. IOSAudioRouter.swift (new): full AVAudioSession routing layer — input port selection, built-in mic orientation/polar patterns, Bluetooth HFP/A2DP/Off modes, Standard/Raw mic processing, stereo capture, AirPlay, UserDefaults persistence. AudioSessionManager delegates to it. 4. Core stereo-mic capture (append-only ABI): vc_set_capture_channels() lets the core open the mic device in stereo (2-ch interleaved). LocalStream gains capture_channels; ensure_audio_running reads it; audio_engine.cpp capture_accum_ + on_capture updated to channel-aware accumulation. Test test_stereo_mic_capture (headless, L!=R stereo round-trip). Swift wrapper VoiceCatClient.setCaptureChannels. 5. Settings UI rework: AVAudioSession-derived input/output tree replaces miniaudio device picker. 6. iOS deployment target raised to 18.0 (Package.swift + project.pbxproj). swift-tools-version 6.0 with swiftLanguageModes .v5. Docs: tech-stack.md, architecture.md, voice.md, roadmap.md, building.md updated; stale 'vc_audio_suspend/resume deferred' claims corrected. Verified: ctest --preset dev 21/21 green; swift test 6/6 green; xcodebuild -target VoiceCatiOS -sdk iphonesimulator BUILD SUCCEEDED.
2026-06-19 13:17:52 +02:00
// Load stored audio routing preferences and apply them before any audio session
// activation. IOSAudioRouter drives all iOS audio route selection via AVAudioSession;
// miniaudio (the core) does NOT touch AVAudioSession on iOS.
IOSAudioRouter.shared.loadStoredPreferences()
IOSAudioRouter.shared.applyConfiguration()
IOSAudioRouter.shared.refreshRoutes()
NotificationCenter.default.addObserver(
self, selector: #selector(handleInterruption),
name: AVAudioSession.interruptionNotification, object: nil)
NotificationCenter.default.addObserver(
self, selector: #selector(handleRouteChange),
name: AVAudioSession.routeChangeNotification, object: nil)
}
fix(ios): output muted with A2DP, session lifecycle, mic input issues Three bugs causing no audio output and no mic input: 1. .voiceChat mode + A2DP = output muted. The .voiceChat mode uses hardware AEC/AGC/HPF but requires HFP-compatible routes. A2DP is NOT HFP — iOS mutes the output because it can't set up the voice processing pipeline on an A2DP route. Fix: use .default mode for Standard+A2DP (no hardware AEC, but audio routes correctly). .voiceChat kept for HFP and speaker modes. Added info warning in Settings UI for A2DP no-AEC. 2. Session lifecycle broken. stopMicStream() called deactivateAfterStreaming() which deactivated the AVAudioSession — but the AudioEngine keeps running for remote audio playback, so leaving voice killed all remote audio. And the session was never activated when a remote user started talking (only on Join Voice), so you couldn't hear anyone before joining voice. Fix: - ensureSessionActive() replaces activateForStreaming() — idempotent, called on Join Voice AND on .streamStarted (remote user starts talking). - stopMicStream() no longer deactivates the session. - deactivateSession() called only on disconnect from server. - isSessionActive flag tracks state, updated by interruption handler. 3. setPreferredInputNumberOfChannels(1) called for mono — unnecessary (1 is the default) and may put the session in a bad state on some devices. Fix: only call it when stereo is explicitly selected. Also handle empty input port ID (selecting 'Default' in the picker) correctly. Added comprehensive route logging — after activation, logs the current output and input route names so issues can be diagnosed from Console.app.
2026-06-19 13:46:20 +02:00
/// Activate the AVAudioSession if not already active. Call before any audio I/O:
/// when the user joins voice, or when a remote stream starts (so playback works even
/// before the user has joined voice). Idempotent safe to call multiple times.
func ensureSessionActive() throws {
guard !isSessionActive else {
logger.debug("ensureSessionActive — already active, skipping")
return
}
feat(ios): audio overhaul, Join/Leave Voice, channel-id sync fix, stereo mic capture Three iOS client problems fixed plus a new core stereo-mic capture ABI: 1. Channel-id sync bug (mic button permanently dimmed): SessionState never synced currentChannelId from the self user's channelId on connect, so the mic button (gated on currentChannelId == 0) stayed dimmed. Added syncSelfChannel() (mirrors macOS MainWindowController.swift:461,491,522); called from init/.channelList/.userJoined/.userLeft/.userUpdated/.joinResult. Added applyServerMuteState() + serverMuted/serverDeafened to VoiceState. 2. Join/Leave Voice button: replaced icon-only mic toggle with explicit text button (parity with macOS). Mute/deafen disable when not in voice. 3. IOSAudioRouter.swift (new): full AVAudioSession routing layer — input port selection, built-in mic orientation/polar patterns, Bluetooth HFP/A2DP/Off modes, Standard/Raw mic processing, stereo capture, AirPlay, UserDefaults persistence. AudioSessionManager delegates to it. 4. Core stereo-mic capture (append-only ABI): vc_set_capture_channels() lets the core open the mic device in stereo (2-ch interleaved). LocalStream gains capture_channels; ensure_audio_running reads it; audio_engine.cpp capture_accum_ + on_capture updated to channel-aware accumulation. Test test_stereo_mic_capture (headless, L!=R stereo round-trip). Swift wrapper VoiceCatClient.setCaptureChannels. 5. Settings UI rework: AVAudioSession-derived input/output tree replaces miniaudio device picker. 6. iOS deployment target raised to 18.0 (Package.swift + project.pbxproj). swift-tools-version 6.0 with swiftLanguageModes .v5. Docs: tech-stack.md, architecture.md, voice.md, roadmap.md, building.md updated; stale 'vc_audio_suspend/resume deferred' claims corrected. Verified: ctest --preset dev 21/21 green; swift test 6/6 green; xcodebuild -target VoiceCatiOS -sdk iphonesimulator BUILD SUCCEEDED.
2026-06-19 13:17:52 +02:00
IOSAudioRouter.shared.applyConfiguration()
fix(ios): stereo mic + A2DP output, add vc_audio_restart ABI Diagnosed by comparing against TeamTalk5 (Client/iTeamTalk), which achieves stereo mic + A2DP output. Five fixes: 1. configureStereoCapture now calls setPreferredInput + setInputDataSource (mirroring TeamTalk5's SoundDevicesModel). Previously omitted based on incorrect diagnosis that setPreferredInput collapsed A2DP — the real culprit was setPreferredInputNumberOfChannels(2), which neither project uses. 2. New C ABI: vc_audio_restart (full stop + re-init, unlike suspend/resume which only stop/start). Swift wrapper added. The withAudioSuspend wrapper that used it was removed after on-device testing showed it killed all audio (including VoiceOver) when switching presets — the core's set_capture_channels handles engine restart internally. 3. Bluetooth options: Voice Chat preset now includes BOTH .allowBluetoothHFP AND .allowBluetoothA2DP (matching TeamTalk5's UtilSound.swift:228). Previously HFP-only blocked A2DP headphones. 4. Capture channels now reset when switching stereo→mono via selectCaptureChannels/applyPreset. AudioSessionManager tracks activeMicStreamId (set by SessionState on join/leave voice). 5. Docs synced: voice.md, tech-stack.md, architecture.md, PROGRESS.md. Removed stale setPreferredInputNumberOfChannels(2) references. Verified: ctest --preset dev 21/21 green, iOS client builds. Stereo mic + A2DP output still needs on-device debugging — the core recipe is correct but iOS 26 route behavior requires hands-on testing with a debugger.
2026-06-19 16:58:21 +02:00
let session = AVAudioSession.sharedInstance()
try session.setActive(true, options: [])
fix(ios): output muted with A2DP, session lifecycle, mic input issues Three bugs causing no audio output and no mic input: 1. .voiceChat mode + A2DP = output muted. The .voiceChat mode uses hardware AEC/AGC/HPF but requires HFP-compatible routes. A2DP is NOT HFP — iOS mutes the output because it can't set up the voice processing pipeline on an A2DP route. Fix: use .default mode for Standard+A2DP (no hardware AEC, but audio routes correctly). .voiceChat kept for HFP and speaker modes. Added info warning in Settings UI for A2DP no-AEC. 2. Session lifecycle broken. stopMicStream() called deactivateAfterStreaming() which deactivated the AVAudioSession — but the AudioEngine keeps running for remote audio playback, so leaving voice killed all remote audio. And the session was never activated when a remote user started talking (only on Join Voice), so you couldn't hear anyone before joining voice. Fix: - ensureSessionActive() replaces activateForStreaming() — idempotent, called on Join Voice AND on .streamStarted (remote user starts talking). - stopMicStream() no longer deactivates the session. - deactivateSession() called only on disconnect from server. - isSessionActive flag tracks state, updated by interruption handler. 3. setPreferredInputNumberOfChannels(1) called for mono — unnecessary (1 is the default) and may put the session in a bad state on some devices. Fix: only call it when stereo is explicitly selected. Also handle empty input port ID (selecting 'Default' in the picker) correctly. Added comprehensive route logging — after activation, logs the current output and input route names so issues can be diagnosed from Console.app.
2026-06-19 13:46:20 +02:00
isSessionActive = true
// For the A2DP output presets, pick the right output once the session is live: defer to
// a connected A2DP/wired/AirPlay route, but fall back to the loud built-in speaker (not
// the quiet earpiece) when nothing external is connected. See applyA2dpSpeakerFallback().
IOSAudioRouter.shared.applyA2dpSpeakerFallback()
fix(ios): output muted with A2DP, session lifecycle, mic input issues Three bugs causing no audio output and no mic input: 1. .voiceChat mode + A2DP = output muted. The .voiceChat mode uses hardware AEC/AGC/HPF but requires HFP-compatible routes. A2DP is NOT HFP — iOS mutes the output because it can't set up the voice processing pipeline on an A2DP route. Fix: use .default mode for Standard+A2DP (no hardware AEC, but audio routes correctly). .voiceChat kept for HFP and speaker modes. Added info warning in Settings UI for A2DP no-AEC. 2. Session lifecycle broken. stopMicStream() called deactivateAfterStreaming() which deactivated the AVAudioSession — but the AudioEngine keeps running for remote audio playback, so leaving voice killed all remote audio. And the session was never activated when a remote user started talking (only on Join Voice), so you couldn't hear anyone before joining voice. Fix: - ensureSessionActive() replaces activateForStreaming() — idempotent, called on Join Voice AND on .streamStarted (remote user starts talking). - stopMicStream() no longer deactivates the session. - deactivateSession() called only on disconnect from server. - isSessionActive flag tracks state, updated by interruption handler. 3. setPreferredInputNumberOfChannels(1) called for mono — unnecessary (1 is the default) and may put the session in a bad state on some devices. Fix: only call it when stereo is explicitly selected. Also handle empty input port ID (selecting 'Default' in the picker) correctly. Added comprehensive route logging — after activation, logs the current output and input route names so issues can be diagnosed from Console.app.
2026-06-19 13:46:20 +02:00
let route = AVAudioSession.sharedInstance().currentRoute
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)]")
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>
2026-06-20 02:34:07 +02:00
logSessionState("after activate")
}
fix(ios): output muted with A2DP, session lifecycle, mic input issues Three bugs causing no audio output and no mic input: 1. .voiceChat mode + A2DP = output muted. The .voiceChat mode uses hardware AEC/AGC/HPF but requires HFP-compatible routes. A2DP is NOT HFP — iOS mutes the output because it can't set up the voice processing pipeline on an A2DP route. Fix: use .default mode for Standard+A2DP (no hardware AEC, but audio routes correctly). .voiceChat kept for HFP and speaker modes. Added info warning in Settings UI for A2DP no-AEC. 2. Session lifecycle broken. stopMicStream() called deactivateAfterStreaming() which deactivated the AVAudioSession — but the AudioEngine keeps running for remote audio playback, so leaving voice killed all remote audio. And the session was never activated when a remote user started talking (only on Join Voice), so you couldn't hear anyone before joining voice. Fix: - ensureSessionActive() replaces activateForStreaming() — idempotent, called on Join Voice AND on .streamStarted (remote user starts talking). - stopMicStream() no longer deactivates the session. - deactivateSession() called only on disconnect from server. - isSessionActive flag tracks state, updated by interruption handler. 3. setPreferredInputNumberOfChannels(1) called for mono — unnecessary (1 is the default) and may put the session in a bad state on some devices. Fix: only call it when stereo is explicitly selected. Also handle empty input port ID (selecting 'Default' in the picker) correctly. Added comprehensive route logging — after activation, logs the current output and input route names so issues can be diagnosed from Console.app.
2026-06-19 13:46:20 +02:00
/// Deactivate the AVAudioSession. Call ONLY when disconnecting from the server not
/// when leaving voice (the user may still want to hear remote audio).
func deactivateSession() {
guard isSessionActive else {
logger.debug("deactivateSession — not active, skipping")
return
}
try? AVAudioSession.sharedInstance().setActive(false,
options: .notifyOthersOnDeactivation)
fix(ios): output muted with A2DP, session lifecycle, mic input issues Three bugs causing no audio output and no mic input: 1. .voiceChat mode + A2DP = output muted. The .voiceChat mode uses hardware AEC/AGC/HPF but requires HFP-compatible routes. A2DP is NOT HFP — iOS mutes the output because it can't set up the voice processing pipeline on an A2DP route. Fix: use .default mode for Standard+A2DP (no hardware AEC, but audio routes correctly). .voiceChat kept for HFP and speaker modes. Added info warning in Settings UI for A2DP no-AEC. 2. Session lifecycle broken. stopMicStream() called deactivateAfterStreaming() which deactivated the AVAudioSession — but the AudioEngine keeps running for remote audio playback, so leaving voice killed all remote audio. And the session was never activated when a remote user started talking (only on Join Voice), so you couldn't hear anyone before joining voice. Fix: - ensureSessionActive() replaces activateForStreaming() — idempotent, called on Join Voice AND on .streamStarted (remote user starts talking). - stopMicStream() no longer deactivates the session. - deactivateSession() called only on disconnect from server. - isSessionActive flag tracks state, updated by interruption handler. 3. setPreferredInputNumberOfChannels(1) called for mono — unnecessary (1 is the default) and may put the session in a bad state on some devices. Fix: only call it when stereo is explicitly selected. Also handle empty input port ID (selecting 'Default' in the picker) correctly. Added comprehensive route logging — after activation, logs the current output and input route names so issues can be diagnosed from Console.app.
2026-06-19 13:46:20 +02:00
isSessionActive = false
logger.info("session deactivated")
}
/// Log the full AVAudioSession state category, mode, options, and active route.
/// Useful for diagnosing routing issues, e.g. confirming the session stays
/// `PlayAndRecord` with `allowBluetoothA2DP` and keeps the A2DP output route even
/// after the mic engine starts.
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>
2026-06-20 02:34:07 +02:00
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,
let type = AVAudioSession.InterruptionType(rawValue: typeValue)
else { return }
switch type {
case .began:
fix(ios): output muted with A2DP, session lifecycle, mic input issues Three bugs causing no audio output and no mic input: 1. .voiceChat mode + A2DP = output muted. The .voiceChat mode uses hardware AEC/AGC/HPF but requires HFP-compatible routes. A2DP is NOT HFP — iOS mutes the output because it can't set up the voice processing pipeline on an A2DP route. Fix: use .default mode for Standard+A2DP (no hardware AEC, but audio routes correctly). .voiceChat kept for HFP and speaker modes. Added info warning in Settings UI for A2DP no-AEC. 2. Session lifecycle broken. stopMicStream() called deactivateAfterStreaming() which deactivated the AVAudioSession — but the AudioEngine keeps running for remote audio playback, so leaving voice killed all remote audio. And the session was never activated when a remote user started talking (only on Join Voice), so you couldn't hear anyone before joining voice. Fix: - ensureSessionActive() replaces activateForStreaming() — idempotent, called on Join Voice AND on .streamStarted (remote user starts talking). - stopMicStream() no longer deactivates the session. - deactivateSession() called only on disconnect from server. - isSessionActive flag tracks state, updated by interruption handler. 3. setPreferredInputNumberOfChannels(1) called for mono — unnecessary (1 is the default) and may put the session in a bad state on some devices. Fix: only call it when stereo is explicitly selected. Also handle empty input port ID (selecting 'Default' in the picker) correctly. Added comprehensive route logging — after activation, logs the current output and input route names so issues can be diagnosed from Console.app.
2026-06-19 13:46:20 +02:00
logger.info("interruption began — session suspended by system")
isSessionActive = false // system deactivated us
client?.audioSuspend()
case .ended:
let optionsValue = info[AVAudioSessionInterruptionOptionKey] as? UInt ?? 0
let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue)
if options.contains(.shouldResume) {
fix(ios): output muted with A2DP, session lifecycle, mic input issues Three bugs causing no audio output and no mic input: 1. .voiceChat mode + A2DP = output muted. The .voiceChat mode uses hardware AEC/AGC/HPF but requires HFP-compatible routes. A2DP is NOT HFP — iOS mutes the output because it can't set up the voice processing pipeline on an A2DP route. Fix: use .default mode for Standard+A2DP (no hardware AEC, but audio routes correctly). .voiceChat kept for HFP and speaker modes. Added info warning in Settings UI for A2DP no-AEC. 2. Session lifecycle broken. stopMicStream() called deactivateAfterStreaming() which deactivated the AVAudioSession — but the AudioEngine keeps running for remote audio playback, so leaving voice killed all remote audio. And the session was never activated when a remote user started talking (only on Join Voice), so you couldn't hear anyone before joining voice. Fix: - ensureSessionActive() replaces activateForStreaming() — idempotent, called on Join Voice AND on .streamStarted (remote user starts talking). - stopMicStream() no longer deactivates the session. - deactivateSession() called only on disconnect from server. - isSessionActive flag tracks state, updated by interruption handler. 3. setPreferredInputNumberOfChannels(1) called for mono — unnecessary (1 is the default) and may put the session in a bad state on some devices. Fix: only call it when stereo is explicitly selected. Also handle empty input port ID (selecting 'Default' in the picker) correctly. Added comprehensive route logging — after activation, logs the current output and input route names so issues can be diagnosed from Console.app.
2026-06-19 13:46:20 +02:00
do {
try AVAudioSession.sharedInstance().setActive(true)
isSessionActive = true
logger.info("interruption ended — session reactivated")
client?.audioResume()
} catch {
logger.error("interruption ended — reactivation failed: \(error.localizedDescription)")
}
}
@unknown default: break
}
}
@objc private func handleRouteChange(_ notification: Notification) {
guard let info = notification.userInfo,
let reasonValue = info[AVAudioSessionRouteChangeReasonKey] as? UInt,
let reason = AVAudioSession.RouteChangeReason(rawValue: reasonValue)
else {
logger.warning("routeChange — unknown reason, refreshing only")
IOSAudioRouter.shared.refreshRoutes()
NotificationCenter.default.post(name: .voiceCatDeviceListChanged, object: nil)
return
}
logger.info("routeChange reason=\(self.reasonLabel(reason))")
// Re-apply preferences ONLY on external device plug/unplug. Do NOT re-apply on
// .categoryChange / .routeConfigurationChange those are triggered by our own
// applyConfiguration() calls (setCategory, setPreferredInput, etc.), and re-applying
// would create an infinite notification loop:
// handleRouteChange applyConfiguration setCategory routeChange ...
// That loop burns CPU and cycles the audio session on/off the "glitching" bug.
// IOSAudioRouter.applyConfiguration() also has a re-entrancy guard for synchronous
// notifications, but the reason check here is the primary defense.
if reason == .oldDeviceUnavailable || reason == .newDeviceAvailable {
logger.info("routeChange — external device change, re-applying config")
IOSAudioRouter.shared.applyConfiguration()
// Re-evaluate the A2DP-mode speaker fallback: a Bluetooth unplug should drop us onto
// the loud speaker (not the earpiece), and a replug should hand output back to A2DP.
if isSessionActive {
IOSAudioRouter.shared.applyA2dpSpeakerFallback()
}
}
feat(ios): audio overhaul, Join/Leave Voice, channel-id sync fix, stereo mic capture Three iOS client problems fixed plus a new core stereo-mic capture ABI: 1. Channel-id sync bug (mic button permanently dimmed): SessionState never synced currentChannelId from the self user's channelId on connect, so the mic button (gated on currentChannelId == 0) stayed dimmed. Added syncSelfChannel() (mirrors macOS MainWindowController.swift:461,491,522); called from init/.channelList/.userJoined/.userLeft/.userUpdated/.joinResult. Added applyServerMuteState() + serverMuted/serverDeafened to VoiceState. 2. Join/Leave Voice button: replaced icon-only mic toggle with explicit text button (parity with macOS). Mute/deafen disable when not in voice. 3. IOSAudioRouter.swift (new): full AVAudioSession routing layer — input port selection, built-in mic orientation/polar patterns, Bluetooth HFP/A2DP/Off modes, Standard/Raw mic processing, stereo capture, AirPlay, UserDefaults persistence. AudioSessionManager delegates to it. 4. Core stereo-mic capture (append-only ABI): vc_set_capture_channels() lets the core open the mic device in stereo (2-ch interleaved). LocalStream gains capture_channels; ensure_audio_running reads it; audio_engine.cpp capture_accum_ + on_capture updated to channel-aware accumulation. Test test_stereo_mic_capture (headless, L!=R stereo round-trip). Swift wrapper VoiceCatClient.setCaptureChannels. 5. Settings UI rework: AVAudioSession-derived input/output tree replaces miniaudio device picker. 6. iOS deployment target raised to 18.0 (Package.swift + project.pbxproj). swift-tools-version 6.0 with swiftLanguageModes .v5. Docs: tech-stack.md, architecture.md, voice.md, roadmap.md, building.md updated; stale 'vc_audio_suspend/resume deferred' claims corrected. Verified: ctest --preset dev 21/21 green; swift test 6/6 green; xcodebuild -target VoiceCatiOS -sdk iphonesimulator BUILD SUCCEEDED.
2026-06-19 13:17:52 +02:00
IOSAudioRouter.shared.refreshRoutes()
NotificationCenter.default.post(name: .voiceCatDeviceListChanged, object: nil)
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>
2026-06-20 02:34:07 +02:00
logSessionState("route change (\(reasonLabel(reason)))")
}
private func reasonLabel(_ reason: AVAudioSession.RouteChangeReason) -> String {
switch reason {
case .oldDeviceUnavailable: return "oldDeviceUnavailable"
case .newDeviceAvailable: return "newDeviceAvailable"
case .categoryChange: return "categoryChange"
case .override: return "override"
case .wakeFromSleep: return "wakeFromSleep"
case .noSuitableRouteForCategory: return "noSuitableRouteForCategory"
case .routeConfigurationChange: return "routeConfigurationChange"
@unknown default: return "unknown"
}
}
}
extension Notification.Name {
static let voiceCatDeviceListChanged = Notification.Name("cat.voice.deviceListChanged")
}