Overview
On iOS, “where sound comes out” is managed byAudioRouteSession.shared. It exists only on iOS—macOS uses an independent input / output hardware device model and goes through setOutputDevice(_:) in Device management.
This API is designed after the common practice of mainstream RTC SDKs (Agora, Tencent TRTC, LiveKit). There are three principles to understand first; otherwise it’s easy to write code that “sets the route but has no effect”.
Principle 1: you can only switch between the speaker and the receiver
AudioRouteTarget has only two values:
- When plugged in, the system switches to them automatically; when several devices are connected at once, the most recently connected one wins
- When unplugged, the SDK falls back to the route you set
- When you need to let the user choose Bluetooth / AirPlay themselves, use the system-provided
AVRoutePickerView
Principle 2: persistent and temporary settings are two layers
The priority is temporary > persistent. After an external device is unplugged, the SDK falls back according to this priority.
Persistent setting: decide the default behavior before joining
Temporary setting: switch during a call
Principle 3: joining sets up the audio path
When you join a channel, the SDK sets up the call audio path and keeps it until you leave, whether or not you turn on the microphone. Turning the microphone on or off only decides “whether to publish”; it doesn’t affect whether underlying audio capture is running. This leads to two consequences that you must tell your product team and users about in advance: Why must it work this way? Because on iOS, when the audio session isn’t in VoIP call mode, route control as a whole is unreliable—receiver output exists only under the.playAndRecord session category, and testing on real devices showed that upgrading the category on demand doesn’t take effect. Tencent TRTC’s documentation records the same phenomenon: with the microphone off it uses the media audio path, and in that state you can’t set speaker or receiver output.
If the user denies microphone permission, the SDK degrades to playback-only mode: the user can still hear others, but the receiver route isn’t available—only the speaker.
We recommend also declaring the background audio capability in Info.plist, so the system doesn’t suspend audio when the app goes to the background:
Query the current state
currentRoute is a read-only five-state type that includes external devices the SDK can’t switch to but must report accurately:
Distinguish between
currentRoute (where sound actually comes out, five states) and effectiveRouteTarget (the target you requested, two states). With headphones plugged in, the former is .bluetooth while the latter may still be .speaker—this isn’t a contradiction; external devices take priority. Your UI should display currentRoute.Stop the audio unit during local-only playback
For scenarios like recorded live classes: the user is in the channel but is only playing a recording, with no call audio to send or receive at all. The VoIP voice processing unit (VPIO) still holds the audio session and lowersAVPlayer playback volume significantly. Stop the audio unit, hand the system audio session back to the local player, and the volume returns to normal.
In one case you don’t need to clean up yourself: each time the audio path is occupied for the first time (joining / starting capture), it’s automatically reset to
true, so a manually disabled state from a previous session doesn’t leak across sessions. This matches the behavior of the older RTCEngineKit, which set enabledAudioModule:YES on joining.This switch is only for scenarios where “the whole session needs no call audio”. If you only want to mute yourself or someone else, use LocalMicTrack.mute() or unsubscribe—don’t stop the audio unit.Listen for route changes
reason parameter deserves attention because it distinguishes the source of the change: .oldDeviceUnavailable means unplugged, .newDeviceAvailable means plugged in, and .override means the app overrode it actively. When troubleshooting route issues, this is often more valuable than the result itself.
Interruptions and incoming system calls
The SDK uses CallKit to detect system call state, and interruption recovery isn’t a one-shot action:- When an audio interruption ends, if the system phone call hasn’t actually hung up yet, it doesn’t recover immediately—reactivating the audio session at that point is guaranteed to fail
- The recovery condition is “the system call has ended” and “the app is in the foreground”; if not met, it stays in a pending-recovery state
- It retries automatically when the app returns to the foreground, and a few seconds after the call ends
audioRouteSessionDidRecoverFromInterruptionfires only after recovery actually succeeds
Relationship with DeviceManager
The iOS audio methods onDeviceManager are a thin wrapper over this API, with semantics aligned with Agora’s setEnableSpeakerphone:
DeviceManager.audioInputs() and setAudioInputDevice(_:) are low-level APIs at the input port level. They’re a separate matter from “where sound comes out”, and they don’t take part in the fallback policy described on this page. To control where output goes, use AudioRouteSession.Complete example
FAQ
Switching has no effect Check in this order:- Whether audio is currently going through an external device (
currentRoute.isExternal)—in that case switching to the speaker has no effect anyway - Whether you’ve joined or started capture (
isEngaged)—before the audio path is set up, the setting is only recorded and applied once the path is set up - Whether microphone permission was denied—if denied, the session is degraded and the receiver isn’t available
availableInputs isn’t realistic either. Audio routing must be verified on a real device.