From 0181b5c467191d48204a0fb49b78ba1838390cc1 Mon Sep 17 00:00:00 2001 From: Max Heimbrock <43608204+MaxHeimbrock@users.noreply.github.com> Date: Wed, 26 Aug 2026 14:07:21 +0200 Subject: [PATCH] Configure the audio session before recording starts, not only on connect On iOS the audio engine refuses to open the microphone unless the audio session already permits recording, and livekit_client owns that session: it disables flutter_webrtc's own session management at plugin registration. The policy was only pushed to native from Room.connect, so any recording that starts earlier ran against the app-default soloAmbient category and was rejected with kAudioEngineErrorAudioSessionInvalidCategory (-9001), surfacing as `AudioProcessingException(applyFailed): Audio engine returned error code: -9001`. That is the default path for agent sessions, since SessionOptions enables pre-connect audio, which buffers the microphone before connect and therefore failed on every attempt. A pre-join microphone preview hits the same wall without pre-connect audio involved (#1165). Push the policy from the capture path instead, where the microphone actually opens. AudioManager.prepareRecording() is a no-op once a policy has been pushed, so it never re-applies a configuration to a live session, and in manual mode, where the app owns the session. Fixes #1165 Co-Authored-By: Claude Opus 5 (1M context) --- lib/src/audio/audio_manager.dart | 32 +++++++++++++++++++++++++++++ lib/src/track/audio_management.dart | 6 ++++++ lib/src/track/local/audio.dart | 4 ++++ test/audio/audio_session_test.dart | 6 ++++++ 4 files changed, 48 insertions(+) diff --git a/lib/src/audio/audio_manager.dart b/lib/src/audio/audio_manager.dart index be25e83e2..7b85aa62f 100644 --- a/lib/src/audio/audio_manager.dart +++ b/lib/src/audio/audio_manager.dart @@ -81,6 +81,11 @@ class AudioManager { bool _forceSpeakerOutput = false; bool _isPlayoutEnabled = false; bool _isRecordingEnabled = false; + // Whether an Apple audio session policy has been pushed to native at least + // once. Native caches the last policy it was given and has no category to + // apply until something has been pushed, which is what [prepareRecording] + // checks before pushing one itself. + bool _hasPushedAppleSessionPolicy = false; final StreamController _audioEngineStateController = StreamController.broadcast(); AudioSessionOptions get options => _options; @@ -124,6 +129,7 @@ class AudioManager { _forceSpeakerOutput = false; _isPlayoutEnabled = false; _isRecordingEnabled = false; + _hasPushedAppleSessionPolicy = false; } /// Invoked from native when the WebRTC audio engine's playout/recording state @@ -310,6 +316,7 @@ class AudioManager { selectCategoryByEngineState: true, forceSpeakerOutput: policy.forceSpeakerOutput, ); + _hasPushedAppleSessionPolicy = true; } else { // Manual mode: re-apply the fixed Apple config. Non-forced receiver vs // speaker behavior comes from that config. Force is carried separately @@ -329,6 +336,30 @@ class AudioManager { } } + /// Prepares the platform audio session for recording. + /// + /// On iOS the audio engine refuses to open the microphone unless the audio + /// session already permits recording, and LiveKit owns that session. The + /// policy is normally pushed on connect, but recording can start earlier — + /// pre-connect audio buffering, or a pre-join microphone preview — so this is + /// called for every local audio track before its capture starts. + /// + /// Does nothing once a policy has been pushed, so it never re-applies a + /// configuration to a live session, and nothing in + /// [AudioSessionManagementMode.manual], where the app owns the session and is + /// responsible for a recording-capable category. iOS only; a no-op elsewhere, + /// so it is always safe to call from cross-platform code. + /// + /// Experimental: this API may change in a future release. + @experimental + Future prepareRecording() async { + if (!lkPlatformIs(PlatformType.iOS)) return; + if (_hasPushedAppleSessionPolicy) return; + await _syncAppleAudioSessionManagementMode(); + if (!_isAutomaticConfigurationEnabled) return; + await _configureAppleAudioSession(_options); + } + @internal Future applyOptionsForConnect() async { await _syncAppleAudioSessionManagementMode(); @@ -360,6 +391,7 @@ class AudioManager { selectCategoryByEngineState: _isAutomaticConfigurationEnabled, forceSpeakerOutput: policy.forceSpeakerOutput, ); + _hasPushedAppleSessionPolicy = true; } Future _configureAndroidAudioSession(AudioSessionOptions options) async { diff --git a/lib/src/track/audio_management.dart b/lib/src/track/audio_management.dart index 3ea407b87..f3c8c3bfc 100644 --- a/lib/src/track/audio_management.dart +++ b/lib/src/track/audio_management.dart @@ -30,6 +30,12 @@ class NativeAudioManagement { await AudioManager.instance.applyOptionsForConnect(); } + /// Makes sure the platform audio session permits recording before a local + /// audio track opens the microphone, which can happen before connect. + static Future prepareRecording() async { + await AudioManager.instance.prepareRecording(); + } + static Future stop() async { // Release mirrors acquire: applyOptionsForConnect starts the Android // audio session in every mode except manual, including externalCallSystem, diff --git a/lib/src/track/local/audio.dart b/lib/src/track/local/audio.dart index 47b21e910..50d281c8e 100644 --- a/lib/src/track/local/audio.dart +++ b/lib/src/track/local/audio.dart @@ -86,6 +86,10 @@ class LocalAudioTrack extends LocalTrack with AudioTrack, LocalAudioManagementMi Future startCapture() async { await super.startCapture(); if (lkPlatformSupportsExplicitAudioRecordingStart()) { + // The audio engine rejects recording while the audio session does not + // permit it, and capture can start before connect has pushed the session + // policy (pre-connect audio, a pre-join preview). No-op once pushed. + await NativeAudioManagement.prepareRecording(); try { // Match Swift: start the ADM before publishing so capture-time audio // processing options are applied before WebRTC opens the microphone. diff --git a/test/audio/audio_session_test.dart b/test/audio/audio_session_test.dart index d02b54d58..6b462ec61 100644 --- a/test/audio/audio_session_test.dart +++ b/test/audio/audio_session_test.dart @@ -603,6 +603,12 @@ void main() { expect(calls.single.arguments, {'enabled': true, 'sessionActivationEnabled': false}); }); + test('prepareRecording leaves the platform alone off iOS', () async { + await AudioManager.instance.prepareRecording(); + + expect(calls, isEmpty); + }); + test('passes engine availability to platform method', () async { await Native.setEngineAvailability(isInputAvailable: false, isOutputAvailable: true);