Skip to content

iOS playout-only: output AudioUnit renders one quantum then stops while ADM keeps output_running=true #99

Description

@Kamel94

Environment

  • Device: iPhone 17 Pro Max (physical device)
  • iOS: 26.6
  • React Native: 0.86.0, bare, New Architecture
  • Expo: not present
  • @livekit/react-native: 2.12.0
  • @livekit/react-native-webrtc: 144.1.2
  • livekit-client: 2.20.1
  • iOS Pod WebRTC-SDK: 144.7559.10
  • Client: receive/playout-only, no local microphone track
  • Launch: process kill followed by cold start

Expected behavior

After StartPlayout(), AVAudioEngine and the output AudioUnit should remain running and continuously render the received remote RTP.

Actual behavior

On an affected cold start:

startAndReturnError = true
NSError = nil
AVAudioEngine.running = true
output AudioUnit IsRunning = true

+19.796 ms:
  PreRender + PostRender
  960 frames
  no PostRenderError

+9.956 ms after the last render callback:
  output AudioUnit IsRunning = false
  AVAudioEngine.running = false

after the drop:
  LastRenderError = 0
  remote RTP bytes keep progressing
  ADM outputRunning = true
  ADM playing = true
  no automatic restart
  totalAudioEnergy = 0
  totalSamplesDuration = 0.02 s

The minimal reproducer observed this exact signature in 2 of 3 playout-only cold starts. The third playout-only run was healthy and rendered continuously, so the issue is intermittent.

Controlled input comparison

The reproducer has two otherwise identical branches before the same room.connect():

  • A: playout-only, no ADM input call — 2/3 signature-drop, 1/3 stable.
  • B: AudioDeviceModule.startLocalRecording() before connect, without publishing a local track — 2/2 stable.

Both B runs confirmed inputEnabled=true, inputRunning=true, recording=true, hundreds of output render callbacks, and progressing RTP energy/duration.

In this deliberately bounded sample, the comparison is consistent with an input-dependent first-cycle difference. It is not proof that input engagement causes stability, nor proof of the exact mechanism: B also causes the automatic LiveKit audio-session policy to use playAndRecord/videoChat instead of A's playback/spokenAudio, and it builds the input mixer/sink path (and potentially VPIO). The report does not propose enabling input as a product workaround.

Important negative evidence

Across the closed failing transition:

  • no WebRTC lifecycle stop/disable/release event was observed;
  • no stop call was observed through the instrumented application/WebRTC paths;
  • no observed AVAudioSession.setActive(false) through those paths;
  • no observed WebRTC AudioOutputUnitStop or AudioUnitUninitialize through those paths;
  • no interruption, route change, engine configuration-change notification, or media-services reset was recorded;
  • autoShutdownEnabled=false in the equivalent product trace;
  • LastRenderError=0, with no PostRenderError.

These are bounded non-hits from the instrumented paths, not a claim that no lower system path exists.

The key state-machine inconsistency is that the hardware-facing output transitions to stopped while AudioEngineDevice::EngineState.output_running remains true. Playing() therefore keeps reporting true and the engine-start condition is never re-entered.

M144 source correlation

In the M144-family audio_engine_device.mm, StartPlayout() commits state.output_running = true, and Playing() reports that logical field. The output-only graph and the input+output graph are configured through different node paths; input engagement can additionally enable the voice-processing path. This source shape is consistent with the observed stale state, but the binary was not rebuilt from that branch and this report does not claim a source-level root cause from correlation alone.

The narrow working hypothesis is therefore: an output-only first-cycle transition stops the physical I/O without entering a lifecycle path that clears or restarts output_running. The bounded A/B result keeps this as the leading family of causes; the exact internal transition remains unknown.

Reproduction steps

  1. Clone the minimal repository and use Node 24+.
  2. Copy runtimeConfig.example.ts to ignored runtimeConfig.ts; configure a short-lived listener URL/token.
  3. Run npm ci, then cd ios && pod install.
  4. Start qa/publisher.mjs with short-lived publisher credentials for the same Room. It publishes a deterministic 440 Hz mono track and prints no credential.
  5. Build and install the native Debug target on a physical iPhone. A native rebuild is required.
  6. Run the separate microphone permission preflight UI test.
  7. Kill/cold-launch and run A (playout-only) several times. The included runner terminates and relaunches the process.
  8. Run B (input-preengaged) with the same app, Room, publisher and device.
  9. Export the closed-trace: attachment from each .xcresult.

The realtime callback performs only preallocated atomic writes. AudioUnit properties, ADM state and receiver stats are read passively after the observed window.

Why this is not Swift SDK react-native-webrtc#1051

#1051 reports remote silence while the engine/output remains running until microphone input is enabled. Here the output AudioUnit starts, renders a real quantum, then IsRunning and AVAudioEngine.running both become false while ADM state remains true. The receive-only/duplex relationship may be in the same family, but the observed state transition is different.

Relation to Swift SDK react-native-webrtc#1069

client-sdk-swift#1069 is close at the product level: a receive-only listener remains silent until local microphone transmission begins, and its snapshots report engineRunning=false. Its reported lifecycle is different, however: no engine-start callback and no remote PCM render are observed before input starts. In this React Native reproducer, startAndReturnError succeeds, the output AudioUnit becomes running, and one 960-frame PreRender/PostRender quantum is observed before both the AudioUnit and AVAudioEngine stop while ADM output state remains true. react-native-webrtc#1069 also uses the Swift SDK and an application-managed .playback/.default session; this reproducer uses React Native with LiveKit's automatic .playback/.spokenAudio path.

Why this is not react-native-webrtc #89

#89 concerns the old JS/worker-queue deadlock window. This reproducer uses the 2.12.0 native default audio-session path, has no JS engine handlers, the JS UI remains responsive, startAndReturnError succeeds, and the AudioUnit actually renders before stopping.

Attachments

Could you confirm whether AudioEngineDevice has a known output-only first-cycle issue on iOS 26.6, or advise which internal I/O lifecycle transition should update/restart output_running when the output AudioUnit stops without an engine configuration notification?

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