Skip to content
Open
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-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.

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.

(Refers to line 240)

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;

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 on lines +263 to +266

@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.

🔍 deactivateAudioSession blocks on Android under externalCallSystem despite 'behaves like automatic' doc

The deactivateAudioSession method (audio_manager.dart:263-274) returns early for externalCallSystem on ALL platforms, including Android. However, the externalCallSystem enum doc (audio_session.dart:40-45) states 'On Android this currently behaves like [automatic]'. This means an app using externalCallSystem mode cannot explicitly deactivate the Android audio session, even though there's no external system managing it on Android yet. This is arguably correct (the mode semantically means an external system owns the lifecycle), but it could surprise cross-platform developers who expect Android to behave like automatic in all respects under this mode.

Open in Devin Review

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

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