Skip to content

feat(ios): configure the default audio session natively in the observer#96

Merged
davidliu merged 10 commits into
masterfrom
hiroshi/native-audio-session
Jul 23, 2026
Merged

feat(ios): configure the default audio session natively in the observer#96
davidliu merged 10 commits into
masterfrom
hiroshi/native-audio-session

Conversation

@hiroshihorie

@hiroshihorie hiroshihorie commented Jul 9, 2026

Copy link
Copy Markdown
Member

Summary

Adds an iOS-only API that pushes the default audio-session policy to native once. When no custom JS handlers are registered, willEnableEngine and didDisableEngine now configure AVAudioSession on the native worker thread. This removes the JS round trip from the default path and the circular-wait risk described in #89.

Custom JS handlers still take precedence and keep the bounded timeout. Native activation tracking handles normal stop, interruptions, and external deactivation. Switching between native and custom management during an active call remains unsupported. Switch while disconnected.

Testing

  • Built for iOS device and simulator with Xcode 26.6 and WebRTC-SDK 144.7559.10
  • Smoke tested on device with CallKit and RNCallKeep through connect, mic publish, mute, and disconnect
  • Observed no freeze, bridge-wait timeout, or native configuration failure
  • Confirmed the audio session returned inactive after teardown

Current iOS CI failure is unrelated to this change. The React Native 0.71 example fails while compiling Yoga after macos-latest moved to macOS 26, before the native code from this PR is compiled.

Before undrafting

Companion PR: livekit/client-sdk-react-native#434

When no JS handler is registered for willEnableEngine/didDisableEngine,
AudioDeviceModuleObserver now applies an audio-session policy natively
on the audio worker thread instead of round-tripping to JS. The policy
(an Apple audio configuration per engine state, plus deactivate-on-stop)
is pushed once from JS via
AudioDeviceModule.setAutomaticAudioSessionConfiguration(), and passing
null clears it. Registered JS handlers keep taking precedence.

This removes the JS round trip from the default configuration path
entirely: the worker thread no longer waits on a JS thread that may
itself be blocked in a synchronous bridge call, which previously could
stall engine operations until the bounded wait timed out (#89).

Session activation is tracked as a hold against RTCAudioSession, which
reference-counts activations: the observer activates only when the
session is not already active and deactivates only while holding the
activation, so repeated policy pushes, switches between the native and
JS paths, and interruptions keep the refcount balanced.
hiroshihorie and others added 9 commits July 11, 2026 00:10
…rnal deactivation

An interruption (or an external deactivation such as CallKit) sets
RTCAudioSession.isActive to NO without touching its activation count.
The next enable transition then saw an inactive session and activated
again, stacking a second count onto the hold the observer already had.
The single release at stop left count 1, so the OS session stayed
active forever.

When the observer still holds an activation but the session reads
inactive, reactivate first and then drop the now-extra count. The order
matters: releasing first would zero the count whenever the reactivation
fails, and RTCAudioSession's interruption-end recovery (and its other
system-event handlers) deactivates outright at count zero, killing the
session the engine was still using. Activating first leaves the prior
hold untouched on failure so that recovery can restore it.

If the drop itself deactivates the session, the held count had already
been consumed by an external unmatched release (for example a stray
stopAudioSession call) and the drop returned the count the reactivation
took. Reactivate once more and keep that single fresh count, so even a
stale hold converges to a balanced state instead of leaking or
oscillating.
Clearing the automatic configuration (teardown of a setup, or switching
paths) while the observer still held an activation stranded the hold:
the didDisable native branch was gated on a non-nil policy, so nothing
ever released the count and the OS session stayed active.

Enter the native branch also when a hold is outstanding with a nil
policy, and release the orphaned hold at the next full stop. No
configuration is applied in that case since the native path is
disarmed. Deactivate-on-stop semantics do not apply to an abandoned
policy, releasing is the only balanced outcome.
didDisable fires after the engine's destructive disable work: buffers
are stopped, converters disposed, and RTCAudioSession has already
decremented its activation count even when setActive:NO fails. Its
rollback replays only constructive actions, so nothing it does can
restore what the disable removed, and a non-zero return only desyncs
libwebrtc's logical engine state from hardware that already changed.
This applies to the final deactivation edge and equally to partial
transitions such as duplex to playout, where the policy branch
reconfigures the session.

Swallow and log every non-zero result at the didDisable entry point.
willEnable keeps propagating configuration and activation errors, where
the enable has not happened yet and a rollback is still meaningful.
- Gate setAutomaticAudioSessionConfiguration to iOS: the macOS build
  excludes the audio device module natives, so reaching the bridge there
  threw on an undefined method. tvOS builds include them and keep working.
