Skip to content

feat(tts): Text-to-Speech HTTP client - #277

Merged
bgotthold-aai merged 5 commits into
masterfrom
ben/tts-http-client
Oct 21, 2026
Merged

bgotthold-aai merged 5 commits into
masterfrom
ben/tts-http-client

Conversation

@bgotthold-aai

@bgotthold-aai bgotthold-aai commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

What

aai.TextToSpeech and aai.AsyncTextToSpeech: a client for the Text-to-Speech HTTP API, POST /v1/speech on tts.us.assemblyai.com (or tts.eu.assemblyai.com via settings.tts_base_url).

  • synthesize(text, config) returns a TTSResponse: 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_id reads X-TTS-Voice-Id and, until the API sends it, is derived from X-TTS-Voice.
  • stream(text, config) returns a TTSStream context manager that yields audio bytes as the API generates them, with the same header info on .info (audio excluded from repr() on responses).
  • TTSConfig carries the request options: voice_id (every voice is named by it: aai-jane, a UUID, or the account's own slug; voice is being retired by the API and is refused with a message pointing to voice_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.
  • TTSError maps the API's error envelope: status_code, error_code, param, request_id, retryable, retry_after, errors.
  • New settings: tts_base_url, tts_http_timeout (streamed, per socket operation; also connect and send for buffered requests) and tts_synthesize_http_timeout (610 s: the buffered read waits for the whole clip, and outlasts the API's 600 s request cap so its 504 session_expired arrives as a TTSError).

Scope is the plain /v1/speech route 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/v1 layout (models, api, async_api, _base, client, async_client) and the LLM gateway's response streaming. synthesize() asks for "stream": false so an API refusal is always an error status, never a cut body, and the WAV header carries real sizes. stream() returns a TTSStream without sending anything; the request goes out on with / async with or the first read (httpx's own client.stream() pattern), so a refusal raises there and the asyncio call is a plain async with tts.stream(...). A body cut after the first audio (RemoteProtocolError, ReadError or ReadTimeout during iteration) raises TTSError(retryable=True) with the request id. Connect and first-byte timeouts raise httpx exceptions, as elsewhere in the SDK. Streams open with client.stream() rather than build_request(timeout=), which the declared httpx floor lacks.

Tests

  • tests/unit/test_tts.py and test_tts_async.py: 42 tests over the body shape, lazy stream open, cut bodies, the voice_id header 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).
  • Live against production tts.us.assemblyai.com: buffered WAV (parses with wave), streamed PCM (first chunk in ~90 ms warm, ~200 ms cold), 8 kHz mu-law and A-law, MP3, the EU host, the invalid_parameter and voice_not_found envelopes, and the async twin.
  • Three independent first-time users (voice agent, telephony, README copy-paste on Python 3.9) installed the branch into fresh venvs and used it from the docs alone. All examples ran; their findings became the doc corrections and the swapped-argument error message in the second commit. Known and left as is: str(TTSError) is the message only (matches the other SDK errors; fields are attributes), encoding accepts any string so a typo is caught by the server with a listing, and help(TTSConfig) shows no per-field descriptions because the SDK's models use attribute docstrings.
  • ruff format/check and mypy clean on the new files.

🤖 Generated with Claude Code

