Skip to content
Draft
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
32 changes: 32 additions & 0 deletions lib/src/audio/audio_manager.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<AudioEngineState> _audioEngineStateController = StreamController<AudioEngineState>.broadcast();

AudioSessionOptions get options => _options;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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<void> prepareRecording() async {
if (!lkPlatformIs(PlatformType.iOS)) return;
if (_hasPushedAppleSessionPolicy) return;
await _syncAppleAudioSessionManagementMode();
if (!_isAutomaticConfigurationEnabled) return;
await _configureAppleAudioSession(_options);
}

@internal
Future<void> applyOptionsForConnect() async {
await _syncAppleAudioSessionManagementMode();
Expand Down Expand Up @@ -360,6 +391,7 @@ class AudioManager {
selectCategoryByEngineState: _isAutomaticConfigurationEnabled,
forceSpeakerOutput: policy.forceSpeakerOutput,
);
_hasPushedAppleSessionPolicy = true;
}

Future<void> _configureAndroidAudioSession(AudioSessionOptions options) async {
Expand Down
6 changes: 6 additions & 0 deletions lib/src/track/audio_management.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> prepareRecording() async {
await AudioManager.instance.prepareRecording();
}

static Future<void> stop() async {
// Release mirrors acquire: applyOptionsForConnect starts the Android
// audio session in every mode except manual, including externalCallSystem,
Expand Down
4 changes: 4 additions & 0 deletions lib/src/track/local/audio.dart
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,10 @@ class LocalAudioTrack extends LocalTrack with AudioTrack, LocalAudioManagementMi
Future<void> 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.
Expand Down
6 changes: 6 additions & 0 deletions test/audio/audio_session_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
Loading