@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/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/dist/tts.cjs +120 -38
- package/dist/tts.cjs.map +1 -1
- package/dist/tts.d.cts +7 -0
- package/dist/tts.d.ts +7 -0
- package/dist/tts.d.ts.map +1 -1
- package/dist/tts.js +121 -38
- package/dist/tts.js.map +1 -1
- package/dist/tts.test.cjs +204 -0
- package/dist/tts.test.cjs.map +1 -1
- package/dist/tts.test.js +207 -3
- package/dist/tts.test.js.map +1 -1
- package/package.json +11 -8
- package/src/tts.test.ts +252 -3
- package/src/tts.ts +177 -33
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 {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
404
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
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
|
-
|
|
562
|
-
|
|
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
|
|
754
|
+
abortSignal?: AbortSignal;
|
|
635
755
|
}) => {
|
|
636
|
-
if (abortSignal
|
|
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
|
|
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
|
|
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
|
|
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 });
|