@livekit/agents-plugin-cartesia 0.0.0-next-20260624041820

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.
Files changed (59) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +24 -0
  3. package/dist/index.cjs +36 -0
  4. package/dist/index.cjs.map +1 -0
  5. package/dist/index.d.cts +3 -0
  6. package/dist/index.d.ts +3 -0
  7. package/dist/index.d.ts.map +1 -0
  8. package/dist/index.js +14 -0
  9. package/dist/index.js.map +1 -0
  10. package/dist/models.cjs +35 -0
  11. package/dist/models.cjs.map +1 -0
  12. package/dist/models.d.cts +27 -0
  13. package/dist/models.d.ts +27 -0
  14. package/dist/models.d.ts.map +1 -0
  15. package/dist/models.js +9 -0
  16. package/dist/models.js.map +1 -0
  17. package/dist/stt.cjs +422 -0
  18. package/dist/stt.cjs.map +1 -0
  19. package/dist/stt.d.cts +53 -0
  20. package/dist/stt.d.ts +53 -0
  21. package/dist/stt.d.ts.map +1 -0
  22. package/dist/stt.js +407 -0
  23. package/dist/stt.js.map +1 -0
  24. package/dist/stt.test.cjs +17 -0
  25. package/dist/stt.test.cjs.map +1 -0
  26. package/dist/stt.test.d.cts +2 -0
  27. package/dist/stt.test.d.ts +2 -0
  28. package/dist/stt.test.d.ts.map +1 -0
  29. package/dist/stt.test.js +16 -0
  30. package/dist/stt.test.js.map +1 -0
  31. package/dist/tts.cjs +596 -0
  32. package/dist/tts.cjs.map +1 -0
  33. package/dist/tts.d.cts +56 -0
  34. package/dist/tts.d.ts +56 -0
  35. package/dist/tts.d.ts.map +1 -0
  36. package/dist/tts.js +596 -0
  37. package/dist/tts.js.map +1 -0
  38. package/dist/tts.test.cjs +17 -0
  39. package/dist/tts.test.cjs.map +1 -0
  40. package/dist/tts.test.d.cts +2 -0
  41. package/dist/tts.test.d.ts +2 -0
  42. package/dist/tts.test.d.ts.map +1 -0
  43. package/dist/tts.test.js +16 -0
  44. package/dist/tts.test.js.map +1 -0
  45. package/dist/types.cjs +124 -0
  46. package/dist/types.cjs.map +1 -0
  47. package/dist/types.d.cts +133 -0
  48. package/dist/types.d.ts +133 -0
  49. package/dist/types.d.ts.map +1 -0
  50. package/dist/types.js +87 -0
  51. package/dist/types.js.map +1 -0
  52. package/package.json +55 -0
  53. package/src/index.ts +19 -0
  54. package/src/models.ts +77 -0
  55. package/src/stt.test.ts +19 -0
  56. package/src/stt.ts +653 -0
  57. package/src/tts.test.ts +19 -0
  58. package/src/tts.ts +799 -0
  59. package/src/types.ts +116 -0
