Skip to content

feat: stream the sync upload with transcribe_stream() - #241

Merged
ccampbell-aai merged 6 commits into
masterfrom
aurpsis/sync-chunked-upload
Sep 10, 2026
Merged

ccampbell-aai merged 6 commits into
masterfrom
aurpsis/sync-chunked-upload

Conversation

@aurpsis-aai

Copy link
Copy Markdown
Contributor

Adds SyncTranscriber.transcribe_stream() and its AsyncSyncTranscriber counterpart, which post audio to POST /v1/transcribe/stream as it is produced instead of waiting for the whole clip. The request starts immediately and the body goes out chunked, so authorization, the upload and every speech segment but the last resolve while the caller is still recording. What is left to wait for once they stop is the final segment.

def mic_chunks():
    while recording:
        yield stream.read(4096)

result = aai.SyncTranscriber().transcribe_stream(mic_chunks())

Why a separate method, not a flag on transcribe()

The two take different input. transcribe() reads its source into bytes, and audio you already hold whole cannot be overlapped with anything — streaming a file already on disk is slower than sending it in one piece, because the saving comes from overlapping the recording, not from chunking. So transcribe_stream() takes an iterable of chunks or a lazily-read file object, and rejects paths and bytes buffers by name, pointing at transcribe().

This mirrors the server, where the streamed route is its own path rather than a header on /v1/transcribe for the same reason: the two differ in part ordering, framing, size-cap enforcement, timeout basis, when errors can surface, GPU admission granularity, and whether the client may go silent.

Shape

The package already splits sync/async as parallel modules over a shared _base.py, so buffered/chunked is a second axis. Sync and async cannot share method bodies, so this stays function-level rather than introducing mix-ins:

  • sync/v1/_multipart.py (new) — StreamingMultipartEncoder plus the chunk iterators, shared by both transports. It emits config before audio by construction, since the server decodes as bytes land and needs sample_rate/channels before the first audio byte. Separate module because _base.py imports api.py, so anything api.py needs cannot live there.
  • api.transcribe_stream / async_api.transcribe_stream — POST an unsized iterator, which httpx frames as Transfer-Encoding: chunked. Error parsing, config serialization and the PCM/WAV decision are shared with the buffered path unchanged.
  • settings.sync_stream_http_timeout (180s) — the 60s buffered value cannot cover a recording.

transcribe() is untouched.

Validation

Live against a real deployment (sync.sandbox000.assemblyai-labs.com), upload paced at real time. The buffered arm waits out the recording first, so both pay their upload from the same instant. post_speech = last byte handed to the transport → parsed response; median of 3.

audio streamed buffered saving
10.0 s 158 ms 314 ms 50%
30.0 s 455 ms 652 ms 30%
101.6 s 339 ms 666 ms 49%

Transcripts byte-identical streamed vs buffered and async vs sync on every fixture. Below roughly a minute there are fewer than three segments, so nothing releases early and the win is upload elision only — which is what the short fixtures show.

A mid-upload rejection reaches the caller as SyncTranscriptError, verified through a real ALB rather than only a local server: httpx returns the early response cleanly, so no special recovery is needed. Note it arrives at the first segment boundary (~30s), not at request start — auth is awaited before the first GPU submission — and a bad key comes back 404 not_found, not 401.

Tests

22 new tests. Body-shape assertions go through httpx.MockTransport: on httpx < 0.23 pytest_httpx records a request without consuming its body, so a body read after the call re-runs a spent generator or reads a closed file. MockTransport reads the stream like a real transport on every supported version.

503 passed on py39, py311 and every floor env (httpx 0.22/0.24, pydantic 1.10). Ruff clean; mypy clean on assemblyai/sync.

Not covered

  • No live test of the >120s silence abort (the documented client duty).
  • Compressed audio is still unsupported on the stream route server-side; a non-PCM stream is labelled audio/wav, matching what the buffered path already does.

🤖 Generated with Claude Code

Adds `SyncTranscriber.transcribe_stream()` and its `AsyncSyncTranscriber`
counterpart, which post audio to `/v1/transcribe/stream` as it is produced
rather than waiting for the whole clip. The request starts immediately and the
body goes out chunked, so authorization, the upload and every speech segment
but the last resolve while the caller is still recording.

A separate method rather than a flag on `transcribe()`, because the two take
different input: `transcribe()` reads its source into bytes, and audio you
already hold whole cannot be overlapped with anything. Streaming a file already
on disk is slower than sending it in one piece, so the streamed path takes an
iterable of chunks or a file object read as it fills, and rejects paths and
bytes buffers by name.

Multipart framing moves to `_multipart.py`, which both transports share:
`config` is emitted before `audio` by construction, since the server decodes as
bytes land and needs `sample_rate` and `channels` before the first audio byte.
Error parsing, config serialization and the PCM/WAV decision are shared with the
buffered path — the server can reject mid-upload, and httpx surfaces that
response rather than a transport error, so the same envelope handling applies.

