Skip to content

Resolve a default audio session from engine state when no policy was pushed - #1182

Merged
hiroshihorie merged 14 commits into
mainfrom
hiroshi/native-audio-session-preset
Sep 1, 2026
Merged

Resolve a default audio session from engine state when no policy was pushed#1182
hiroshihorie merged 14 commits into
mainfrom
hiroshi/native-audio-session-preset

Conversation

@hiroshihorie

@hiroshihorie hiroshihorie commented Aug 27, 2026

Copy link
Copy Markdown
Member

Problem

On iOS the WebRTC audio engine refuses to enable recording unless the audio session category permits input. Its pre-enable check returns kAudioEngineErrorAudioSessionInvalidCategory (-9001), which the SDK reported as AudioProcessingException(applyFailed): Audio engine returned error code: -9001.

livekit_client owns the iOS audio session since #1108 (LiveKitPlugin.swift disables flutter_webrtc's session management at registration). The native engine observer (LKAudioEngineObserver.willEnableEngine) is the right hook and runs before the engine's check, but it only applied a configuration that Dart had pushed. In automatic mode the only push site was Room.connect, so anything that started recording earlier met an empty cache, the observer returned "proceed" with the session still soloAmbient, and the engine rolled back:

  • Room.withPreConnectAudio, which SessionOptions.preConnectAudio enables by default, so the Flutter agent starter failed on every "Start call"
  • a pre-join microphone preview ([bug] Audio engine returned error code #1165)
  • an engine start driven from native before the Flutter side exists, for example the plugin's static setEngineAvailability on a CallKit killed-state wake

Because the preconnect throw happens before connect, the cache was never seeded and the failure repeated on every attempt.

How the Swift SDK handles this

AudioSessionEngineObserver.engineWillEnable derives the session configuration from the requested engine state alone (playAndRecord presets while recording, playback for playout only), synchronously inside the engine's enable call. Nothing is configured "before connect". The engine asks, the observer configures, the engine starts. The Flutter plugin already has the same observer in the same place, it just had no built-in policy.

Fix

LKAudioEngineObserver.effectiveConfigurationLocked now resolves a built-in playAndRecord preset (allowBluetooth | allowBluetoothA2DP | allowAirPlay, videoChat) whenever nothing has been pushed and automatic management is on. The existing playout-only playback branch applies to it as well. Manual mode still leaves the session alone. The Dart-pushed policy becomes an override rather than a prerequisite, and for the default AudioSessionOptions.communication it pushes the same values, so the connect-time push does not change the live session.

The preset is built on a copy of the shared RTCAudioSessionConfiguration.webRTC() object, and it is best-effort: if applying it fails, the engine start proceeds and the ADM's own pre-enable checks still gate recording, so apps whose own session was already valid are not newly rolled back with -4100. Only a policy the app actually pushed keeps failing the engine start hard. The mode is fixed to videoChat because it matches the Dart default speaker preference, and a non-default preference always arrives as a pushed policy whose mode already carries it, so the preset can never observe anything else.

LocalAudioTrack.startCapture additionally pushes the resolved Dart policy to native before recording starts (cache-only while the engine is idle in automatic mode). Flutter-driven starts therefore always use the real Dart policy, and the built-in preset only stands in for engine starts that happen before the Flutter side exists, for example the plugin's static setEngineAvailability on a CallKit killed-state wake.

The engine observer is also shared across plugin registrations now: a second Flutter engine registering in the same process (add-to-app, FlutterEngineGroup) previously reset the pushed policy and management mode silently, which the preset would have turned into an unwanted session activation. Only the notification channel is rebound per registration.

Error mapping

Audio device module results now get their own error codes on the startLocalRecording, setEngineAvailability, setMicrophoneMuteMode and stopLocalRecording channels, mirroring client-sdk-swift's checkAdmResult. A mute-mode change can rebuild the engine and hit the same pre-enable checks as a recording start, so all entry points surface the same failure the same way:

ADM result Native error code Dart exception
-9000 InsufficientDevicePermission deviceAccessDenied TrackCreateException
-9001 AudioSessionInvalidCategory audioSessionInvalidCategory AudioSessionException (new)
-4100 FailedToConfigureAudioSession audioSessionConfigureFailed AudioSessionException (new)
anything else caller fallback (applyFailed, setEngineAvailability, ...) unchanged

AudioSessionException is a new public class with its own changeset entry.

Relation to #1179

@MaxHeimbrock's #1179 diagnosed this first and fixes it by pushing the policy from LocalAudioTrack.startCapture (prepareRecording()). This PR ends up covering both layers: startCapture pushes the resolved policy like #1179 does (without a Dart-side flag tracking native cache state), and the native observer is additionally self-sufficient like Swift's, so engine enables that never pass through Dart are covered too. With this merged, prepareRecording() becomes redundant.

Testing

  • Flutter agent starter on an iPhone 17 Pro (iOS 27.0): unpatched, Start call failed with -9001 on every attempt. Patched, startCapture() succeeds two seconds before the connect-time policy push, the preconnect buffer is sent to the agent, and the call connects.
  • flutter analyze, flutter test, dart format --set-exit-if-changed, import_sorter --exit-if-changed clean.
  • New unit tests cover the error mapping. The iOS-gated branches are not reachable from unit tests (lkPlatform() has no seam), same limitation as Configure the audio session before recording starts, not only on connect #1179 noted.

Fixes #1165
Refs #1042

…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.
…ping 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.
@hiroshihorie
hiroshihorie marked this pull request as ready for review August 27, 2026 16:47

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no bugs or issues to report.

Devin Review

@MaxHeimbrock

Copy link
Copy Markdown
Contributor

I am able to confirm this also fixes the issue 👍

…fault 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.
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.
…e 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.
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.
…e 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.
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.
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.
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).
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.
@hiroshihorie
hiroshihorie merged commit 8827c32 into main Sep 1, 2026
15 checks passed
@hiroshihorie
hiroshihorie deleted the hiroshi/native-audio-session-preset branch September 1, 2026 06:16
hiroshihorie added a commit that referenced this pull request Sep 1, 2026
Flutter counterpart of client-sdk-swift #1085, matching its final merged
behavior. Builds on #1182.

