From ea519098786098c3a60168182ea520773715c17e Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Thu, 27 Aug 2026 23:29:14 +0800 Subject: [PATCH 01/14] 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. --- .changes/audio-engine-error-mapping | 1 + .changes/native-audio-session-preset | 1 + lib/src/audio/audio_engine_error.dart | 53 +++++++++++++ lib/src/audio/audio_manager.dart | 22 ++++-- lib/src/exceptions.dart | 10 +++ lib/src/support/native.dart | 4 + lib/src/track/local/audio.dart | 13 +++- shared_swift/LiveKitPlugin.swift | 102 ++++++++++++++++++++++---- test/audio/audio_session_test.dart | 52 +++++++++++++ 9 files changed, 234 insertions(+), 24 deletions(-) create mode 100644 .changes/audio-engine-error-mapping create mode 100644 .changes/native-audio-session-preset create mode 100644 lib/src/audio/audio_engine_error.dart diff --git a/.changes/audio-engine-error-mapping b/.changes/audio-engine-error-mapping new file mode 100644 index 000000000..9d973077b --- /dev/null +++ b/.changes/audio-engine-error-mapping @@ -0,0 +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)" diff --git a/.changes/native-audio-session-preset b/.changes/native-audio-session-preset new file mode 100644 index 000000000..fb4bb7ab8 --- /dev/null +++ b/.changes/native-audio-session-preset @@ -0,0 +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" diff --git a/lib/src/audio/audio_engine_error.dart b/lib/src/audio/audio_engine_error.dart new file mode 100644 index 000000000..49bddf4c1 --- /dev/null +++ b/lib/src/audio/audio_engine_error.dart @@ -0,0 +1,53 @@ +// 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. + +import 'package:flutter/services.dart' show PlatformException; + +import 'package:meta/meta.dart'; + +import '../exceptions.dart'; + +/// Error codes the native plugin uses for audio device module failures with a +/// known cause. Anything else keeps the caller-specific fallback code. +@internal +const String audioEngineErrorCodeDeviceAccessDenied = 'deviceAccessDenied'; +@internal +const String audioEngineErrorCodeAudioSessionInvalidCategory = 'audioSessionInvalidCategory'; +@internal +const String audioEngineErrorCodeAudioSessionConfigureFailed = 'audioSessionConfigureFailed'; + +/// Maps a [PlatformException] from an audio device module call to the +/// [LiveKitException] describing its cause, or `null` when the code is not one +/// of the known audio engine failures and the caller should apply its own +/// mapping. +@internal +LiveKitException? audioEngineExceptionFrom(PlatformException error) { + final message = error.message?.trim(); + switch (error.code) { + case audioEngineErrorCodeDeviceAccessDenied: + return TrackCreateException( + message?.isNotEmpty == true ? message! : 'Microphone permission is not granted', + ); + case audioEngineErrorCodeAudioSessionInvalidCategory: + return AudioSessionException( + message?.isNotEmpty == true ? message! : 'Audio session category does not support recording', + ); + case audioEngineErrorCodeAudioSessionConfigureFailed: + return AudioSessionException( + message?.isNotEmpty == true ? message! : 'Failed to configure the audio session', + ); + default: + return null; + } +} diff --git a/lib/src/audio/audio_manager.dart b/lib/src/audio/audio_manager.dart index be25e83e2..9ab66d2f8 100644 --- a/lib/src/audio/audio_manager.dart +++ b/lib/src/audio/audio_manager.dart @@ -14,6 +14,8 @@ import 'dart:async'; +import 'package:flutter/services.dart' show PlatformException; + import 'package:meta/meta.dart'; import '../logger.dart'; @@ -21,6 +23,7 @@ import '../support/native.dart'; import '../support/platform.dart'; import 'android_audio_session_adapter.dart'; import 'audio_engine_availability.dart'; +import 'audio_engine_error.dart'; import 'audio_processing_state.dart'; import 'audio_session.dart'; import 'audio_session_policy.dart'; @@ -214,16 +217,23 @@ class AudioManager { /// cross-platform code. /// /// Throws if the native side rejects the change, so callers never assume - /// the engine is gated when it is not. + /// the engine is gated when it is not. Enabling input requires microphone + /// permission, which is not requested here: a [TrackCreateException] is + /// thrown when it is missing, and an [AudioSessionException] when the audio + /// session does not permit recording. /// /// Experimental: this API may change in a future release. @experimental Future setEngineAvailability(AudioEngineAvailability availability) async { if (!lkPlatformIsApple()) return; - await Native.setEngineAvailability( - isInputAvailable: availability.isInputAvailable, - isOutputAvailable: availability.isOutputAvailable, - ); + try { + await Native.setEngineAvailability( + isInputAvailable: availability.isInputAvailable, + isOutputAvailable: availability.isOutputAvailable, + ); + } on PlatformException catch (error) { + throw audioEngineExceptionFrom(error) ?? error; + } } /// Selects whether LiveKit manages the platform audio session automatically. @@ -309,6 +319,7 @@ class AudioManager { automatic: true, selectCategoryByEngineState: true, forceSpeakerOutput: policy.forceSpeakerOutput, + preferSpeakerOutput: policy.preferSpeakerOutput, ); } else { // Manual mode: re-apply the fixed Apple config. Non-forced receiver vs @@ -359,6 +370,7 @@ class AudioManager { automatic: _isAutomaticConfigurationEnabled, selectCategoryByEngineState: _isAutomaticConfigurationEnabled, forceSpeakerOutput: policy.forceSpeakerOutput, + preferSpeakerOutput: policy.preferSpeakerOutput, ); } diff --git a/lib/src/exceptions.dart b/lib/src/exceptions.dart index bae9a4819..8e30b4030 100644 --- a/lib/src/exceptions.dart +++ b/lib/src/exceptions.dart @@ -82,6 +82,16 @@ class TrackCreateException extends LiveKitException { TrackCreateException([String msg = 'Failed to create track']) : super._(msg); } +/// The platform audio session could not be configured for, or does not permit, +/// the requested audio operation (Apple platforms). +/// Common reasons: +/// - Recording was started while the app-managed audio session +/// (`AudioSessionManagementMode.manual`) has a category without input. +/// - The system rejected the audio session configuration. +class AudioSessionException extends LiveKitException { + AudioSessionException([String msg = 'Audio session error']) : super._(msg); +} + /// Failed to publish a local track. /// Common reasons: /// - Token does not have track publish permission. diff --git a/lib/src/support/native.dart b/lib/src/support/native.dart index ff5f75802..62bcd2c05 100644 --- a/lib/src/support/native.dart +++ b/lib/src/support/native.dart @@ -50,6 +50,7 @@ class Native { bool automatic = false, bool selectCategoryByEngineState = false, bool forceSpeakerOutput = false, + bool preferSpeakerOutput = false, }) async { try { final result = await channel.invokeMethod( @@ -59,6 +60,9 @@ class Native { 'automatic': automatic, 'selectCategoryByEngineState': selectCategoryByEngineState, 'forceSpeakerOutput': forceSpeakerOutput, + // Lets the native built-in recording preset pick the same mode the + // Dart policy would, for engine starts that happen before any push. + 'preferSpeakerOutput': preferSpeakerOutput, }, ); return result == true; diff --git a/lib/src/track/local/audio.dart b/lib/src/track/local/audio.dart index 47b21e910..051eb00f2 100644 --- a/lib/src/track/local/audio.dart +++ b/lib/src/track/local/audio.dart @@ -20,6 +20,7 @@ import 'package:collection/collection.dart'; import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc; import 'package:meta/meta.dart'; +import '../../audio/audio_engine_error.dart'; import '../../events.dart'; import '../../internal/events.dart'; import '../../logger.dart'; @@ -91,10 +92,14 @@ class LocalAudioTrack extends LocalTrack with AudioTrack, LocalAudioManagementMi // processing options are applied before WebRTC opens the microphone. await Native.startLocalRecording(currentOptions.processing.toMap()); } on PlatformException catch (error) { - throw track_options.AudioProcessingException( - _audioProcessingFailureReason(error.code), - error.message ?? '', - ); + // Missing microphone permission or an audio session that does not + // permit recording are not audio processing failures, so they surface + // as their own exception types. + throw audioEngineExceptionFrom(error) ?? + track_options.AudioProcessingException( + _audioProcessingFailureReason(error.code), + error.message ?? '', + ); } } } diff --git a/shared_swift/LiveKitPlugin.swift b/shared_swift/LiveKitPlugin.swift index a4f1b6b48..babb4b6a6 100644 --- a/shared_swift/LiveKitPlugin.swift +++ b/shared_swift/LiveKitPlugin.swift @@ -360,10 +360,12 @@ public class LiveKitPlugin: NSObject, FlutterPlugin { let automatic = args["automatic"] as? Bool ?? false let selectCategoryByEngineState = args["selectCategoryByEngineState"] as? Bool ?? false let forceSpeakerOutput = args["forceSpeakerOutput"] as? Bool ?? false + let preferSpeakerOutput = args["preferSpeakerOutput"] as? Bool ?? false audioEngineObserver?.updatePolicy(configuration, automaticManagementEnabled: automatic, selectCategoryByEngineState: selectCategoryByEngineState, - forceSpeakerOutput: forceSpeakerOutput) + forceSpeakerOutput: forceSpeakerOutput, + preferSpeakerOutput: preferSpeakerOutput) let shouldApplyNow = !automatic || (audioEngineObserver?.isSessionActive ?? false) guard shouldApplyNow else { @@ -524,11 +526,8 @@ public class LiveKitPlugin: NSObject, FlutterPlugin { if admResult == 0 { result(nil) } else { - result(FlutterError( - code: "setEngineAvailability", - message: "Audio engine returned error code: \(admResult)", - details: nil - )) + result(LiveKitPlugin.flutterError(forAudioEngineResult: admResult, + fallbackCode: "setEngineAvailability")) } } } @@ -606,11 +605,10 @@ public class LiveKitPlugin: NSObject, FlutterPlugin { if admResult == 0 { result(nil) } else { - result(FlutterError( - code: "applyFailed", - message: "Audio engine returned error code: \(admResult)", - details: nil - )) + // Permission and audio session failures get their own codes so + // Dart does not report them as audio processing failures. + result(LiveKitPlugin.flutterError(forAudioEngineResult: admResult, + fallbackCode: "applyFailed")) } } } @@ -786,14 +784,46 @@ public class LiveKitPlugin: NSObject, FlutterPlugin { } } -#if !os(macOS) -@available(iOS 13.0, *) extension LiveKitPlugin { /// SDK-side audio engine error code (mirrors client-sdk-swift): returned /// from a delegate callback to make WebRTC abort / roll back the engine /// operation when the audio session cannot be configured. static let kAudioEngineErrorFailedToConfigureAudioSession = -4100 + /// Error codes originating from the WebRTC AudioEngineDevice. Keep in sync + /// with `audio_engine_device.h` in the webrtc-sdk fork. + static let kAudioEngineErrorInsufficientDevicePermission = -9000 + static let kAudioEngineErrorAudioSessionInvalidCategory = -9001 + + /// Maps a non-zero audio device module result to a `FlutterError` whose code + /// the Dart side can act on. Codes with a known cause get their own error + /// code, mirroring client-sdk-swift's `checkAdmResult`. Anything else falls + /// back to `fallbackCode` with the raw value in the message. + static func flutterError(forAudioEngineResult result: Int, fallbackCode: String) -> FlutterError { + switch result { + case kAudioEngineErrorInsufficientDevicePermission: + return FlutterError(code: "deviceAccessDenied", + message: "Microphone permission is not granted (audio engine error \(result))", + details: result) + case kAudioEngineErrorAudioSessionInvalidCategory: + return FlutterError(code: "audioSessionInvalidCategory", + message: "Audio session category does not support recording (audio engine error \(result))", + details: result) + case kAudioEngineErrorFailedToConfigureAudioSession: + return FlutterError(code: "audioSessionConfigureFailed", + message: "Failed to configure the audio session (audio engine error \(result))", + details: result) + default: + return FlutterError(code: fallbackCode, + message: "Audio engine returned error code: \(result)", + details: result) + } + } +} + +#if !os(macOS) +@available(iOS 13.0, *) +extension LiveKitPlugin { /// Applies an `RTCAudioSessionConfiguration` to the shared `RTCAudioSession`. /// Returns `nil` on success or the thrown error. Safe to call on any thread. static func applyAudioSessionConfiguration(_ configuration: RTCAudioSessionConfiguration, @@ -869,6 +899,13 @@ class LKAudioEngineObserver: NSObject, RTCAudioDeviceModuleDelegate { private weak var channel: FlutterMethodChannel? #if !os(macOS) + // Policy pushed from Dart, if any. It is an override: when nothing has been + // pushed yet (recording before the room connects, or an engine start driven + // from native before the Flutter side exists) the observer resolves a + // built-in preset from engine state instead, so the engine never enables + // against the app-default soloAmbient category. Mirrors the Swift SDK's + // AudioSessionEngineObserver, which derives the session from engine state + // alone. private var cachedConfiguration: RTCAudioSessionConfiguration? // When true, the category is chosen from the live engine state at apply time // (playAndRecord while recording, playback for playout-only) rather than @@ -878,6 +915,9 @@ class LKAudioEngineObserver: NSObject, RTCAudioDeviceModuleDelegate { // override or manual mode, where the config is applied verbatim. private var selectCategoryByEngineState = false private var forceSpeakerOutput = false + // Speaker preference for the built-in recording preset (videoChat routes to + // the speaker, voiceChat to the receiver), same as the Dart-side policy. + private var preferSpeakerOutput = false private var isAutomaticManagementEnabled = true // False when an external call system (CallKit) owns session activation: // configurations are applied without activating, and the session is never @@ -916,13 +956,15 @@ class LKAudioEngineObserver: NSObject, RTCAudioDeviceModuleDelegate { func updatePolicy(_ configuration: RTCAudioSessionConfiguration, automaticManagementEnabled: Bool, selectCategoryByEngineState: Bool, - forceSpeakerOutput: Bool) { + forceSpeakerOutput: Bool, + preferSpeakerOutput: Bool) { let cachedConfiguration = copyConfiguration(configuration) lock.lock() self.cachedConfiguration = cachedConfiguration self.isAutomaticManagementEnabled = automaticManagementEnabled self.selectCategoryByEngineState = selectCategoryByEngineState self.forceSpeakerOutput = forceSpeakerOutput + self.preferSpeakerOutput = preferSpeakerOutput lock.unlock() } @@ -972,8 +1014,23 @@ class LKAudioEngineObserver: NSObject, RTCAudioDeviceModuleDelegate { /// category would leave playAndRecord-only mode/options (e.g. videoChat, /// allowBluetooth) that are invalid for the playback category. Mirrors the /// Swift SDK's `.playback` preset (playback + spokenAudio + mixWithOthers). + /// + /// With no pushed config and automatic management on, the built-in + /// recording preset stands in for the Dart policy, so the result is the + /// same as if the default policy had been pushed. Returns `nil` only in + /// manual mode with nothing pushed, where the app owns the session. private func effectiveConfigurationLocked(isRecordingEnabled: Bool) -> RTCAudioSessionConfiguration? { - guard let configuration = cachedConfiguration else { return nil } + let configuration: RTCAudioSessionConfiguration + let selectCategoryByEngineState: Bool + if let cachedConfiguration { + configuration = cachedConfiguration + selectCategoryByEngineState = self.selectCategoryByEngineState + } else if isAutomaticManagementEnabled { + configuration = defaultRecordingConfigurationLocked() + selectCategoryByEngineState = true + } else { + return nil + } guard selectCategoryByEngineState, !isRecordingEnabled else { return configuration } let playback = copyConfiguration(configuration) @@ -983,6 +1040,21 @@ class LKAudioEngineObserver: NSObject, RTCAudioDeviceModuleDelegate { return playback } + /// Built-in playAndRecord preset used until Dart pushes a policy. Must be + /// called with `lock` held. + /// + /// Keep in sync with the automatic-mode branch of + /// `ResolvedAudioSessionPolicy.appleConfiguration` in + /// `lib/src/audio/audio_session_policy.dart`, so a later push of the default + /// policy (on connect) does not change the live session. + private func defaultRecordingConfigurationLocked() -> RTCAudioSessionConfiguration { + let configuration = RTCAudioSessionConfiguration.webRTC() + configuration.category = AVAudioSession.Category.playAndRecord.rawValue + configuration.categoryOptions = [.allowBluetooth, .allowBluetoothA2DP, .allowAirPlay] + configuration.mode = (preferSpeakerOutput ? AVAudioSession.Mode.videoChat : AVAudioSession.Mode.voiceChat).rawValue + return configuration + } + private func copyConfiguration(_ configuration: RTCAudioSessionConfiguration) -> RTCAudioSessionConfiguration { let copy = RTCAudioSessionConfiguration() copy.category = configuration.category diff --git a/test/audio/audio_session_test.dart b/test/audio/audio_session_test.dart index d02b54d58..24303288d 100644 --- a/test/audio/audio_session_test.dart +++ b/test/audio/audio_session_test.dart @@ -17,9 +17,11 @@ import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:livekit_client/src/audio/android_audio_session_adapter.dart'; +import 'package:livekit_client/src/audio/audio_engine_error.dart'; import 'package:livekit_client/src/audio/audio_manager.dart'; import 'package:livekit_client/src/audio/audio_session.dart'; import 'package:livekit_client/src/audio/audio_session_policy.dart'; +import 'package:livekit_client/src/exceptions.dart'; import 'package:livekit_client/src/support/native.dart'; import 'package:livekit_client/src/support/native_audio.dart' as native_audio; import 'package:livekit_client/src/support/webrtc_initialize_options.dart'; @@ -654,6 +656,7 @@ void main() { automatic: true, selectCategoryByEngineState: true, forceSpeakerOutput: true, + preferSpeakerOutput: true, ); expect(result, isTrue); @@ -662,6 +665,25 @@ void main() { calls.single.arguments, containsPair('forceSpeakerOutput', true), ); + expect( + calls.single.arguments, + containsPair('preferSpeakerOutput', true), + ); + }); + + test('passes speaker preference to native so its built-in preset matches the Dart policy', () async { + await Native.configureAudio( + native_audio.NativeAudioConfiguration( + appleAudioCategory: AppleAudioCategory.playAndRecord, + appleAudioMode: AppleAudioMode.voiceChat, + ), + automatic: true, + selectCategoryByEngineState: true, + ); + + expect(calls.single.method, 'configureNativeAudio'); + expect(calls.single.arguments, containsPair('preferSpeakerOutput', false)); + expect(calls.single.arguments, containsPair('forceSpeakerOutput', false)); }); test('returns platform unavailable when audio processing channel is missing', () async { @@ -873,4 +895,34 @@ void main() { ); }); }); + + group('audioEngineExceptionFrom', () { + test('maps missing microphone permission to TrackCreateException', () { + final error = audioEngineExceptionFrom( + PlatformException(code: audioEngineErrorCodeDeviceAccessDenied, message: 'no mic'), + ); + + expect(error, isA()); + expect(error!.message, 'no mic'); + }); + + test('maps audio session failures to AudioSessionException', () { + final invalidCategory = audioEngineExceptionFrom( + PlatformException(code: audioEngineErrorCodeAudioSessionInvalidCategory), + ); + final configureFailed = audioEngineExceptionFrom( + PlatformException(code: audioEngineErrorCodeAudioSessionConfigureFailed, message: ' detail '), + ); + + expect(invalidCategory, isA()); + expect(invalidCategory!.message, 'Audio session category does not support recording'); + expect(configureFailed, isA()); + expect(configureFailed!.message, 'detail'); + }); + + test('leaves other codes to the caller', () { + expect(audioEngineExceptionFrom(PlatformException(code: 'applyFailed')), isNull); + expect(audioEngineExceptionFrom(PlatformException(code: 'setEngineAvailability')), isNull); + }); + }); } From 838ec12a3eedb977814a182aa3081b7af75ef0dd Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Thu, 27 Aug 2026 23:58:25 +0800 Subject: [PATCH 02/14] Match the native preset's speaker default to Dart and tighten the mapping helpers AudioManager prefers speaker output by default, so the built-in preset now defaults to videoChat as well. Otherwise the connect-time push would switch the live session from voiceChat to videoChat. Also simplifies effectiveConfigurationLocked and the Dart error mapping, and shortens the changeset entries. --- .changes/audio-engine-error-mapping | 2 +- .changes/native-audio-session-preset | 2 +- lib/src/audio/audio_engine_error.dart | 15 +++++---------- lib/src/support/native.dart | 2 +- shared_swift/LiveKitPlugin.swift | 26 +++++++++++--------------- test/audio/audio_session_test.dart | 1 + 6 files changed, 20 insertions(+), 28 deletions(-) diff --git a/.changes/audio-engine-error-mapping b/.changes/audio-engine-error-mapping index 9d973077b..56f911842 100644 --- a/.changes/audio-engine-error-mapping +++ b/.changes/audio-engine-error-mapping @@ -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)" +patch type="changed" "Microphone permission and audio session failures now throw TrackCreateException / AudioSessionException instead of AudioProcessingException" diff --git a/.changes/native-audio-session-preset b/.changes/native-audio-session-preset index fb4bb7ab8..5c3677a01 100644 --- a/.changes/native-audio-session-preset +++ b/.changes/native-audio-session-preset @@ -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" +patch type="fixed" "iOS: audio session is configured for recording even when capture starts before connect (pre-connect audio, pre-join mic), fixing audio engine error -9001" diff --git a/lib/src/audio/audio_engine_error.dart b/lib/src/audio/audio_engine_error.dart index 49bddf4c1..aeaad2e80 100644 --- a/lib/src/audio/audio_engine_error.dart +++ b/lib/src/audio/audio_engine_error.dart @@ -33,20 +33,15 @@ const String audioEngineErrorCodeAudioSessionConfigureFailed = 'audioSessionConf /// mapping. @internal LiveKitException? audioEngineExceptionFrom(PlatformException error) { - final message = error.message?.trim(); + final native = error.message?.trim() ?? ''; + String message(String fallback) => native.isEmpty ? fallback : native; switch (error.code) { case audioEngineErrorCodeDeviceAccessDenied: - return TrackCreateException( - message?.isNotEmpty == true ? message! : 'Microphone permission is not granted', - ); + return TrackCreateException(message('Microphone permission is not granted')); case audioEngineErrorCodeAudioSessionInvalidCategory: - return AudioSessionException( - message?.isNotEmpty == true ? message! : 'Audio session category does not support recording', - ); + return AudioSessionException(message('Audio session category does not support recording')); case audioEngineErrorCodeAudioSessionConfigureFailed: - return AudioSessionException( - message?.isNotEmpty == true ? message! : 'Failed to configure the audio session', - ); + return AudioSessionException(message('Failed to configure the audio session')); default: return null; } diff --git a/lib/src/support/native.dart b/lib/src/support/native.dart index 62bcd2c05..b73750cc5 100644 --- a/lib/src/support/native.dart +++ b/lib/src/support/native.dart @@ -50,7 +50,7 @@ class Native { bool automatic = false, bool selectCategoryByEngineState = false, bool forceSpeakerOutput = false, - bool preferSpeakerOutput = false, + bool preferSpeakerOutput = true, }) async { try { final result = await channel.invokeMethod( diff --git a/shared_swift/LiveKitPlugin.swift b/shared_swift/LiveKitPlugin.swift index babb4b6a6..05eb65ea5 100644 --- a/shared_swift/LiveKitPlugin.swift +++ b/shared_swift/LiveKitPlugin.swift @@ -360,7 +360,7 @@ public class LiveKitPlugin: NSObject, FlutterPlugin { let automatic = args["automatic"] as? Bool ?? false let selectCategoryByEngineState = args["selectCategoryByEngineState"] as? Bool ?? false let forceSpeakerOutput = args["forceSpeakerOutput"] as? Bool ?? false - let preferSpeakerOutput = args["preferSpeakerOutput"] as? Bool ?? false + let preferSpeakerOutput = args["preferSpeakerOutput"] as? Bool ?? true audioEngineObserver?.updatePolicy(configuration, automaticManagementEnabled: automatic, selectCategoryByEngineState: selectCategoryByEngineState, @@ -916,8 +916,9 @@ class LKAudioEngineObserver: NSObject, RTCAudioDeviceModuleDelegate { private var selectCategoryByEngineState = false private var forceSpeakerOutput = false // Speaker preference for the built-in recording preset (videoChat routes to - // the speaker, voiceChat to the receiver), same as the Dart-side policy. - private var preferSpeakerOutput = false + // the speaker, voiceChat to the receiver). Defaults to true like the Dart + // AudioManager, so the preset matches the policy Dart pushes on connect. + private var preferSpeakerOutput = true private var isAutomaticManagementEnabled = true // False when an external call system (CallKit) owns session activation: // configurations are applied without activating, and the session is never @@ -1020,18 +1021,13 @@ class LKAudioEngineObserver: NSObject, RTCAudioDeviceModuleDelegate { /// same as if the default policy had been pushed. Returns `nil` only in /// manual mode with nothing pushed, where the app owns the session. private func effectiveConfigurationLocked(isRecordingEnabled: Bool) -> RTCAudioSessionConfiguration? { - let configuration: RTCAudioSessionConfiguration - let selectCategoryByEngineState: Bool - if let cachedConfiguration { - configuration = cachedConfiguration - selectCategoryByEngineState = self.selectCategoryByEngineState - } else if isAutomaticManagementEnabled { - configuration = defaultRecordingConfigurationLocked() - selectCategoryByEngineState = true - } else { - return nil - } - guard selectCategoryByEngineState, !isRecordingEnabled else { return configuration } + let usesDefaultPreset = cachedConfiguration == nil + guard let configuration = cachedConfiguration + ?? (isAutomaticManagementEnabled ? defaultRecordingConfigurationLocked() : nil) + else { return nil } + // The default preset is always resolved by engine state, like the Dart + // automatic-mode push it stands in for. + guard usesDefaultPreset || selectCategoryByEngineState, !isRecordingEnabled else { return configuration } let playback = copyConfiguration(configuration) playback.category = AVAudioSession.Category.playback.rawValue diff --git a/test/audio/audio_session_test.dart b/test/audio/audio_session_test.dart index 24303288d..79be9a5df 100644 --- a/test/audio/audio_session_test.dart +++ b/test/audio/audio_session_test.dart @@ -679,6 +679,7 @@ void main() { ), automatic: true, selectCategoryByEngineState: true, + preferSpeakerOutput: false, ); expect(calls.single.method, 'configureNativeAudio'); From dd031d5b0a4b0fbdb4b4ba60cd87e5b15ada5cc6 Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Mon, 31 Aug 2026 15:59:07 +0800 Subject: [PATCH 03/14] Update pubspec.lock --- pubspec.lock | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index 86f4871ee..b43f6672f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -452,10 +452,10 @@ packages: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.20" material_color_utilities: dependency: transitive description: @@ -468,10 +468,10 @@ packages: dependency: "direct main" description: name: meta - sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9" url: "https://pub.dev" source: hosted - version: "1.18.0" + version: "1.19.0" mime: dependency: transitive description: @@ -753,10 +753,10 @@ packages: dependency: transitive description: name: test_api - sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" url: "https://pub.dev" source: hosted - version: "0.7.11" + version: "0.7.12" tint: dependency: transitive description: @@ -785,10 +785,10 @@ packages: dependency: transitive description: name: vector_math - sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + sha256: f36f9f3be64c6198714492bb455c11056e33e2f85d9a0b676a48301e44fdcf47 url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.4.2" vm_service: dependency: transitive description: From f1a783079f93752075d3abb5d96576daf4677cae Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Mon, 31 Aug 2026 16:05:11 +0800 Subject: [PATCH 04/14] Copy the shared webRTC configuration instead of mutating it in the default preset RTCAudioSessionConfiguration.webRTC() returns the process-wide shared instance. Mutating it rewrote WebRTC's global default config on the first pre-connect engine start, leaked the preset into later partial policy pushes (which merge into that same object) and into flutter_webrtc's own session handling, and let the object escape the observer's lock while the platform thread could mutate it concurrently. Build the preset on a copy. --- shared_swift/LiveKitPlugin.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/shared_swift/LiveKitPlugin.swift b/shared_swift/LiveKitPlugin.swift index 05eb65ea5..bc5baac3f 100644 --- a/shared_swift/LiveKitPlugin.swift +++ b/shared_swift/LiveKitPlugin.swift @@ -1044,7 +1044,10 @@ class LKAudioEngineObserver: NSObject, RTCAudioDeviceModuleDelegate { /// `lib/src/audio/audio_session_policy.dart`, so a later push of the default /// policy (on connect) does not change the live session. private func defaultRecordingConfigurationLocked() -> RTCAudioSessionConfiguration { - let configuration = RTCAudioSessionConfiguration.webRTC() + // `webRTC()` returns the process-wide shared configuration object; + // mutating it would rewrite WebRTC's defaults for every other consumer + // (and the result escapes `lock`), so start from a copy. + let configuration = copyConfiguration(RTCAudioSessionConfiguration.webRTC()) configuration.category = AVAudioSession.Category.playAndRecord.rawValue configuration.categoryOptions = [.allowBluetooth, .allowBluetoothA2DP, .allowAirPlay] configuration.mode = (preferSpeakerOutput ? AVAudioSession.Mode.videoChat : AVAudioSession.Mode.voiceChat).rawValue From 9752bafa848dded7c6182473fa3bfe2b61790b1d Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Mon, 31 Aug 2026 16:06:49 +0800 Subject: [PATCH 05/14] Remove the inert preferSpeakerOutput plumbing The built-in native preset only runs while no policy has been pushed (cachedConfiguration == nil), but the only writer of the native preferSpeakerOutput field was updatePolicy, which sets cachedConfiguration in the same locked section and is never cleared. The preset could therefore only ever observe the compiled-in default, and a non-default preference always arrives as a pushed policy whose mode already carries it. Drop the Dart parameter, channel key, Swift field and the tests asserting them, and hardcode the preset's videoChat mode with a comment explaining why. --- lib/src/audio/audio_manager.dart | 2 -- lib/src/support/native.dart | 4 ---- shared_swift/LiveKitPlugin.swift | 18 +++++++----------- test/audio/audio_session_test.dart | 21 --------------------- 4 files changed, 7 insertions(+), 38 deletions(-) diff --git a/lib/src/audio/audio_manager.dart b/lib/src/audio/audio_manager.dart index 9ab66d2f8..f0390db55 100644 --- a/lib/src/audio/audio_manager.dart +++ b/lib/src/audio/audio_manager.dart @@ -319,7 +319,6 @@ class AudioManager { automatic: true, selectCategoryByEngineState: true, forceSpeakerOutput: policy.forceSpeakerOutput, - preferSpeakerOutput: policy.preferSpeakerOutput, ); } else { // Manual mode: re-apply the fixed Apple config. Non-forced receiver vs @@ -370,7 +369,6 @@ class AudioManager { automatic: _isAutomaticConfigurationEnabled, selectCategoryByEngineState: _isAutomaticConfigurationEnabled, forceSpeakerOutput: policy.forceSpeakerOutput, - preferSpeakerOutput: policy.preferSpeakerOutput, ); } diff --git a/lib/src/support/native.dart b/lib/src/support/native.dart index b73750cc5..ff5f75802 100644 --- a/lib/src/support/native.dart +++ b/lib/src/support/native.dart @@ -50,7 +50,6 @@ class Native { bool automatic = false, bool selectCategoryByEngineState = false, bool forceSpeakerOutput = false, - bool preferSpeakerOutput = true, }) async { try { final result = await channel.invokeMethod( @@ -60,9 +59,6 @@ class Native { 'automatic': automatic, 'selectCategoryByEngineState': selectCategoryByEngineState, 'forceSpeakerOutput': forceSpeakerOutput, - // Lets the native built-in recording preset pick the same mode the - // Dart policy would, for engine starts that happen before any push. - 'preferSpeakerOutput': preferSpeakerOutput, }, ); return result == true; diff --git a/shared_swift/LiveKitPlugin.swift b/shared_swift/LiveKitPlugin.swift index bc5baac3f..5861739c1 100644 --- a/shared_swift/LiveKitPlugin.swift +++ b/shared_swift/LiveKitPlugin.swift @@ -360,12 +360,10 @@ public class LiveKitPlugin: NSObject, FlutterPlugin { let automatic = args["automatic"] as? Bool ?? false let selectCategoryByEngineState = args["selectCategoryByEngineState"] as? Bool ?? false let forceSpeakerOutput = args["forceSpeakerOutput"] as? Bool ?? false - let preferSpeakerOutput = args["preferSpeakerOutput"] as? Bool ?? true audioEngineObserver?.updatePolicy(configuration, automaticManagementEnabled: automatic, selectCategoryByEngineState: selectCategoryByEngineState, - forceSpeakerOutput: forceSpeakerOutput, - preferSpeakerOutput: preferSpeakerOutput) + forceSpeakerOutput: forceSpeakerOutput) let shouldApplyNow = !automatic || (audioEngineObserver?.isSessionActive ?? false) guard shouldApplyNow else { @@ -915,10 +913,6 @@ class LKAudioEngineObserver: NSObject, RTCAudioDeviceModuleDelegate { // override or manual mode, where the config is applied verbatim. private var selectCategoryByEngineState = false private var forceSpeakerOutput = false - // Speaker preference for the built-in recording preset (videoChat routes to - // the speaker, voiceChat to the receiver). Defaults to true like the Dart - // AudioManager, so the preset matches the policy Dart pushes on connect. - private var preferSpeakerOutput = true private var isAutomaticManagementEnabled = true // False when an external call system (CallKit) owns session activation: // configurations are applied without activating, and the session is never @@ -957,15 +951,13 @@ class LKAudioEngineObserver: NSObject, RTCAudioDeviceModuleDelegate { func updatePolicy(_ configuration: RTCAudioSessionConfiguration, automaticManagementEnabled: Bool, selectCategoryByEngineState: Bool, - forceSpeakerOutput: Bool, - preferSpeakerOutput: Bool) { + forceSpeakerOutput: Bool) { let cachedConfiguration = copyConfiguration(configuration) lock.lock() self.cachedConfiguration = cachedConfiguration self.isAutomaticManagementEnabled = automaticManagementEnabled self.selectCategoryByEngineState = selectCategoryByEngineState self.forceSpeakerOutput = forceSpeakerOutput - self.preferSpeakerOutput = preferSpeakerOutput lock.unlock() } @@ -1050,7 +1042,11 @@ class LKAudioEngineObserver: NSObject, RTCAudioDeviceModuleDelegate { let configuration = copyConfiguration(RTCAudioSessionConfiguration.webRTC()) configuration.category = AVAudioSession.Category.playAndRecord.rawValue configuration.categoryOptions = [.allowBluetooth, .allowBluetoothA2DP, .allowAirPlay] - configuration.mode = (preferSpeakerOutput ? AVAudioSession.Mode.videoChat : AVAudioSession.Mode.voiceChat).rawValue + // videoChat routes to the speaker, matching the Dart AudioManager's + // default speaker preference. A non-default preference always arrives + // as a pushed policy (its mode carries the preference), which bypasses + // this preset entirely. + configuration.mode = AVAudioSession.Mode.videoChat.rawValue return configuration } diff --git a/test/audio/audio_session_test.dart b/test/audio/audio_session_test.dart index 79be9a5df..286a69ce3 100644 --- a/test/audio/audio_session_test.dart +++ b/test/audio/audio_session_test.dart @@ -656,7 +656,6 @@ void main() { automatic: true, selectCategoryByEngineState: true, forceSpeakerOutput: true, - preferSpeakerOutput: true, ); expect(result, isTrue); @@ -665,26 +664,6 @@ void main() { calls.single.arguments, containsPair('forceSpeakerOutput', true), ); - expect( - calls.single.arguments, - containsPair('preferSpeakerOutput', true), - ); - }); - - test('passes speaker preference to native so its built-in preset matches the Dart policy', () async { - await Native.configureAudio( - native_audio.NativeAudioConfiguration( - appleAudioCategory: AppleAudioCategory.playAndRecord, - appleAudioMode: AppleAudioMode.voiceChat, - ), - automatic: true, - selectCategoryByEngineState: true, - preferSpeakerOutput: false, - ); - - expect(calls.single.method, 'configureNativeAudio'); - expect(calls.single.arguments, containsPair('preferSpeakerOutput', false)); - expect(calls.single.arguments, containsPair('forceSpeakerOutput', false)); }); test('returns platform unavailable when audio processing channel is missing', () async { From 6d5405c216f83bad31b50a5149560256dac480b9 Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Mon, 31 Aug 2026 16:07:10 +0800 Subject: [PATCH 06/14] Declare the new public exception as a minor addition and fix the stale audio doc AudioSessionException is a new exported public class; repo convention labels additive API with a minor changeset (the dart-apitool CI gate only catches breaking changes, not additions). Also update doc/audio.md, which still promised AudioProcessingException for capture-time setup failures that the start path now surfaces as TrackCreateException / AudioSessionException. --- .changes/audio-session-exception | 1 + doc/audio.md | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 .changes/audio-session-exception diff --git a/.changes/audio-session-exception b/.changes/audio-session-exception new file mode 100644 index 000000000..d90016ece --- /dev/null +++ b/.changes/audio-session-exception @@ -0,0 +1 @@ +minor type="added" "Add AudioSessionException, thrown when the Apple audio session cannot be configured for or does not permit the requested audio operation" diff --git a/doc/audio.md b/doc/audio.md index 47718bfb4..dcb05913c 100644 --- a/doc/audio.md +++ b/doc/audio.md @@ -139,7 +139,7 @@ The session intent decides how the platform treats audio. Capture options decide - Use `AudioProcessingOptions.communication()` when you want all four voice filters on for an existing local audio track. - Use `AudioProcessingOptions.noProcessing()` for local capture where you want minimal processing, such as high quality recording or app-managed audio effects. -Create-time processing is configured through `AudioCaptureOptions`. `LocalAudioTrack.create(...)` stores these options, and LiveKit prepares them when local recording starts, such as during publish or preconnect. If the exposed native platform API reports that capture-time setup failed, the start path throws `AudioProcessingException` before publish creates a server-side publication. +Create-time processing is configured through `AudioCaptureOptions`. `LocalAudioTrack.create(...)` stores these options, and LiveKit prepares them when local recording starts, such as during publish or preconnect. If the exposed native platform API reports that capture-time setup failed, the start path throws before publish creates a server-side publication: `TrackCreateException` when microphone permission is missing, `AudioSessionException` when the audio session cannot be configured for or does not permit recording, and `AudioProcessingException` for other capture-time setup failures. ```dart final track = await LocalAudioTrack.create( From 57a47aa5978d7589e1b950997c14621055491ed4 Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Mon, 31 Aug 2026 16:07:21 +0800 Subject: [PATCH 07/14] Mention the playout-only session change in the changeset The built-in preset also fires for playout-only engine starts before any policy is pushed (applying a playback session where the observer was previously a no-op). The changeset only advertised the recording case, so integrators could not anticipate the playout-side behavior change. --- .changes/native-audio-session-preset | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changes/native-audio-session-preset b/.changes/native-audio-session-preset index 5c3677a01..42688c063 100644 --- a/.changes/native-audio-session-preset +++ b/.changes/native-audio-session-preset @@ -1 +1 @@ -patch type="fixed" "iOS: audio session is configured for recording even when capture starts before connect (pre-connect audio, pre-join mic), fixing audio engine error -9001" +patch type="fixed" "iOS: in automatic mode the audio session is now configured from engine state even before any policy is pushed — capture that starts before connect (pre-connect audio, pre-join mic) gets a recording session, fixing audio engine error -9001, and playout-only starts get a playback session" From e62d9f555f5845363d3a09efa89db5a52bdbe816 Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Mon, 31 Aug 2026 16:08:03 +0800 Subject: [PATCH 08/14] Treat the built-in preset as best-effort instead of failing the engine start With nothing pushed, willEnableEngine previously always proceeded (the observer was a no-op). Applying the new default preset made it able to return -4100 and roll the engine start back when the session reconfigure was rejected, even where the app's own already-valid session would have sufficed. Tolerate apply failures for the preset (log and proceed; the ADM's own pre-enable checks still gate recording on an unsuitable category), and keep failing hard only for a policy the app actually pushed. --- shared_swift/LiveKitPlugin.swift | 37 ++++++++++++++++++++++---------- 1 file changed, 26 insertions(+), 11 deletions(-) diff --git a/shared_swift/LiveKitPlugin.swift b/shared_swift/LiveKitPlugin.swift index 5861739c1..1e2164b11 100644 --- a/shared_swift/LiveKitPlugin.swift +++ b/shared_swift/LiveKitPlugin.swift @@ -966,12 +966,12 @@ class LKAudioEngineObserver: NSObject, RTCAudioDeviceModuleDelegate { /// manual mode and for re-applying while the engine is already running. func applyCachedConfiguration() -> Error? { lock.lock() - let configuration = effectiveConfigurationLocked(isRecordingEnabled: lastIsRecordingEnabled) + let resolved = effectiveConfigurationLocked(isRecordingEnabled: lastIsRecordingEnabled) let forceSpeakerOutput = self.forceSpeakerOutput let isActive = isSessionActivationEnabled lock.unlock() - guard let configuration else { return nil } - return LiveKitPlugin.applyAudioSessionConfiguration(configuration, + guard let resolved else { return nil } + return LiveKitPlugin.applyAudioSessionConfiguration(resolved.configuration, forceSpeakerOutput: forceSpeakerOutput, isActive: isActive) } @@ -979,15 +979,26 @@ class LKAudioEngineObserver: NSObject, RTCAudioDeviceModuleDelegate { private func applyManagedConfiguration(isRecordingEnabled: Bool) -> Error? { lock.lock() let shouldManageSession = isAutomaticManagementEnabled - let configuration = effectiveConfigurationLocked(isRecordingEnabled: isRecordingEnabled) + let resolved = effectiveConfigurationLocked(isRecordingEnabled: isRecordingEnabled) let forceSpeakerOutput = self.forceSpeakerOutput let isActive = isSessionActivationEnabled lock.unlock() - guard shouldManageSession, let configuration else { return nil } - return LiveKitPlugin.applyAudioSessionConfiguration(configuration, - forceSpeakerOutput: forceSpeakerOutput, - isActive: isActive) + guard shouldManageSession, let resolved else { return nil } + guard let error = LiveKitPlugin.applyAudioSessionConfiguration(resolved.configuration, + forceSpeakerOutput: forceSpeakerOutput, + isActive: isActive) + else { return nil } + // The built-in preset is best-effort: before it existed, engine starts + // with nothing pushed proceeded against whatever session the app had, + // and the ADM's own pre-enable checks still gate recording on an + // unsuitable category. Only a policy the app actually pushed aborts + // the engine operation when it cannot be applied. + if resolved.isDefaultPreset { + print("[LiveKit] AudioEngine: default audio session preset not applied, continuing: \(error)") + return nil + } + return error } private func recordEngineState(isPlayoutEnabled: Bool, isRecordingEnabled: Bool) { @@ -1012,20 +1023,24 @@ class LKAudioEngineObserver: NSObject, RTCAudioDeviceModuleDelegate { /// recording preset stands in for the Dart policy, so the result is the /// same as if the default policy had been pushed. Returns `nil` only in /// manual mode with nothing pushed, where the app owns the session. - private func effectiveConfigurationLocked(isRecordingEnabled: Bool) -> RTCAudioSessionConfiguration? { + private func effectiveConfigurationLocked(isRecordingEnabled: Bool) + -> (configuration: RTCAudioSessionConfiguration, isDefaultPreset: Bool)? + { let usesDefaultPreset = cachedConfiguration == nil guard let configuration = cachedConfiguration ?? (isAutomaticManagementEnabled ? defaultRecordingConfigurationLocked() : nil) else { return nil } // The default preset is always resolved by engine state, like the Dart // automatic-mode push it stands in for. - guard usesDefaultPreset || selectCategoryByEngineState, !isRecordingEnabled else { return configuration } + guard usesDefaultPreset || selectCategoryByEngineState, !isRecordingEnabled else { + return (configuration, usesDefaultPreset) + } let playback = copyConfiguration(configuration) playback.category = AVAudioSession.Category.playback.rawValue playback.categoryOptions = [.mixWithOthers] playback.mode = AVAudioSession.Mode.spokenAudio.rawValue - return playback + return (playback, usesDefaultPreset) } /// Built-in playAndRecord preset used until Dart pushes a policy. Must be From 08330348ce50e039630d532e47f7a100f1131bb2 Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Mon, 31 Aug 2026 16:09:03 +0800 Subject: [PATCH 09/14] Keep the audio engine observer's state across plugin registrations register(with:) built a fresh LKAudioEngineObserver every time, so a second Flutter engine registering in the same process (add-to-app, FlutterEngineGroup) silently reset the pushed policy, management mode and activation flag to their defaults. Before the built-in preset that reset was a silent no-op; now it would make the next engine lifecycle event apply the preset and activate a session the app may own. Share one observer process-wide (matching the process-wide session it manages) and rebind only the notification channel per registration, guarding channel access with the existing lock since it is now mutable. --- shared_swift/LiveKitPlugin.swift | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/shared_swift/LiveKitPlugin.swift b/shared_swift/LiveKitPlugin.swift index 1e2164b11..30b586b1d 100644 --- a/shared_swift/LiveKitPlugin.swift +++ b/shared_swift/LiveKitPlugin.swift @@ -57,6 +57,9 @@ public class LiveKitPlugin: NSObject, FlutterPlugin { // the audio device module both hold it weakly, so LiveKit must keep it alive. var channel: FlutterMethodChannel? var audioEngineObserver: LKAudioEngineObserver? + // Process-wide engine observer, kept across plugin registrations so a + // second Flutter engine does not wipe the pushed policy / management mode. + private static var sharedAudioEngineObserver: LKAudioEngineObserver? #if os(iOS) var cancellable = Set() @@ -84,8 +87,16 @@ public class LiveKitPlugin: NSObject, FlutterPlugin { // engine emits these events on both iOS and macOS. macOS has no // AVAudioSession to configure, so there it only surfaces engine state. // Set before the peer connection factory is created. + // + // The observer is shared across plugin registrations: its state (the + // pushed policy and management mode) describes the process-wide audio + // session, so a later registration (e.g. a second Flutter engine in + // the same process) must not reset it. Only the notification channel + // is rebound to the latest registration. instance.channel = channel - let audioEngineObserver = LKAudioEngineObserver(channel: channel) + let audioEngineObserver = sharedAudioEngineObserver ?? LKAudioEngineObserver(channel: channel) + audioEngineObserver.updateChannel(channel) + sharedAudioEngineObserver = audioEngineObserver instance.audioEngineObserver = audioEngineObserver FlutterWebRTCPlugin.setAudioDeviceModuleObserver(audioEngineObserver) @@ -933,6 +944,14 @@ class LKAudioEngineObserver: NSObject, RTCAudioDeviceModuleDelegate { super.init() } + /// Rebinds Dart notifications to the given channel. Called on every plugin + /// registration, since the observer itself outlives registrations. + func updateChannel(_ channel: FlutterMethodChannel) { + lock.lock() + self.channel = channel + lock.unlock() + } + #if !os(macOS) var isSessionActive: Bool { lock.lock(); defer { lock.unlock() } @@ -1153,7 +1172,10 @@ class LKAudioEngineObserver: NSObject, RTCAudioDeviceModuleDelegate { } private func notifyEngineState(isPlayoutEnabled: Bool, isRecordingEnabled: Bool) { - guard let channel = channel else { return } + lock.lock() + let channel = channel + lock.unlock() + guard let channel else { return } DispatchQueue.main.async { channel.invokeMethod("onAudioEngineState", arguments: [ "isPlayoutEnabled": isPlayoutEnabled, From 7972d4f09683097e85503c117e0571e559d35227 Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Mon, 31 Aug 2026 16:09:52 +0800 Subject: [PATCH 10/14] Map audio engine errors consistently on the mute-mode and stop paths handleSetMicrophoneMuteMode and handleStopLocalRecording still hand-built generic FlutterErrors, so the same ADM failure surfaced differently depending on entry point: a mute-mode change can rebuild the engine and hit the same permission / audio session pre-enable checks (-9000/-9001) as a recording start. Route both sites through flutterError(forAudioEngineResult:), and map the codes in AudioManager.setMicrophoneMuteMode like setEngineAvailability does. --- lib/src/audio/audio_manager.dart | 11 +++++++++-- shared_swift/LiveKitPlugin.swift | 16 ++++++---------- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/lib/src/audio/audio_manager.dart b/lib/src/audio/audio_manager.dart index f0390db55..fd0c9f234 100644 --- a/lib/src/audio/audio_manager.dart +++ b/lib/src/audio/audio_manager.dart @@ -424,13 +424,20 @@ class AudioManager { /// Bluetooth headsets). /// /// Throws if the native side rejects the change, so callers never assume - /// a muting behavior that is not actually in effect. + /// a muting behavior that is not actually in effect. A mode change can + /// rebuild the audio engine, so like [setEngineAvailability] it throws a + /// [TrackCreateException] when microphone permission is missing and an + /// [AudioSessionException] when the audio session does not permit recording. /// /// This is engine-wide state. Prefer setting it once before connecting. Future setMicrophoneMuteMode(MicrophoneMuteMode mode) async { if (mode == MicrophoneMuteMode.unknown) return; if (!lkPlatformIsApple()) return; - await Native.setMicrophoneMuteMode(mode.name); + try { + await Native.setMicrophoneMuteMode(mode.name); + } on PlatformException catch (error) { + throw audioEngineExceptionFrom(error) ?? error; + } } /// Diagnostic snapshot of the resolved audio processing state. diff --git a/shared_swift/LiveKitPlugin.swift b/shared_swift/LiveKitPlugin.swift index 30b586b1d..cc09b4d78 100644 --- a/shared_swift/LiveKitPlugin.swift +++ b/shared_swift/LiveKitPlugin.swift @@ -583,11 +583,10 @@ public class LiveKitPlugin: NSObject, FlutterPlugin { if admResult == 0 { result(nil) } else { - result(FlutterError( - code: "setMicrophoneMuteMode", - message: "Audio engine returned error code: \(admResult)", - details: nil - )) + // A mute-mode change can rebuild the engine and hit the same + // permission / audio session checks as a recording start. + result(LiveKitPlugin.flutterError(forAudioEngineResult: admResult, + fallbackCode: "setMicrophoneMuteMode")) } } } @@ -635,11 +634,8 @@ public class LiveKitPlugin: NSObject, FlutterPlugin { if admResult == 0 { result(nil) } else { - result(FlutterError( - code: "stopLocalRecording", - message: "Audio engine returned error code: \(admResult)", - details: nil - )) + result(LiveKitPlugin.flutterError(forAudioEngineResult: admResult, + fallbackCode: "stopLocalRecording")) } } } From d7d84e66c28793b2325b5eca234447fcbbed584b Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Mon, 31 Aug 2026 16:11:27 +0800 Subject: [PATCH 11/14] Push the resolved Dart policy to native before a local recording starts The native built-in preset duplicates the Dart default policy with only a keep-in-sync comment tying the two, so any change to the Dart automatic branch would silently diverge for pre-connect capture. Have LocalAudioTrack.startCapture push the resolved policy first (cache-only while the engine is idle in automatic mode), so Flutter-driven starts always use the real Dart policy and the native preset is reduced to a fallback for engine starts before the Flutter side exists (CallKit killed-state wake). --- lib/src/audio/audio_manager.dart | 18 ++++++++++++++++++ lib/src/track/local/audio.dart | 6 ++++++ 2 files changed, 24 insertions(+) diff --git a/lib/src/audio/audio_manager.dart b/lib/src/audio/audio_manager.dart index fd0c9f234..ad07917b0 100644 --- a/lib/src/audio/audio_manager.dart +++ b/lib/src/audio/audio_manager.dart @@ -347,6 +347,24 @@ class AudioManager { } } + /// Pushes the current resolved policy to native before a local recording + /// starts (pre-connect audio, pre-join microphone), so the session comes + /// from the real Dart policy instead of the native built-in preset. The + /// preset remains as a fallback for engine starts that happen before the + /// Flutter side exists (e.g. a CallKit killed-state wake). + /// + /// In automatic mode with the engine idle this only caches the policy + /// natively; it is applied on engine start. iOS only: manual mode (app owns + /// the session) and other platforms are untouched. + @internal + Future ensureAppleAudioSessionPolicy() async { + if (!lkPlatformIs(PlatformType.iOS)) return; + await _syncAppleAudioSessionManagementMode(); + if (_isAutomaticConfigurationEnabled) { + await _configureAppleAudioSession(_options); + } + } + Future _syncAppleAudioSessionManagementMode() async { if (lkPlatformIs(PlatformType.iOS)) { await Native.setAppleAudioSessionAutomaticManagementEnabled( diff --git a/lib/src/track/local/audio.dart b/lib/src/track/local/audio.dart index 051eb00f2..c92af74ae 100644 --- a/lib/src/track/local/audio.dart +++ b/lib/src/track/local/audio.dart @@ -21,6 +21,7 @@ import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc; import 'package:meta/meta.dart'; import '../../audio/audio_engine_error.dart'; +import '../../audio/audio_manager.dart'; import '../../events.dart'; import '../../internal/events.dart'; import '../../logger.dart'; @@ -87,6 +88,11 @@ class LocalAudioTrack extends LocalTrack with AudioTrack, LocalAudioManagementMi Future startCapture() async { await super.startCapture(); if (lkPlatformSupportsExplicitAudioRecordingStart()) { + // Recording can start before any policy push (pre-connect audio, + // pre-join mic). Hand native the resolved Dart policy first — cache-only + // while the engine is idle — so the session is not left to the native + // built-in preset. + await AudioManager.instance.ensureAppleAudioSessionPolicy(); try { // Match Swift: start the ADM before publishing so capture-time audio // processing options are applied before WebRTC opens the microphone. From 48737c026815c8f5e3e136015eaf57d57e60d3c5 Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Mon, 31 Aug 2026 16:12:15 +0800 Subject: [PATCH 12/14] Scope AudioSessionException docs to iOS The codes that map to AudioSessionException cannot occur on macOS: the invalid-category check (-9001) is compiled out under !TARGET_OS_OSX in the audio device module, and the configure failure (-4100) is only produced inside !os(macOS) blocks. Saying 'Apple platforms' over-promised for macOS integrators, where only the permission mapping (-9000) is live. --- lib/src/audio/audio_manager.dart | 5 +++-- lib/src/exceptions.dart | 3 ++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/lib/src/audio/audio_manager.dart b/lib/src/audio/audio_manager.dart index ad07917b0..67e50dff1 100644 --- a/lib/src/audio/audio_manager.dart +++ b/lib/src/audio/audio_manager.dart @@ -220,7 +220,7 @@ class AudioManager { /// the engine is gated when it is not. Enabling input requires microphone /// permission, which is not requested here: a [TrackCreateException] is /// thrown when it is missing, and an [AudioSessionException] when the audio - /// session does not permit recording. + /// session does not permit recording (iOS only; macOS has no audio session). /// /// Experimental: this API may change in a future release. @experimental @@ -445,7 +445,8 @@ class AudioManager { /// a muting behavior that is not actually in effect. A mode change can /// rebuild the audio engine, so like [setEngineAvailability] it throws a /// [TrackCreateException] when microphone permission is missing and an - /// [AudioSessionException] when the audio session does not permit recording. + /// [AudioSessionException] when the audio session does not permit recording + /// (iOS only; macOS has no audio session). /// /// This is engine-wide state. Prefer setting it once before connecting. Future setMicrophoneMuteMode(MicrophoneMuteMode mode) async { diff --git a/lib/src/exceptions.dart b/lib/src/exceptions.dart index 8e30b4030..db437810d 100644 --- a/lib/src/exceptions.dart +++ b/lib/src/exceptions.dart @@ -83,7 +83,8 @@ class TrackCreateException extends LiveKitException { } /// The platform audio session could not be configured for, or does not permit, -/// the requested audio operation (Apple platforms). +/// the requested audio operation (iOS; macOS has no audio session, so engine +/// failures there keep their generic error codes). /// Common reasons: /// - Recording was started while the app-managed audio session /// (`AudioSessionManagementMode.manual`) has a category without input. From 3f3565d957f14922796fdb740557e6cc2b9faf44 Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Mon, 31 Aug 2026 16:13:00 +0800 Subject: [PATCH 13/14] Shorten the changeset entries --- .changes/audio-session-exception | 2 +- .changes/native-audio-session-preset | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.changes/audio-session-exception b/.changes/audio-session-exception index d90016ece..599a23109 100644 --- a/.changes/audio-session-exception +++ b/.changes/audio-session-exception @@ -1 +1 @@ -minor type="added" "Add AudioSessionException, thrown when the Apple audio session cannot be configured for or does not permit the requested audio operation" +minor type="added" "Add AudioSessionException for iOS audio session failures" diff --git a/.changes/native-audio-session-preset b/.changes/native-audio-session-preset index 42688c063..5b6e51de8 100644 --- a/.changes/native-audio-session-preset +++ b/.changes/native-audio-session-preset @@ -1 +1 @@ -patch type="fixed" "iOS: in automatic mode the audio session is now configured from engine state even before any policy is pushed — capture that starts before connect (pre-connect audio, pre-join mic) gets a recording session, fixing audio engine error -9001, and playout-only starts get a playback session" +patch type="fixed" "iOS: the audio session is configured from engine state even before a policy is pushed (pre-connect audio, pre-join mic, playout-only), fixing audio engine error -9001" From fc60897ded29e752d7b4d6582e3b31f1e7d20e8c Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Tue, 1 Sep 2026 14:07:46 +0800 Subject: [PATCH 14/14] Keep the AudioSessionException changeset at patch --- .changes/audio-session-exception | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changes/audio-session-exception b/.changes/audio-session-exception index 599a23109..a6d8645fe 100644 --- a/.changes/audio-session-exception +++ b/.changes/audio-session-exception @@ -1 +1 @@ -minor type="added" "Add AudioSessionException for iOS audio session failures" +patch type="added" "Add AudioSessionException for iOS audio session failures"