Skip to content

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

Draft
hiroshihorie wants to merge 9 commits into
masterfrom
hiroshi/native-audio-session
Draft

feat(ios): configure the default audio session natively in the observer#96
hiroshihorie wants to merge 9 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.
…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.
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.

1 participant