Repository navigation
feat: stream the sync upload with transcribe_stream() - #241
Conversation
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>
- 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>
|
Pushed 8dde898 on top of the branch with a set of hardening fixes from review. Behaviour changes
Docs
Tests (
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 |
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>
|
Pushed ec0f2c9 with a fix found while exercising the branch end to end from a quickstart script. Bug: the streaming endpoint returns Fix: Verified: 509 passed locally, ruff and mypy clean. Live on prod after the fix, Worth a thought server-side: defaulting an absent |
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>
|
Pushed b0d429d: Why the rename. The package already has a "streaming" API ( Scope. Public method on both transcribers, the impl, the transport functions, error messages, tests ( 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 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 |
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>
…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>
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>
…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>
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>
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>
Adds
SyncTranscriber.transcribe_stream()and itsAsyncSyncTranscribercounterpart, which post audio toPOST /v1/transcribe/streamas 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.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. Sotranscribe_stream()takes an iterable of chunks or a lazily-read file object, and rejects paths and bytes buffers by name, pointing attranscribe().This mirrors the server, where the streamed route is its own path rather than a header on
/v1/transcribefor 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) —StreamingMultipartEncoderplus the chunk iterators, shared by both transports. It emitsconfigbeforeaudioby construction, since the server decodes as bytes land and needssample_rate/channelsbefore the first audio byte. Separate module because_base.pyimportsapi.py, so anythingapi.pyneeds cannot live there.api.transcribe_stream/async_api.transcribe_stream— POST an unsized iterator, which httpx frames asTransfer-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.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 back404 not_found, not 401.Tests
22 new tests. Body-shape assertions go through
httpx.MockTransport: on httpx < 0.23pytest_httpxrecords a request without consuming its body, so a body read after the call re-runs a spent generator or reads a closed file.MockTransportreads the stream like a real transport on every supported version.503 passedon py39, py311 and every floor env (httpx 0.22/0.24, pydantic 1.10). Ruff clean; mypy clean onassemblyai/sync.Not covered
audio/wav, matching what the buffered path already does.🤖 Generated with Claude Code