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);