Skip to content

iOS: recording never resumes after an interruption (onInterruptionEnd starts the engine without activating the audio session) #1220

Description

@yash-tequity

Summary

AudioEngine.onInterruptionEnd starts the audio engine without ever activating the audio session, on a session the interruption-began branch explicitly marked inactive. For a recorder that means capture never comes back after a phone call. We patched it locally and verified on device that recording now resumes mid-call-end with the app in the background, so this is a confirmed fix rather than a proposal.

Note on revisions. This report has been trimmed. Earlier versions led with shouldResume and with a claim that consumers cannot recover from JS. We later measured shouldResume=1 on every call and found our own symptom had a consumer-side cause (see the last section), so both have been removed rather than left to mislead. The defect below is the one that survived.

Environment

  • react-native-audio-api 0.13.2
  • React Native 0.85.3, Expo SDK 56, Hermes, iOS device (not reproducible on the simulator)
  • session playAndRecord / spokenAudio, UIBackgroundModes: audio, interruption observation OFF

1. onInterruptionEnd never activates the session

Compare it with startEngine, the path taken when the user taps record:

startEngine (AudioEngine.mm:441) onInterruptionEnd (AudioEngine.mm:366)
activates the session [sessionManager ensureActive:true] (:452) never
checks the input node materialized yes (:464) no
on failure returns false, caller may retry sets state Idle and returns (:390)

The session it starts on was explicitly marked inactive when the interruption began:

