Skip to content

Commit ea51909

Browse files
committed
Resolve a default audio session from engine state when no policy was pushed
On iOS the audio engine refuses to enable recording unless the audio session category permits input, and livekit_client owns that session. The native engine observer only applied a configuration that Dart had pushed, and the only push site in automatic mode was Room.connect. Any recording that started earlier (pre-connect audio, a pre-join microphone preview, an engine start driven from native before the Flutter side exists) ran against the app-default soloAmbient category and failed with kAudioEngineErrorAudioSessionInvalidCategory (-9001), reported as AudioProcessingException(applyFailed). The observer now resolves a built-in playAndRecord preset from engine state when nothing has been pushed and automatic management is on, matching the Swift SDK's AudioSessionEngineObserver, which derives the session from engine state alone. The Dart-pushed policy becomes an override rather than a prerequisite. Dart passes preferSpeakerOutput so the preset picks the same mode the Dart policy would. Audio device module results -9000, -9001 and -4100 now get their own error codes and surface as TrackCreateException or the new AudioSessionException instead of an audio processing failure.
1 parent dd2c804 commit ea51909

9 files changed

Lines changed: 234 additions & 24 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
patch type="changed" "Microphone permission and audio session failures from the audio engine surface as TrackCreateException and the new AudioSessionException instead of AudioProcessingException(applyFailed)"
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
patch type="fixed" "iOS: the audio engine now configures a playAndRecord audio session on its own when recording starts before any session policy was pushed (pre-connect audio, pre-join microphone preview, CallKit-driven engine start), instead of failing with audio engine error -9001"
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
// Copyright 2026 LiveKit, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
import 'package:flutter/services.dart' show PlatformException;
16+
17+
import 'package:meta/meta.dart';
18+
19+
import '../exceptions.dart';
20+
21+
/// Error codes the native plugin uses for audio device module failures with a
22+
/// known cause. Anything else keeps the caller-specific fallback code.
23+
@internal
24+
const String audioEngineErrorCodeDeviceAccessDenied = 'deviceAccessDenied';
25+
@internal
26+
const String audioEngineErrorCodeAudioSessionInvalidCategory = 'audioSessionInvalidCategory';
27+
@internal
28+
const String audioEngineErrorCodeAudioSessionConfigureFailed = 'audioSessionConfigureFailed';
29+
30+
/// Maps a [PlatformException] from an audio device module call to the
31+
/// [LiveKitException] describing its cause, or `null` when the code is not one
32+
/// of the known audio engine failures and the caller should apply its own
33+
/// mapping.
34+
@internal
35+
LiveKitException? audioEngineExceptionFrom(PlatformException error) {
36+
final message = error.message?.trim();
37+
switch (error.code) {
38+
case audioEngineErrorCodeDeviceAccessDenied:
39+
return TrackCreateException(
40+
message?.isNotEmpty == true ? message! : 'Microphone permission is not granted',
41+
);
42+
case audioEngineErrorCodeAudioSessionInvalidCategory:
43+
return AudioSessionException(
44+
message?.isNotEmpty == true ? message! : 'Audio session category does not support recording',
45+
);
46+
case audioEngineErrorCodeAudioSessionConfigureFailed:
47+
return AudioSessionException(
48+
message?.isNotEmpty == true ? message! : 'Failed to configure the audio session',
49+
);
50+
default:
51+
return null;
52+
}
53+
}

lib/src/audio/audio_manager.dart

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,16 @@
1414

1515
import 'dart:async';
1616

17+
import 'package:flutter/services.dart' show PlatformException;
18+
1719
import 'package:meta/meta.dart';
1820

1921
import '../logger.dart';
2022
import '../support/native.dart';
2123
import '../support/platform.dart';
2224
import 'android_audio_session_adapter.dart';
2325
import 'audio_engine_availability.dart';
26+
import 'audio_engine_error.dart';
2427
import 'audio_processing_state.dart';
2528
import 'audio_session.dart';
2629
import 'audio_session_policy.dart';
@@ -214,16 +217,23 @@ class AudioManager {
214217
/// cross-platform code.
215218
///
216219
/// Throws if the native side rejects the change, so callers never assume
217-
/// the engine is gated when it is not.
220+
/// the engine is gated when it is not. Enabling input requires microphone
221+
/// permission, which is not requested here: a [TrackCreateException] is
222+
/// thrown when it is missing, and an [AudioSessionException] when the audio
223+
/// session does not permit recording.
218224
///
219225
/// Experimental: this API may change in a future release.
220226
@experimental
221227
Future<void> setEngineAvailability(AudioEngineAvailability availability) async {
222228
if (!lkPlatformIsApple()) return;
223-
await Native.setEngineAvailability(
224-
isInputAvailable: availability.isInputAvailable,
225-
isOutputAvailable: availability.isOutputAvailable,
226-
);
229+
try {
230+
await Native.setEngineAvailability(
231+
isInputAvailable: availability.isInputAvailable,
232+
isOutputAvailable: availability.isOutputAvailable,
233+
);
234+
} on PlatformException catch (error) {
235+
throw audioEngineExceptionFrom(error) ?? error;
236+
}
227237
}
228238

229239
/// Selects whether LiveKit manages the platform audio session automatically.
@@ -309,6 +319,7 @@ class AudioManager {
309319
automatic: true,
310320
selectCategoryByEngineState: true,
311321
forceSpeakerOutput: policy.forceSpeakerOutput,
322+
preferSpeakerOutput: policy.preferSpeakerOutput,
312323
);
313324
} else {
314325
// Manual mode: re-apply the fixed Apple config. Non-forced receiver vs
@@ -359,6 +370,7 @@ class AudioManager {
359370
automatic: _isAutomaticConfigurationEnabled,
360371
selectCategoryByEngineState: _isAutomaticConfigurationEnabled,
361372
forceSpeakerOutput: policy.forceSpeakerOutput,
373+
preferSpeakerOutput: policy.preferSpeakerOutput,
362374
);
363375
}
364376

lib/src/exceptions.dart

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,16 @@ class TrackCreateException extends LiveKitException {
8282
TrackCreateException([String msg = 'Failed to create track']) : super._(msg);
8383
}
8484

85+
/// The platform audio session could not be configured for, or does not permit,
86+
/// the requested audio operation (Apple platforms).
87+
/// Common reasons:
88+
/// - Recording was started while the app-managed audio session
89+
/// (`AudioSessionManagementMode.manual`) has a category without input.
90+
/// - The system rejected the audio session configuration.
91+
class AudioSessionException extends LiveKitException {
92+
AudioSessionException([String msg = 'Audio session error']) : super._(msg);
93+
}
94+
8595
/// Failed to publish a local track.
8696
/// Common reasons:
8797
/// - Token does not have track publish permission.

lib/src/support/native.dart

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ class Native {
5050
bool automatic = false,
5151
bool selectCategoryByEngineState = false,
5252
bool forceSpeakerOutput = false,
53+
bool preferSpeakerOutput = false,
5354
}) async {
5455
try {
5556
final result = await channel.invokeMethod<bool>(
@@ -59,6 +60,9 @@ class Native {
5960
'automatic': automatic,
6061
'selectCategoryByEngineState': selectCategoryByEngineState,
6162
'forceSpeakerOutput': forceSpeakerOutput,
63+
// Lets the native built-in recording preset pick the same mode the
64+
// Dart policy would, for engine starts that happen before any push.
65+
'preferSpeakerOutput': preferSpeakerOutput,
6266
},
6367
);
6468
return result == true;

lib/src/track/local/audio.dart

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import 'package:collection/collection.dart';
2020
import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
2121
import 'package:meta/meta.dart';
2222

23+
import '../../audio/audio_engine_error.dart';
2324
import '../../events.dart';
2425
import '../../internal/events.dart';
2526
import '../../logger.dart';
@@ -91,10 +92,14 @@ class LocalAudioTrack extends LocalTrack with AudioTrack, LocalAudioManagementMi
9192
// processing options are applied before WebRTC opens the microphone.
9293
await Native.startLocalRecording(currentOptions.processing.toMap());
9394
} on PlatformException catch (error) {
94-
throw track_options.AudioProcessingException(
95-
_audioProcessingFailureReason(error.code),
96-
error.message ?? '',
97-
);
95+
// Missing microphone permission or an audio session that does not
96+
// permit recording are not audio processing failures, so they surface
97+
// as their own exception types.
98+
throw audioEngineExceptionFrom(error) ??
99+
track_options.AudioProcessingException(
100+
_audioProcessingFailureReason(error.code),
101+
error.message ?? '',
102+
);
98103
}
99104
}
100105
}

shared_swift/LiveKitPlugin.swift

Lines changed: 87 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -360,10 +360,12 @@ public class LiveKitPlugin: NSObject, FlutterPlugin {
360360
let automatic = args["automatic"] as? Bool ?? false
361361
let selectCategoryByEngineState = args["selectCategoryByEngineState"] as? Bool ?? false
362362
let forceSpeakerOutput = args["forceSpeakerOutput"] as? Bool ?? false
363+
let preferSpeakerOutput = args["preferSpeakerOutput"] as? Bool ?? false
363364
audioEngineObserver?.updatePolicy(configuration,
364365
automaticManagementEnabled: automatic,
365366
selectCategoryByEngineState: selectCategoryByEngineState,
366-
forceSpeakerOutput: forceSpeakerOutput)
367+
forceSpeakerOutput: forceSpeakerOutput,
368+
preferSpeakerOutput: preferSpeakerOutput)
367369

