0064 — Inbound Audio Voice Note Transcription via ElevenLabs Scribe


Context

A PM replied to a turnover recap SMS with an audio voice note sent as an MMS (MediaContentType: audio/amr, empty Body). The voice note was silently dropped — no error surfaced, no retry, no record of the audio content.

Evidence:


Root Cause

Two lines in src/app/api/twilio/webhook/route.ts conspired to drop the message:

  1. Line ~224 — the media processing loop skipped non-image media with if (!mediaContentType.startsWith('image/')) continue;. An audio/amr voice note never entered the images array.

  2. Line ~244 — after the loop, the handler returned HTTP 400 when the message body was empty and the images array was empty:

    if (!messageText && images.length === 0) {
      return NextResponse.json({ error: 'Missing Body and no media' }, { status: 400 });
    }
    

Twilio does not retry 400 responses (unlike 5xx). The voice note was permanently lost with no user-visible signal and no Sentry alert.


Existing Capability

ElevenLabs Scribe (/v1/speech-to-text, scribe_v1 model) is already integrated in this codebase:

The Twilio MMS webhook just never routed inbound audio into this path.


Decision

Transcribe inbound audio MMS via ElevenLabs Scribe, then route the transcript through the normal message pipeline.

New module: src/lib/integrations/voice/scribe-transcribe.ts

Extracts the Scribe call pattern from post-transfer.ts into a reusable, exported function:

export async function transcribeAudioBuffer(
  audioBuffer: Buffer,
  filename: string,    // e.g. "voice-note-MMxxx.amr" — hints codec to Scribe
  contentType: string, // e.g. "audio/amr"
  apiKey: string,
): Promise<ScribeResult>

No side effects, no data writes. Returns { text: string }.

Webhook changes: src/app/api/twilio/webhook/route.ts

The image/-only branch becomes three branches:

After the loop, messageText is assembled with priority:

body > audioText > '(photo attached)' > ''

If messageText is non-empty, formFields.Body is patched to the computed value before adapter.parseInbound is called, so the MessageEnvelope.body carries the transcript through the SQS/Lambda/processEnvelope pipeline.

Error handling: Scribe failures push a placeholder and continue — the message always routes and Twilio always gets a 200. A 400 would be unretried by Twilio and the voice note would be permanently lost; the fix avoids that failure mode even on partial outages.


Consequences

Positive:

Neutral:

Negative / Risks:


Alternatives Considered

  1. Acknowledge audio without transcribing — return 200 + placeholder text. Rejected: the PM gets no useful feedback, and the action remains unactioned.

  2. Store raw audio URL and flag for async transcription — Twilio media URLs expire in 4h without re-authentication. Deferred transcription is complex and would require a separate job queue. Not worth the complexity for PM voice notes.

  3. Use Twilio's built-in transcription — Twilio Transcriptions uses a different API and produces lower-quality output. Scribe is already battle-tested in this codebase on the post-transfer recording path.