## Why

webrtc-sdk/webrtc#265 (first shipped in `m144.7559.12`) removed the
blocking mic permission request from the AudioEngine device. The
pre-enable check is now passive: it returns
`kAudioEngineErrorInsufficientDevicePermission` (-9000) instead of
prompting, so requesting permission is the SDK's job.

flutter-webrtc still pins `144.7559.10`, so this is not load-bearing
yet. It is harmless there, since `getUserMedia` in flutter-webrtc
already prompts and the status is resolved before the device's blocking
path runs. Once flutter-webrtc bumps past `.12` and the pin here
follows, this is what keeps the current behavior.

## What Flutter already had

flutter-webrtc's `getUserMedia` calls `AVCaptureDevice
requestAccessForMediaType:` and waits for the answer, so every
livekit_client mic path (publish, `restartTrack` on unmute, pre-connect
audio) already prompted before the audio device saw the track. That part
of #1085 needs no port. #1182 already maps -9000 to
`TrackCreateException` for the direct ADM entry points
(`setEngineAvailability`, `startLocalRecording`).

## What this adds

The one behavior from #1085 that was missing: only prompt while the app
can show the alert.

- Native `ensureMicrophoneAccess` in `LiveKitPlugin.swift`: `authorized`
passes, `denied`/`restricted` fail, `notDetermined` requests access. On
iOS the request is only made while
`UIApplication.shared.applicationState == .active`. An inactive or
backgrounded app (locked screen, CallKit wake, app switcher) has the
alert deferred by the system, and awaiting it would suspend
`getUserMedia` and the `_publishRunner` behind it, blocking camera and
screen share publishes for as long as the app stays there. Failing fast
lets the next foreground attempt prompt normally. macOS can present the
prompt regardless, so it always requests. No app extension concern here,
the plugin is app-only.
- The gate is skipped while engine input availability is disabled
(`setEngineAvailability`, the CallKit flow), mirroring the same late fix
in #1085: the audio device module defers opening input entirely and runs
no permission check there, so gating would turn a working background
connect into a `deviceAccessDenied` failure. The check reads the
plugin's tracked availability value, so it also covers gating done
natively before the Flutter engine exists.
- `LocalTrack.createStream` calls it for `AudioCaptureOptions` on Apple
platforms before `getUserMedia`. That is the Flutter choke point:
`LocalAudioTrack.create()`, `restartTrack()` and
`PreConnectAudioBuffer.startRecording()` all reach it. Since the prompt
in Flutter happens at `getUserMedia` rather than at capture start, the
gate sits in front of that instead of in `startCapture` as in Swift.
- Failures surface as `TrackCreateException` through the
`deviceAccessDenied` code introduced in #1182.
- Docs for `withPreConnectAudio` and
`PreConnectAudioBuffer.startRecording` now say permission is requested
at recording start but only while the app is active, so callers running
at app launch should request it up front (matching the final #1085
wording). `Native.setEngineAvailability` documents that permission is
not requested there and must be granted before input availability is
restored.

## Testing

- `flutter analyze`, `flutter test`, `dart format
--set-exit-if-changed`, `import_sorter --exit-if-changed` clean.
- Unit tests cover `Native.ensureMicrophoneAccess` (no-op when
unimplemented, propagates `deviceAccessDenied`). The `createStream` gate
is behind `lkPlatformIsApple()` and not reachable from unit tests.
- The example app builds for iOS (device SDK) and macOS with the change,
re-verified after the rebase onto `main`. On-device run against a fresh
install (first-launch prompt) and a CallKit background wake still to do.

Refs CLT-3243, client-sdk-swift#1085
rokk4 added a commit to rokk4/client-sdk-flutter that referenced this pull request Sep 1, 2026
Brings in three upstream commits:

  8827c32  Resolve a default audio session from engine state when no
           policy was pushed (livekit#1182)
  f014f69  Request microphone permission before audio capture starts (livekit#1183)
  b0e5db2  Declare the UTF-8 byte length in the sendText stream header (livekit#1184)

Upstream touched none of the files this fork patches, so the only conflict
was pubspec.lock (matcher, meta, test_api - all SDK-vendored). Resolved by
re-resolving with Flutter 3.47.2, the SDK the consuming app builds against.
The lock now differs from upstream by two lines: our flutter_webrtc
1.6.0+hotfix.1 pin, which the podspecs' WebRTC-SDK 144.7559.10 depends on.

The two audio commits are Apple-platform only (LiveKitPlugin.swift); they do
not overlap the Android audio-routing work in the app.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[bug] Audio engine returned error code

3 participants