2026-06-19 02:10:25 +02:00
|
|
|
import AVFoundation
|
2026-06-19 13:26:23 +02:00
|
|
|
import os
|
2026-06-19 02:10:25 +02:00
|
|
|
import VoiceCatCore
|
|
|
|
|
|
2026-06-19 13:26:23 +02:00
|
|
|
private let logger = Logger(subsystem: "cat.voice.VoiceCatiOS", category: "AudioSessionManager")
|
|
|
|
|
|
2026-06-19 02:10:25 +02:00
|
|
|
@MainActor
|
|
|
|
|
final class AudioSessionManager {
|
|
|
|
|
static let shared = AudioSessionManager()
|
|
|
|
|
|
|
|
|
|
weak var client: VoiceCatClient?
|
|
|
|
|
|
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?
|
|
|
|
|
|
2026-06-22 02:38:01 +02:00
|
|
|
/// 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)?
|
|
|
|
|
|
2026-06-19 13:46:20 +02:00
|
|
|
/// Tracks whether WE activated the session. The session must be active whenever the
|
2026-06-20 03:03:34 +02:00
|
|
|
/// 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.
|
2026-06-19 13:46:20 +02:00
|
|
|
private var isSessionActive = false
|
|
|
|
|
|
2026-06-19 02:10:25 +02:00
|
|
|
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()
|
2026-06-19 02:10:25 +02:00
|
|
|
|
|
|
|
|
NotificationCenter.default.addObserver(
|
|
|
|
|
self, selector: #selector(handleInterruption),
|
|
|
|
|
name: AVAudioSession.interruptionNotification, object: nil)
|
|
|
|
|
NotificationCenter.default.addObserver(
|
|
|
|
|
self, selector: #selector(handleRouteChange),
|
|
|
|
|
name: AVAudioSession.routeChangeNotification, object: nil)
|
|
|
|
|
}
|
|
|
|
|
|
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()
|
2026-06-19 16:58:21 +02:00
|
|
|
let session = AVAudioSession.sharedInstance()
|
|
|
|
|
try session.setActive(true, options: [])
|
2026-06-19 13:46:20 +02:00
|
|
|
isSessionActive = true
|
2026-06-19 16:58:21 +02:00
|
|
|
// For the A2DP output presets, make sure output isn't pinned to the built-in speaker.
|
|
|
|
|
// A2DP routing in .playAndRecord is fragile; clearing any speaker override after the
|
|
|
|
|
// session is live nudges iOS to honor the Bluetooth output route.
|
|
|
|
|
if IOSAudioRouter.shared.wantsA2dpOutput {
|
|
|
|
|
try? session.overrideOutputAudioPort(.none)
|
|
|
|
|
}
|
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")
|
2026-06-19 02:10:25 +02:00
|
|
|
}
|
|
|
|
|
|
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
|
|
|
|
|
}
|
2026-06-19 02:10:25 +02:00
|
|
|
try? AVAudioSession.sharedInstance().setActive(false,
|
|
|
|
|
options: .notifyOthersOnDeactivation)
|
2026-06-19 13:46:20 +02:00
|
|
|
isSessionActive = false
|
|
|
|
|
logger.info("session deactivated")
|
2026-06-19 02:10:25 +02:00
|
|
|
}
|
|
|
|
|
|
2026-06-20 03:03:34 +02:00
|
|
|
/// 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)
|
|
|
|
|
""")
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-19 02:10:25 +02:00
|
|
|
@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:
|
2026-06-19 13:46:20 +02:00
|
|
|
logger.info("interruption began — session suspended by system")
|
|
|
|
|
isSessionActive = false // system deactivated us
|
2026-06-19 02:10:25 +02:00
|
|
|
client?.audioSuspend()
|
|
|
|
|
case .ended:
|
|
|
|
|
let optionsValue = info[AVAudioSessionInterruptionOptionKey] as? UInt ?? 0
|
|
|
|
|
let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue)
|
|
|
|
|
if options.contains(.shouldResume) {
|
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)")
|
|
|
|
|
}
|
2026-06-19 02:10:25 +02:00
|
|
|
}
|
|
|
|
|
@unknown default: break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@objc private func handleRouteChange(_ notification: Notification) {
|
2026-06-19 13:26:23 +02:00
|
|
|
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()
|
|
|
|
|
}
|
|
|
|
|
|
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()
|
2026-06-19 02:10:25 +02:00
|
|
|
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)))")
|
2026-06-19 02:10:25 +02:00
|
|
|
}
|
2026-06-19 13:26:23 +02:00
|
|
|
|
|
|
|
|
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"
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-06-19 02:10:25 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
extension Notification.Name {
|
|
|
|
|
static let voiceCatDeviceListChanged = Notification.Name("cat.voice.deviceListChanged")
|
|
|
|
|
}
|