Skip to content

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

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

fix(cartesia): reuse one TTS websocket across generations#2085
sunnyysetia wants to merge 3 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: e590394

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 Major
@livekit/agents Major
@livekit/agents-plugin-anam Major
@livekit/agents-plugin-anthropic Major
@livekit/agents-plugin-assemblyai Major
@livekit/agents-plugin-azure Major
@livekit/agents-plugin-baseten Major
@livekit/agents-plugin-bey Major
@livekit/agents-plugin-cerebras Major
@livekit/agents-plugin-deepgram Major
@livekit/agents-plugin-did Major
@livekit/agents-plugin-elevenlabs Major
@livekit/agents-plugin-fishaudio Major
@livekit/agents-plugin-google Major
@livekit/agents-plugin-hedra Major
@livekit/agents-plugin-hume Major
@livekit/agents-plugin-inworld Major
@livekit/agents-plugin-lemonslice Major
@livekit/agents-plugin-liveavatar Major
@livekit/agents-plugin-livekit Major
@livekit/agents-plugin-minimax Major
@livekit/agents-plugin-mistral Major
@livekit/agents-plugin-mistralai Major
@livekit/agents-plugin-neuphonic Major
@livekit/agents-plugin-openai Major
@livekit/agents-plugin-perplexity Major
@livekit/agents-plugin-phonic Major
@livekit/agents-plugin-protoface Major
@livekit/agents-plugin-resemble Major
@livekit/agents-plugin-rime Major
@livekit/agents-plugin-runway Major
@livekit/agents-plugin-sarvam Major
@livekit/agents-plugin-silero Major
@livekit/agents-plugin-soniox Major
@livekit/agents-plugin-tavus Major
@livekit/agents-plugin-trugen Major
@livekit/agents-plugin-xai Major
@livekit/agents-plugins-test Major

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 devin-ai-integration Bot 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.

Devin Review found 1 potential issue.

View 1 additional finding in Devin Review.

Open in Devin Review

Comment on lines 476 to 482
const onClose = (code: number, reason: Buffer) => {
if (!closing) {
this.#logger.debug(`WebSocket closed with code ${code}: ${reason.toString()}`);
}
// A close during an active generation is unexpected: the pool owns the
// socket lifecycle and does not close it between turns.
this.#logger.debug(`WebSocket closed with code ${code}: ${reason.toString()}`);
clearTTSChunkTimeout();
void eventChannel.close();
};

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.

🟡 A dropped connection mid-speech ends the turn silently and caches the dead connection for reuse

When the voice connection drops or errors partway through producing speech (onClose/onError at plugins/cartesia/src/tts.ts:476-487 only close the internal channel without signaling failure), the receive loop just ends, so the turn is treated as if it finished normally and the now-dead connection is handed back for reuse.
Impact: The agent can go silent for that turn with no retry, and the next turn wastes an attempt on the dead connection before reconnecting.

Why the loop exit is indistinguishable from success

On a normal generation the loop exits via the explicit break after a done message (plugins/cartesia/src/tts.ts:589-591). On an unexpected close/error, onClose/onError call eventChannel.close(), which makes reader.read() return {done:true} and the loop exits at plugins/cartesia/src/tts.ts:500. In both cases timedOut is false, so the post-loop check at plugins/cartesia/src/tts.ts:602-606 does not throw. recvTask therefore resolves, Promise.all resolves, and ConnectionPool.withConnection treats it as success and calls put(conn) (agents/src/connection_pool.ts:286), returning the closed socket to available.

On the next turn get() returns that dead socket and the ws.readyState !== WebSocket.OPEN guard (plugins/cartesia/src/tts.ts:640) throws, triggering remove + a reconnect, so it self-heals but wastes an attempt. The sibling fishaudio implementation instead rejects a future in its onClose (plugins/fishaudio/src/tts.ts:562-574), surfacing the disconnect as a retryable error so the current turn can retry. The silent-close behavior is pre-existing, but pooling newly causes the broken socket to be cached.

(Refers to lines 476-487)

Prompt for agents
In SynthesizeStream.run()'s recvTask (plugins/cartesia/src/tts.ts), an unexpected WebSocket 'close' or 'error' during an active generation is not surfaced as an error: onClose/onError merely call eventChannel.close(), which ends the reader loop the same way a normal 'done' break does. Because the loop exit is indistinguishable from success (timedOut stays false and no error is thrown), withConnection treats the generation as successful and calls put() to return the now-closed socket to the pool. Consequences: (1) the turn ends silently with incomplete audio and no retry, and (2) a dead socket is cached and only discarded on the next turn's readyState guard, wasting one connection attempt. Consider tracking whether the loop exited because a matching 'done' was received vs. because the channel closed unexpectedly, and in the unexpected case throw a retryable APIConnectionError (mirroring the fishaudio plugin's onClose in plugins/fishaudio/src/tts.ts:562-574, which rejects with an APIStatusError). This ensures withConnection removes the broken socket instead of returning it, and lets the base SynthesizeStream retry logic run.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

… 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.

1 participant