Skip to content

Phone Audio Capture

Metadata

  • System type: library

System Intent

  • What this is: A module that captures audio from the phone's built-in microphone in sequential 30-second WAV chunks using expo-av. Each completed chunk is delivered to a caller-supplied callback so it can be enqueued for upload. This is the audio source for the phone-only recording path in capture-session.ts; it mirrors the windowed audio output that the Wearables SDK produces for the glasses-audio path.

Mermaid Diagram

flowchart TD
  Caller[capture-session.ts] -->|startPhoneAudioCapture callback| Start[startPhoneAudioCapture]
  Start -->|requestPermissionsAsync| ExpoAV[expo-av Audio]
  ExpoAV -->|granted| SetMode[setAudioModeAsync\nallowsRecordingIOS: true\nplaysInSilentModeIOS: true]
  SetMode --> FirstChunk[startNewChunk\nAudio.RecordingOptionsPresets.HIGH_QUALITY]
  FirstChunk --> Timer[setInterval every 30 s]
  Timer -->|tick| NextChunk[startNewChunk\nnext recording]
  Timer -->|tick| Finalize[finalizeChunk\nstopAndUnloadAsync]
  Finalize -->|AudioChunkEvent filePath windowIndex| Callback[onChunkReady callback]

  Caller -->|stopPhoneAudioCapture| Stop[stopPhoneAudioCapture]
  Stop -->|clearInterval| Timer
  Stop -->|finalizeChunk last partial| Callback

Flows

Flow: startPhoneAudioCapture

  • Test files: main/app/__tests__/phone-audio-capture.test.ts
  • Core files: main/app/lib/phone-audio-capture.ts

Types

AudioChunkEvent {
  filePath: string      (absolute file:// URI to WAV file on device)
  windowIndex: number   (0-based sequential chunk index)
  durationMs: number    (actual duration of this chunk in milliseconds)
  sizeBytes: number     (file size in bytes; 0 on stat failure)
}

StartPhoneAudioCaptureInput {
  onChunkReady: (event: AudioChunkEvent) => void   (invoked once per finalized 30-second chunk)
}

StartPhoneAudioCaptureOutput {
  void   (resolves when first recording segment is active; rejects on error)
}

Paths

path input output path-type notes
startPhoneAudioCapture.success StartPhoneAudioCaptureInput void; Audio.Recording active; 30-second interval running happy path requests microphone permission; sets audio mode; creates first chunk; starts interval. Next chunk starts before current is stopped to avoid gaps.
startPhoneAudioCapture.permission-denied StartPhoneAudioCaptureInput throws Error("Microphone permission denied") error no recording started; caller catches and calls cleanupSession
startPhoneAudioCapture.already-active StartPhoneAudioCaptureInput throws Error("Phone audio capture already active") error guard against double-start; activeRecording !== null

Pseudocode

if activeRecording !== null → throw "Phone audio capture already active"
{ granted } = await Audio.requestPermissionsAsync()
if !granted → throw "Microphone permission denied"
await Audio.setAudioModeAsync({ allowsRecordingIOS: true, playsInSilentModeIOS: true })
windowIndex = 0; chunkCallback = onChunkReady; chunkStartedAt = Date.now()
activeRecording = await startNewChunk()
chunkTimer = setInterval(() => {
  snap: currentRecording, currentWindowIndex, currentChunkStartedAt
  startNewChunk().then(nextRecording => {
    windowIndex++; chunkStartedAt = Date.now(); activeRecording = nextRecording
    finalizeChunk(currentRecording, currentWindowIndex, elapsed)
  }).catch(() => { /* non-fatal; chunk lost, recording continues */ })
}, 30_000)

Flow: stopPhoneAudioCapture

  • Test files: main/app/__tests__/phone-audio-capture.test.ts
  • Core files: main/app/lib/phone-audio-capture.ts

Types

StopPhoneAudioCaptureInput {
  void
}

StopPhoneAudioCaptureOutput {
  void   (resolves after final partial chunk is finalized and onChunkReady fired)
}

Paths

path input output path-type notes
stopPhoneAudioCapture.success void void; interval cleared; final partial WAV chunk delivered via onChunkReady happy path partial last chunk (< 30 s) is always finalized so no audio is lost
stopPhoneAudioCapture.not-active void void (no-op) subpath idempotent; safe to call when not recording
stopPhoneAudioCapture.finalize-error void finalizeChunk throws; chunkCallback still cleared in finally error try/finally guarantees stale callback is not left behind even if stopAndUnloadAsync rejects

Pseudocode

if !activeRecording → return (no-op)
clearInterval(chunkTimer); chunkTimer = null
snap: last = activeRecording, lastWindowIndex, lastChunkStartedAt
activeRecording = null
try:
  await finalizeChunk(last, lastWindowIndex, Date.now() - lastChunkStartedAt)
finally:
  chunkCallback = null

Flow: finalizeChunk (internal)

  • Core files: main/app/lib/phone-audio-capture.ts

Paths

path input output path-type notes
finalizeChunk.success Audio.Recording, windowIndex, durationMs onChunkReady fired with AudioChunkEvent happy path getURI() returns file:// URI; size read via expo-file-system File.size
finalizeChunk.no-uri Audio.Recording with null URI onChunkReady not fired subpath chunk silently dropped; occurs if recording never started correctly

Logs

Source Location
Timer errors silently caught in interval callback; chunk is dropped and recording continues

Deployment

  • Mechanism: local only (shipped as part of the React Native app bundle)
  • Deploy command:
    # iOS
    npx expo run:ios
    
    # Android
    npx expo run:android
    
  • Notes: Requires expo-av in main/app/package.json (npx expo install expo-av). Audio.RecordingOptionsPresets.HIGH_QUALITY records 16-bit 44.1 kHz mono WAV, which is compatible with the /sessions/{sessionId}/audio endpoint. On iOS, allowsRecordingIOS: true must be set before recording begins to prevent silent failure in silent-mode.