Skip to content

fix(cartesia): reuse one TTS websocket across generations#2085

Open
sunnyysetia wants to merge 4 commits into
livekit:mainfrom
sunnyysetia:fix/cartesia-persistent-tts-websocket
Open

fix(cartesia): reuse one TTS websocket across generations#2085
sunnyysetia wants to merge 4 commits into
livekit:mainfrom
sunnyysetia:fix/cartesia-persistent-tts-websocket

Conversation

@sunnyysetia

@sunnyysetia sunnyysetia commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Problem

plugins/cartesia/src/tts.ts opens a new Cartesia WebSocket for every streaming synthesis and force-terminates it when the generation ends. SynthesizeStream.run() connects per call:

ws = await connectCartesiaWebSocket({ url, headers, timeoutMs: this.connOptions.timeoutMs, abortSignal: this.abortSignal });
await Promise.all([inputTask(), sentenceStreamTask(ws), recvTask(ws)]);

closes it on the normal done path:

if (segmentId === requestId) {
  closing = true;
  clearTTSChunkTimeout();
  ws.close();
  break;
}

and tears it down again in finally:

} finally {
  if (ws && ws.readyState !== WebSocket.CLOSED) {
    safeTerminateWebSocket(ws);
  }
}

The TTS instance holds no connection state, so every turn repays the TCP, TLS, and WebSocket handshake. Cartesia recommends the opposite: keep one socket open and run many generations over it, because a fresh connection costs tens to low hundreds of milliseconds per turn (https://docs.cartesia.ai/use-the-api/compare-tts-endpoints). The Python plugin already follows that advice with a pooled connection (livekit-plugins-cartesia, utils.ConnectionPool, max_session_duration=300), and the JS recvTask even carried a // IMPORTANT: Remove listeners so connection can be reused comment that was never wired to a pool.

Everything Cartesia needs per generation (model, voice, encoding, sample rate, speed, emotion, volume, language) already travels in-band on each message, and a fresh context_id is generated per run(), so the socket is safe to reuse as-is. Only the auth and version headers are set at handshake time.

Fix

Hold a ConnectionPool<WebSocket> on the TTS instance, the same pattern plugins/fishaudio, plugins/inworld, and plugins/xai already use, and run each generation inside pool.withConnection:

await this.#pool.withConnection(
  async (ws) => {
    if (ws.readyState !== WebSocket.OPEN) {
      throw new APIConnectionError({ message: 'Cartesia pooled websocket is not open' });
    }
    await Promise.all([inputTask(), sentenceStreamTask(ws), recvTask(ws)]);
  },
  { timeout: this.connOptions.timeoutMs, signal: this.abortSignal },
);
  • A generation no longer closes the socket on done; it just breaks the receive loop, so withConnection returns the socket to the pool and the next turn skips the handshake.
  • The chunk-timeout watchdog now poisons its socket and throws a retryable APITimeoutError, so withConnection removes that socket from the pool instead of returning a stuck one.
  • updateOptions invalidates the pool only when a handshake input actually changes (apiKey, apiVersion, baseUrl). Model, voice, and the generation controls are sent per message, so a pooled socket serves the new values without reconnecting.
  • Adds TTS.prewarm() to open the socket before the first turn and TTS.close() to drain the pool. maxSessionDuration is 300000 ms with markRefreshedOnGet, matching the Python plugin.

This mirrors the Python plugin's behaviour and keeps the existing IPv4 happy-eyeballs connect retry, chunk-timeout, and error-propagation logic intact.

Verification

Added a local-WebSocketServer test suite in plugins/cartesia/src/tts.test.ts that needs no API key (mirrors plugins/fishaudio/src/tts.test.ts):

  • reuses one websocket across sequential turns (connectionCount === 1)
  • prewarms and reuses the ready websocket
  • discards a poisoned websocket after a failure, then reconnects
  • replaces a websocket that closed while idle
  • closes the pooled websocket when the TTS closes

vitest run plugins/cartesia/src/tts.test.ts: 5 passed, 1 skipped (the key-gated integration test). Build, typecheck, lint, and prettier are clean. api:check for this plugin fails, but it fails identically on a clean checkout of main (there is no checked-in etc/*.api.md report for the plugin), so it is unrelated to this change. Added a minor changeset.

I also measured the actual patched plugin against direct Cartesia Sonic from a hosted us-east worker, 11 sequential turns, first-audio latency:

Path first-audio p50 first-audio p95
Reconnect per turn (current behaviour) 364 ms 757 ms
Pooled, warm (this change) 262 ms 300 ms

Pooling removes about 102 ms at p50 and collapses the tail, because the reconnect p95 is a handshake landing on a turn while the pooled path has already paid it once. A prewarmed first turn measured 274 ms, within the warm band.

@sunnyysetia
sunnyysetia requested a review from a team as a code owner July 22, 2026 06:53
@changeset-bot

changeset-bot Bot commented Jul 22, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 65283c5

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 38 packages
Name Type
@livekit/agents-plugin-cartesia Patch
@livekit/agents Patch
@livekit/agents-plugin-anam Patch
@livekit/agents-plugin-anthropic Patch
@livekit/agents-plugin-assemblyai Patch
@livekit/agents-plugin-azure Patch
@livekit/agents-plugin-baseten Patch
@livekit/agents-plugin-bey Patch
@livekit/agents-plugin-cerebras Patch
@livekit/agents-plugin-deepgram Patch
@livekit/agents-plugin-did Patch
@livekit/agents-plugin-elevenlabs Patch
@livekit/agents-plugin-fishaudio Patch
@livekit/agents-plugin-google Patch
@livekit/agents-plugin-hedra Patch
@livekit/agents-plugin-hume Patch
@livekit/agents-plugin-inworld Patch
@livekit/agents-plugin-lemonslice Patch
@livekit/agents-plugin-liveavatar Patch
@livekit/agents-plugin-livekit Patch
@livekit/agents-plugin-minimax Patch
@livekit/agents-plugin-mistral Patch
@livekit/agents-plugin-mistralai Patch
@livekit/agents-plugin-neuphonic Patch
@livekit/agents-plugin-openai Patch
@livekit/agents-plugin-perplexity Patch
@livekit/agents-plugin-phonic Patch
@livekit/agents-plugin-protoface Patch
@livekit/agents-plugin-resemble Patch
@livekit/agents-plugin-rime Patch
@livekit/agents-plugin-runway Patch
@livekit/agents-plugin-sarvam Patch
@livekit/agents-plugin-silero Patch
@livekit/agents-plugin-soniox Patch
@livekit/agents-plugin-tavus Patch
@livekit/agents-plugin-trugen Patch
@livekit/agents-plugin-xai Patch
@livekit/agents-plugins-test Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

`SynthesizeStream.run()` opened a fresh Cartesia WebSocket for every synthesis
and force-terminated it in `finally`, so each turn repaid the TCP/TLS and
WebSocket handshake. Cartesia documents the opposite: keep one socket open and
run many generations over it, because a new connection costs tens to low
hundreds of milliseconds per turn. The Python plugin already does this with a
pooled connection, and the JS plugin even carried a `so connection can be
reused` comment that was never wired to a pool.

Hold a `ConnectionPool<WebSocket>` on the `TTS` instance, the same pattern the
fishaudio, inworld, and xai plugins use, and run each generation inside
`pool.withConnection`. A generation no longer closes the socket on `done`, so
the pool hands the same socket to the next turn and only the first turn pays the
connect. The chunk-timeout watchdog now poisons and discards its socket instead
of closing a pooled one, and the socket is invalidated only when a field that is
sent at handshake time changes (apiKey, apiVersion, baseUrl); model, voice,
encoding, sample rate, and the generation controls travel in-band per message,
so a pooled socket serves new values without reconnecting.

Adds `TTS.prewarm()` to open the socket before the first turn and a
`TTS.close()` that drains the pool. New tests cover socket reuse across turns,
prewarm, discard-on-failure, idle-close replacement, and close.
@sunnyysetia
sunnyysetia force-pushed the fix/cartesia-persistent-tts-websocket branch from f480c6b to 72d40b8 Compare July 22, 2026 06:56

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f480c6bb29

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread plugins/cartesia/src/tts.ts Outdated
}

async #connectWebSocket(timeoutMs: number): Promise<WebSocket> {
const wsUrl = this.#opts.baseUrl.replace(/^http/, 'ws');

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve stream-specific handshake options

When a stream is created and TTS.updateOptions() changes apiKey, apiVersion, or baseUrl before that stream's pool checkout opens a socket, this helper reads the mutable TTS.#opts instead of the stream's snapshotted options. The stream still sends its old per-generation payload from SynthesizeStream.#opts, so the request can be sent over a WebSocket authenticated to a different key/version or pointed at a different host; before this change the URL and headers were built from the stream's own options. Please keep handshake options with the stream/pool entry or cancel/version in-flight connects on option changes.

Useful? React with 👍 / 👎.

Comment on lines +640 to +641
if (ws.readyState !== WebSocket.OPEN) {
throw new APIConnectionError({ message: 'Cartesia pooled websocket is not open' });

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Discard idle-closed sockets before retry accounting

When Cartesia or a proxy closes a pooled socket while it is idle, there are no generation listeners attached, so the pool keeps that closed WebSocket in available. The next synthesis hits this readyState check and spends a normal TTS retry just to discard it; with maxRetry: 0 the turn fails before any transcript is sent, whereas the old per-turn connection would have opened a fresh socket. Please drop closed idle sockets before returning them from the pool or attach an idle close handler that removes them.

Useful? React with 👍 / 👎.

devin-ai-integration[bot]

This comment was marked as resolved.

… opts

Addresses review feedback on the pooling change.

- Attach an idle close and error handler when a pooled socket is created, so a
  socket that closes between turns is removed from the pool instead of being
  handed to the next turn, where the readyState guard would spend a retry, or
  fail at maxRetry:0, just to discard it.
- Re-check the handshake inputs (apiKey, apiVersion, baseUrl) after connect and
  reconnect if one changed mid-connect, so a pooled socket is never built on
  stale credentials.

Strengthens the idle-close test to assert recovery at maxRetry:0.
@sunnyysetia

Copy link
Copy Markdown
Contributor Author

Thanks, both are fair. Addressed in 00780c4:

Idle-closed sockets burning a retry. When a pooled socket is created it now gets an idle close handler that removes it from the pool, plus a no-op error handler so an idle error does not crash the process. A socket that closes between turns is dropped from available instead of being handed to the next turn, so the readyState guard no longer spends a retry to discard it and a maxRetry: 0 turn recovers. The idle-close test now runs the recovery turn at maxRetry: 0 to lock this in.

Handshake options read live during connect. #connectWebSocket now snapshots apiKey, apiVersion, and baseUrl before connecting and, if one changed while the connect was in flight, closes the socket and reconnects on the new value, so a pooled socket is never built on stale credentials. This mirrors the fishaudio plugin's post-connect model re-check, and it composes with the existing updateOptions invalidate so a handshake change never leaves a stale-credential socket in the pool.

Addresses review feedback. If the WebSocket closed or errored before the
generation's done message, recvTask ended its loop and returned normally, so the
turn was treated as finished: the agent went silent for the rest of it and the
dead socket could be handed back to the pool.

Track completion and capture an early close or error as a pending error, then
throw it after the receive loop. The turn now fails and the framework retries,
and withConnection removes the dead socket instead of pooling it. Adds a test
that drops the socket after one chunk and asserts fail-over plus a fresh
reconnect.
@sunnyysetia
sunnyysetia force-pushed the fix/cartesia-persistent-tts-websocket branch from 4cc0cd8 to e590394 Compare July 22, 2026 07:14
@sunnyysetia

Copy link
Copy Markdown
Contributor Author

Addressed the newer feedback in the latest commits (e590394):

Mid-generation socket drop (Devin). Good catch. recvTask now tracks whether the generation completed, and onClose/onError capture an early drop as a pending error that is thrown after the receive loop. So a socket that dies before done fails the turn (the framework retries) instead of ending it silently mid-speech, and withConnection discards the dead socket rather than pooling it. Added a test that emits one chunk, drops the socket without done, and asserts fail-over plus a fresh reconnect.

Idle-closed socket / readyState guard (Codex, re-flag). The common idle-close path no longer reaches that guard: a pooled socket now carries a close handler that removes it from the pool the moment it closes while idle, so the next checkout opens a fresh one and does not spend a retry. The readyState guard remains only as a defense-in-depth backstop for the narrow window where a socket starts closing between checkout and use. Combined with the mid-generation fix above, a dead socket is never returned as a normal result.

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.

2 participants