bgotthold-aai and others added 2 commits October 21, 2026 16:39
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>
Comment thread assemblyai/tts/v1/async_client.py Outdated
Comment thread assemblyai/tts/v1/api.py Outdated
Comment thread assemblyai/tts/v1/models.py Outdated
Comment thread assemblyai/tts/v1/api.py Outdated
…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 aleks-mitov left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. voice is retired. Every voice is named by voice_id (an aai-<name> library slug such as aai-jane, a voice UUID, or the account's own voice). A request that sends voice is refused with 422 invalid_parameter on voice. voice_id=aai-jane already works today for all eleven built-in voices.
  2. X-TTS-Voice is removed. The new X-TTS-Voice-Id header carries the voice used (the request's voice_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:

  • TTSConfig has only voice_id; passing voice fails locally with a message pointing to voice_id. Examples, README, CLAUDE.md and tests use aai- slugs only.
  • TTSResponse / TTSStream.info expose voice_id from X-TTS-Voice-Id and no longer read X-TTS-Voice.
  • The biometric terms errors (403 biometric_terms_not_accepted, 503 auth_unavailable), merged in DeepLearning tonight, are documented on TTSError and extend_biometric_consent.
  • A stream cut after the first audio (RemoteProtocolError, ReadError, ReadTimeout during iteration) now raises TTSError with status_code 200, retryable true and the request_id, sync and async.
  • synthesize() read timeout: 120 s became 610 s, so the server's 600 s cap and its 504 session_expired arrive as a TTSError instead of a raw httpx.ReadTimeout.
  • model_id no longer triggers pydantic 2.0 to 2.9's protected-namespace UserWarning on every import assemblyai (protected_namespaces=()).
  • Docstrings: the voice_id language rule (the eleven built-in voices speak all six languages; other voices only their own), voice_expires_at and billed_characters on a stream are the values when the stream started and provisional, and audio_duration_ms is also sent on a complete response without audio.
  • container accepts a string like encoding (the server is case-insensitive); TTSResponse.audio is repr=False so 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.stream is async def, so callers must write async with await tts.stream(...); the natural async with tts.stream(...) fails with a TypeError. Returning an object that is both awaitable and an async context manager would match httpx's client.stream().
  • Sync lifecycle: TextToSpeech has no close() or __enter__, while AsyncTextToSpeech supports async with / aclose() and the other sync clients support with.
  • httpx floor: client.build_request(..., timeout=...) needs httpx 0.20, but setup.py declares httpx>=0.19.0.
  • Headers not exposed: X-TTS-Region, Server-Timing (open, model, ttfa), X-TTS-Unknown-Params, and no way to send X-Client-Request-Id.
  • Error extras: the envelope's limit / limit_window (on 429 and size refusals) are dropped; TTSError.errors is List[dict] while LLMGatewayError.errors is List[str].
  • README: no mention of model_id (assemblyai-tts-1) or extend_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>
@bgotthold-aai
bgotthold-aai marked this pull request as ready for review October 21, 2026 22:37
@bgotthold-aai

Copy link
Copy Markdown
Contributor Author

@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; voice_id and region on responses (voice_id falls back to X-TTS-Voice until the API sends X-TTS-Voice-Id, since prod still sends the old header today); cut errors cover RemoteProtocolError, ReadError and ReadTimeout; 610 s buffered read with connect/send on tts_http_timeout; protected_namespaces=(); container as a string; the biometric-terms docs; and the httpx floor, fixed by opening streams with client.stream() so 0.19 works without touching setup.py. The async shape was already fixed in abeedaa: async with tts.stream(...).

Left as is: a sync with/close() on TextToSpeech, which owns nothing (the transcribers have one because they own an executor); the extra headers, which client.last_response exposes raw; and limit/limit_window plus the errors type, where I'd rather keep the API's own shape than add fields for two refusal kinds. Happy to revisit any of these if you feel strongly.

Full suite passes on pydantic 2, pydantic 1.10 and httpx 0.22; CI is green. Marking ready for review.

@aleks-mitov aleks-mitov left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread assemblyai/tts/v1/models.py Outdated
Comment thread README.md Outdated
Comment thread README.md Outdated
Comment thread tests/unit/test_tts.py
Comment thread assemblyai/tts/v1/models.py Outdated
Comment thread assemblyai/tts/v1/client.py
Comment thread assemblyai/tts/v1/api.py
Comment thread assemblyai/tts/v1/api.py
Comment thread assemblyai/tts/v1/models.py Outdated
Comment thread README.md Outdated
…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>
@bgotthold-aai
bgotthold-aai merged commit f3be1af into master Oct 21, 2026
8 checks passed
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.

3 participants