368370
let shouldApplyNow = !automatic || (audioEngineObserver?.isSessionActive ?? false)
369371
guard shouldApplyNow else {
@@ -524,11 +526,8 @@ public class LiveKitPlugin: NSObject, FlutterPlugin {
524526
if admResult == 0 {
525527
result(nil)
526528
} else {
527-
result(FlutterError(
528-
code: "setEngineAvailability",
529-
message: "Audio engine returned error code: \(admResult)",
530-
details: nil
531-
))
529+
result(LiveKitPlugin.flutterError(forAudioEngineResult: admResult,
530+
fallbackCode: "setEngineAvailability"))
532531
}
533532
}
534533
}
@@ -606,11 +605,10 @@ public class LiveKitPlugin: NSObject, FlutterPlugin {
606605
if admResult == 0 {
607606
result(nil)
608607
} else {
609-
result(FlutterError(
610-
code: "applyFailed",
611-
message: "Audio engine returned error code: \(admResult)",
612-
details: nil
613-
))
608+
// Permission and audio session failures get their own codes so
609+
// Dart does not report them as audio processing failures.
610+
result(LiveKitPlugin.flutterError(forAudioEngineResult: admResult,
611+
fallbackCode: "applyFailed"))
614612
}
615613
}
616614
}
@@ -786,14 +784,46 @@ public class LiveKitPlugin: NSObject, FlutterPlugin {
786784
}
787785
}
788786

