Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .changes/audio-engine-error-mapping
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
patch type="changed" "Microphone permission and audio session failures now throw TrackCreateException / AudioSessionException instead of AudioProcessingException"
1 change: 1 addition & 0 deletions .changes/audio-session-exception
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
patch type="added" "Add AudioSessionException for iOS audio session failures"
1 change: 1 addition & 0 deletions .changes/native-audio-session-preset
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
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"
2 changes: 1 addition & 1 deletion doc/audio.md
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
48 changes: 48 additions & 0 deletions lib/src/audio/audio_engine_error.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
// 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 native = error.message?.trim() ?? '';
String message(String fallback) => native.isEmpty ? fallback : native;
switch (error.code) {
case audioEngineErrorCodeDeviceAccessDenied:
return TrackCreateException(message('Microphone permission is not granted'));
case audioEngineErrorCodeAudioSessionInvalidCategory:
return AudioSessionException(message('Audio session category does not support recording'));
case audioEngineErrorCodeAudioSessionConfigureFailed:
return AudioSessionException(message('Failed to configure the audio session'));
default:
return null;
}
}
50 changes: 43 additions & 7 deletions lib/src/audio/audio_manager.dart
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,16 @@

import 'dart:async';

import 'package:flutter/services.dart' show PlatformException;

import 'package:meta/meta.dart';

import '../logger.dart';
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';
Expand Down Expand Up @@ -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 (iOS only; macOS has no audio session).
///
/// Experimental: this API may change in a future release.
@experimental
Future<void> 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.
Expand Down Expand Up @@ -337,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<void> ensureAppleAudioSessionPolicy() async {
if (!lkPlatformIs(PlatformType.iOS)) return;
await _syncAppleAudioSessionManagementMode();
if (_isAutomaticConfigurationEnabled) {
await _configureAppleAudioSession(_options);
}
}

Future<void> _syncAppleAudioSessionManagementMode() async {
if (lkPlatformIs(PlatformType.iOS)) {
await Native.setAppleAudioSessionAutomaticManagementEnabled(
Expand Down Expand Up @@ -414,13 +442,21 @@ 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
/// (iOS only; macOS has no audio session).
///
/// This is engine-wide state. Prefer setting it once before connecting.
Future<void> 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.
Expand Down
11 changes: 11 additions & 0 deletions lib/src/exceptions.dart
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,17 @@ 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 (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.
/// - 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.
Expand Down
19 changes: 15 additions & 4 deletions lib/src/track/local/audio.dart
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ 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 '../../audio/audio_manager.dart';
import '../../events.dart';
import '../../internal/events.dart';
import '../../logger.dart';
Expand Down Expand Up @@ -86,15 +88,24 @@ class LocalAudioTrack extends LocalTrack with AudioTrack, LocalAudioManagementMi
Future<void> 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.
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 ?? '',
);
}
}
}
Expand Down
16 changes: 8 additions & 8 deletions pubspec.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
Loading
Loading