@livekit/agents-plugin-cartesia 1.5.3 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/tts.test.ts CHANGED
@@ -1,19 +1,268 @@
1
1
  // SPDX-FileCopyrightText: 2024 LiveKit, Inc.
2
2
  //
3
3
  // SPDX-License-Identifier: Apache-2.0
4
+ import { type APIConnectOptions, DEFAULT_API_CONNECT_OPTIONS, tts } from '@livekit/agents';
4
5
  import { STT } from '@livekit/agents-plugin-openai';
5
- import { tts } from '@livekit/agents-plugins-test';
6
- import { describe, it } from 'vitest';
6
+ import { tts as testTts } from '@livekit/agents-plugins-test';
7
+ import { once } from 'node:events';
8
+ import type { AddressInfo } from 'node:net';
9
+ import { describe, expect, it } from 'vitest';
10
+ import { type WebSocket, WebSocketServer } from 'ws';
7
11
  import { TTS } from './tts.js';
8
12
 
9
13
  const hasCartesiaConfig = Boolean(process.env.CARTESIA_API_KEY && process.env.OPENAI_API_KEY);
10
14
 
11
15
  if (hasCartesiaConfig) {
12
16
  describe('Cartesia', async () => {
13
- await tts(new TTS(), new STT());
17
+ await testTts(new TTS(), new STT());
14
18
  });
15
19
  } else {
16
20
  describe('Cartesia', () => {
17
21
  it.skip('requires CARTESIA_API_KEY and OPENAI_API_KEY', () => {});
18
22
  });
19
23
  }
