Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .changes/audio-session-interruptions
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
patch type="fixed" "iOS: audio not resuming after a system interruption (incoming call, alarm, Siri)"
6 changes: 6 additions & 0 deletions Docs/audio.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,12 @@ When `isAutomaticConfigurationEnabled` is `true`, the SDK picks the audio sessio

The category is reset to `.ambient` when both playout and recording stop (typically after `Room.disconnect()`).

## Interruption recovery

After a system interruption (incoming phone call, alarm, Siri, FaceTime), iOS re-activates your audio session but does not restore the category, mode, or options. When `isAutomaticConfigurationEnabled` is `true`, the SDK re-applies its configuration on interruption-end and externally-driven category-change events so audio resumes in a known state.

If you manage `AVAudioSession` yourself (e.g., CallKit apps with `isAutomaticConfigurationEnabled = false`), the SDK does not re-apply on these events — handle interruption recovery in your `CXProviderDelegate` instead.

## Disabling Voice Processing

Apple's voice processing is enabled by default, such as echo cancellation and auto-gain control.
Expand Down
73 changes: 73 additions & 0 deletions Sources/LiveKit/Audio/AudioSessionEngineObserver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,12 @@ public class AudioSessionEngineObserver: AudioEngineObserver, Loggable, @uncheck
set { _state.mutate { $0.next = newValue } }
}

// Listens to iOS-driven session events (interruption-end, externally-driven
// category change) that may leave the session in a state different from
// what we configured. Held as a strong reference because `LKRTCAudioSession`
// stores its delegates weakly.
private let rtcDelegateAdapter = RTCAudioSessionDelegateAdapter()

public init() {
_state.onDidMutate = { [weak self] new, old in
guard let self,
Expand All @@ -111,6 +117,9 @@ public class AudioSessionEngineObserver: AudioEngineObserver, Loggable, @uncheck
log("Failed to configure audio session after speaker preference change: \(error)", .error)
}
}

rtcDelegateAdapter.owner = self
LKRTCAudioSession.sharedInstance().add(rtcDelegateAdapter)
}

/// Acquires an audio session requirement handle for external ownership.
Expand Down Expand Up @@ -259,6 +268,35 @@ public class AudioSessionEngineObserver: AudioEngineObserver, Loggable, @uncheck
return config
}

/// Re-applies the current category/mode/options after an external event
/// (interruption-end, category-change) that may have mutated the session.
/// WebRTC re-activates the session on these events but does not re-apply
/// our configuration; iOS can leave us in a state different from what we
/// configured.
///
/// Also calls `overrideOutputAudioPort` as a workaround for a VPIO
/// low-volume regression where audio comes back inaudibly quiet after
/// resume (#1011); toggling the output port forces a fresh route
/// selection that picks up the correct gain.
fileprivate func reapplyConfiguration(reason: String) {
let snapshot = _state.copy()
guard snapshot.isAutomaticConfigurationEnabled else { return }
guard snapshot.isPlayoutEnabled || snapshot.isRecordingEnabled else { return }

let config = selectConfiguration(state: snapshot)
let session = AVAudioSession.sharedInstance()
do {
log("AudioSession re-applying configuration (\(reason)) to: \(config.category)")
try session.setCategory(config.category, mode: config.mode, options: config.categoryOptions)
try session.setPreferredIOBufferDuration(LKRTCAudioSessionConfiguration.webRTC().ioBufferDuration)
if config.category == .playAndRecord {
try session.overrideOutputAudioPort(snapshot.isSpeakerOutputPreferred ? .speaker : .none)
}
} catch {
log("AudioSession failed to re-apply configuration: \(error)", .error)
}
}

// MARK: - AudioEngineObserver

public func engineWillEnable(_ engine: AVAudioEngine, isPlayoutEnabled: Bool, isRecordingEnabled: Bool) -> Int {
Expand Down Expand Up @@ -298,4 +336,39 @@ extension AudioSessionEngineObserver.State {
var isRecordingEnabled: Bool { sessionRequirements.values.contains(where: \.isRecordingEnabled) }
}

// MARK: - LKRTCAudioSessionDelegate

/// Forwards iOS-driven session events to ``AudioSessionEngineObserver``
/// so the configuration can be re-applied when needed.
private final class RTCAudioSessionDelegateAdapter: NSObject, LKRTCAudioSessionDelegate, Loggable {
weak var owner: AudioSessionEngineObserver?

/// iOS finished an interruption (cellular call, alarm, Siri, FaceTime, …).
/// WebRTC re-activates the session here but does not re-apply our
/// category/mode/options.
func audioSessionDidEndInterruption(_: LKRTCAudioSession, shouldResumeSession: Bool) {
guard shouldResumeSession else {
log("AudioSession interruption ended (shouldResumeSession=false); skipping re-apply")
return
}
owner?.reapplyConfiguration(reason: "interruption-end")
}

/// iOS reported a route change. Only re-apply for reasons that suggest the
/// session configuration was mutated externally (CallKit activation, system
/// audio takeover); user/system port overrides and BT route connect/
/// disconnect manage themselves.
func audioSessionDidChangeRoute(_: LKRTCAudioSession,
reason: AVAudioSession.RouteChangeReason,
previousRoute _: AVAudioSessionRouteDescription)
{
switch reason {
case .categoryChange, .routeConfigurationChange:
owner?.reapplyConfiguration(reason: "route-change(\(reason))")
default:
log("AudioSession route changed (not handled): \(reason)")
}
}
}

#endif