Streamed requests get their own budget, `settings.sync_stream_http_timeout`
(180s), which has to cover the recording as well as the transcription.

Co-Authored-By: Claude Fable 5 (Jeeves) <noreply@anthropic.com>
Comment thread assemblyai/sync/v1/_base.py Outdated
- Reject async iterables on SyncTranscriber.transcribe_stream() up front,
  pointing at AsyncSyncTranscriber, instead of failing mid-request.
- Validate every chunk is bytes-like; accept bytearray/memoryview and name
  text-mode files as the likely cause when a producer yields str.
- Escape the audio part's filename the way httpx's multipart encoder does,
  so quotes and control characters cannot corrupt the framing.
- Read file objects in a worker thread on the async path so a blocking
  read() no longer stalls the event loop.
- Correct the sync_stream_http_timeout docs: httpx timeouts bound each
  socket operation, not the request end to end, so the value does not
  need to cover the recording.
- Document that producer exceptions propagate unchanged; trim history
  from the resolve_format docstring.
- Tests: assert bounded read sizes instead of "lazily"; add coverage for
  each new behaviour (28 tests in the module, 509 overall).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@ccampbell-aai

Copy link
Copy Markdown
Contributor

Pushed 8dde898 on top of the branch with a set of hardening fixes from review. transcribe() and the wire format for well-formed input are unchanged; the buffered path is untouched.

Behaviour changes

  • Sync path rejects async iterables early (_base.py). check_chunks gained an allow_async keyword. SyncTranscriber.transcribe_stream() now raises a TypeError pointing at AsyncSyncTranscriber instead of passing validation and failing inside the body generator mid-request. The async client passes allow_async=True, so the shared helper still serves both paths (the Aikido suggestion above would have broken the async one).
  • Chunk type validation (_multipart.py, new _as_bytes). Both iterators accept bytes, bytearray and memoryview, and raise a clear TypeError on str naming a text-mode file as the likely cause. Previously a file opened without "b" died deep in h11 with "a bytes-like object is required".
  • Filename escaping (StreamingMultipartEncoder.audio_header). Quotes, backslashes and control characters in a file object's name are escaped using the same replacement table httpx's own multipart encoder uses, so a name can no longer break out of the Content-Disposition header.
  • Async file reads run off the event loop (aiter_chunks). File objects are read via asyncio.to_thread (available on the 3.9 floor), so a read() blocking on a pipe or socket no longer stalls the loop. Plain sync iterables are still consumed inline, as documented.

Docs

  • Timeout wording corrected in types.py, api.py, async_api.py and CLAUDE.md. httpx timeouts bound each socket operation (connect, each write, each read while waiting for the response), not the request end to end, and no timer runs while the caller's generator is blocked waiting for audio. The previous text said the value "must cover the recording", which it does not and need not. The 180s setting itself is kept; whether a separate setting is still warranted is your call.
  • Raises sections now mention chunk type errors and that producer exceptions propagate unchanged with the connection dropped.
  • Trimmed the "split out of _resolve_audio" history from the resolve_format docstring.

Tests (test_sync_stream.py, 22 → 28)

  • Renamed test_body_reads_a_file_object_lazily to ..._in_bounded_pieces; it now uses a spy file object and asserts the read sizes and count, rather than only the final body.
  • Added: async iterable rejected on the sync path, str chunks rejected, text-mode file rejected, bytearray/memoryview accepted, filename escaping, async file reads observed on a worker thread.

Verified locally: 509 passed on py3.9; the sync test files pass on the httpx 0.22 and 0.24 floors; ruff check/format clean on touched files; mypy clean on assemblyai/sync. Only py3.9 is on the machine so the py311 tox envs were not run here; CI will cover them.

