Kamau Wanyee

A love letter to react-native-audio-api

·7 min read
#posts#react-native#audio#recording#open-source

A love letter to react-native-audio-api

For months, our interruption detector was a setInterval.

We knew it was. We had a recording badge that showed green, a state machine full of named events for interruption and silence, and a getStatus() poll running every 500ms underneath it all to synthesize those events from isRecording flips. The badge looked authoritative. The code below it was a heuristic.

The part that bothered us most was not the code. It was the not-knowing. A clinician finishes a home visit. The recording looked fine. The badge was green for the whole session. Did a phone call steal the mic for thirty seconds in the middle? Was there a silent stretch when they walked outside? We would find out at transcription, which is too late. The visit happened once, in that house, with that patient.

react-native-audio-api is why we stopped not-knowing.

Inferring interruptions from status

After we moved from expo-av to expo-audio, the old setOnRecordingStatusUpdate callback was gone. The replacement was a poll:

const statusInterval = setInterval(() => {
  const status = currentRecording.getStatus();

  if (lastKnownStatus) {
    const wasRecording = lastKnownStatus.isRecording;
    const isRecording = status.isRecording;

    if (wasRecording && !isRecording) {
      onInterruptionBegin?.();
    }

    if (!wasRecording && isRecording) {
      onInterruptionEnd?.();
    }
  }

  lastKnownStatus = status;
}, 500);

A phone call that stopped the recorder showed up as isRecording flipping false on the next tick. Resume showed up as it flipping true. There was no event type, no shouldResume, no route reason. Just a boolean sampled twice a second.

App backgrounding got the same treatment. We listened to AppState, then asked metering whether the mic still had signal:

AppState.addEventListener("change", (nextAppState) => {
  if (nextAppState === "background" && currentRecording) {
    const { metering, isRecording } = currentRecording.getStatus();
    const isDeadAir =
      metering !== undefined && metering < SILENCE_DETECTION_CONFIG.THRESHOLD_DB;

    if (isDeadAir) {
      onInterruptionBegin?.();
    }
  }
});

Background plus dead air meant “probably a phone call.” Background without dead air meant we hoped it was fine. This went to production. We were proud of almost none of it.

Dead air as a stand-in for silence

Silence detection had the same shape. Early on, we warned the clinician after ten seconds of low metering:

export const SILENCE_DETECTION_CONFIG = {
  THRESHOLD_DB: -50,
  DURATION_MS: 10_000,
} as const;

It fired too often on thinking pauses and ambient quiet. So we tightened the threshold all the way to the floor and retargeted the same mechanism at interruptions:

export const SILENCE_DETECTION_CONFIG = {
  THRESHOLD_DB: -160,
  DURATION_MS: 1_500,
} as const;

if (onInterruptionBegin && status.metering !== undefined) {
  const isDeadAir = status.metering < SILENCE_DETECTION_CONFIG.THRESHOLD_DB;
  const now = Date.now();

  if (isDeadAir) {
    if (silenceStartTime === null) {
      silenceStartTime = now;
    } else if (
      now - silenceStartTime >= SILENCE_DETECTION_CONFIG.DURATION_MS &&
      !interruptionTriggered
    ) {
      interruptionTriggered = true;
      onInterruptionBegin();
    }
  } else {
    silenceStartTime = null;
    interruptionTriggered = false;
  }
}

-160 is basically the floor of expo-audio metering. Sustained floor for 1.5 seconds became our definition of “the mic is gone.” Quiet speech, a long pause mid-sentence, a bad route, and a real phone call all collapsed into the same number. We tuned it by feel.

The state machine had proper interruption events. The service layer was confessing those events out of a poll.

flowchart LR
  P[setInterval 500ms] --> S[getStatus]
  S --> B{isRecording flip?}
  S --> M{metering floor?}
  B -->|yes| I[onInterruptionBegin / End]
  M -->|yes| I
  A[AppState background] --> M

The architecture looked fine from above. The seams showed when you read setupInterruptionListeners and found a setInterval with a comment that said “catches phone calls.”

Native interruption events

With react-native-audio-api, interruption is a system event. We subscribed and the platform delivered:

AudioManager.addSystemEventListener("interruption", (event) => {
  if (event.type === "began") {
    getBridge()?.send({
      type: AudioEventType.SYSTEM_AUDIO_INTERRUPTION_BEGIN,
      shouldResume: event.shouldResume,
      nativeSource: "interruption",
    });
    void finalizeInterruptedSegment(session);
    return;
  }

  getBridge()?.send({
    type: AudioEventType.SYSTEM_AUDIO_INTERRUPTION_END,
    shouldResume: event.shouldResume,
  });
});

