Skip to content

feat(dictation): fall back to the buffered endpoint past the live 120 s cap - #202

Draft
dan-ince-aai wants to merge 1 commit into
mainfrom
feat/dictation-buffered-fallback
Draft

dan-ince-aai wants to merge 1 commit into
mainfrom
feat/dictation-buffered-fallback

Conversation

@dan-ince-aai

Copy link
Copy Markdown
Contributor

What and why

client.dictation (transcribeLive(), openLive()) only wraps the live endpoint, POST /v1/transcribe/live, which caps a request at 120 s of audio. Past the cap the server answers 413 {"error":"audio exceeds 120000 ms","error_code":"audio_too_large"}, so any live dictation over two minutes fails today.

A customer uses live dictation for notes under two minutes but needs it to keep working, without stitching anything together themselves, up to 20 minutes. The buffered endpoint, POST /v1/transcribe, already takes up to 20 minutes / 256 MB with the same config fields and returns the same response shape. This PR makes the SDK fall back to it automatically. The same behaviour is being built in the Python SDK and in the Blurt macOS app against a shared spec, so the three should match.

Behaviour

New option DictationLiveOptions.fallbackToBuffered?: boolean, default true. It applies to both transcribeLive() and openLive().

  1. Keep a copy. From the first byte, the SDK keeps an in-memory copy of every audio byte it hands to the live upload. Only audio is kept, never the multipart framing.
  2. Trigger. It falls back only when the live request fails with HTTP 413 and errorCode === "audio_too_large". Every other failure (other 413 codes, 401, 429, 5xx, a transport error) is thrown exactly as today.
  3. Drain to key-up. On the trigger, the SDK keeps reading the caller's source until it ends (the iterable or stream ends, or close() / result() on a session) and appends to the copy. This works whether the 413 arrives mid-upload with the producer still recording or after the whole body was sent.
  4. One buffered POST. It posts the complete copy once to ${dictationBaseUrl}/v1/transcribe as multipart, with the same config JSON (including sample_rate/channels for PCM) and the same audio filename and Content-Type. It returns that response exactly like a live result, including final_text. If the buffered request fails, its own error is thrown (for example its own 413 past 20 minutes), not the live 413.
  5. Bounded memory. If the copy passes 256 MB (256 × 1024 × 1024 bytes), it is dropped and the call throws DictationError (413, audio_too_large). The source is released and no request is sent.
  6. Abort means abort. session.abort(), an abort signal, or a producer error drops everything. No fallback request is sent.
  7. Timeouts. Once the fallback engages, the live timeout no longer cuts off the rest of the recording, because draining is recording time. The buffered POST gets a fresh options.timeout deadline. The abort signal is honoured throughout.

Opt-out

{ fallbackToBuffered: false } keeps no copy and takes the exact code path that exists today, so the caller gets the live 413 as before.

Trade-offs (also in the JSDoc, README and CLAUDE.md)

  • Latency. After a fallback, the result waits for the whole recording to upload and be transcribed, so a long dictation returns more slowly than a live one. Short dictations are unaffected: they never leave the live path.
  • Memory. The copy costs about 32 KB per second at 16 kHz mono 16-bit PCM, about 38 MB at 20 minutes. While the buffered body is being built, the peak is briefly about twice that, because the chunks are copied into one Uint8Array and then released.

How it works

  • src/services/dictation/recording.ts (new, internal): AudioRecording wraps the source. The live body reads it through a view, and every chunk is recorded as it is produced. The source has one reader at a time, so a chunk the live body asked for but abandoned when it was ended early is still recorded, and it is the first one the drain sees. Ending the view does not close the source. On the trigger the source is drained. On any other outcome it is released (iterator.return()), which is what the live upload did before.
  • How fetch surfaces the mid-upload 413. Measured against production with Node 22 / undici: fetch() resolves with a 413 Response while the request body is still streaming. The existing code already calls body.finish() there, which sends the closing boundary on the next pull so the response body (with error_code) can be read. After that, fetch stops pulling, so AudioRecording.drain() reads the producer directly until it ends.
  • service.ts: transcribe() runs the live request and then the fallback. postBuffered() builds the body with the existing multipartHead / multipartClosing framing and sends it with a Content-Length (not streamed). Response parsing is shared with the live path (dictationResult()).
  • src/utils/live.ts and src/services/sync/ are unchanged, so Sync behaves exactly as before.
  • Version bumped to 4.43.0, with a CHANGELOG entry and README/CLAUDE.md docs.