ccampbell-aai and others added 2 commits September 9, 2026 11:27
The streaming endpoint rejects a body whose audio is not preceded by a
`config` part (400: "the `config` part must be sent before the `audio`
part"). With a default SyncTranscriptionConfig the serializer yields no
options and the part was omitted, so the documented no-config call
`transcribe_stream(mic_chunks())` failed against both sandbox000 and prod.

Send the part unconditionally, as `{}` when there are no options; the
server accepts that. The buffered route still omits it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@ccampbell-aai

Copy link
Copy Markdown
Contributor

Pushed ec0f2c9 with a fix found while exercising the branch end to end from a quickstart script.

Bug: the streaming endpoint returns 400 "the config part must be sent before the audio part on the streaming endpoint, because decoding cannot start without it" when the body has no config part at all. With a default SyncTranscriptionConfig, _config_to_json yields no options and StreamingMultipartEncoder.config_part returned nothing, so the part was omitted and the documented no-config call, transcribe_stream(mic_chunks()), failed. Reproduced against both sandbox000 and prod with a WAV stream; passing any option (e.g. timestamps=True) made it work, which is presumably why the live validation did not catch it.

Fix: config_part now always emits the part, as {} when there are no options. A raw probe confirmed the server accepts an empty object. The buffered route is unchanged and still omits the part. test_body_omits_the_config_part_when_there_is_no_config became test_body_sends_an_empty_config_part_when_there_is_no_config, asserting ["config", "audio"] ordering and a {} payload; the two config arg docstrings were updated.

Verified: 509 passed locally, ruff and mypy clean. Live on prod after the fix, test.wav paced at real time: streamed post-speech ~180 ms vs ~380 ms buffered, transcripts identical; mic PCM path also verified on sandbox000.

Worth a thought server-side: defaulting an absent config part rather than rejecting it would make the route forgiving of other clients too, but the SDK fix stands either way.

The package already has a "streaming" API (streaming.v3) that streams
results back over a websocket; a Sync API method called transcribe_stream
read as more of the same. It streams the upload and returns one finished
transcript, so name it after what it is for: audio that is still being
recorded.

Public rename on both transcribers plus the impl, transport functions,
error messages and docs; settings.sync_stream_http_timeout becomes
sync_live_http_timeout. The route constant keeps the server's name.

README gains the missing examples: a copy-pasteable sounddevice microphone
producer, an asyncio producer fed from a capture callback, a file-object
source, the distinction from the streaming API, and the settings entry.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@ccampbell-aai

Copy link
Copy Markdown
Contributor

Pushed b0d429d: transcribe_stream() → transcribe_live(), plus README examples.

Why the rename. The package already has a "streaming" API (streaming.v3) that streams results back over a websocket, and this method streams the upload and returns one finished transcript. transcribe_stream invited the wrong expectation, especially next to transcribe_async. transcribe_live says what it is for: audio that is still being recorded.

Scope. Public method on both transcribers, the impl, the transport functions, error messages, tests (test_sync_stream.py → test_sync_live.py) and docs. settings.sync_stream_http_timeout → sync_live_http_timeout. The route constant keeps the server's name.

README. The Sync section gains: a copy-pasteable sounddevice microphone producer with a stop thread, an asyncio producer fed from a capture callback, a recorder-subprocess file-object source, an explicit "this is not the streaming API" note pointing at RealTimeTranscriber, the 120 s cap and late-auth-error caveats with warm(), and the settings entry. The transcriber table and the AsyncSyncTranscriber summary line mention the method.

Verified: 509 passed, ruff/mypy clean, and the quickstart script's live prod runs pass under the new name.

Stacked on this: #244 adds a push-style open_live() session for callback-driven sources, built on transcribe_live().

warm() treats any HTTP response as success, so it cannot fail fast on a
bad key as the live-upload README section claimed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@ccampbell-aai
ccampbell-aai merged commit f70dba9 into master Sep 10, 2026
8 checks passed
ccampbell-aai added a commit that referenced this pull request Sep 10, 2026
…ell/dictation-sdk

Keeps the shared _audio helpers for sync's _resolve_audio and adds
master's resolve_format() as a wrapper over _audio.resolve_format so
stream_filename() keeps its helper; merges the typing imports in
sync/v1/api.py; keeps both sync_live_http_timeout and the dictation
settings. Version to 1.7.0 on top of master's 1.6.0.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
ccampbell-aai added a commit that referenced this pull request Sep 10, 2026
The merge left two identical copies of the streaming multipart encoder:
sync/v1/_multipart.py from #241 and the product-neutral
assemblyai/_multipart.py the dictation client uses. Sync now imports the
shared one, including _Aborted, and its own copy is removed. No behaviour
change; the sync live tests import _STREAM_READ_SIZE from the shared path.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
ccampbell-aai added a commit that referenced this pull request Sep 10, 2026
…ell/dictation-sdk

Keeps the shared _audio helpers for sync's _resolve_audio and adds
master's resolve_format() as a wrapper over _audio.resolve_format so
stream_filename() keeps its helper; merges the typing imports in
sync/v1/api.py; keeps both sync_live_http_timeout and the dictation
settings. Version to 1.7.0 on top of master's 1.6.0.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
ccampbell-aai added a commit that referenced this pull request Sep 10, 2026
The merge left two identical copies of the streaming multipart encoder:
sync/v1/_multipart.py from #241 and the product-neutral
assemblyai/_multipart.py the dictation client uses. Sync now imports the
shared one, including _Aborted, and its own copy is removed. No behaviour
change; the sync live tests import _STREAM_READ_SIZE from the shared path.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
ccampbell-aai added a commit that referenced this pull request Sep 10, 2026
Master is at 1.4.0 (the sync live stack squash-merged with only #241's
bump surviving), so 1.7.0 skipped two versions.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
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.

2 participants