// SystemNotificationManager.mm:121
if (interruptionType == AVAudioSessionInterruptionTypeBegan) {
    dispatch_async(dispatch_get_main_queue(), ^{
      [audioEngine onInterruptionBegin];
      [sessionManager markInactive];      // isActive = false
    });

Nothing reactivates it in between, so startAndReturnError at :384 runs against an inactive session and cannot succeed with an input node attached.

Three things compound it:

  1. The failure poisons the state. self.state = Idle at :390, while the guard at :371 requires Interrupted. Once it has failed it can never run again for that recording, so a re-delivered ended notification and activelyReclaimSession's onInterruptionEnd:true (:303) are both no-ops.
  2. Ordering matters more than just "activate before start". stopEngine (:375) already sets Idle (:510), so an activation inserted lower down still returns Idle on refusal. And rebuildAudioEngine (:376) materializes the input node by reading currentInputConnectionFormat (:189), which is unavailable while the session is inactive. The activation has to come before any engine call, which is what startEngine does.
  3. No missing-input-node guard. Unlike startEngine (:482), a failed materialization here is indistinguishable from success, so the engine can start with no sink attached: isRecording() reports true and not one buffer is ever delivered.

The shape we are running locally, and would be glad to send as a PR:

- (void)onInterruptionEnd:(bool)shouldResume
{
  std::scoped_lock lock(_engineLock);
  NSError *error = nil;

  if (self.state != AudioEngineState::AudioEngineStateInterrupted) {
    return;
  }

  if (!shouldResume && self.inputRegistration == nil) {
    [self stopEngine];
    [self rebuildAudioEngine];
    self.state = AudioEngineState::AudioEngineStatePaused;
    return;
  }

  // BEFORE any engine call: the format read inside rebuildAudioEngine needs an active session, and a
  // refusal here must return with Interrupted still intact so a later trigger can retry.
  if (![self.sessionManager ensureActive:true error:&error]) {
    NSLog(@"Error while activating audio session after interruption: %@", [error debugDescription]);
    return;
  }

  [self stopEngine];
  [self rebuildAudioEngine];

  if (self.inputRegistration != nil && self.inputNode == nil) {
    NSLog(@"Error while materializing the audio input node after interruption: missing live input format");
    self.state = AudioEngineState::AudioEngineStateInterrupted;
    return;
  }

  [self.audioEngine prepare];
  [self.audioEngine startAndReturnError:&error];

  if (error != nil) {
    NSLog(@"Error while restarting the audio engine after interruption: %@", [error debugDescription]);
    self.state = AudioEngineState::AudioEngineStateInterrupted;   // not Idle: stopEngine already set that
    return;
  }

  self.state = AudioEngineState::AudioEngineStateRunning;
  self.sessionDeactivationInvalidatedGraph = false;
}

The inputRegistration == nil condition on the shouldResume bail is a separate opinion, not part of the bug: a recorder the user explicitly started should always come back, while playback keeps the polite behaviour. Drop it if you disagree; the rest stands on its own.

Device log with the patch applied, app backgrounded, real incoming call (Interrupted=3, Running=1):

[audioapi] onInterruptionEnd shouldResume=1 state=3 hasInput=1   <- guard passed, no activation error follows
[audioapi] onInterruptionEnd shouldResume=1 state=1 hasInput=1   <- duplicate notification correctly bails

Capture resumed into the same file, with only the call's own audio missing, before the app was reopened.

2. Docs and behaviour disagree on observeAudioInterruptions

The AudioManager docs say:

On iOS, it enables/disables event emission only.

That reads as a pure observability toggle. But the interruption-ended branch is an either/or:

// SystemNotificationManager.mm ~line 138
if (self.audioInterruptionsObserved) {
  // emit the JS "ended" event, and nothing else
} else {
  dispatch_async(dispatch_get_main_queue(), ^{ [audioEngine onInterruptionEnd:shouldResume]; });
}

So subscribing to interruption events hands recovery to JavaScript, which for a recorder is a trap: iOS can suspend the runtime while the session is inactive, so the app takes ownership of a recovery it may not be scheduled to perform. The same either/or appears at lines 171 and 177.

Either run the native recovery regardless of whether events are observed (they are not mutually exclusive concerns), or document that observing transfers recovery responsibility to the consumer.

3. Two smaller things

  • A no-op deactivation still has side effects. setActive:false short-circuits on !self.isActive (AudioSessionManager.mm:155) and never touches AVAudioSession, but AudioAPIModule.mm:180 runs handleSessionDeactivation regardless of whether anything was deactivated, and that forces the engine state to Paused (:352, :357). A consumer calling setAudioSessionActivity(false) to reclaim a session therefore moves its engine out of Interrupted, which is the state onInterruptionEnd requires.
  • resume() gives JS nothing to act on. startEngine returns bool, but startIfNecessary's result is dropped and IOSAudioRecorder::resume returns void, so a failed engine start reaches JS as nothing at all, only an NSLog. A consumer cannot distinguish "resumed" from "silently did nothing", which makes any retry policy guesswork.

Footnote on a related hazard we avoided rather than hit: IOSAudioRecorder::resume (:446) stores Recording unconditionally even when [nativeRecorder_ resume] failed to start the engine. If inputArmed was cleared by a preceding pause() and the engine is later restarted by the native path (which sets Running without re-arming), the recorder reports isRecording() == true while the receiver block drops every buffer at NativeAudioRecorder.m:40, with no error anywhere and no way to re-arm. Storing Recording only on success, or treating inputArmed == NO as not-recording, would close it.

For other consumers hitting "recording never resumes": check your session options first

Our symptom had a second, consumer-side cause, and it is worth stating plainly so nobody else spends as long on it as we did. With a non-mixable playAndRecord session, iOS refuses activation to a backgrounded app:

[CannotInterruptOthers] code: 560557684    // refused, app in background

Adding mixWithOthers to iosOptions changed the error to [InsufficientPriority] 561017449, which appears only while the call is genuinely still in progress, and after that both the native path and ours activated successfully from the background. A mixable session does not need to interrupt anyone, so it can be activated when a non-mixable one cannot.

This is not a library bug, and the library exposes the option already. It does interact with the defect above, though: the missing session activation in onInterruptionEnd is what made the whole thing undiagnosable, because the native path failed for a reason no log ever reported.

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working as expected or produces unexpected errors

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions