Repository navigation
feat(tts): WebSocket session client - #279
Conversation
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>
7143ac5 to
1cc5a41
Compare
…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
left a comment
There was a problem hiding this comment.
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()andspeak()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.codeand.reason, so the sessions no longer hang under-W error. Any failure in the reader still ends the session with aTTSError. 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, soaudio(flush_id)always ends. The warning now also covers a reader that is just slow. - 81bb42f: sync
flush()andcancel()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 rawConnectionClosedError. - 377107f:
AsyncTTSSessionis cancellation safe. A cancelledconnect()closes what it opened,terminate()always closes, andclose()lets the caller'sCancelledErrorthrough. - 2992e60: waiters are released before
Errorhandlers run, andspeak(),audio()andterminate()raiseRuntimeErrorfrom 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 atoken=session cannot reconnect, since the token is spent atBegin. - 418feae: the
inactivity_timeoutandkeep_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 theTTSErrordocstring, README and CLAUDE.md describe session errors (status_codeNone, close codes 3006 and 1011,retryablefrom the frame). - 9a19c69:
generate()checks text against the limitBeginreports (limits.max_generate_text_length) and falls back to 2,000. - 27b13b3: a
Beginwith a missing or nullconfigurationstill opens the session. - 6c83204:
TTSSession.audio()before the first connect raisesRuntimeError, as the async session already did. - 3775aeb: a path prefix in
tts_base_urlis kept, sohttps://proxy.example.com/ttsbecomeswss://proxy.example.com/tts/v1/ws. - c6fbbbb:
container=rawis accepted on a session, and the wav andbit_ratecheck 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.
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>
|
Thanks for the fixes, Aleks. Pulled them as they are, and the close-code change was right: on websockets 17 Reviewing the new code paths turned up four things, fixed in 1946d4e with tests: a stale Two smaller things I left alone: the 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 |
Builds on the HTTP client from #277 (merged).
What
aai.TTSSessionandaai.AsyncTTSSession: a client for the Text-to-Speech WebSocket API (wss://tts.us.assemblyai.com/v1/ws, host fromsettings.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 forBegin; a refusal raisesTTSErrorthere. Leaving the block sendsTerminate, waits forTerminationand setssession.termination.speak(text)sendsGenerate+Flush(splitting text longer than one frame) and returns an iterator of that request's audio bytes, ending at itsFlushDone.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)deliversAudio,FlushDone,WordBoundaries,Cancelled,TerminationandErrorframes tohandler(event)on the reader thread. With anAudiohandler registered, audio is pushed instead of queued for pulling.token=for a temporary token (query parameter) instead of the API key header;word_boundariesandinactivity_timeoutas keyword arguments.TTSConfigandTTSErrorare shared with the HTTP client (container/bit_rateare left out of the URI; anErrorframe mapserror_typetoerror_codewith the frame inbody).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
websocketsimport through its connect seams,websockets>=11) with a pull queue on top, since the HTTP client already teachesfor chunk in .... Details that matter:flush_idis tracked client-side: everyFlushandCanceladvances it, as the API does. ACancelrecords a watermark so every pipelined request it dropped ends itsaudio()iteration instead of hanging.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 aTTSErrorrather than killing the reader. A send after a failure raises the stored error, and a failure nothing in thewithblock observed is raised when the block ends.resolve_settings), so a session builds no HTTP pool andtoken=does not cache a keyless default client.connect()resets its per-connection state.Tests
tests/unit/test_tts_session.pyandtest_tts_session_async.py: 37 tests against a small fake server that answersGenerate/Flush/Cancel/Terminatethe way the API does: URI and headers, speak, pipelining, barge-in before and after a request, pipelined cancel, errors before and afterBegin, abnormal close, unreadable frames, handlers (sync and coroutine), the pull/push rule, token auth, settings host and scheme, text limits, reuse, the bounded queue.voice_not_foundrefusal raised from thewith.audio()cannot hang on an unissued id and can follow the request being written, misspelled event names and pulling under anAudiohandler 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_jsonnow prefersmodel_dump, a one-line change that also removes the pydantic warning from the dictation and sync clients).🤖 Generated with Claude Code