Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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-engine-availability
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
minor type="added" "AudioManager engine availability control and externalCallSystem session mode for CallKit coordination"
56 changes: 56 additions & 0 deletions lib/src/audio/audio_engine_availability.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
// Copyright 2026 LiveKit, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

/// Whether the WebRTC audio engine is allowed to run, per direction.
///
/// This is the highest-priority gate over anything that may start the engine
/// (enabling the microphone, starting playback of remote audio). Requests
/// made while a direction is unavailable are not lost: the engine starts as
/// soon as availability allows.
///
/// Used to coordinate with platform call systems that own audio activation
/// timing, such as CallKit on iOS: keep the engine unavailable until
/// `provider(didActivate:)` and make it available inside the
/// activate/deactivate window. See `AudioManager.setEngineAvailability`.
class AudioEngineAvailability {
/// Whether the engine may run its input (microphone / recording) side.
final bool isInputAvailable;

/// Whether the engine may run its output (playout / remote audio) side.
final bool isOutputAvailable;

const AudioEngineAvailability({
required this.isInputAvailable,
required this.isOutputAvailable,
});

/// Both input and output are available (the default).
static const defaultAvailability = AudioEngineAvailability(isInputAvailable: true, isOutputAvailable: true);

/// Neither input nor output is available. The engine will not run.
static const none = AudioEngineAvailability(isInputAvailable: false, isOutputAvailable: false);

@override
bool operator ==(Object other) =>
other is AudioEngineAvailability &&
other.isInputAvailable == isInputAvailable &&
other.isOutputAvailable == isOutputAvailable;

@override
int get hashCode => Object.hash(isInputAvailable, isOutputAvailable);

@override
String toString() =>
'AudioEngineAvailability(isInputAvailable: $isInputAvailable, isOutputAvailable: $isOutputAvailable)';
}
78 changes: 73 additions & 5 deletions lib/src/audio/audio_manager.dart
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.

@devin-ai-integration devin-ai-integration Bot Jul 7, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Audio session configuration not re-applied when switching from manual to external-call-system mode

The mode transition check (mode == AudioSessionManagementMode.automatic at lib/src/audio/audio_manager.dart:240) only re-applies the audio configuration when switching to automatic, so switching from manual to externalCallSystem while the engine is running leaves stale native configuration in place.

Impact: An app that switches from manual to external-call-system mode mid-session will keep the old audio session configuration until the next engine lifecycle event, causing incorrect audio routing or category.

Mechanism: the re-apply condition doesn't account for externalCallSystem being an automatic-configuration mode

Before this PR there were only two modes (automatic and manual), so the condition previousMode != automatic && mode == automatic correctly covered the only transition that needed a re-apply. With the new externalCallSystem mode, _isAutomaticConfigurationEnabled returns true for both automatic and externalCallSystem (lib/src/audio/audio_manager.dart:104). But the re-apply guard at line 240 still only checks for mode == AudioSessionManagementMode.automatic, missing the manual → externalCallSystem transition.

The fix should check whether the new mode is any automatic-configuration mode, e.g.:

if (previousMode == AudioSessionManagementMode.manual && _isAutomaticConfigurationEnabled) {

or equivalently check both automatic and externalCallSystem.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yea I think that's expected, mid-session switching is not really supported.

Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,13 @@ import '../logger.dart';
import '../support/native.dart';
import '../support/platform.dart';
import 'android_audio_session_adapter.dart';
import 'audio_engine_availability.dart';
import 'audio_processing_state.dart';
import 'audio_session.dart';
import 'audio_session_policy.dart';

export 'audio_engine_availability.dart';

/// Snapshot of the WebRTC audio engine's playout/recording state.
///
/// Surfaced by [AudioManager] from real audio-engine lifecycle events on the
Expand Down Expand Up @@ -96,7 +99,11 @@ class AudioManager {
/// A broadcast stream of audio engine state changes (native engine lifecycle).
Stream<AudioEngineState> get audioEngineStateStream => _audioEngineStateController.stream;

bool get _isAutomaticConfigurationEnabled => _managementMode == AudioSessionManagementMode.automatic;
// externalCallSystem keeps engine-driven configuration (like automatic) but
// the native side never activates/deactivates the session.
bool get _isAutomaticConfigurationEnabled => _managementMode != AudioSessionManagementMode.manual;
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.

bool get _isSessionActivationEnabled => _managementMode != AudioSessionManagementMode.externalCallSystem;

@visibleForTesting
void resetForTest() {
Expand Down Expand Up @@ -141,11 +148,16 @@ class AudioManager {
/// `LiveKitClient.initialize(initialAudioSessionOptions: ...)` uses this so the
/// WebRTC initialization-time Android audio attributes and LiveKit's automatic
/// runtime session policy start from the same intent. Unlike
/// [setAudioSessionOptions], this keeps automatic management enabled and does
/// [setAudioSessionOptions], this keeps the current management mode and does
/// not apply native session changes immediately.
///
/// Applies under [AudioSessionManagementMode.automatic] and
/// [AudioSessionManagementMode.externalCallSystem], both of which drive
/// configuration from engine lifecycle. Skipped under
/// [AudioSessionManagementMode.manual], where the app owns the options.
@internal
void setInitialAudioSessionOptions(AudioSessionOptions options) {
if (_managementMode != AudioSessionManagementMode.automatic) {
if (!_isAutomaticConfigurationEnabled) {
return;
}
_options = options;
Expand All @@ -160,12 +172,57 @@ class AudioManager {
///
/// The speaker preference and force flag are owned by setSpeakerOutputPreferred
/// and are preserved across this call.
///
/// Under [AudioSessionManagementMode.externalCallSystem] the mode is
/// preserved: the configuration is applied without activating the session.
Future<void> setAudioSessionOptions(AudioSessionOptions options) async {
await _enterManualMode();
_options = options;
await _applyCurrentAudioSessionPolicy();
}

/// Sets whether the WebRTC audio engine is allowed to run.
///
/// This is the highest-priority gate over anything that may start the
/// engine (enabling the microphone, remote audio playback). Requests made
/// while unavailable are honored as soon as availability allows, so it is
/// safe to connect a room and publish the microphone while the engine is
/// still gated off.
///
/// This is the core of CallKit coordination on iOS: set
/// [AudioEngineAvailability.none] before connecting, then flip to
/// [AudioEngineAvailability.defaultAvailability] in CallKit's
/// `provider(didActivate:)` and back to `none` in `didDeactivate`. Combine
/// with [AudioSessionManagementMode.externalCallSystem] so LiveKit never
/// activates the session itself.
///
/// iOS/macOS only. A no-op elsewhere, so it is always safe to call from
/// cross-platform code.
///
/// Throws if the native side rejects the change, so callers never assume
/// the engine is gated when it is not.
Future<void> setEngineAvailability(AudioEngineAvailability availability) async {
if (!lkPlatformIsApple()) return;
await Native.setEngineAvailability(
isInputAvailable: availability.isInputAvailable,
isOutputAvailable: availability.isOutputAvailable,
);
}

/// The WebRTC audio engine's current availability.
///
/// Returns [AudioEngineAvailability.defaultAvailability] on platforms
/// without engine gating (everything except iOS/macOS).
Future<AudioEngineAvailability> getEngineAvailability() async {
if (!lkPlatformIsApple()) return AudioEngineAvailability.defaultAvailability;
final response = await Native.getEngineAvailability();
if (response == null) return AudioEngineAvailability.defaultAvailability;
return AudioEngineAvailability(
isInputAvailable: response['isInputAvailable'] as bool? ?? true,
isOutputAvailable: response['isOutputAvailable'] as bool? ?? true,
);
}

/// Selects whether LiveKit manages the platform audio session automatically.
///
/// In [AudioSessionManagementMode.manual], LiveKit does not update the audio
Expand All @@ -186,8 +243,12 @@ class AudioManager {
}

/// Switches to manual mode if not already, syncing the native side once.
///
/// [AudioSessionManagementMode.externalCallSystem] is preserved: an explicit
/// configuration under an external call system applies without LiveKit
/// taking over the session lifecycle.
Future<void> _enterManualMode() async {
if (_managementMode == AudioSessionManagementMode.manual) return;
if (_managementMode != AudioSessionManagementMode.automatic) return;
_managementMode = AudioSessionManagementMode.manual;
await _syncAppleAudioSessionManagementMode();
}
Expand All @@ -199,6 +260,10 @@ class AudioManager {
/// session on its own. Re-apply a configuration with [setAudioSessionOptions],
/// or hand control back with [setAudioSessionManagementMode].
Future<void> deactivateAudioSession() async {
if (_managementMode == AudioSessionManagementMode.externalCallSystem) {
logger.warning('deactivateAudioSession skipped: the external call system owns session activation');
return;
}
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
await _enterManualMode();
if (lkPlatformIs(PlatformType.iOS)) {
await Native.deactivateAppleAudioSession();
Expand Down Expand Up @@ -265,7 +330,10 @@ class AudioManager {

Future<void> _syncAppleAudioSessionManagementMode() async {
if (lkPlatformIs(PlatformType.iOS)) {
await Native.setAppleAudioSessionAutomaticManagementEnabled(_isAutomaticConfigurationEnabled);
await Native.setAppleAudioSessionAutomaticManagementEnabled(
_isAutomaticConfigurationEnabled,
sessionActivationEnabled: _isSessionActivationEnabled,
);
}
}

Expand Down
22 changes: 22 additions & 0 deletions lib/src/audio/audio_session.dart
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,28 @@ enum AudioSessionManagementMode {
/// The app must call AudioManager APIs when it wants to apply a session
/// configuration.
manual,

/// An external telephony system (iOS CallKit / Android Telecom) owns the
/// platform audio lifecycle.
///
/// On iOS, LiveKit keeps configuring the session's category/mode/options
/// from the audio engine lifecycle like [automatic], but never activates or
/// deactivates the session, since CallKit owns activation timing. Combine
/// with `AudioManager.setEngineAvailability` so the engine only runs inside
/// CallKit's `didActivate`/`didDeactivate` window.
///
/// On Android this currently behaves like [automatic] for session
/// configuration, so a cross-platform app can set this mode once at
/// startup: iOS gets the CallKit contract and Android keeps LiveKit's
/// normal session management. When Telecom (`androidx.core.telecom`)
/// integration lands, this mode will stand down LiveKit's audio-focus and
/// routing management, which the Telecom framework owns for registered
/// calls.
///
/// `AudioManager.deactivateAudioSession` is disabled in this mode on all
/// platforms, since releasing platform audio belongs to the external call
/// system.
externalCallSystem,
}

@immutable
Expand Down
46 changes: 44 additions & 2 deletions lib/src/support/native.dart
Original file line number Diff line number Diff line change
Expand Up @@ -219,18 +219,60 @@ class Native {

/// Enable or disable LiveKit's automatic iOS audio-session management from
/// native WebRTC audio-engine lifecycle callbacks.
///
/// [sessionActivationEnabled] controls whether LiveKit may activate or
/// deactivate the session at all. When false, LiveKit only configures the
/// category/mode and an external system (CallKit) owns activation timing.
@internal
static Future<void> setAppleAudioSessionAutomaticManagementEnabled(bool enabled) async {
static Future<void> setAppleAudioSessionAutomaticManagementEnabled(
bool enabled, {
bool sessionActivationEnabled = true,
}) async {
try {
await channel.invokeMethod<void>(
'setAppleAudioSessionAutomaticManagementEnabled',
<String, dynamic>{'enabled': enabled},
<String, dynamic>{
'enabled': enabled,
'sessionActivationEnabled': sessionActivationEnabled,
},
);
} catch (error) {
logger.warning('setAppleAudioSessionAutomaticManagementEnabled did throw $error');
}
}

/// Sets whether the WebRTC audio engine is allowed to run (iOS/macOS).
///
/// Unlike most methods in this class this deliberately does not swallow
/// platform errors: a failed availability change means the engine may run
/// outside the window the caller intended (e.g. CallKit's
/// didActivate/didDeactivate), so the error must reach the caller.
@internal
static Future<void> setEngineAvailability({
required bool isInputAvailable,
required bool isOutputAvailable,
}) async {
await channel.invokeMethod<void>(
'setEngineAvailability',
<String, dynamic>{
'isInputAvailable': isInputAvailable,
'isOutputAvailable': isOutputAvailable,
},
);
}
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.

/// Reads the WebRTC audio engine's availability (iOS/macOS). Returns null
/// when the native side cannot provide it.
@internal
static Future<Map<dynamic, dynamic>?> getEngineAvailability() async {
try {
return await channel.invokeMethod<Map<dynamic, dynamic>>('getEngineAvailability', <String, dynamic>{});
} catch (error) {
logger.warning('getEngineAvailability did throw $error');
return null;
}
}

@internal
static Future<bool> startVisualizer(
String trackId, {
Expand Down
2 changes: 2 additions & 0 deletions lib/src/support/platform.dart
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ bool lkPlatformIsMobile() => [PlatformType.iOS, PlatformType.android].contains(l

bool lkPlatformIsWebMobile() => lkPlatformIsWebMobileImplementation();

bool lkPlatformIsApple() => [PlatformType.iOS, PlatformType.macOS].contains(lkPlatform());

bool lkPlatformIsDesktop() => [
PlatformType.macOS,
PlatformType.windows,
Expand Down
Loading
Loading