24
+
25
+ // A single 24 kHz mono s16le frame's worth of silence, base64-encoded the way
26
+ // Cartesia sends audio chunks.
27
+ const CHUNK_BASE64 = Buffer.alloc(4800).toString('base64');
28
+
29
+ async function startWebSocketServer() {
30
+ const wss = new WebSocketServer({ host: '127.0.0.1', port: 0 });
31
+ await once(wss, 'listening');
32
+ const address = wss.address() as AddressInfo;
33
+ return { wss, baseURL: `http://127.0.0.1:${address.port}` };
34
+ }
35
+
36
+ async function closeWebSocketServer(wss: WebSocketServer): Promise<void> {
37
+ for (const client of wss.clients) {
38
+ client.close();
39
+ }
40
+ await new Promise<void>((resolve) => wss.close(() => resolve()));
41
+ }
42
+
43
+ async function waitFor<T>(promise: Promise<T>, timeoutMs = 1000): Promise<T> {
44
+ let timeout: ReturnType<typeof setTimeout> | undefined;
45
+ try {
46
+ return await Promise.race([
47
+ promise,
48
+ new Promise<never>((_, reject) => {
49
+ timeout = setTimeout(() => reject(new Error('timed out waiting for promise')), timeoutMs);
50
+ }),
51
+ ]);
52
+ } finally {
53
+ if (timeout) clearTimeout(timeout);
54
+ }
55
+ }
56
+
57
+ // A minimal Cartesia TTS WebSocket server: for every generation it replies with
58
+ // one audio chunk and a done message, echoing the caller's context_id. `onStop`
59
+ // lets a test override the reply (e.g. to simulate a provider failure); return
60
+ // false to suppress the normal chunk/done reply.
61
+ function serveCartesia(
62
+ wss: WebSocketServer,
63
+ onStop?: (ws: WebSocket, contextId: string, connectionNumber: number) => boolean,
64
+ ): { connectionCount: () => number } {
65
+ let connectionCount = 0;
66
+ wss.on('connection', (ws) => {
67
+ connectionCount++;
68
+ const connectionNumber = connectionCount;
69
+ ws.on('message', (raw) => {
70
+ const message = JSON.parse(raw.toString()) as { context_id: string; continue?: boolean };
71
+ if (message.continue !== false) return; // only reply once the turn is closed
72
+ const contextId = message.context_id;
73
+ if (onStop && !onStop(ws, contextId, connectionNumber)) return;
74
+ ws.send(
75
+ JSON.stringify({
76
+ type: 'chunk',
77
+ data: CHUNK_BASE64,
78
+ done: false,
79
+ status_code: 200,
80
+ step_time: 0,
81
+ context_id: contextId,
82
+ }),
83
+ );
84
+ ws.send(
85
+ JSON.stringify({ type: 'done', done: true, status_code: 200, context_id: contextId }),
86
+ );
87
+ });
88
+ });
89
+ return { connectionCount: () => connectionCount };
90
+ }
91
+
92
+ async function synthesizeTurn(
93
+ cartesia: TTS,
94
+ text: string,
95
+ connOptions?: APIConnectOptions,
96
+ ): Promise<tts.SynthesizedAudio[]> {
97
+ const stream = cartesia.stream({ connOptions });
98
+ stream.pushText(text);
99
+ stream.endInput();
100
+
101
+ try {
102
+ const events: tts.SynthesizedAudio[] = [];
103
+ for await (const event of stream) {
104
+ if (event !== tts.SynthesizeStream.END_OF_STREAM) events.push(event);
105
+ }
106
+ return events;
107
+ } finally {
108
+ stream.close();
109
+ }
110
+ }
111
+
112
+ describe('Cartesia streaming pool', () => {
113
+ it('reuses one websocket across sequential turns', async () => {
114
+ const { wss, baseURL } = await startWebSocketServer();
115
+ const server = serveCartesia(wss);
116
+
117
+ const cartesia = new TTS({ apiKey: 'test-key', baseUrl: baseURL });
118
+ try {
119
+ expect(await synthesizeTurn(cartesia, 'first turn.')).not.toHaveLength(0);
120
+ expect(await synthesizeTurn(cartesia, 'second turn.')).not.toHaveLength(0);
121
+ expect(server.connectionCount()).toBe(1);
122
+ } finally {
123
+ await cartesia.close();
124
+ await closeWebSocketServer(wss);
125
+ }
126
+ });
127
+
128
+ it('prewarms and reuses the ready websocket', async () => {
129
+ const { wss, baseURL } = await startWebSocketServer();
130
+ const server = serveCartesia(wss);
131
+ const connected = once(wss, 'connection');
132
+
133
+ const cartesia = new TTS({ apiKey: 'test-key', baseUrl: baseURL });
134
+ try {
135
+ cartesia.prewarm();
136
+ await waitFor(connected);
137
+ expect(await synthesizeTurn(cartesia, 'prewarmed turn.')).not.toHaveLength(0);
138
+ expect(server.connectionCount()).toBe(1);
139
+ } finally {
140
+ await cartesia.close();
141
+ await closeWebSocketServer(wss);
142
+ }
143
+ });
144
+
145
+ it('discards a poisoned websocket after a failure', async () => {
146
+ const { wss, baseURL } = await startWebSocketServer();
147
+ // The first connection drops the turn; the second serves it normally.
148
+ const server = serveCartesia(wss, (ws, _contextId, connectionNumber) => {
149
+ if (connectionNumber === 1) {
150
+ ws.close(1011, 'provider failure');
151
+ return false;
152
+ }
153
+ return true;
154
+ });
155
+
156
+ const cartesia = new TTS({ apiKey: 'test-key', baseUrl: baseURL });
157
+ try {
158
+ expect(
159
+ await synthesizeTurn(cartesia, 'failing turn.', {
160
+ ...DEFAULT_API_CONNECT_OPTIONS,
161
+ maxRetry: 0,
162
+ }),
163
+ ).toHaveLength(0);
164
+ expect(await synthesizeTurn(cartesia, 'recovery turn.')).not.toHaveLength(0);
165
+ expect(server.connectionCount()).toBe(2);
166
+ } finally {
167
+ await cartesia.close();
168
+ await closeWebSocketServer(wss);
169
+ }
170
+ });
171
+
172
+ it('fails over when the socket drops mid-generation instead of ending silently', async () => {
173
+ const { wss, baseURL } = await startWebSocketServer();
174
+ // Connection 1 emits one audio chunk, then drops WITHOUT a done message,
175
+ // i.e. mid-speech. Connection 2 serves the recovery turn normally.
176
+ const server = serveCartesia(wss, (ws, contextId, connectionNumber) => {
177
+ if (connectionNumber === 1) {
178
+ ws.send(
179
+ JSON.stringify({
180
+ type: 'chunk',
181
+ data: CHUNK_BASE64,
182
+ done: false,
183
+ status_code: 200,
184
+ step_time: 0,
185
+ context_id: contextId,
186
+ }),
187
+ );
188
+ setTimeout(() => ws.close(1011, 'mid-speech drop'), 5);
189
+ return false; // suppress the normal chunk/done reply
190
+ }
191
+ return true;
192
+ });
193
+
194
+ const cartesia = new TTS({ apiKey: 'test-key', baseUrl: baseURL });
195
+ try {
196
+ // The dropped turn does not complete successfully (it fails over rather
197
+ // than silently ending); at maxRetry: 0 that surfaces as no audio.
198
+ expect(
199
+ await synthesizeTurn(cartesia, 'dropping turn.', {
200
+ ...DEFAULT_API_CONNECT_OPTIONS,
201
+ maxRetry: 0,
202
+ }),
203
+ ).toHaveLength(0);
204
+ // The dead socket is discarded, so the next turn opens a fresh one.
205
+ expect(await synthesizeTurn(cartesia, 'recovery turn.')).not.toHaveLength(0);
206
+ expect(server.connectionCount()).toBe(2);
207
+ } finally {
208
+ await cartesia.close();
209
+ await closeWebSocketServer(wss);
210
+ }
211
+ });
212
+
213
+ it('replaces a websocket that closed while idle', async () => {
214
+ const { wss, baseURL } = await startWebSocketServer();
215
+ let firstConnectionClosed: (() => void) | undefined;
216
+ const firstClosed = new Promise<void>((resolve) => {
217
+ firstConnectionClosed = resolve;
218
+ });
219
+ const server = serveCartesia(wss, (ws, _contextId, connectionNumber) => {
220
+ if (connectionNumber === 1) {
221
+ ws.on('close', () => firstConnectionClosed?.());
222
+ // Serve the turn, then drop the idle socket so the next turn reconnects.
223
+ setTimeout(() => ws.close(), 10);
224
+ }
225
+ return true;
226
+ });
227
+
228
+ const cartesia = new TTS({ apiKey: 'test-key', baseUrl: baseURL });
229
+ try {
230
+ expect(await synthesizeTurn(cartesia, 'first turn.')).not.toHaveLength(0);
231
+ await waitFor(firstClosed);
232
+ // Let the client observe the close so the idle handler removes the socket
233
+ // before the next checkout, making the maxRetry: 0 assertion deterministic.
234
+ await new Promise((resolve) => setTimeout(resolve, 100));
235
+ // maxRetry: 0 proves the idle-closed socket was dropped from the pool, not
236
+ // handed back to burn the turn's only attempt.
237
+ expect(
238
+ await waitFor(
239
+ synthesizeTurn(cartesia, 'second turn.', { ...DEFAULT_API_CONNECT_OPTIONS, maxRetry: 0 }),
240
+ ),
241
+ ).not.toHaveLength(0);
242
+ expect(server.connectionCount()).toBe(2);
243
+ } finally {
244
+ await cartesia.close();
245
+ await closeWebSocketServer(wss);
246
+ }
247
+ });
248
+
249
+ it('closes the pooled websocket when the TTS closes', async () => {
250
+ const { wss, baseURL } = await startWebSocketServer();
251
+ serveCartesia(wss);
252
+
253
+ const cartesia = new TTS({ apiKey: 'test-key', baseUrl: baseURL });
254
+ try {
255
+ await synthesizeTurn(cartesia, 'closing turn.');
256
+ await cartesia.close();
257
+ // close() drains the pooled socket; give the close frame a beat to land.
258
+ await waitFor(
259
+ (async () => {
260
+ while (wss.clients.size > 0) await new Promise((r) => setTimeout(r, 5));
261
+ })(),
262
+ );
263
+ expect(wss.clients.size).toBe(0);
264
+ } finally {
265
+ await closeWebSocketServer(wss);
266
+ }
267
+ });
268
+ });
package/src/tts.ts CHANGED
@@ -8,6 +8,7 @@ import {
8
8
  APIStatusError,
9
9
  APITimeoutError,
10
10
  AudioByteStream,
11
+ ConnectionPool,
11
12
  Future,
12
13
  type TimedString,
13
14
  asError,
@@ -48,6 +49,13 @@ const API_VERSION_WITH_EXPERIMENTAL_CONTROLS = '2024-11-13';
48
49
  const MODEL_WITH_EXPERIMENTAL_CONTROLS = 'sonic-2-2025-03-07';
49
50
  const NUM_CHANNELS = 1;
50
51
  const BUFFERED_WORDS_COUNT = 8;
52
+ // Cartesia refreshes a pooled socket after this long so a very long call cannot
53
+ // keep one connection open indefinitely. Matches the Python plugin's 300s.
54
+ const MAX_SESSION_DURATION_MS = 300_000;
55
+
56
+ // Lets each SynthesizeStream reach the pool owned by the TTS that created it,
57
+ // without widening the constructor signature the base class fixes.
58
+ const connectionPools = new WeakMap<TTS, ConnectionPool<WebSocket>>();
51
59
 
52
60
  export interface TTSOptions {
53
61
  model: TTSModels | string;
@@ -128,6 +136,8 @@ const checkGenerationConfig = (opts: TTSOptions) => {
128
136
 
129
137
  export class TTS extends tts.TTS {
130
138
  #opts: TTSOptions;
139
+ #pool: ConnectionPool<WebSocket>;
140
+ #closed = false;
131
141
  label = 'cartesia.TTS';
132
142
 
133
143
  get model(): string {
@@ -166,9 +176,31 @@ export class TTS extends tts.TTS {
166
176
  ) {
167
177
  checkGenerationConfig(this.#opts);
168
178
  }
179
+
180
+ // One socket, reused across generations. Cartesia recommends a single
181
+ // preconnected WebSocket for many generations because a fresh connection
182
+ // repays TCP/TLS setup on every turn:
183
+ // https://docs.cartesia.ai/use-the-api/compare-tts-endpoints
184
+ this.#pool = new ConnectionPool<WebSocket>({
185
+ connectCb: (timeoutMs) => this.#connectWebSocket(timeoutMs),
186
+ closeCb: async (ws) => safeCloseWebSocket(ws),
187
+ maxSessionDuration: MAX_SESSION_DURATION_MS,
188
+ markRefreshedOnGet: true,
189
+ });
190
+ connectionPools.set(this, this.#pool);
169
191
  }
170
192
 
171
193
  updateOptions(opts: Partial<TTSOptions>) {
194
+ // Only these three fields reach Cartesia at WebSocket-handshake time (auth
195
+ // header, version header, host). Everything else (model, voice, encoding,
196
+ // sample rate, speed, emotion, volume, language) is sent in-band on each
197
+ // generation, so a pooled socket serves the new value without reconnecting.
198
+ // Reconnect only when one of the handshake inputs actually changes.
199
+ const handshakeChanged =
200
+ (opts.apiKey !== undefined && opts.apiKey !== this.#opts.apiKey) ||
201
+ (opts.apiVersion !== undefined && opts.apiVersion !== this.#opts.apiVersion) ||
202
+ (opts.baseUrl !== undefined && opts.baseUrl !== this.#opts.baseUrl);
203
+
172
204
  this.#opts = { ...this.#opts, ...opts };
173
205
  if (opts.language !== undefined) {
174
206
  this.#opts.language = normalizeLanguage(opts.language);
@@ -182,6 +214,10 @@ export class TTS extends tts.TTS {
182
214
  ) {
183
215
  checkGenerationConfig(this.#opts);
184
216
  }
217
+
218
+ if (handshakeChanged) {
219
+ this.#pool.invalidate();
220
+ }
185
221
  }
186
222
 
187
223
  synthesize(
@@ -189,11 +225,67 @@ export class TTS extends tts.TTS {
189
225
  connOptions?: APIConnectOptions,
190
226
  abortSignal?: AbortSignal,
191
227
  ): tts.ChunkedStream {
192
- return new ChunkedStream(this, text, this.#opts, connOptions, abortSignal);
228
+ return new ChunkedStream(this, text, { ...this.#opts }, connOptions, abortSignal);
193
229
  }
194
230
 
195
231
  stream(options?: { connOptions?: APIConnectOptions }): SynthesizeStream {
196
- return new SynthesizeStream(this, this.#opts, options?.connOptions);
232
+ return new SynthesizeStream(this, { ...this.#opts }, options?.connOptions);
233
+ }
234
+
235
+ /**
236
+ * Open the pooled WebSocket ahead of the first generation so the first turn
237
+ * does not pay the connect. Safe to call more than once; it is a no-op when a
238
+ * connection already exists.
239
+ */
240
+ prewarm(): void {
241
+ this.#pool.prewarm();
242
+ }
243
+
244
+ override async close(): Promise<void> {
245
+ this.#closed = true;
246
+ await this.#pool.close();
247
+ await super.close();
248
+ }
249
+
250
+ async #connectWebSocket(timeoutMs: number): Promise<WebSocket> {
251
+ // Snapshot the handshake inputs. If a concurrent updateOptions() changes one
252
+ // of them while this connect is in flight, reconnect on the new value rather
253
+ // than pooling a socket built on stale credentials (mirrors the fishaudio
254
+ // plugin's model re-check).
255
+ const apiKey = this.#opts.apiKey!;
256
+ const apiVersion = this.#opts.apiVersion;
257
+ const baseUrl = this.#opts.baseUrl;
258
+ const url = `${baseUrl.replace(/^http/, 'ws')}/tts/websocket`;
259
+ const ws = await connectCartesiaWebSocket({
260
+ url,
261
+ headers: {
262
+ [AUTHORIZATION_HEADER]: apiKey,
263
+ [VERSION_HEADER]: apiVersion,
264
+ },
265
+ timeoutMs,
266
+ });
267
+ if (this.#closed) {
268
+ safeCloseWebSocket(ws);
269
+ throw new APIConnectionError({ message: 'Cartesia TTS is closed' });
270
+ }
271
+ if (
272
+ apiKey !== this.#opts.apiKey ||
273
+ apiVersion !== this.#opts.apiVersion ||
274
+ baseUrl !== this.#opts.baseUrl
275
+ ) {
276
+ safeCloseWebSocket(ws);
277
+ return await this.#connectWebSocket(timeoutMs);
278
+ }
279
+ // Drop a socket that closes (or errors) while idle in the pool. Between turns
280
+ // no generation listeners are attached, so without this the pool keeps a dead
281
+ // socket in `available` and the next turn spends a retry to discard it, or
282
+ // fails outright at maxRetry:0. A generation attaches its own listeners on top
283
+ // of these; the no-op error listener also stops an idle 'error' from crashing
284
+ // the process. Remove is a no-op once the socket is no longer pooled, so this
285
+ // is safe during an active generation and during close().
286
+ ws.on('error', () => {});
287
+ ws.on('close', () => this.#pool.remove(ws));
288
+ return ws;
197
289
  }
198
290
  }
199
291
 
@@ -290,6 +382,7 @@ export class ChunkedStream extends tts.ChunkedStream {
290
382
 
291
383
  export class SynthesizeStream extends tts.SynthesizeStream {
292
384
  #opts: TTSOptions;
385
+ #pool: ConnectionPool<WebSocket>;
293
386
  #logger = log();
294
387
  #tokenizer = new tokenize.basic.SentenceTokenizer({
295
388
  minSentenceLength: BUFFERED_WORDS_COUNT,
@@ -298,6 +391,9 @@ export class SynthesizeStream extends tts.SynthesizeStream {
298
391
 
299
392
  constructor(tts: TTS, opts: TTSOptions, connOptions?: APIConnectOptions) {
300
393
  super(tts, connOptions);
394
+ const pool = connectionPools.get(tts);
395
+ if (!pool) throw new Error('Cartesia connection pool is not initialized');
396
+ this.#pool = pool;
301
397
  this.#opts = opts;
302
398
  }
303
399
 
@@ -316,8 +412,7 @@ export class SynthesizeStream extends tts.SynthesizeStream {
316
412
 
317
413
  protected async run() {
318
414
  const requestId = shortuuid();
319
- let closing = false;
320
- // Only close WebSocket when both: 1) Cartesia returns done, AND 2) all sentences have been sent
415
+ // Only finish the generation once both: 1) Cartesia returns done, AND 2) all sentences have been sent
321
416
  let sentenceStreamClosed = false;
322
417
 
323
418
  const sentenceStreamTask = async (ws: WebSocket) => {
@@ -384,6 +479,14 @@ export class SynthesizeStream extends tts.SynthesizeStream {
384
479
  };
385
480
 
386
481
  let timeout: NodeJS.Timeout | null = null;
482
+ // Set when the chunk watchdog fires: the socket is discarded, not pooled.
483
+ let timedOut = false;
484
+ // Set once this generation's `done` has been handled. Until then, a socket
485
+ // close or error is a mid-generation drop, not a normal end.
486
+ let completed = false;
487
+ // A socket close/error before completion. Thrown after the loop so the turn
488
+ // fails over (and the dead socket is discarded) instead of ending silently.
489
+ let streamError: Error | undefined;
387
490
 
388
491
  const clearTTSChunkTimeout = () => {
389
492
  if (timeout) {
@@ -400,15 +503,25 @@ export class SynthesizeStream extends tts.SynthesizeStream {
400
503
  };
401
504
 
402
505
  const onClose = (code: number, reason: Buffer) => {
403
- if (!closing) {
404
- this.#logger.debug(`WebSocket closed with code ${code}: ${reason.toString()}`);
405
- }
506
+ // A close during an active generation is unexpected: the pool owns the
507
+ // socket lifecycle and does not close it between turns. If it happens
508
+ // before `done`, surface it so the turn retries rather than ending mid
509
+ // speech, and so withConnection discards the dead socket.
510
+ this.#logger.debug(`WebSocket closed with code ${code}: ${reason.toString()}`);
406
511
  clearTTSChunkTimeout();
512
+ if (!completed && !timedOut && !streamError) {
513
+ streamError = new APIConnectionError({
514
+ message: `Cartesia WebSocket closed mid-generation (code=${code})`,
515
+ });
516
+ }
407
517
  void eventChannel.close();
408
518
  };
409
519
 
410
520
  const onError = (err: Error) => {
411
521
  this.#logger.error({ err }, 'Cartesia WebSocket error');
522
+ if (!completed && !timedOut && !streamError) {
523
+ streamError = err instanceof APIError ? err : toRetryableConnectionError(err);
524
+ }
412
525
  void eventChannel.close();
413
526
  };
414
527
 
@@ -476,7 +589,12 @@ export class SynthesizeStream extends tts.SynthesizeStream {
476
589
  this.#logger.debug(
477
590
  `Cartesia WebSocket TTS chunk stream timeout after ${this.#opts.chunkTimeout}ms`,
478
591
  );
479
- ws.close();
592
+ // The socket is stuck mid-generation, so it must not return to the
593
+ // pool. Poison it and unblock the reader; the post-loop check turns
594
+ // this into a retryable error so withConnection discards the socket.
595
+ timedOut = true;
596
+ safeCloseWebSocket(ws);
597
+ void eventChannel.close();
480
598
  }, this.#opts.chunkTimeout);
481
599
  } else if (this.#opts.wordTimestamps !== false && hasWordTimestamps(serverMsg)) {
482
600
  const wordTimestamps = serverMsg.word_timestamps;
@@ -507,9 +625,9 @@ export class SynthesizeStream extends tts.SynthesizeStream {
507
625
  }
508
626
 
509
627
  if (segmentId === requestId) {
510
- closing = true;
511
628
  clearTTSChunkTimeout();
512
- ws.close();
629
+ completed = true;
630
+ // Leave the socket open so the pool reuses it on the next turn.
513
631
  break; // Exit the loop
514
632
  }
515
633
  }
@@ -520,6 +638,15 @@ export class SynthesizeStream extends tts.SynthesizeStream {
520
638
  this.#logger.warn({ message: serverMsg }, 'Unknown Cartesia message');
521
639
  }
522
640
  }
641
+
642
+ if (timedOut) {
643
+ throw new APITimeoutError({
644
+ message: `Cartesia TTS chunk stream timed out after ${this.#opts.chunkTimeout}ms`,
645
+ });
646
+ }
647
+ if (streamError) {
648
+ throw streamError;
649
+ }
523
650
  } catch (err) {
524
651
  // Always propagate API errors so the base SynthesizeStream can retry
525
652
  // and emit tts_error once retries are exhausted.
@@ -547,32 +674,25 @@ export class SynthesizeStream extends tts.SynthesizeStream {
547
674
  }
548
675
  };
549
676
 
550
- const wsUrl = this.#opts.baseUrl.replace(/^http/, 'ws');
551
- const url = `${wsUrl}/tts/websocket`;
552
-
553
- let ws: WebSocket | undefined;
554
677
  try {
555
- ws = await connectCartesiaWebSocket({
556
- url,
557
- headers: {
558
- [AUTHORIZATION_HEADER]: this.#opts.apiKey!,
559
- [VERSION_HEADER]: this.#opts.apiVersion,
678
+ // The pool hands back one live socket per call and reclaims it on success
679
+ // (put) or discards it on any thrown error (remove). A generation never
680
+ // closes the socket itself, so the next turn skips the handshake.
681
+ await this.#pool.withConnection(
682
+ async (ws) => {
683
+ if (ws.readyState !== WebSocket.OPEN) {
684
+ throw new APIConnectionError({ message: 'Cartesia pooled websocket is not open' });
685
+ }
686
+ await Promise.all([inputTask(), sentenceStreamTask(ws), recvTask(ws)]);
560
687
  },
561
- timeoutMs: this.connOptions.timeoutMs,
562
- abortSignal: this.abortSignal,
563
- });
564
- await Promise.all([inputTask(), sentenceStreamTask(ws), recvTask(ws)]);
688
+ { timeout: this.connOptions.timeoutMs, signal: this.abortSignal },
689
+ );
565
690
  } catch (e) {
566
691
  if (this.abortSignal.aborted) {
567
692
  return;
568
693
  }
569
694
  if (e instanceof APIError) throw e;
570
695
  throw toRetryableConnectionError(e);
571
- } finally {
572
- // Ensure we don't leak sockets/tasks across retry attempts.
573
- if (ws && ws.readyState !== WebSocket.CLOSED) {
574
- safeTerminateWebSocket(ws);
575
- }
576
696
  }
577
697
  }
578
698
  }
@@ -631,9 +751,9 @@ const waitForWsOpen = async ({
631
751
  }: {
632
752
  ws: WebSocket;
633
753
  timeoutMs: number;
634
- abortSignal: AbortSignal;
754
+ abortSignal?: AbortSignal;
635
755
  }) => {
636
- if (abortSignal.aborted) {
756
+ if (abortSignal?.aborted) {
637
757
  throw new Error('aborted');
638
758
  }
639
759
 
@@ -645,7 +765,7 @@ const waitForWsOpen = async ({
645
765
  ws.off('open', onOpen);
646
766
  ws.off('error', onError);
647
767
  ws.off('close', onClose);
648
- abortSignal.removeEventListener('abort', onAbort);
768
+ abortSignal?.removeEventListener('abort', onAbort);
649
769
  };
650
770
 
651
771
  const onOpen = () => fut.resolve();
@@ -659,7 +779,7 @@ const waitForWsOpen = async ({
659
779
  ws.on('open', onOpen);
660
780
  ws.on('error', onError);
661
781
  ws.on('close', onClose);
662
- abortSignal.addEventListener('abort', onAbort, { once: true });
782
+ abortSignal?.addEventListener('abort', onAbort, { once: true });
663
783
 
664
784
  if (timeoutMs > 0) {
665
785
  timeout = setTimeout(() => fut.reject(new Error('connect timeout')), timeoutMs);
@@ -693,6 +813,30 @@ const safeTerminateWebSocket = (ws: WebSocket) => {
693
813
  }
694
814
  };
695
815
 
816
+ // Graceful close used by the connection pool. A pooled socket is healthy when it
817
+ // is retired (session age, option change, or TTS close), so a clean close frame
818
+ // is preferable to an abrupt terminate; terminate remains the fallback for a
819
+ // socket caught mid-handshake.
820
+ const safeCloseWebSocket = (ws: WebSocket) => {
821
+ try {
822
+ // `ws` can emit 'error' during teardown; without a listener Node treats it as
823
+ // unhandled and crashes the process.
824
+ ws.on('error', () => {});
825
+ } catch {
826
+ // ignore
827
+ }
828
+
829
+ try {
830
+ if (ws.readyState === WebSocket.CONNECTING || ws.readyState === WebSocket.OPEN) {
831
+ ws.close();
832
+ } else if (ws.readyState !== WebSocket.CLOSED && ws.readyState !== WebSocket.CLOSING) {
833
+ ws.terminate();
834
+ }
835
+ } catch {
836
+ // ignore
837
+ }
838
+ };
839
+
696
840
  const connectCartesiaWebSocket = async ({
697
841
  url,
698
842
  headers,
@@ -702,7 +846,7 @@ const connectCartesiaWebSocket = async ({
702
846
  url: string;
703
847
  headers: Record<string, string>;
704
848
  timeoutMs: number;
705
- abortSignal: AbortSignal;
849
+ abortSignal?: AbortSignal;
706
850
  }): Promise<WebSocket> => {
707
851
  const connectOnce = async (family?: number): Promise<WebSocket> => {
708
852
  const ws = new WebSocket(url, { handshakeTimeout: timeoutMs, family, headers });