789-
#if !os(macOS)
790-
@available(iOS 13.0, *)
791787
extension LiveKitPlugin {
792788
/// SDK-side audio engine error code (mirrors client-sdk-swift): returned
793789
/// from a delegate callback to make WebRTC abort / roll back the engine
794790
/// operation when the audio session cannot be configured.
795791
static let kAudioEngineErrorFailedToConfigureAudioSession = -4100
796792

793+
/// Error codes originating from the WebRTC AudioEngineDevice. Keep in sync
794+
/// with `audio_engine_device.h` in the webrtc-sdk fork.
795+
static let kAudioEngineErrorInsufficientDevicePermission = -9000
796+
static let kAudioEngineErrorAudioSessionInvalidCategory = -9001
797+
798+
/// Maps a non-zero audio device module result to a `FlutterError` whose code
799+
/// the Dart side can act on. Codes with a known cause get their own error
800+
/// code, mirroring client-sdk-swift's `checkAdmResult`. Anything else falls
801+
/// back to `fallbackCode` with the raw value in the message.
802+
static func flutterError(forAudioEngineResult result: Int, fallbackCode: String) -> FlutterError {
803+
switch result {
804+
case kAudioEngineErrorInsufficientDevicePermission:
805+
return FlutterError(code: "deviceAccessDenied",
806+
message: "Microphone permission is not granted (audio engine error \(result))",
807+
details: result)
808+
case kAudioEngineErrorAudioSessionInvalidCategory:
809+
return FlutterError(code: "audioSessionInvalidCategory",
810+
message: "Audio session category does not support recording (audio engine error \(result))",
811+
details: result)
812+
case kAudioEngineErrorFailedToConfigureAudioSession:
813+
return FlutterError(code: "audioSessionConfigureFailed",
814+
message: "Failed to configure the audio session (audio engine error \(result))",
815+
details: result)
816+
default:
817+
return FlutterError(code: fallbackCode,
818+
message: "Audio engine returned error code: \(result)",
819+
details: result)
820+
}
821+
}
822+
}
823+
824+
#if !os(macOS)
825+
@available(iOS 13.0, *)
826+
extension LiveKitPlugin {
797827
/// Applies an `RTCAudioSessionConfiguration` to the shared `RTCAudioSession`.
798828
/// Returns `nil` on success or the thrown error. Safe to call on any thread.
799829
static func applyAudioSessionConfiguration(_ configuration: RTCAudioSessionConfiguration,
@@ -869,6 +899,13 @@ class LKAudioEngineObserver: NSObject, RTCAudioDeviceModuleDelegate {
869899
private weak var channel: FlutterMethodChannel?
870900

871901
#if !os(macOS)
902+
// Policy pushed from Dart, if any. It is an override: when nothing has been
903+
// pushed yet (recording before the room connects, or an engine start driven
904+
// from native before the Flutter side exists) the observer resolves a
905+
// built-in preset from engine state instead, so the engine never enables
906+
// against the app-default soloAmbient category. Mirrors the Swift SDK's
907+
// AudioSessionEngineObserver, which derives the session from engine state
908+
// alone.
872909
private var cachedConfiguration: RTCAudioSessionConfiguration?
873910
// When true, the category is chosen from the live engine state at apply time
874911
// (playAndRecord while recording, playback for playout-only) rather than
@@ -878,6 +915,9 @@ class LKAudioEngineObserver: NSObject, RTCAudioDeviceModuleDelegate {
878915
// override or manual mode, where the config is applied verbatim.
879916
private var selectCategoryByEngineState = false
880917
private var forceSpeakerOutput = false
918+
// Speaker preference for the built-in recording preset (videoChat routes to
919+
// the speaker, voiceChat to the receiver), same as the Dart-side policy.
920+
private var preferSpeakerOutput = false
881921
private var isAutomaticManagementEnabled = true
882922
// False when an external call system (CallKit) owns session activation:
883923
// configurations are applied without activating, and the session is never
@@ -916,13 +956,15 @@ class LKAudioEngineObserver: NSObject, RTCAudioDeviceModuleDelegate {
916956
func updatePolicy(_ configuration: RTCAudioSessionConfiguration,
917957
automaticManagementEnabled: Bool,
918958
selectCategoryByEngineState: Bool,
919-
forceSpeakerOutput: Bool) {
959+
forceSpeakerOutput: Bool,
960+
preferSpeakerOutput: Bool) {
920961
let cachedConfiguration = copyConfiguration(configuration)
921962
lock.lock()
922963
self.cachedConfiguration = cachedConfiguration
923964
self.isAutomaticManagementEnabled = automaticManagementEnabled
924965
self.selectCategoryByEngineState = selectCategoryByEngineState
925966
self.forceSpeakerOutput = forceSpeakerOutput
967+
self.preferSpeakerOutput = preferSpeakerOutput
926968
lock.unlock()
927969
}
928970

@@ -972,8 +1014,23 @@ class LKAudioEngineObserver: NSObject, RTCAudioDeviceModuleDelegate {
9721014
/// category would leave playAndRecord-only mode/options (e.g. videoChat,
9731015
/// allowBluetooth) that are invalid for the playback category. Mirrors the
9741016
/// Swift SDK's `.playback` preset (playback + spokenAudio + mixWithOthers).
1017+
///
1018+
/// With no pushed config and automatic management on, the built-in
1019+
/// recording preset stands in for the Dart policy, so the result is the
1020+
/// same as if the default policy had been pushed. Returns `nil` only in
1021+
/// manual mode with nothing pushed, where the app owns the session.
9751022
private func effectiveConfigurationLocked(isRecordingEnabled: Bool) -> RTCAudioSessionConfiguration? {
976-
guard let configuration = cachedConfiguration else { return nil }
1023+
let configuration: RTCAudioSessionConfiguration
1024+
let selectCategoryByEngineState: Bool
1025+
if let cachedConfiguration {
1026+
configuration = cachedConfiguration
1027+
selectCategoryByEngineState = self.selectCategoryByEngineState
1028+
} else if isAutomaticManagementEnabled {
1029+
configuration = defaultRecordingConfigurationLocked()
1030+
selectCategoryByEngineState = true
1031+
} else {
1032+
return nil
1033+
}
9771034
guard selectCategoryByEngineState, !isRecordingEnabled else { return configuration }
9781035

9791036
let playback = copyConfiguration(configuration)
@@ -983,6 +1040,21 @@ class LKAudioEngineObserver: NSObject, RTCAudioDeviceModuleDelegate {
9831040
return playback
9841041
}
9851042

1043+
/// Built-in playAndRecord preset used until Dart pushes a policy. Must be
1044+
/// called with `lock` held.
1045+
///
1046+
/// Keep in sync with the automatic-mode branch of
1047+
/// `ResolvedAudioSessionPolicy.appleConfiguration` in
1048+
/// `lib/src/audio/audio_session_policy.dart`, so a later push of the default
1049+
/// policy (on connect) does not change the live session.
1050+
private func defaultRecordingConfigurationLocked() -> RTCAudioSessionConfiguration {
1051+
let configuration = RTCAudioSessionConfiguration.webRTC()
1052+
configuration.category = AVAudioSession.Category.playAndRecord.rawValue
1053+
configuration.categoryOptions = [.allowBluetooth, .allowBluetoothA2DP, .allowAirPlay]
1054+
configuration.mode = (preferSpeakerOutput ? AVAudioSession.Mode.videoChat : AVAudioSession.Mode.voiceChat).rawValue
1055+
return configuration
1056+
}
1057+
9861058
private func copyConfiguration(_ configuration: RTCAudioSessionConfiguration) -> RTCAudioSessionConfiguration {
9871059
let copy = RTCAudioSessionConfiguration()
9881060
copy.category = configuration.category

0 commit comments

Comments
 (0)