AudioManager.addSystemEventListener("routeChange", (event) => {
  if (
    event.reason === "NoSuitableRouteForCategory" ||
    event.reason === "OldDeviceUnavailable"
  ) {
    getBridge()?.send({
      type: AudioEventType.SYSTEM_AUDIO_INTERRUPTION_BEGIN,
      routeChangeReason: event.reason,
      nativeSource: "route_change",
    });
  }
});

Begin and end arrive as separate events. shouldResume comes from the platform. Route loss carries a named reason; ducking has its own listener. The machine can finalize the in-progress segment at interruption begin on the same tick the OS reports it, before a prolonged hold kills the recorder.

We deleted the 500ms status interval. It felt like putting down something we had been carrying for a while without realizing.

Seeing the audio

The metering approach had one problem we never fully solved: it measured a single number per poll. Flatline looked like a quiet pause. A frozen buffer looked like a clean signal. We could not tell them apart.

onAudioReady delivers the actual samples:

session.recorder.onAudioReady(
  { sampleRate: 16_000, bufferLength: 1024, channelCount: 1 },
  ({ buffer, when }) => {
    const samples = buffer.getChannelData(0);
    const health = session.recorderHealthMonitor.accept(
      samples,
      Math.round(when * 1000)
    );
    emitCaptureHealth(session, health);
  }
);

CaptureQualityMonitor classifies each buffer: signal, quiet silence (valid), flatline, repeated buffer, clipped, or static. Quiet ambient stays healthy. Flatline escalates to suspect, then invalid. After an interruption, the monitor pauses briefly so frozen HAL buffers do not fake a capture failure on resume.

accept(samples: Float32Array, durationMs?: number): CaptureHealthSnapshot {
  const features = this.inspect(samples);
  const reason = this.classifyFeatures(features);

  if (reason === "signal_detected" || reason === "silence") {
    return { status: "healthy", reason, ...features };
  }

  return this.updatePoorSignal(now, reason, features);
}

The first session we ran with this wired up, we could see the signal health changing in real time: healthy, then suspect as the mic path shifted, then healthy again. We had never seen that before. For two years we had a green badge. Now we had data.

flowchart TB
  subgraph Before["expo-audio"]
    S1[start] --> P[poll getStatus]
    P --> B[isRecording / metering]
    B --> H[synthesized interruption]
  end

  subgraph After["react-native-audio-api"]
    S2[start] --> PCM[onAudioReady buffers]
    S2 --> SYS[interruption / route / duck]
    PCM --> MON[CaptureQualityMonitor]
    SYS --> MACHINE[session machine]
    MON --> MACHINE
  end

The altitude of the abstraction

The problem was not that expo-audio was poorly built. It was built at the wrong altitude for what we needed. start(), stop(), and a status object describe a product recorder. They hide the native audio system: session ownership, interruption lifecycle, route changes, and the sample stream. For a short clip in a foreground app, that tradeoff is fine. For a long-running field recorder on a phone doing other things, it means every hard fact the OS knows is inaccessible, and you build a ghost of it from polling.

We rebuilt interruption and silence detection on top of metering because the library had already decided we should not need them.

react-native-audio-api sits lower. It exposes the system:

  • interruption, route, and duck events with native fields
  • PCM buffers while recording
  • explicit session activation and category
  • file primitives, duration, and segment-friendly output

It does not decide what a field-visit product should do with those facts. Preserve versus abandon, resume versus restart, how long to pause health after interruption end, when to surface recovery UI: those stay in the session machine. See Recording is a distributed system and Incomplete media is a checkpoint.

flowchart LR
  LIB[Library<br/>system facts] --> APP[App<br/>product policy]
  APP --> UX[Recovery UI]
  APP --> DISK[Segment preserve]

Keeping native semantics visible lets each product build its own policy without having to rebuild its own physics.

The question to ask when choosing or designing an API: are the hard states observable? Can you subscribe to the failure modes that actually happen on device? Can you read the signal, or only a summary that already collapsed it? Can you own the session when the OS will fight you for it?

If the answer is no, someone downstream will write a setInterval, tune a threshold by feel, and ship code they are proud of almost none of.