Test evidence

Unit tests (tests/unit/dictation.test.ts, 22 new; 88/88 dictation tests pass, stable across 6 repeated runs)

  • The success path is unchanged: one live request, no buffered request.
  • A mid-upload 413 triggers the fallback. The buffered request carries all the audio, including chunks produced after the 413, in order and once each. It has the same config (PCM fields and llm_instruction), filename and Content-Type, and final_text is derived. The live body is ended at the 413.
  • A 413 after the full body was sent (an fs stream, byte-exact against the file; audio held whole) triggers the fallback.
  • fallbackToBuffered: false throws the live 413 as before, with one request, and the producer is released.
  • No fallback on another 413 code (payload_too_large, or a problem-details 413 titled "Payload Too Large"), a 429, a 500, a 401, or a transport failure.
  • A failed fallback throws the buffered request's own error (413 message; 503 with Retry-After).
  • A producer error while draining is rethrown with no fallback request.
  • An abort signal while draining, and session.abort() after the 413, make no fallback request and release the producer.
  • 256 MB guard: draining stops at the chunk that crosses the cap, throws 413 audio_too_large, and makes no fallback request.
  • The live deadline doesn't stop draining (timeout: 50 with about 200 ms of recording still succeeds with all audio). The buffered request gets a fresh deadline.
  • Both transcribeLive() and openLive() are covered (writes after the 413, a 413 at close, abort(), the opt-out, the deadline).
  • One existing test now opts out, because its problem-details 413 "Audio Too Large" payload now triggers the fallback.

The full unit suite gives 570 pass and 3 fail. The 3 failures are in tests/unit/utils.test.ts (user-agent runtime_env) and fail the same way on unmodified main under Node 22. lint:eslint (no new warnings), lint:tsc, lint:format, publint and build all pass. Integration tests were not run.

Real API (production dictation.assemblyai.com, built package from this branch, 5 requests total)

The audio is 130 s of 16 kHz mono S16LE speech. The producer sends the first 121 s as fast as the upload takes it, then 1 s chunks with 250 ms sleeps, so the 413 lands while it is still recording.

Run Endpoints hit Live 413 at Producer at 413 Chunks after 413 Buffered POST Result
transcribeLive() 130 s /v1/transcribe/live → 413, then /v1/transcribe → 200 716 ms active (121 s sent) 9 4,160,369 B (4,160,000 audio), 200 after 1.8 s audio_duration_ms=130000, 300 words, 4.6 s total
openLive() 130 s /v1/transcribe/live → 413, then /v1/transcribe → 200 807 ms active (writes still coming) 6 4,160,369 B, 200 after 1.7 s audio_duration_ms=130000, 300 words, 4.0 s total
transcribeLive() 30 s /v1/transcribe/live → 200 only n/a n/a n/a none audio_duration_ms=30000, 73 words, 1.5 s total

The script lives outside the repo and is not part of this PR.

Notes for reviewers

  1. The fallback depends on POST /v1/transcribe staying available. It is publicly documented today, but a docs PR (AssemblyAI/assemblyai-docs#490) proposes removing it from the docs. The docs and this fallback need reconciling before either lands.
  2. Raising the live endpoint's 120 s cap server-side would make this fallback unnecessary. If that is on the roadmap, this could ship as a stopgap or be skipped.
  3. The 256 MB guard uses 256 MiB (268,435,456 bytes). The buffered endpoint's own cap is enforced server-side, and a fallback past 20 minutes surfaces that request's own 413.

🤖 Generated with Claude Code

… s cap

The live endpoint (`POST /v1/transcribe/live`) refuses audio past 120 s
with 413 `audio_too_large`, so a live dictation over two minutes failed.
With the new `DictationLiveOptions.fallbackToBuffered` (default `true`),
`transcribeLive()` and `openLive()` keep an in-memory copy of every audio
byte they upload. On that 413 (mid-upload or after the body was sent) they
read the source on until it ends, then post the whole copy once to the
buffered endpoint (`POST /v1/transcribe`, up to 20 minutes / 256 MB) with
the same config, file name and content type, and return that result like
a live one.

Any other failure is unchanged; a failed buffered request throws its own
error; abort() or an abort signal drops everything with no fallback
request; a copy past 256 MB is dropped and throws DictationError (413,
`audio_too_large`). The live deadline stops applying once the fallback
engages and the buffered request gets a fresh `timeout`. With
`fallbackToBuffered: false` no copy is kept and the live 413 is thrown as
before. Bump to 4.43.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant