Skip to content

feat(tts): WebSocket session client - #279

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

bgotthold-aai merged 21 commits into
masterfrom
ben/tts-ws-client

Conversation

@bgotthold-aai

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

Copy link
Copy Markdown
Contributor

Builds on the HTTP client from #277 (merged).

What

aai.TTSSession and aai.AsyncTTSSession: a client for the Text-to-Speech WebSocket API (wss://tts.us.assemblyai.com/v1/ws, host from settings.tts_base_url). One connection, text in as it is written, audio out, for voice agents.

  • with aai.TTSSession(config=TTSConfig(voice_id="aai-jane")) as session: connects and waits for Begin; a refusal raises TTSError there. Leaving the block sends Terminate, waits for Termination and sets session.termination.
  • speak(text) sends Generate + Flush (splitting text longer than one frame) and returns an iterator of that request's audio bytes, ending at its FlushDone.
  • generate(text), flush() -> flush_id, audio(flush_id) stream text in as an LLM produces it and pipeline requests; cancel() is the barge-in; keep_alive() for sparse sessions.
  • on(TTSEvents.X, handler) delivers Audio, FlushDone, WordBoundaries, Cancelled, Termination and Error frames to handler(event) on the reader thread. With an Audio handler registered, audio is pushed instead of queued for pulling.
  • token= for a temporary token (query parameter) instead of the API key header; word_boundaries and inactivity_timeout as keyword arguments. TTSConfig and TTSError are shared with the HTTP client (container/bit_rate are left out of the URI; an Error frame maps error_type to error_code with the frame in body).
  • AsyncTTSSession: async with, coroutine sends, async for chunk in session.speak(text), handlers may be coroutines.

Out of scope: reconnect/backoff, the OpenAI route, voice management.

How

Mirrors the streaming STT client's shape (reader thread or task, lazy websockets import through its connect seams, websockets>=11) with a pull queue on top, since the HTTP client already teaches for chunk in .... Details that matter:

  • flush_id is tracked client-side: every Flush and Cancel advances it, as the API does. A Cancel records a watermark so every pipelined request it dropped ends its audio() iteration instead of hanging.
  • The pull queue is bounded (2,000 frames, about 160 s); when nothing reads it the oldest audio is dropped with one warning, so a push-style caller cannot grow memory without bound.
  • Close codes come from the close frame (exc.rcvd; none means 1006), the non-deprecated reading on every supported websockets release; 1006, 1011, 1012 and bare 3005/3008/3009 closes are retryable. An unreadable frame ends the session with a TTSError rather than killing the reader. A send after a failure raises the stored error, and a failure nothing in the with block observed is raised when the block ends.
  • Settings are copied (resolve_settings), so a session builds no HTTP pool and token= does not cache a keyless default client.
  • A session object can be reused: connect() resets its per-connection state.

Tests

  • tests/unit/test_tts_session.py and test_tts_session_async.py: 37 tests against a small fake server that answers Generate/Flush/Cancel/Terminate the way the API does: URI and headers, speak, pipelining, barge-in before and after a request, pipelined cancel, errors before and after Begin, abnormal close, unreadable frames, handlers (sync and coroutine), the pull/push rule, token auth, settings host and scheme, text limits, reuse, the bounded queue.
  • Full suite (830) passes on pydantic 2 with and without pydantic-settings, pydantic 1.10, httpx 0.22 and websockets 11.0.
  • Live against production: first audio 84 to 118 ms per sentence on a warm session, pipelined flush ids, barge-in with the next sentence back in ~300 ms, six word-boundary frames, the async twin over 8 kHz mu-law, session reuse, and a voice_not_found refusal raised from the with.
  • ruff and mypy clean on the new files. A high-effort self-review ran before the first push; its ten findings are fixed in the first commit.
  • Three independent first-time users (voice agent with token streaming and barge-in, telephony with mu-law re-framing, word boundaries, temporary tokens and idle timeouts, and a README copy-paster on Python 3.9 with mypy) installed the branch into fresh venvs and used it from the docs alone. Their findings are the second fix commit: a barge-in no longer plays in-transit audio, audio() cannot hang on an unissued id and can follow the request being written, misspelled event names and pulling under an Audio handler are refused, HTTP-only config options are refused on a session, session errors carry the session id, and a session opens cleanly under -W error (shared _audio._config_to_json now prefers model_dump, a one-line change that also removes the pydantic warning from the dictation and sync clients).

🤖 Generated with Claude Code

Adds `aai.TTSSession` and `aai.AsyncTTSSession` for the Text-to-Speech
WebSocket API (`/v1/ws`): one connection, text in as it is written, audio
out. `with` connects and waits for `Begin`; `speak(text)` yields one
request's audio; `generate()` / `flush()` / `audio(flush_id)` stream text in
and pipeline requests; `cancel()` is the barge-in; `on(TTSEvents.X, handler)`
delivers the other frames. Exit sends `Terminate` and waits for
`Termination`. Refusals and failures are `TTSError`. Temporary tokens go in
the query (`token=`). Reuses `TTSConfig`, `TTSError` and the settings host.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@bgotthold-aai
bgotthold-aai changed the base branch from ben/tts-http-client to master October 21, 2026 23:24
@bgotthold-aai bgotthold-aai reopened this Oct 21, 2026
bgotthold-aai and others added 18 commits October 21, 2026 23:32
…3.9)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- `cancel()` ends the current `audio()` iteration at once and discards the
  request's in-transit audio, so a barge-in does not play stale speech.
- `audio(flush_id)` raises for an id no `flush()` has returned instead of
  waiting forever; with no id it follows the request being written, so a
  player thread can start before the flush.
- `on()` refuses an unknown event name; pulling while an `Audio` handler is
  registered raises instead of yielding nothing; a send on a session that is
  not open says how to open one.
- `container`/`bit_rate` in a session's config are refused, not dropped.
- Session errors carry the session id as `request_id`.
- No warnings under `-W error`: the shared config dump uses `model_dump`
  when available, and the sync seam passes `legacy=True` on websockets 17.
- Docs: raw-audio-only sessions, the text-rate limit, the 120 s default
  idle timeout, `Begin` and `session.begin`, word-boundary timing, an async
  code block, the error fields on a session, and a barge-in example that
  breaks.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Drain audio() and speak() through a helper with a deadline (a thread
joined with a timeout for the sync session, asyncio.wait_for for the
async one), so a regression in the flush_id or cancel bookkeeping fails
the run instead of hanging it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KcyY6MAEUVjqBt5pMpetvD
ConnectionClosed.code and .reason are deprecated since websockets 13.1.
Under -W error the warning was raised inside the reader before the
session was marked stopped, so audio() waited forever. Read the code and
reason from exc.rcvd instead (1006 when no close frame arrived, which
also covers a socket error that is not ConnectionClosed), and make sure
any failure in the reader still ends the session with a TTSError.

A bare close (no Error frame) is now also retryable for 3005, 3008 and
3009, as the API marks those codes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KcyY6MAEUVjqBt5pMpetvD
When the pull queue was full, the oldest item of any kind was evicted,
including the FlushDone, Cancelled, error and closed markers. Losing the
marker that ends the request being read left audio(flush_id) discarding
every later request's items and then waiting until the session ended.

Only audio frames now count toward the cap and only the oldest audio
frame is dropped; markers are always kept. The warning also covers a
reader that is reading, just more slowly than the audio arrives.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KcyY6MAEUVjqBt5pMpetvD
flush() and cancel() took their flush_id before taking the send lock, so
a cancel() from another thread (a barge-in from a VAD or STT callback
while the app thread streams text) could reach the socket in a different
order from its id. The API numbers sequences in wire order, so audio()
then waited for an id the API never sent, or dropped a request's audio
as cancelled.

Take the id, record the cancel and write the frame under one reentrant
lock. The reader no longer raises the cancel watermark itself: cancel()
has already raised it, and a second writer on another thread could lower
it again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KcyY6MAEUVjqBt5pMpetvD
If the API closed the socket (a 3010 input_rate_exceeded while text is
streaming in, say) and generate(), flush() or cancel() ran before the
reader had handled the Error frame, the call raised websockets'
ConnectionClosedError, losing error_type, param and retryable.

A send that hits a closed socket now gives the reader a moment to take
in what arrived first and raises the session's TTSError, or one built
from the close code. The socket is read into a local once, so a close()
on another thread can no longer turn the send into an AttributeError.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KcyY6MAEUVjqBt5pMpetvD
A cancellation (an outer timeout, a TaskGroup, a call hanging up) could
leave the session half open or hide itself:

- connect() cancelled while waiting for Begin left the socket open and
  the reader running, and the next connect() said the session was
  already connected. It now closes before re-raising.
- terminate() cancelled during its waits skipped close(). close() now
  runs in a finally.
- close() caught CancelledError from its wait on the reader, swallowing
  the caller's own cancellation. It now waits with asyncio.wait, cancels
  a reader still running, and lets the cancellation propagate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KcyY6MAEUVjqBt5pMpetvD
Handlers run on the session's reader, and nothing stopped them from
calling methods that wait on it. A FlushDone handler that iterated
speak() for the next sentence deadlocked both itself and the app's
audio(), and an Error handler that called terminate() blocked the
reader for the whole terminate_timeout.

speak(), audio() and terminate() now raise RuntimeError when called from
a handler. When the session fails, waiters see the stopped state and the
error before the Error handler runs, so a handler that waits on the app
no longer holds the app's audio() open. The README says that sending
from a handler is fine and waiting is not.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KcyY6MAEUVjqBt5pMpetvD
After the API closed a session (an inactivity_timeout, an abnormal
close), connect() kept raising "the session is already connected" until
the caller called close(). It now closes the ended connection and opens
a new one; a session that is still live is refused as before.

A reader that outlived close() (an Audio handler blocked on a playback
write, say) kept dispatching the old socket's frames into the new
connection's state and finally stopped it. connect() now waits up to
connect_timeout for the previous reader to finish, and refuses to run
from a session handler.

The README and the token docstring now say that a temporary token is
spent when the session begins, so a token= session cannot reconnect:
mint a new token and build a new session.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KcyY6MAEUVjqBt5pMpetvD
inactivity_timeout has no account default: every session closes after
120 s with no frame either way, and the argument can only shorten it.
The README now also says that playing audio already received, or
waiting for the user, counts as idle time for the API, so keep_alive()
is needed during both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KcyY6MAEUVjqBt5pMpetvD
The status of a rejected upgrade was read only from exc.response, which
the legacy asyncio client of websockets 11 and 12 does not have (its
InvalidStatusCode carries status_code), so a 429 or 5xx reported
status_code None. Neither session set retryable for a handshake failure.

Both sessions now build the error in one place: the status from either
exception shape, retryable for the statuses the HTTP client retries
(408, 429, 500, 502, 503, 504), and with no HTTP answer, retryable for a
refused or dropped connection and a timeout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KcyY6MAEUVjqBt5pMpetvD
An Error frame's TTSError kept the close code as body["error_code"] (the
body is the frame), and a close without a frame as body["close_code"],
so retry logic keyed on close codes had to check both. The frame's
error now also carries body["close_code"].

The TTSError docstring, CLAUDE.md and the README now describe session
errors: no HTTP status, the close code in body["close_code"], retryable
from the API's Error frame, and biometric_terms_not_accepted and
auth_unavailable arriving as closes 3006 and 1011.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KcyY6MAEUVjqBt5pMpetvD
The 2,000-character limit of one Generate frame was hard-coded, while
the API reports the limit in force in
Begin.configuration.limits.max_generate_text_length and says it may
change. A raised limit made the SDK refuse valid text; a lowered one
turned the SDK's clear ValueError into a 3006 close. generate() now
checks against the reported limit (0 means none) and falls back to
2,000.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KcyY6MAEUVjqBt5pMpetvD
TTSBeginEvent.configuration was required, while the API declares it
optional (default null). A Begin without it, or with null, ended the
session as an unreadable frame instead of opening it. The field now
defaults to an empty dict, and a null is read as one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KcyY6MAEUVjqBt5pMpetvD
audio() on a TTSSession that never connected polled forever, since its
queue exists from __init__ and nothing would ever stop it. It now raises
RuntimeError('the session is not open'), as AsyncTTSSession.audio()
already did; the async test pins that.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KcyY6MAEUVjqBt5pMpetvD
build_uri dropped any path in tts_base_url, so
https://proxy.example.com/tts became wss://proxy.example.com/v1/ws while
the HTTP client kept the prefix. A deployment behind a path-prefixed
proxy then worked over HTTP and failed over WebSocket. The prefix now
goes before /v1/ws.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KcyY6MAEUVjqBt5pMpetvD
The session refused every container value, including raw, which is what
it streams, so a TTSConfig shared with the HTTP client that set raw
explicitly failed. The refusal also surfaced only at connect. Only a wav
container and bit_rate are refused now, and both sessions check when
they are built.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KcyY6MAEUVjqBt5pMpetvD

@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 Ben, this is a really nice client. The pull and push split, the flush_id watermark and the fake server made it easy to review and to build on.

I pushed fixes to this branch as normal commits on top of 1e822d3 (no force push). Every code change comes with tests. At c6fbbbb the full unit suite, ruff and mypy pass, also on the websockets 11 and pydantic 1.10 floors and with the session tests under -W error. A live run against our sandbox (speak, pipelining, barge-in, a cancel from another thread, an idle close followed by a reconnect, the async twin and a cancelled async connect) passed too.

  • c287dff: every audio() and speak() drain in the session tests has a deadline, so a regression fails the run instead of hanging it.
  • 36b4b4a: the close code is read from the close frame (exc.rcvd, 1006 when none came) instead of the deprecated .code and .reason, so the sessions no longer hang under -W error. Any failure in the reader still ends the session with a TTSError. A bare close with 3005, 3008 or 3009 is retryable, and a missing code counts as 1006.
  • 60b8e96: a full pull queue drops only the oldest audio frame and never a FlushDone, Cancelled, error or close marker, so audio(flush_id) always ends. The warning now also covers a reader that is just slow.
  • 81bb42f: sync flush() and cancel() take their id and send under one lock, so ids follow wire order when a cancel comes from another thread (the usual barge-in).
  • 0a0d8ed: a send that races the API's close raises the session's TTSError (or one built from the close code) instead of a raw ConnectionClosedError.
  • 377107f: AsyncTTSSession is cancellation safe. A cancelled connect() closes what it opened, terminate() always closes, and close() lets the caller's CancelledError through.
  • 2992e60: waiters are released before Error handlers run, and speak(), audio() and terminate() raise RuntimeError from a handler instead of deadlocking.
  • 0210912: connect() reopens a session the API has ended (for example after an idle close) instead of raising "already connected", and refuses while a handler of the previous connection is still running. The docs say a token= session cannot reconnect, since the token is spent at Begin.
  • 418feae: the inactivity_timeout and keep_alive() docs match the API: 5 to 120 s, 120 s by default, and playing audio already received counts as idle time.
  • d36dcbe: a failed handshake keeps its HTTP status (legacy and current websockets clients) and is retryable for 408, 429, 500, 502, 503 and 504, a refused connection and a timeout.
  • 8da63b1: a session error's close code is always in body["close_code"], and the TTSError docstring, README and CLAUDE.md describe session errors (status_code None, close codes 3006 and 1011, retryable from the frame).
  • 9a19c69: generate() checks text against the limit Begin reports (limits.max_generate_text_length) and falls back to 2,000.
  • 27b13b3: a Begin with a missing or null configuration still opens the session.
  • 6c83204: TTSSession.audio() before the first connect raises RuntimeError, as the async session already did.
  • 3775aeb: a path prefix in tts_base_url is kept, so https://proxy.example.com/tts becomes wss://proxy.example.com/tts/v1/ws.
  • c6fbbbb: container=raw is accepted on a session, and the wav and bit_rate check runs when the session is built.

With these, the client matches two API changes that just merged on the server and go live with the next TTS release: voices are named by voice_id only (the shared TTSConfig from #277 already refuses voice), and every session closes after 120 s with no message either way, which inactivity_timeout can only shorten. That close reaches the app as a retryable inactivity_timeout error, and the same session object can then reconnect.

The remaining items are inline: a few design calls I left for you and one follow-up idea. One small thing outside the diff: the "How" section of the description still says close codes come from exc.code; after 36b4b4a they come from the close frame.

Comment thread assemblyai/tts/v1/session.py Outdated
Comment thread assemblyai/tts/v1/session.py
Comment thread assemblyai/tts/v1/session.py Outdated
Comment thread assemblyai/tts/v1/session.py
Comment thread assemblyai/tts/v1/async_session.py Outdated
Comment thread assemblyai/tts/_base_ws.py
bgotthold-aai and others added 2 commits October 22, 2026 21:04
Design calls from Aleks's review:
- a session failure nothing in the `with` block observed (push mode, or
  text flushed and never read) is raised when the block ends;
- `speak()` splits text longer than one Generate frame across frames;
- `word_boundaries` is documented as off by default here while the API's
  default is on; the async `speak()` is documented as sending on first
  iteration.

Found reviewing the fixes:
- a stale `audio()` iterator ends when the session reconnects instead of
  reading the new connection's queue;
- a send that meets the API's close waits for the reader outside the send
  lock, so a handler that sends cannot block the reader;
- a handshake that fails on a reconnect leaves no fresh state for `audio()`
  to wait on;
- a connection dropped during the handshake (`InvalidMessage`) is retryable;
- the async dispatch no longer moves the cancel watermark twice.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ect test

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

Copy link
Copy Markdown
Contributor Author

Thanks for the fixes, Aleks. Pulled them as they are, and the close-code change was right: on websockets 17 .code / .reason warn, and my earlier reading hung the suite under -W error. The design calls are answered inline (four taken, two kept with reasons).

Reviewing the new code paths turned up four things, fixed in 1946d4e with tests: a stale audio() iterator kept reading a reconnected session's queue (it now ends when the connection changes), a send that met the API's close waited up to a second while holding the send lock so a handler that sends could block the reader (the wait now happens outside the lock), a handshake that failed on a reconnect left fresh state for audio() to poll forever (close() runs on that path), and a connection dropped during the handshake raises InvalidMessage, which was not retryable. The async dispatch also moved the cancel watermark a second time; removed.

Two smaller things I left alone: the code is None branch in error_from_close is dead now that close_code_and_reason always returns an int, and the wire-order test's final assertion holds whatever the order (it catches a regression only through the drain timeout). Happy to take either if you want them in this PR.

Full suite passes on Python 3.9, pydantic 1.10, pydantic 2 with and without pydantic-settings, and websockets 11 and 17; live against production for long-text speak() and a push-mode failure surfacing at the end of the block.

@bgotthold-aai
bgotthold-aai marked this pull request as ready for review October 22, 2026 21:40
@bgotthold-aai
bgotthold-aai merged commit 1999cb2 into master Oct 22, 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