package/src/tts.ts ADDED
@@ -0,0 +1,799 @@
1
+ // SPDX-FileCopyrightText: 2024 LiveKit, Inc.
2
+ //
3
+ // SPDX-License-Identifier: Apache-2.0
4
+ import {
5
+ type APIConnectOptions,
6
+ APIConnectionError,
7
+ APIError,
8
+ APIStatusError,
9
+ APITimeoutError,
10
+ AudioByteStream,
11
+ Future,
12
+ type TimedString,
13
+ asError,
14
+ createTimedString,
15
+ getBaseLanguage,
16
+ log,
17
+ normalizeLanguage,
18
+ shortuuid,
19
+ stream,
20
+ tokenize,
21
+ tts,
22
+ } from '@livekit/agents';
23
+ import type { AudioFrame } from '@livekit/rtc-node';
24
+ import { request } from 'node:https';
25
+ import { type RawData, WebSocket } from 'ws';
26
+ import {
27
+ TTSDefaultVoiceId,
28
+ type TTSEncoding,
29
+ type TTSModels,
30
+ type TTSVoiceEmotion,
31
+ type TTSVoiceSpeed,
32
+ isSonic3,
33
+ } from './models.js';
34
+ import {
35
+ type CartesiaServerMessage,
36
+ cartesiaMessageSchema,
37
+ hasWordTimestamps,
38
+ isChunkMessage,
39
+ isDoneMessage,
40
+ isErrorMessage,
41
+ isFlushDoneMessage,
42
+ } from './types.js';
43
+
44
+ const AUTHORIZATION_HEADER = 'X-API-Key';
45
+ const VERSION_HEADER = 'Cartesia-Version';
46
+ const API_VERSION = '2025-04-16';
47
+ const API_VERSION_WITH_EXPERIMENTAL_CONTROLS = '2024-11-13';
48
+ const MODEL_WITH_EXPERIMENTAL_CONTROLS = 'sonic-2-2025-03-07';
49
+ const NUM_CHANNELS = 1;
50
+ const BUFFERED_WORDS_COUNT = 8;
51
+
52
+ export interface TTSOptions {
53
+ model: TTSModels | string;
54
+ encoding: TTSEncoding;
55
+ sampleRate: number;
56
+ voice: string | number[];
57
+ speed?: TTSVoiceSpeed | number;
58
+ emotion?: (TTSVoiceEmotion | string)[];
59
+ /**
60
+ * Volume of the speech. For sonic-3, the value is valid between 0.5 and 2.0.
61
+ * @see https://docs.cartesia.ai/api-reference/tts/bytes#body-generation-config-volume
62
+ */
63
+ volume?: number;
64
+ apiKey?: string;
65
+ language: string;
66
+ baseUrl: string;
67
+ apiVersion: string;
68
+
69
+ /**
70
+ * The timeout for the next chunk to be received from the Cartesia API.
71
+ */
72
+ chunkTimeout: number;
73
+
74
+ /**
75
+ * Whether to add word timestamps to the output. When enabled, the TTS will return
76
+ * timing information for each word in the transcript.
77
+ * @defaultValue true
78
+ */
79
+ wordTimestamps?: boolean;
80
+
81
+ pronunciationDictId?: string;
82
+ }
83
+
84
+ const defaultTTSOptions: TTSOptions = {
85
+ model: 'sonic-3',
86
+ encoding: 'pcm_s16le',
87
+ sampleRate: 24000,
88
+ voice: TTSDefaultVoiceId,
89
+ apiKey: process.env.CARTESIA_API_KEY,
90
+ language: 'en',
91
+ baseUrl: 'https://api.cartesia.ai',
92
+ apiVersion: API_VERSION,
93
+ chunkTimeout: 5000,
94
+ wordTimestamps: true,
95
+ };
96
+
97
+ const checkGenerationConfig = (opts: TTSOptions) => {
98
+ const logger = log();
99
+ if (isSonic3(opts.model)) {
100
+ if (opts.speed !== undefined && typeof opts.speed === 'number') {
101
+ if (opts.speed < 0.6 || opts.speed > 2.0) {
102
+ logger.warn('speed must be between 0.6 and 2.0 for sonic-3');
103
+ }
104
+ }
105
+ if (opts.volume !== undefined && (opts.volume < 0.5 || opts.volume > 2.0)) {
106
+ logger.warn('volume must be between 0.5 and 2.0 for sonic-3');
107
+ }
108
+ } else if (
109
+ opts.apiVersion !== API_VERSION_WITH_EXPERIMENTAL_CONTROLS ||
110
+ opts.model !== MODEL_WITH_EXPERIMENTAL_CONTROLS
111
+ ) {
112
+ if (opts.speed || opts.emotion) {
113
+ logger.warn(
114
+ { model: opts.model, speed: opts.speed, emotion: opts.emotion },
115
+ `speed and emotion controls are only supported for model '${MODEL_WITH_EXPERIMENTAL_CONTROLS}' ` +
116
+ `or sonic-3 models, see https://docs.cartesia.ai/developer-tools/changelog for details`,
117
+ );
118
+ }
119
+ }
120
+
121
+ if (opts.pronunciationDictId && !isSonic3(opts.model)) {
122
+ logger.warn(
123
+ { model: opts.model, pronunciationDictId: opts.pronunciationDictId },
124
+ 'pronunciationDictId is only supported for sonic-3 models',
125
+ );
126
+ }
127
+ };
128
+
129
+ export class TTS extends tts.TTS {
130
+ #opts: TTSOptions;
131
+ label = 'cartesia.TTS';
132
+
133
+ get model(): string {
134
+ return this.#opts.model;
135
+ }
136
+
137
+ get provider(): string {
138
+ return 'Cartesia';
139
+ }
140
+
141
+ constructor(opts: Partial<TTSOptions> = {}) {
142
+ const resolvedOpts = {
143
+ ...defaultTTSOptions,
144
+ ...opts,
145
+ };
146
+
147
+ super(resolvedOpts.sampleRate || defaultTTSOptions.sampleRate, NUM_CHANNELS, {
148
+ streaming: true,
149
+ alignedTranscript: resolvedOpts.wordTimestamps ?? true,
150
+ });
151
+
152
+ this.#opts = resolvedOpts;
153
+ this.#opts.language = normalizeLanguage(this.#opts.language);
154
+
155
+ if (this.#opts.apiKey === undefined) {
156
+ throw new Error(
157
+ 'Cartesia API key is required, whether as an argument or as $CARTESIA_API_KEY',
158
+ );
159
+ }
160
+
161
+ if (
162
+ this.#opts.speed ||
163
+ this.#opts.emotion ||
164
+ this.#opts.volume ||
165
+ this.#opts.pronunciationDictId
166
+ ) {
167
+ checkGenerationConfig(this.#opts);
168
+ }
169
+ }
170
+
171
+ updateOptions(opts: Partial<TTSOptions>) {
172
+ this.#opts = { ...this.#opts, ...opts };
173
+ if (opts.language !== undefined) {
174
+ this.#opts.language = normalizeLanguage(opts.language);
175
+ }
176
+
177
+ if (
178
+ this.#opts.speed ||
179
+ this.#opts.emotion ||
180
+ this.#opts.volume ||
181
+ this.#opts.pronunciationDictId
182
+ ) {
183
+ checkGenerationConfig(this.#opts);
184
+ }
185
+ }
186
+
187
+ synthesize(
188
+ text: string,
189
+ connOptions?: APIConnectOptions,
190
+ abortSignal?: AbortSignal,
191
+ ): tts.ChunkedStream {
192
+ return new ChunkedStream(this, text, this.#opts, connOptions, abortSignal);
193
+ }
194
+
195
+ stream(options?: { connOptions?: APIConnectOptions }): SynthesizeStream {
196
+ return new SynthesizeStream(this, this.#opts, options?.connOptions);
197
+ }
198
+ }
199
+
200
+ export class ChunkedStream extends tts.ChunkedStream {
201
+ label = 'cartesia.ChunkedStream';
202
+ #logger = log();
203
+ #opts: TTSOptions;
204
+ #text: string;
205
+
206
+ constructor(
207
+ tts: TTS,
208
+ text: string,
209
+ opts: TTSOptions,
210
+ connOptions?: APIConnectOptions,
211
+ abortSignal?: AbortSignal,
212
+ ) {
213
+ super(text, tts, connOptions, abortSignal);
214
+ this.#text = text;
215
+ this.#opts = opts;
216
+ }
217
+
218
+ protected async run() {
219
+ const requestId = shortuuid();
220
+ const bstream = new AudioByteStream(this.#opts.sampleRate, NUM_CHANNELS);
221
+ const json = toCartesiaOptions(this.#opts);
222
+ json.transcript = this.#text;
223
+
224
+ const baseUrl = new URL(this.#opts.baseUrl);
225
+ const doneFut = new Future<void>();
226
+
227
+ const req = request(
228
+ {
229
+ hostname: baseUrl.hostname,
230
+ port: parseInt(baseUrl.port) || (baseUrl.protocol === 'https:' ? 443 : 80),
231
+ path: '/tts/bytes',
232
+ method: 'POST',
233
+ headers: {
234
+ [AUTHORIZATION_HEADER]: this.#opts.apiKey!,
235
+ [VERSION_HEADER]: this.#opts.apiVersion,
236
+ },
237
+ signal: this.abortSignal,
238
+ },
239
+ (res) => {
240
+ res.on('data', (chunk) => {
241
+ for (const frame of bstream.write(chunk)) {
242
+ this.queue.put({
243
+ requestId,
244
+ frame,
245
+ final: false,
246
+ segmentId: requestId,
247
+ });
248
+ }
249
+ });
250
+ res.on('close', () => {
251
+ for (const frame of bstream.flush()) {
252
+ this.queue.put({
253
+ requestId,
254
+ frame,
255
+ final: false,
256
+ segmentId: requestId,
257
+ });
258
+ }
259
+ this.queue.close();
260
+ if (!doneFut.done) doneFut.resolve();
261
+ });
262
+ res.on('error', (err) => {
263
+ if (err.message === 'aborted') return;
264
+ this.#logger.error({ err }, 'Cartesia TTS response error');
265
+ if (!doneFut.done) doneFut.reject(err);
266
+ });
267
+ },
268
+ );
269
+
270
+ req.on('error', (err) => {
271
+ if (err.name === 'AbortError') return;
272
+ this.#logger.error({ err }, 'Cartesia TTS request error');
273
+ if (!doneFut.done) doneFut.reject(err);
274
+ });
275
+ req.on('close', () => {
276
+ if (!doneFut.done) doneFut.resolve();
277
+ });
278
+ req.write(JSON.stringify(json));
279
+ req.end();
280
+
281
+ try {
282
+ await doneFut.await;
283
+ } catch (e) {
284
+ if (this.abortSignal.aborted) return;
285
+ if (!this.queue.closed) this.queue.close();
286
+ throw toRetryableConnectionError(e);
287
+ }
288
+ }
289
+ }
290
+
291
+ export class SynthesizeStream extends tts.SynthesizeStream {
292
+ #opts: TTSOptions;
293
+ #logger = log();
294
+ #tokenizer = new tokenize.basic.SentenceTokenizer({
295
+ minSentenceLength: BUFFERED_WORDS_COUNT,
296
+ }).stream();
297
+ label = 'cartesia.SynthesizeStream';
298
+
299
+ constructor(tts: TTS, opts: TTSOptions, connOptions?: APIConnectOptions) {
300
+ super(tts, connOptions);
301
+ this.#opts = opts;
302
+ }
303
+
304
+ updateOptions(opts: Partial<TTSOptions>) {
305
+ this.#opts = { ...this.#opts, ...opts };
306
+
307
+ if (
308
+ this.#opts.speed ||
309
+ this.#opts.emotion ||
310
+ this.#opts.volume ||
311
+ this.#opts.pronunciationDictId
312
+ ) {
313
+ checkGenerationConfig(this.#opts);
314
+ }
315
+ }
316
+
317
+ protected async run() {
318
+ const requestId = shortuuid();
319
+ let closing = false;
320
+ // Only close WebSocket when both: 1) Cartesia returns done, AND 2) all sentences have been sent
321
+ let sentenceStreamClosed = false;
322
+
323
+ const sentenceStreamTask = async (ws: WebSocket) => {
324
+ const packet = toCartesiaOptions(this.#opts, true);
325
+ for await (const event of this.#tokenizer) {
326
+ const msg = {
327
+ ...packet,
328
+ context_id: requestId,
329
+ transcript: event.token + ' ',
330
+ continue: true,
331
+ };
332
+ ws.send(JSON.stringify(msg));
333
+ }
334
+
335
+ const endMsg = {
336
+ ...packet,
337
+ context_id: requestId,
338
+ transcript: ' ',
339
+ continue: false,
340
+ };
341
+ ws.send(JSON.stringify(endMsg));
342
+ // Mark sentence stream as closed
343
+ sentenceStreamClosed = true;
344
+ };
345
+
346
+ const inputTask = async () => {
347
+ for await (const data of this.input) {
348
+ if (data === SynthesizeStream.FLUSH_SENTINEL) {
349
+ this.#tokenizer.flush();
350
+ continue;
351
+ }
352
+ this.#tokenizer.pushText(data);
353
+ }
354
+ this.#tokenizer.endInput();
355
+ this.#tokenizer.close();
356
+ };
357
+
358
+ // Use event channel and set up listeners ONCE to avoid missing messages during listener re-registration
359
+ const recvTask = async (ws: WebSocket) => {
360
+ const bstream = new AudioByteStream(this.#opts.sampleRate, NUM_CHANNELS);
361
+
362
+ // Create event channel to buffer incoming messages
363
+ // This prevents message loss between listener re-registrations
364
+ const eventChannel = stream.createStreamChannel<RawData>();
365
+
366
+ let lastFrame: AudioFrame | undefined;
367
+ let pendingTimedTranscripts: TimedString[] = [];
368
+
369
+ const sendLastFrame = (segmentId: string, final: boolean) => {
370
+ if (lastFrame && !this.queue.closed) {
371
+ // Include timedTranscripts with the audio frame
372
+ this.queue.put({
373
+ requestId,
374
+ segmentId,
375
+ frame: lastFrame,
376
+ final,
377
+ timedTranscripts:
378
+ pendingTimedTranscripts.length > 0 ? pendingTimedTranscripts : undefined,
379
+ });
380
+ lastFrame = undefined;
381
+ pendingTimedTranscripts = [];
382
+ }
383
+ };
384
+
385
+ let timeout: NodeJS.Timeout | null = null;
386
+
387
+ const clearTTSChunkTimeout = () => {
388
+ if (timeout) {
389
+ clearTimeout(timeout);
390
+ timeout = null;
391
+ }
392
+ };
393
+
394
+ // Set up WebSocket listeners ONCE (not in a loop)
395
+ const onMessage = (data: RawData) => {
396
+ void eventChannel.write(data).catch((error: unknown) => {
397
+ this.#logger.debug({ error }, 'Failed writing Cartesia event to channel (likely closed)');
398
+ });
399
+ };
400
+
401
+ const onClose = (code: number, reason: Buffer) => {
402
+ if (!closing) {
403
+ this.#logger.debug(`WebSocket closed with code ${code}: ${reason.toString()}`);
404
+ }
405
+ clearTTSChunkTimeout();
406
+ void eventChannel.close();
407
+ };
408
+
409
+ const onError = (err: Error) => {
410
+ this.#logger.error({ err }, 'Cartesia WebSocket error');
411
+ void eventChannel.close();
412
+ };
413
+
414
+ // Attach listeners ONCE
415
+ ws.on('message', onMessage);
416
+ ws.on('close', onClose);
417
+ ws.on('error', onError);
418
+
419
+ try {
420
+ // Process messages from the channel
421
+ const reader = eventChannel.stream().getReader();
422
+
423
+ while (!this.closed && !this.abortController.signal.aborted) {
424
+ const result = await reader.read();
425
+ if (result.done) break;
426
+
427
+ const rawMsg = result.value;
428
+
429
+ // Parse message with Zod schema for type safety
430
+ let serverMsg: CartesiaServerMessage;
431
+ try {
432
+ const json = JSON.parse(rawMsg.toString());
433
+ serverMsg = cartesiaMessageSchema.parse(json);
434
+ } catch (parseErr) {
435
+ this.#logger.warn({ parseErr }, 'Failed to parse Cartesia message');
436
+ continue;
437
+ }
438
+
439
+ const segmentId = serverMsg.context_id;
440
+
441
+ // Handle error frames first. 4xx (e.g. empty-transcript on
442
+ // function-call turns) is non-fatal — log and fall through so an
443
+ // accompanying done:true still triggers the unified close path
444
+ // below. 5xx bubbles up so the base SynthesizeStream can retry.
445
+ if (isErrorMessage(serverMsg)) {
446
+ if (serverMsg.status_code >= 400 && serverMsg.status_code < 500) {
447
+ this.#logger.debug({ error: serverMsg.error }, 'Cartesia sent a non-fatal error');
448
+ } else {
449
+ this.#logger.error({ error: serverMsg.error }, 'Cartesia returned error');
450
+ throw new APIStatusError({
451
+ message: `Cartesia returned error: ${serverMsg.error}`,
452
+ options: { statusCode: serverMsg.status_code, retryable: true },
453
+ });
454
+ }
455
+ }
456
+
457
+ if (isChunkMessage(serverMsg)) {
458
+ const audioBuffer = Buffer.from(serverMsg.data, 'base64');
459
+ // Extract ArrayBuffer from Buffer for AudioByteStream compatibility
460
+ const audioData = audioBuffer.buffer.slice(
461
+ audioBuffer.byteOffset,
462
+ audioBuffer.byteOffset + audioBuffer.byteLength,
463
+ );
464
+ for (const frame of bstream.write(audioData)) {
465
+ sendLastFrame(segmentId, false);
466
+ lastFrame = frame;
467
+ }
468
+
469
+ // IMPORTANT: close WS if TTS chunk stream been stuck too long
470
+ // this allows unblock the current "broken" TTS node so that any future TTS nodes
471
+ // can continue to process the stream without been blocked by the stuck node
472
+ clearTTSChunkTimeout();
473
+ timeout = setTimeout(() => {
474
+ // cartesia chunk timeout quite often, so we make it a debug log
475
+ this.#logger.debug(
476
+ `Cartesia WebSocket TTS chunk stream timeout after ${this.#opts.chunkTimeout}ms`,
477
+ );
478
+ ws.close();
479
+ }, this.#opts.chunkTimeout);
480
+ } else if (this.#opts.wordTimestamps !== false && hasWordTimestamps(serverMsg)) {
481
+ const wordTimestamps = serverMsg.word_timestamps;
482
+ for (let i = 0; i < wordTimestamps.words.length; i++) {
483
+ const word = wordTimestamps.words[i];
484
+ const startTime = wordTimestamps.start[i];
485
+ const endTime = wordTimestamps.end[i];
486
+ if (word !== undefined && startTime !== undefined && endTime !== undefined) {
487
+ pendingTimedTranscripts.push(
488
+ createTimedString({
489
+ text: word + ' ', // Add space after word for consistency
490
+ startTime,
491
+ endTime,
492
+ }),
493
+ );
494
+ }
495
+ }
496
+ } else if (isDoneMessage(serverMsg) || (isErrorMessage(serverMsg) && serverMsg.done)) {
497
+ // This ensures all sentences have been sent before closing
498
+ if (sentenceStreamClosed) {
499
+ for (const frame of bstream.flush()) {
500
+ sendLastFrame(segmentId, false);
501
+ lastFrame = frame;
502
+ }
503
+ sendLastFrame(segmentId, true);
504
+ if (!this.queue.closed) {
505
+ this.queue.put(SynthesizeStream.END_OF_STREAM);
506
+ }
507
+
508
+ if (segmentId === requestId) {
509
+ closing = true;
510
+ clearTTSChunkTimeout();
511
+ ws.close();
512
+ break; // Exit the loop
513
+ }
514
+ }
515
+ // If sentenceStreamClosed is false, continue receiving - more done messages will come
516
+ } else if (!isFlushDoneMessage(serverMsg) && !isErrorMessage(serverMsg)) {
517
+ // flush_done is an ack with nothing to do; error frames without
518
+ // done:true were already logged above.
519
+ this.#logger.warn({ message: serverMsg }, 'Unknown Cartesia message');
520
+ }
521
+ }
522
+ } catch (err) {
523
+ // Always propagate API errors so the base SynthesizeStream can retry
524
+ // and emit tts_error once retries are exhausted.
525
+ if (err instanceof APIError) throw err;
526
+ // skip log error for normal websocket close
527
+ if (err instanceof Error && !err.message.includes('WebSocket closed')) {
528
+ if (
529
+ err.message.includes('Queue is closed') ||
530
+ err.message.includes('Channel is closed')
531
+ ) {
532
+ this.#logger.warn(
533
+ { err },
534
+ 'Channel closed during transcript processing (expected during disconnect)',
535
+ );
536
+ } else {
537
+ this.#logger.error({ err }, 'Error in recvTask from Cartesia WebSocket');
538
+ }
539
+ }
540
+ } finally {
541
+ // IMPORTANT: Remove listeners so connection can be reused
542
+ ws.off('message', onMessage);
543
+ ws.off('close', onClose);
544
+ ws.off('error', onError);
545
+ clearTTSChunkTimeout();
546
+ }
547
+ };
548
+
549
+ const wsUrl = this.#opts.baseUrl.replace(/^http/, 'ws');
550
+ const url = `${wsUrl}/tts/websocket`;
551
+
552
+ let ws: WebSocket | undefined;
553
+ try {
554
+ ws = await connectCartesiaWebSocket({
555
+ url,
556
+ headers: {
557
+ [AUTHORIZATION_HEADER]: this.#opts.apiKey!,
558
+ [VERSION_HEADER]: this.#opts.apiVersion,
559
+ },
560
+ timeoutMs: this.connOptions.timeoutMs,
561
+ abortSignal: this.abortSignal,
562
+ });
563
+ await Promise.all([inputTask(), sentenceStreamTask(ws), recvTask(ws)]);
564
+ } catch (e) {
565
+ if (this.abortSignal.aborted) {
566
+ return;
567
+ }
568
+ if (e instanceof APIError) throw e;
569
+ throw toRetryableConnectionError(e);
570
+ } finally {
571
+ // Ensure we don't leak sockets/tasks across retry attempts.
572
+ if (ws && ws.readyState !== WebSocket.CLOSED) {
573
+ safeTerminateWebSocket(ws);
574
+ }
575
+ }
576
+ }
577
+ }
578
+
579
+ const transientNetworkCodes = new Set([
580
+ 'ETIMEDOUT',
581
+ 'ECONNRESET',
582
+ 'EAI_AGAIN',
583
+ 'ENETUNREACH',
584
+ 'ECONNREFUSED',
585
+ 'EHOSTUNREACH',
586
+ ]);
587
+
588
+ const isRecord = (v: unknown): v is Record<string, unknown> => {
589
+ return v !== null && typeof v === 'object';
590
+ };
591
+
592
+ const isAggregateErrorLike = (e: unknown): e is { errors: unknown[]; name?: string } => {
593
+ if (!isRecord(e)) return false;
594
+ return e.name === 'AggregateError' && Array.isArray(e.errors);
595
+ };
596
+
597
+ const hasErrorCode = (e: unknown, code: string): boolean => {
598
+ if (isRecord(e) && e.code === code) return true;
599
+ if (isAggregateErrorLike(e)) {
600
+ return e.errors.some((inner) => hasErrorCode(inner, code));
601
+ }
602
+ return false;
603
+ };
604
+
605
+ const hasAnyTransientCode = (e: unknown): boolean => {
606
+ if (isRecord(e) && typeof e.code === 'string') {
607
+ return transientNetworkCodes.has(e.code);
608
+ }
609
+ if (isAggregateErrorLike(e)) {
610
+ return e.errors.some((inner) => hasAnyTransientCode(inner));
611
+ }
612
+ return false;
613
+ };
614
+
615
+ const toRetryableConnectionError = (e: unknown): APIConnectionError => {
616
+ const err = asError(e);
617
+ const isTimeout =
618
+ hasErrorCode(e, 'ETIMEDOUT') ||
619
+ (typeof err.message === 'string' && err.message.includes('ETIMEDOUT'));
620
+ const message = isTimeout
621
+ ? `Cartesia connection timed out`
622
+ : `Cartesia connection failed: ${err.message || 'unknown error'}`;
623
+ return isTimeout ? new APITimeoutError({ message }) : new APIConnectionError({ message });
624
+ };
625
+
626
+ const waitForWsOpen = async ({
627
+ ws,
628
+ timeoutMs,
629
+ abortSignal,
630
+ }: {
631
+ ws: WebSocket;
632
+ timeoutMs: number;
633
+ abortSignal: AbortSignal;
634
+ }) => {
635
+ if (abortSignal.aborted) {
636
+ throw new Error('aborted');
637
+ }
638
+
639
+ const fut = new Future<void>();
640
+ let timeout: NodeJS.Timeout | undefined;
641
+
642
+ const cleanup = () => {
643
+ if (timeout) clearTimeout(timeout);
644
+ ws.off('open', onOpen);
645
+ ws.off('error', onError);
646
+ ws.off('close', onClose);
647
+ abortSignal.removeEventListener('abort', onAbort);
648
+ };
649
+
650
+ const onOpen = () => fut.resolve();
651
+ const onError = (err: Error) => fut.reject(asError(err));
652
+ const onClose = (code: number, reason: Buffer) =>
653
+ fut.reject(
654
+ new Error(`WebSocket closed before open (code=${code}, reason=${reason.toString()})`),
655
+ );
656
+ const onAbort = () => fut.reject(new Error('aborted'));
657
+
658
+ ws.on('open', onOpen);
659
+ ws.on('error', onError);
660
+ ws.on('close', onClose);
661
+ abortSignal.addEventListener('abort', onAbort, { once: true });
662
+
663
+ if (timeoutMs > 0) {
664
+ timeout = setTimeout(() => fut.reject(new Error('connect timeout')), timeoutMs);
665
+ }
666
+
667
+ try {
668
+ await fut.await;
669
+ } finally {
670
+ cleanup();
671
+ }
672
+ };
673
+
674
+ const safeTerminateWebSocket = (ws: WebSocket) => {
675
+ // `ws` can emit an 'error' event during teardown (especially if CONNECTING).
676
+ // If there is no error listener at that moment, Node will treat it as unhandled and crash the process.
677
+ try {
678
+ ws.on('error', () => {});
679
+ } catch {
680
+ // ignore
681
+ }
682
+
683
+ try {
684
+ // `terminate()` can throw if the socket was never established; `close()` is safer in CONNECTING.
685
+ if (ws.readyState === WebSocket.CONNECTING) {
686
+ ws.close();
687
+ } else {
688
+ ws.terminate();
689
+ }
690
+ } catch {
691
+ // ignore
692
+ }
693
+ };
694
+
695
+ const connectCartesiaWebSocket = async ({
696
+ url,
697
+ headers,
698
+ timeoutMs,
699
+ abortSignal,
700
+ }: {
701
+ url: string;
702
+ headers: Record<string, string>;
703
+ timeoutMs: number;
704
+ abortSignal: AbortSignal;
705
+ }): Promise<WebSocket> => {
706
+ const connectOnce = async (family?: number): Promise<WebSocket> => {
707
+ const ws = new WebSocket(url, { handshakeTimeout: timeoutMs, family, headers });
708
+ try {
709
+ await waitForWsOpen({ ws, timeoutMs, abortSignal });
710
+ return ws;
711
+ } catch (e) {
712
+ safeTerminateWebSocket(ws);
713
+ throw e;
714
+ }
715
+ };
716
+
717
+ try {
718
+ return await connectOnce();
719
+ } catch (e) {
720
+ // Mitigation for Node.js dual-stack (IPv6/IPv4) connect flakiness ("happy eyeballs"):
721
+ // some environments surface `AggregateError` with nested `ETIMEDOUT` during the initial
722
+ // WebSocket open. In that case we do a one-off retry forcing IPv4 (`family: 4`) before
723
+ // letting the outer framework retry loop handle further attempts.
724
+ //
725
+ // If you still see `AggregateError`/`ETIMEDOUT`:
726
+ // - Increase the session TTS connect timeout (`connOptions.ttsConnOptions.timeoutMs`)
727
+ // - Or adjust Node's family autoselection behavior via `NODE_OPTIONS`, e.g.
728
+ // `--network-family-autoselection-attempt-timeout=5000` (or disable it entirely).
729
+ if (hasAnyTransientCode(e) || isAggregateErrorLike(e)) {
730
+ return await connectOnce(4);
731
+ }
732
+ throw e;
733
+ }
734
+ };
735
+
736
+ const toCartesiaOptions = (
737
+ opts: TTSOptions,
738
+ streaming: boolean = false,
739
+ ): { [id: string]: unknown } => {
740
+ const voice: { [id: string]: unknown } = {};
741
+ if (typeof opts.voice === 'string') {
742
+ voice.mode = 'id';
743
+ voice.id = opts.voice;
744
+ } else {
745
+ voice.mode = 'embedding';
746
+ voice.embedding = opts.voice;
747
+ }
748
+
749
+ if (opts.apiVersion === API_VERSION_WITH_EXPERIMENTAL_CONTROLS) {
750
+ const voiceControls: { [id: string]: unknown } = {};
751
+ if (opts.speed) {
752
+ voiceControls.speed = opts.speed;
753
+ }
754
+ if (opts.emotion) {
755
+ voiceControls.emotion = opts.emotion;
756
+ }
757
+ if (Object.keys(voiceControls).length) {
758
+ voice.__experimental_controls = voiceControls;
759
+ }
760
+ }
761
+
762
+ const result: { [id: string]: unknown } = {
763
+ model_id: opts.model,
764
+ voice,
765
+ output_format: {
766
+ container: 'raw',
767
+ encoding: opts.encoding,
768
+ sample_rate: opts.sampleRate,
769
+ },
770
+ language: getBaseLanguage(opts.language),
771
+ max_buffer_delay_ms: 0,
772
+ };
773
+
774
+ if (opts.pronunciationDictId) {
775
+ result.pronunciation_dict_id = opts.pronunciationDictId;
776
+ }
777
+
778
+ if (opts.apiVersion > API_VERSION_WITH_EXPERIMENTAL_CONTROLS && isSonic3(opts.model)) {
779
+ const generationConfig: { [id: string]: unknown } = {};
780
+ if (opts.speed) {
781
+ generationConfig.speed = opts.speed;
782
+ }
783
+ if (opts.emotion) {
784
+ generationConfig.emotion = opts.emotion[0];
785
+ }
786
+ if (opts.volume) {
787
+ generationConfig.volume = opts.volume;
788
+ }
789
+ if (Object.keys(generationConfig).length) {
790
+ result.generation_config = generationConfig;
791
+ }
792
+ }
793
+
794
+ if (streaming && opts.wordTimestamps !== false) {
795
+ result.add_timestamps = true;
796
+ }
797
+
798
+ return result;
799
+ };