- Type the configuration with literal unions matching the native maps
  instead of bare strings, so unknown categories, modes, and options are
  rejected at compile time rather than silently falling back.
- Add voicePrompt to the native mode map, matching AudioUtils in the
  LiveKit React Native SDK and keeping the SDK's AppleAudioMode
  assignable to the new union.
- Export the configuration types from the package root.
The native path's decision logs were os_log_debug, which iOS neither
streams to a console attach nor persists to collected log archives.
Field verification of this subsystem had to be inferred from audiomxd
system records. Log the per-transition decisions (skip, configure,
activate, deactivate, hold rebalance) at default level so they survive
into log archives. Frequency is a handful of lines per engine
transition.
- Handler precedence is evaluated per hook, so custom willEnable and
  didDisable handlers must be registered or cleared as a pair while a
  policy is set. A JS handler owning one hook while the native policy
  owns the other can activate a session that the owning regime never
  releases. registerGlobals() must have reconciled the native handler
  flags before the policy takes effect.
- Clearing a deactivateOnStop:NO policy while the engine is already
  stopped keeps the session activation held until the next engine
  cycle, because no callback fires in between. Callers who need an
  immediate release should stop under a deactivateOnStop:YES policy
  before clearing.
- Update the reassignment note now that a hold orphaned by clearing is
  released at the next full stop.
@davidliu
davidliu marked this pull request as ready for review July 23, 2026 13:33
@davidliu
davidliu merged commit a312ef9 into master Jul 23, 2026
9 checks passed
@davidliu
davidliu deleted the hiroshi/native-audio-session branch July 23, 2026 13:33
davidliu added a commit that referenced this pull request Jul 23, 2026
a312ef9 feat(ios): configure the default audio session natively in the observer (#96)  ( Hiroshi Horie 2026-07-23 22:33:50 +0900)
@davidliu davidliu mentioned this pull request Jul 23, 2026
davidliu added a commit that referenced this pull request Jul 23, 2026
a312ef9 feat(ios): configure the default audio session natively in the
observer (#96) ( Hiroshi Horie 2026-07-23 22:33:50 +0900)
davidliu added a commit to livekit/client-sdk-react-native that referenced this pull request Jul 23, 2026
)

## Summary

`setupIOSAudioManagement`'s default path now pushes the audio-session
policy to native via
`AudioDeviceModule.setAutomaticAudioSessionConfiguration()` instead of
registering `willEnable`/`didDisable` JS handlers, so the audio engine's
worker thread no longer round-trips to JS in the stock path. That round
trip could stall or deadlock engine operations when the JS thread was
itself blocked in a synchronous bridge call
(livekit/react-native-webrtc#89). Custom configurations via
`onConfigureNativeAudio` keep the JS handler path, still bounded by the
native wait timeout.

- Repeated setup calls supersede the previous setup by claiming a token
(stale cleanup functions become no-ops), and each path arms its own
mechanism before tearing down the other's so the engine hooks stay owned
throughout a switch.
- Switching paths mid-call is documented as unsupported: switching away
from a custom setup mid-call abandons the audio session activation its
handlers took, and with the shared activation count nobody releases it.
Switch while disconnected.
- The `onConfigureNativeAudio` contract is documented: it runs under a
bounded native wait and must not call into the WebRTC engine or a peer
connection.
- Example `Podfile.lock` synced with the WebRTC-SDK 144.7559.10 pin.
Changeset included (minor).

## Dependency

**Depends on livekit/react-native-webrtc#96.** CI here installs
`@livekit/react-native-webrtc` from the registry, which does not yet
ship `setAutomaticAudioSessionConfiguration`, so typecheck stays red
until that PR merges, a release is cut, and the dependency here is
bumped. Local verification was done against the linked fork.

## Testing

- `tsc` clean for `src/` against the linked fork branch (including the
fork's literal-union types).
- Device smoke test (iPhone, iOS 26.5.2) with the example app: connect,
publish mic (duplex engine recreate), mute toggles, disconnect. No
freeze, zero bounded-wait timeouts or native config failures in the
unified log, and the system audio records show the session ran with the
default policy and ended inactive at teardown.

## Before undrafting

- [ ] livekit/react-native-webrtc#96 merged and released
- [ ] Bump `@livekit/react-native-webrtc` dependency to that release

---------

Co-authored-by: davidliu <davidliu@deviange.net>
Co-authored-by: davidliu <dl@livekit.io>
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.

2 participants