Skip to content

iOS: AVAudioEngine playout silently stops shortly after subscribing; ADM state desyncs (isPlaying() == true while isEngineRunning() == false) and never self-recovers #450

Description

@msdnld

Environment

  • @livekit/react-native: 2.12.0
  • @livekit/react-native-webrtc: 144.1.2
  • livekit-client: 2.20.1
  • React Native 0.85 (Fabric), Expo SDK 55, Hermes
  • iOS 18, 26, devices from iPhone 12 through iPhone 17 generation (not device-specific)
  • Android: never reproduces (0 occurrences over 30 days vs ~120 on iOS)
  • Audio session driven natively via the official setAppleAudioConfiguration / audio session management in 144.x. Viewer (playout-only) session: category .playback, mode .moviePlayback, empty options. Viewers never publish; microphone permission is never requested.

Summary

For a subscriber-only participant on iOS, the AVAudioEngine used by the AudioEngine-based audio device module stops within the first ~5–25 seconds after subscribing to a remote audio track (occasionally later, mid-session). Playback goes silent while everything else looks healthy. The ADM's internal state desynchronizes from the engine: it still reports playout as active, so nothing inside the ADM ever restarts the engine, and the participant stays silent until the receive stream is torn down and rebuilt.

We instrumented production heavily (Sentry), so we can share the exact state fingerprint and hard field numbers, plus a recovery experiment that narrows the defect down.

State fingerprint at the moment of failure

Captured via AudioDeviceModule / RTCAudioSession state and WebRTC inbound-rtp stats while the user reports silence:

AudioDeviceModule.isEngineRunning()  == false   // engine actually stopped
AudioDeviceModule.isPlaying()        == true    // ADM believes playout is active
room.state                           == connected
canPlaybackAudio                     == true
rtcSessionActive                     == true    // AVAudioSession category .playback, active
inbound-rtp packetsReceived          -> increasing normally
inbound-rtp totalAudioEnergy         -> flat (nothing decoded/played)

Packets keep arriving and queue in the jitter buffer; nothing is rendered. Because isPlaying() still returns true, the ADM has no reason to re-init, so the stall is permanent from the user's perspective.

There is a second, rarer variant where the desync is inverted:

isEngineRunning() == true
isPlaying()       == true
rtcSessionActive  == false   // AVAudioSession was never activated
totalAudioEnergy  -> flat

Frequency

  • ~120 occurrences / ~50 distinct users over 2 weeks, 100% iOS.
  • Most stalls begin within the first 25 seconds after the audio track subscription.
  • In a single 40-minute live room we observed ~10 distinct viewers hit it, several of them repeatedly (stall → recovery → stall again minutes later).

We cannot reproduce it on demand; it is statistical, but frequent enough that any live room with a few dozen iOS viewers hits it multiple times.

Recovery experiment (this is what points at the ADM)

We shipped a client-side watchdog that polls isEngineRunning() every 5s for playout-only participants and attempts recovery when it reads false while connected:

  1. Availability cyclesetEngineAvailability({input:false, output:false}) then {input:false, output:true}: restores audio sub-second in the majority of stalls (engine restarts, isEngineRunning() flips to true, decoded energy resumes). This strongly suggests the engine is simply stopped and a forced reconfiguration is sufficient — i.e. the ADM could self-heal.
  2. A raw AudioDeviceModule.startPlayout() is rejected with -1 in this state (presumably because libwebrtc requires its own InitPlayout first), so that is not a viable recovery path from the JS side.
  3. A minority of devices are toggle-refractory: the availability cycle brings the engine back up but it stops again immediately (3 attempts, all isEngineRunning() == false afterwards). For those, unsubscribing and resubscribing the remote audio publications — which rebuilds the receive stream and drives a full InitPlayout from inside libwebrtc — recovers audio (~15s).

What we ruled out

  • Network / SFU: packets flow normally the whole time; video keeps playing.
  • Mute state, volume, AudioSession category conflicts: session is active with .playback at failure time (variant 1).
  • Device class / iOS version specific: spread across models and 26.x versions.
  • Android: never occurs, same JS code path.

Disclosure

We maintain local patches on @livekit/react-native-webrtc (audio-session policy application, an interruption watchdog, PiP changes). None of them modify AudioEngineDevice start/stop or the ADM state machine, but we want to be upfront that our binary is not stock. The failure class predates several of those patches; we have not yet run an A/B against fully-default audio session management.

Ask

  1. Should the AudioEngine ADM detect engine stopped && playout active and re-init itself? From our field data, an internal restart equivalent to the availability cycle would silently fix the majority of stalls.
  2. Is there a known race in engine (re)build vs. AVAudioSession activation around track subscription that matches this fingerprint (isPlaying() == true, engine dead)? The inverted variant (engine running, session never activated) looks like the same race from the other side.
  3. Happy to provide full sanitized telemetry payloads, timing sequences per user, or to test candidate fixes in our production demo channel — we can validate a fix against real traffic within days.

Sample telemetry (sanitized)

Stall detected (flow sample ~20s after join):

{
  "roomState": "connected",
  "canPlaybackAudio": true,
  "enginePlaying": true,
  "engineRecording": false,
  "engineRunning": false,
  "appleAudio": {
    "category": "AVAudioSessionCategoryPlayback",
    "mode": "AVAudioSessionModeMoviePlayback",
    "otherAudioPlaying": false,
    "outputs": "Speaker",
    "rtcSessionActive": true,
    "rtcSessionInterrupted": false
  },
  "packetsDelta": 250,
  "packetsTotal": 934,
  "energyTotal": 0,
  "consecutiveSilentSamples": 3
}

Successful recovery via availability cycle:

{
  "attempt": 1,
  "recovered": true,
  "enginePlaying": true,
  "engineRecording": false,
  "engineRunning": true
}

Toggle-refractory device (3 attempts, later recovered by resubscribe):

{ "attempt": 3, "recovered": false, "enginePlaying": true, "engineRunning": false }

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions