Repository navigation
feat(tts): Text-to-Speech HTTP client - #277
Conversation
Adds `aai.TextToSpeech` and `aai.AsyncTextToSpeech` for the Text-to-Speech HTTP API (`POST /v1/speech`): `synthesize()` returns the complete audio, `stream()` yields it as the API generates it. Options are a `TTSConfig` (voice or voice_id, model_id, language, sample_rate, encoding, container, bit_rate); errors are `TTSError` with the API's envelope fields. The host comes from the new `settings.tts_base_url` (US zone by default). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Pydantic 1.10 also exports `ConfigDict`, so the version probe took the v2 path on v1 and `model_config` became a field (CI py311-pydantic1.10). Use the `pydantic_v2` flag from `types.py`. From three fresh-user runs: a swapped `(config, text)` call now says "text comes first"; the README and CLAUDE.md gain the preset voice list, rates, the 5,000-character limit, chunk-size and barge-in notes, the wave-module caveat for G.711 WAV, the TTS settings and env var, and a closed stream in the telephony example. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…pr, retry hints - `stream()` returns the stream without sending; the request goes out on `with` / `async with` or the first read, so the asyncio call is `async with tts.stream(...)` instead of `async with await tts.stream(...)`. - A body cut after the first audio raises `TTSError(retryable=True)` from the iteration instead of `httpx.RemoteProtocolError`, so httpx leaves the documented contract. Connect and read timeouts stay httpx exceptions, as in the rest of the SDK. - `TTSResponse.audio` is excluded from `repr()`. - `retry_after_seconds` is rounded up to whole seconds. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
aleks-mitov
left a comment
There was a problem hiding this comment.
I reviewed this against the gateway (DeepLearning master, api_tts/http_api) and today's TTS API decisions, and checked it live against sandbox000. The structure follows the SDK well, and the request fields, hosts, error envelope parsing and the buffered/streamed split all match the server.
Two API changes land in the next TTS releases and affect this client:
voiceis retired. Every voice is named byvoice_id(anaai-<name>library slug such asaai-jane, a voice UUID, or the account's own voice). A request that sendsvoiceis refused with 422invalid_parameteronvoice.voice_id=aai-janealready works today for all eleven built-in voices.X-TTS-Voiceis removed. The newX-TTS-Voice-Idheader carries the voice used (the request'svoice_id, or the default's slug,aai-jane, when none was sent).
I opened #278 into this branch with those changes plus the verified fixes below, so you can merge it here or cherry-pick:
TTSConfighas onlyvoice_id; passingvoicefails locally with a message pointing tovoice_id. Examples, README, CLAUDE.md and tests useaai-slugs only.TTSResponse/TTSStream.infoexposevoice_idfromX-TTS-Voice-Idand no longer readX-TTS-Voice.- The biometric terms errors (403
biometric_terms_not_accepted, 503auth_unavailable), merged in DeepLearning tonight, are documented onTTSErrorandextend_biometric_consent. - A stream cut after the first audio (
RemoteProtocolError,ReadError,ReadTimeoutduring iteration) now raisesTTSErrorwithstatus_code200,retryabletrue and therequest_id, sync and async. synthesize()read timeout: 120 s became 610 s, so the server's 600 s cap and its 504session_expiredarrive as aTTSErrorinstead of a rawhttpx.ReadTimeout.model_idno longer triggers pydantic 2.0 to 2.9's protected-namespaceUserWarningon everyimport assemblyai(protected_namespaces=()).- Docstrings: the
voice_idlanguage rule (the eleven built-in voices speak all six languages; other voices only their own),voice_expires_atandbilled_characterson a stream are the values when the stream started and provisional, andaudio_duration_msis also sent on a complete response without audio. containeraccepts a string likeencoding(the server is case-insensitive);TTSResponse.audioisrepr=Falseso logging a response doesn't dump the clip.- 18 more unit tests (sync and async), including ones that fail if the stream close paths are removed. The full suite passes on the tox factors (pydantic 1.10, 2.9, latest; httpx 0.22, 0.24, latest), plus ruff and mypy as CI runs them.
Left for you, since they're design calls:
- Async stream shape:
AsyncTextToSpeech.streamisasync def, so callers must writeasync with await tts.stream(...); the naturalasync with tts.stream(...)fails with aTypeError. Returning an object that is both awaitable and an async context manager would matchhttpx'sclient.stream(). - Sync lifecycle:
TextToSpeechhas noclose()or__enter__, whileAsyncTextToSpeechsupportsasync with/aclose()and the other sync clients supportwith. - httpx floor:
client.build_request(..., timeout=...)needs httpx 0.20, butsetup.pydeclareshttpx>=0.19.0. - Headers not exposed:
X-TTS-Region,Server-Timing(open,model,ttfa),X-TTS-Unknown-Params, and no way to sendX-Client-Request-Id. - Error extras: the envelope's
limit/limit_window(on 429 and size refusals) are dropped;TTSError.errorsisList[dict]whileLLMGatewayError.errorsisList[str]. - README: no mention of
model_id(assemblyai-tts-1) orextend_biometric_consent.
Note: the PR description says the full unit suite passed, but the Python 3.11 / pydantic 1.10 job failed on the pydantic detection; your 95ebec1 fixed that, and #278 makes the detection independent of pydantic-settings and adds a test for it.
…ract From Aleks's review and #278, integrated onto the lazy streams: - `TTSConfig` has only `voice_id`; a `voice` key is refused with a message that points to `voice_id`. Docs and examples use `aai-` slugs. - Responses expose `voice_id` (from `X-TTS-Voice-Id`, derived from `X-TTS-Voice` until the API sends it) and `region`. - A stream cut by `RemoteProtocolError`, `ReadError` or `ReadTimeout` raises `TTSError(retryable=True)`. - `synthesize()` reads for up to 610 s (the API caps a request at 600 s, so its `504 session_expired` arrives as a `TTSError`); connect and send keep `tts_http_timeout`. - `protected_namespaces=()` so `model_id` does not warn on pydantic 2.0-2.9. - `container` takes a string like `encoding`; biometric-terms errors and consent renewal are documented. - Streams open with `client.stream()` instead of `build_request(timeout=)`, which the declared httpx floor (0.19) lacks. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
@aleks-mitov thanks, this was thorough. Integrated into 0ac0598 on top of the lazy streams (abeedaa) rather than merging #278, which conflicted with it; please close #278 once you've checked nothing is missing. Taken: voice_id-only config with the pointer message; Left as is: a sync Full suite passes on pydantic 2, pydantic 1.10 and httpx 0.22; CI is green. Marking ready for review. |
aleks-mitov
left a comment
There was a problem hiding this comment.
Thanks for the quick follow-ups, Ben: abeedaa and 0ac0598 take care of most of the earlier review (voice_id only, X-TTS-Voice-Id, lazy streams, cut bodies as TTSError, the 610 s read timeout, the biometric codes, repr, container strings and the httpx floor). These inline comments are the items still open at 0ac0598, plus two that come from the lazy streams; where #278 already has the fix, the comment says so. The earlier summary review covers everything else.
…view follow-ups `types.pydantic_v2` is False on a default install (pydantic 2 without pydantic-settings on Python <= 3.13), so the TTS models took the v1 branch under pydantic 2 and `import assemblyai` warned. Probe a v2-only name here, as the dictation models do, and test that the models are native to the installed pydantic and define without warnings. Also from review: `info` raises before the stream is opened instead of being Optional; `TextToSpeech` closes a client built from `api_key=` and supports `with`; `TTSError.body` keeps the whole envelope; docs fixed for the 610 s timeout, the lazy stream, `audio_duration_ms` of an empty stream, cut errors and the biometric codes; connection release paths are tested. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
a6a1805 to
8150b2c
Compare
What
aai.TextToSpeechandaai.AsyncTextToSpeech: a client for the Text-to-Speech HTTP API,POST /v1/speechontts.us.assemblyai.com(ortts.eu.assemblyai.comviasettings.tts_base_url).synthesize(text, config)returns aTTSResponse: the complete audio plus the response headers (request_id,region,model,voice_id,language,encoding,sample_rate,container,bit_rate,billed_characters,audio_duration_ms,voice_expires_at).voice_idreadsX-TTS-Voice-Idand, until the API sends it, is derived fromX-TTS-Voice.stream(text, config)returns aTTSStreamcontext manager that yields audio bytes as the API generates them, with the same header info on.info(audioexcluded fromrepr()on responses).TTSConfigcarries the request options:voice_id(every voice is named by it:aai-jane, a UUID, or the account's own slug;voiceis being retired by the API and is refused with a message pointing tovoice_id),model_id,language,sample_rate,encoding(TTSEncoding),container(TTSContainer),bit_rate,extend_biometric_consent. Unknown fields are rejected. A per-call config replaces the client's default whole, as the dictation and sync clients do.TTSErrormaps the API's error envelope:status_code,error_code,param,request_id,retryable,retry_after,errors.tts_base_url,tts_http_timeout(streamed, per socket operation; also connect and send for buffered requests) andtts_synthesize_http_timeout(610 s: the buffered read waits for the whole clip, and outlasts the API's 600 s request cap so its504 session_expiredarrives as aTTSError).Scope is the plain
/v1/speechroute only. The OpenAI-compatible route,/v1/speech/events(word boundaries), the Voices API and the WebSocket API are not included; voice management is done in the dashboard. The WebSocket client is the next PR.How
Follows the
dictation/v1layout (models,api,async_api,_base,client,async_client) and the LLM gateway's response streaming.synthesize()asks for"stream": falseso an API refusal is always an error status, never a cut body, and the WAV header carries real sizes.stream()returns aTTSStreamwithout sending anything; the request goes out onwith/async withor the first read (httpx's ownclient.stream()pattern), so a refusal raises there and the asyncio call is a plainasync with tts.stream(...). A body cut after the first audio (RemoteProtocolError,ReadErrororReadTimeoutduring iteration) raisesTTSError(retryable=True)with the request id. Connect and first-byte timeouts raise httpx exceptions, as elsewhere in the SDK. Streams open withclient.stream()rather thanbuild_request(timeout=), which the declared httpx floor lacks.Tests
tests/unit/test_tts.pyandtest_tts_async.py: 42 tests over the body shape, lazy stream open, cut bodies, thevoice_idheader fallback, biometric-terms errors, header parsing, both error shapes (envelope and non-JSON), retry hints, the EU host setting, per-route timeouts, config validation, argument-order mistakes and pool ownership. Full unit suite passes on pydantic 2, pydantic 1.10 and httpx 0.22 (the tox floor envs).tts.us.assemblyai.com: buffered WAV (parses withwave), streamed PCM (first chunk in ~90 ms warm, ~200 ms cold), 8 kHz mu-law and A-law, MP3, the EU host, theinvalid_parameterandvoice_not_foundenvelopes, and the async twin.str(TTSError)is the message only (matches the other SDK errors; fields are attributes),encodingaccepts any string so a typo is caught by the server with a listing, andhelp(TTSConfig)shows no per-field descriptions because the SDK's models use attribute docstrings.🤖 Generated with Claude Code