Phone Audio Capture
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.