@kuralle-syrinx/cartesia 2.1.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/LICENSE +22 -0
- package/package.json +23 -0
- package/src/index.test.ts +884 -0
- package/src/index.ts +395 -0
- package/tsconfig.json +21 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kuralle
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|
package/package.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@kuralle-syrinx/cartesia",
|
|
3
|
+
"version": "2.1.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"main": "./src/index.ts",
|
|
8
|
+
"types": "./src/index.ts",
|
|
9
|
+
"dependencies": {
|
|
10
|
+
"@kuralle-syrinx/core": "2.1.0",
|
|
11
|
+
"@kuralle-syrinx/ws": "2.1.0"
|
|
12
|
+
},
|
|
13
|
+
"devDependencies": {
|
|
14
|
+
"@types/ws": "^8.5.0",
|
|
15
|
+
"typescript": "^5.7.0",
|
|
16
|
+
"vitest": "^2.1.0",
|
|
17
|
+
"ws": "^8.18.0"
|
|
18
|
+
},
|
|
19
|
+
"scripts": {
|
|
20
|
+
"typecheck": "tsc --noEmit",
|
|
21
|
+
"test": "vitest run"
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,884 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { WebSocketServer, type WebSocket } from "ws";
|
|
5
|
+
import {
|
|
6
|
+
PipelineBusImpl,
|
|
7
|
+
Route,
|
|
8
|
+
type TextToSpeechAudioPacket,
|
|
9
|
+
type TextToSpeechEndPacket,
|
|
10
|
+
type TextToSpeechWordTimestampsPacket,
|
|
11
|
+
type TtsErrorPacket,
|
|
12
|
+
} from "@kuralle-syrinx/core";
|
|
13
|
+
|
|
14
|
+
import { CartesiaTTSPlugin } from "./index.js";
|
|
15
|
+
|
|
16
|
+
let servers: WebSocketServer[] = [];
|
|
17
|
+
|
|
18
|
+
afterEach(async () => {
|
|
19
|
+
await Promise.all(
|
|
20
|
+
servers.splice(0).map(
|
|
21
|
+
(server) =>
|
|
22
|
+
new Promise<void>((resolve) => {
|
|
23
|
+
for (const client of server.clients) client.terminate();
|
|
24
|
+
server.close(() => resolve());
|
|
25
|
+
}),
|
|
26
|
+
),
|
|
27
|
+
);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
async function createLocalServer(onConnection: (socket: WebSocket, requestUrl: string, apiKeyHeader: string) => void): Promise<string> {
|
|
31
|
+
const server = await new Promise<WebSocketServer>((resolve) => {
|
|
32
|
+
let nextServer: WebSocketServer;
|
|
33
|
+
nextServer = new WebSocketServer({ port: 0 }, () => {
|
|
34
|
+
resolve(nextServer);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
servers.push(server);
|
|
38
|
+
server.on("connection", (socket, request) => {
|
|
39
|
+
const header = request.headers["x-api-key"];
|
|
40
|
+
onConnection(socket, request.url ?? "", Array.isArray(header) ? header[0] ?? "" : header ?? "");
|
|
41
|
+
});
|
|
42
|
+
const address = server.address();
|
|
43
|
+
if (!address || typeof address === "string") throw new Error("Expected TCP server address");
|
|
44
|
+
return `ws://127.0.0.1:${address.port}/tts/websocket`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function startBus(bus: PipelineBusImpl): Promise<void> {
|
|
48
|
+
return bus.start();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function waitForCondition(predicate: () => boolean, timeoutMs = 1000): Promise<void> {
|
|
52
|
+
const startedAt = Date.now();
|
|
53
|
+
while (Date.now() - startedAt < timeoutMs) {
|
|
54
|
+
if (predicate()) return;
|
|
55
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
56
|
+
}
|
|
57
|
+
throw new Error("Timed out waiting for Cartesia test condition");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
describe("CartesiaTTSPlugin", () => {
|
|
61
|
+
it("streams text over one authenticated websocket without leaking the API key in the URL", async () => {
|
|
62
|
+
const receivedRequests: any[] = [];
|
|
63
|
+
const endpointUrl = await createLocalServer((socket, requestUrl, apiKeyHeader) => {
|
|
64
|
+
expect(requestUrl).toContain("cartesia_version=");
|
|
65
|
+
expect(requestUrl).not.toContain("test-cartesia-key");
|
|
66
|
+
expect(apiKeyHeader).toBe("test-cartesia-key");
|
|
67
|
+
socket.on("message", (data) => {
|
|
68
|
+
const msg = JSON.parse(data.toString());
|
|
69
|
+
receivedRequests.push(msg);
|
|
70
|
+
if (msg.transcript === "Hello there.") {
|
|
71
|
+
socket.send(JSON.stringify({
|
|
72
|
+
type: "chunk",
|
|
73
|
+
context_id: msg.context_id,
|
|
74
|
+
data: Buffer.from(new Uint8Array([1, 2, 3, 4])).toString("base64"),
|
|
75
|
+
done: false,
|
|
76
|
+
status_code: 206,
|
|
77
|
+
}));
|
|
78
|
+
}
|
|
79
|
+
if (msg.context_id === "turn-1" && msg.continue === false) {
|
|
80
|
+
socket.send(JSON.stringify({
|
|
81
|
+
type: "done",
|
|
82
|
+
context_id: "turn-1",
|
|
83
|
+
done: true,
|
|
84
|
+
status_code: 206,
|
|
85
|
+
}));
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
const bus = new PipelineBusImpl();
|
|
90
|
+
const started = startBus(bus);
|
|
91
|
+
const plugin = new CartesiaTTSPlugin();
|
|
92
|
+
const audio: TextToSpeechAudioPacket[] = [];
|
|
93
|
+
const ends: TextToSpeechEndPacket[] = [];
|
|
94
|
+
bus.on("tts.audio", (pkt) => {
|
|
95
|
+
audio.push(pkt as TextToSpeechAudioPacket);
|
|
96
|
+
});
|
|
97
|
+
bus.on("tts.end", (pkt) => {
|
|
98
|
+
ends.push(pkt as TextToSpeechEndPacket);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
await plugin.initialize(bus, {
|
|
102
|
+
api_key: "test-cartesia-key",
|
|
103
|
+
endpoint_url: endpointUrl,
|
|
104
|
+
voice_id: "voice-test",
|
|
105
|
+
model_id: "sonic-test",
|
|
106
|
+
sample_rate: 16000,
|
|
107
|
+
});
|
|
108
|
+
bus.push(Route.Main, {
|
|
109
|
+
kind: "tts.text",
|
|
110
|
+
contextId: "turn-1",
|
|
111
|
+
timestampMs: Date.now(),
|
|
112
|
+
text: "Hello there.",
|
|
113
|
+
});
|
|
114
|
+
bus.push(Route.Main, {
|
|
115
|
+
kind: "tts.done",
|
|
116
|
+
contextId: "turn-1",
|
|
117
|
+
timestampMs: Date.now(),
|
|
118
|
+
text: "Hello there.",
|
|
119
|
+
});
|
|
120
|
+
await waitForCondition(() => receivedRequests.length >= 2 && audio.length >= 1 && ends.length >= 1);
|
|
121
|
+
|
|
122
|
+
expect(receivedRequests).toEqual([
|
|
123
|
+
expect.objectContaining({
|
|
124
|
+
context_id: "turn-1",
|
|
125
|
+
continue: true,
|
|
126
|
+
transcript: "Hello there.",
|
|
127
|
+
}),
|
|
128
|
+
expect.objectContaining({
|
|
129
|
+
context_id: "turn-1",
|
|
130
|
+
continue: false,
|
|
131
|
+
flush: true,
|
|
132
|
+
transcript: "",
|
|
133
|
+
}),
|
|
134
|
+
]);
|
|
135
|
+
expect(audio).toEqual([
|
|
136
|
+
expect.objectContaining({
|
|
137
|
+
contextId: "turn-1",
|
|
138
|
+
audio: new Uint8Array([1, 2, 3, 4]),
|
|
139
|
+
}),
|
|
140
|
+
]);
|
|
141
|
+
expect(ends).toEqual([expect.objectContaining({ contextId: "turn-1" })]);
|
|
142
|
+
|
|
143
|
+
await plugin.close();
|
|
144
|
+
bus.stop();
|
|
145
|
+
await started;
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("cancels active Cartesia contexts on TTS interruption", async () => {
|
|
149
|
+
const receivedRequests: any[] = [];
|
|
150
|
+
const endpointUrl = await createLocalServer((socket) => {
|
|
151
|
+
socket.on("message", (data) => {
|
|
152
|
+
receivedRequests.push(JSON.parse(data.toString()));
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
const bus = new PipelineBusImpl();
|
|
156
|
+
const started = startBus(bus);
|
|
157
|
+
const plugin = new CartesiaTTSPlugin();
|
|
158
|
+
|
|
159
|
+
await plugin.initialize(bus, {
|
|
160
|
+
api_key: "test-cartesia-key",
|
|
161
|
+
endpoint_url: endpointUrl,
|
|
162
|
+
voice_id: "voice-test",
|
|
163
|
+
model_id: "sonic-test",
|
|
164
|
+
});
|
|
165
|
+
bus.push(Route.Main, {
|
|
166
|
+
kind: "tts.text",
|
|
167
|
+
contextId: "turn-interrupt",
|
|
168
|
+
timestampMs: Date.now(),
|
|
169
|
+
text: "This will be interrupted.",
|
|
170
|
+
});
|
|
171
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
172
|
+
bus.push(Route.Critical, {
|
|
173
|
+
kind: "interrupt.tts",
|
|
174
|
+
contextId: "turn-interrupt",
|
|
175
|
+
timestampMs: Date.now(),
|
|
176
|
+
});
|
|
177
|
+
await new Promise((resolve) => setTimeout(resolve, 40));
|
|
178
|
+
|
|
179
|
+
expect(receivedRequests).toEqual([
|
|
180
|
+
expect.objectContaining({
|
|
181
|
+
context_id: "turn-interrupt",
|
|
182
|
+
continue: true,
|
|
183
|
+
}),
|
|
184
|
+
{
|
|
185
|
+
context_id: "turn-interrupt",
|
|
186
|
+
cancel: true,
|
|
187
|
+
},
|
|
188
|
+
]);
|
|
189
|
+
|
|
190
|
+
await plugin.close();
|
|
191
|
+
bus.stop();
|
|
192
|
+
await started;
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("drops late Cartesia audio and done frames for cancelled contexts", async () => {
|
|
196
|
+
const endpointUrl = await createLocalServer((socket) => {
|
|
197
|
+
socket.on("message", (data) => {
|
|
198
|
+
const msg = JSON.parse(data.toString());
|
|
199
|
+
if (msg.cancel === true) {
|
|
200
|
+
socket.send(JSON.stringify({
|
|
201
|
+
context_id: msg.context_id,
|
|
202
|
+
data: Buffer.from([9, 8, 7, 6]).toString("base64"),
|
|
203
|
+
}));
|
|
204
|
+
socket.send(JSON.stringify({
|
|
205
|
+
context_id: msg.context_id,
|
|
206
|
+
done: true,
|
|
207
|
+
}));
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
const bus = new PipelineBusImpl();
|
|
212
|
+
const started = startBus(bus);
|
|
213
|
+
const plugin = new CartesiaTTSPlugin();
|
|
214
|
+
const audio: TextToSpeechAudioPacket[] = [];
|
|
215
|
+
const ends: TextToSpeechEndPacket[] = [];
|
|
216
|
+
|
|
217
|
+
bus.on("tts.audio", (pkt) => {
|
|
218
|
+
audio.push(pkt as TextToSpeechAudioPacket);
|
|
219
|
+
});
|
|
220
|
+
bus.on("tts.end", (pkt) => {
|
|
221
|
+
ends.push(pkt as TextToSpeechEndPacket);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
await plugin.initialize(bus, {
|
|
225
|
+
api_key: "test-cartesia-key",
|
|
226
|
+
endpoint_url: endpointUrl,
|
|
227
|
+
voice_id: "voice-test",
|
|
228
|
+
model_id: "sonic-test",
|
|
229
|
+
});
|
|
230
|
+
bus.push(Route.Main, {
|
|
231
|
+
kind: "tts.text",
|
|
232
|
+
contextId: "turn-cancelled",
|
|
233
|
+
timestampMs: Date.now(),
|
|
234
|
+
text: "This generation will be cancelled.",
|
|
235
|
+
});
|
|
236
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
237
|
+
bus.push(Route.Critical, {
|
|
238
|
+
kind: "interrupt.tts",
|
|
239
|
+
contextId: "turn-cancelled",
|
|
240
|
+
timestampMs: Date.now(),
|
|
241
|
+
});
|
|
242
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
243
|
+
|
|
244
|
+
expect(audio).toEqual([]);
|
|
245
|
+
expect(ends).toEqual([]);
|
|
246
|
+
|
|
247
|
+
await plugin.close();
|
|
248
|
+
bus.stop();
|
|
249
|
+
await started;
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it("emits tts.end when Cartesia streams audio but never acknowledges flush", async () => {
|
|
253
|
+
const endpointUrl = await createLocalServer((socket) => {
|
|
254
|
+
socket.on("message", (data) => {
|
|
255
|
+
const msg = JSON.parse(data.toString());
|
|
256
|
+
if (msg.transcript !== "Hello there.") return;
|
|
257
|
+
socket.send(JSON.stringify({
|
|
258
|
+
type: "chunk",
|
|
259
|
+
context_id: msg.context_id,
|
|
260
|
+
data: Buffer.from(new Uint8Array([1, 2, 3, 4])).toString("base64"),
|
|
261
|
+
done: false,
|
|
262
|
+
status_code: 206,
|
|
263
|
+
}));
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
const bus = new PipelineBusImpl();
|
|
267
|
+
const started = startBus(bus);
|
|
268
|
+
const plugin = new CartesiaTTSPlugin();
|
|
269
|
+
const ends: TextToSpeechEndPacket[] = [];
|
|
270
|
+
bus.on("tts.end", (pkt) => {
|
|
271
|
+
ends.push(pkt as TextToSpeechEndPacket);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
await plugin.initialize(bus, {
|
|
275
|
+
api_key: "test-cartesia-key",
|
|
276
|
+
endpoint_url: endpointUrl,
|
|
277
|
+
voice_id: "voice-test",
|
|
278
|
+
model_id: "sonic-test",
|
|
279
|
+
finish_timeout_ms: 20,
|
|
280
|
+
});
|
|
281
|
+
bus.push(Route.Main, {
|
|
282
|
+
kind: "tts.text",
|
|
283
|
+
contextId: "turn-timeout-end",
|
|
284
|
+
timestampMs: Date.now(),
|
|
285
|
+
text: "Hello there.",
|
|
286
|
+
});
|
|
287
|
+
bus.push(Route.Main, {
|
|
288
|
+
kind: "tts.done",
|
|
289
|
+
contextId: "turn-timeout-end",
|
|
290
|
+
timestampMs: Date.now(),
|
|
291
|
+
text: "Hello there.",
|
|
292
|
+
});
|
|
293
|
+
await waitForCondition(() => ends.length >= 1);
|
|
294
|
+
|
|
295
|
+
expect(ends).toEqual([expect.objectContaining({ contextId: "turn-timeout-end" })]);
|
|
296
|
+
|
|
297
|
+
await plugin.close();
|
|
298
|
+
bus.stop();
|
|
299
|
+
await started;
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it("emits a typed TTS error and closes the context when Cartesia returns an error frame", async () => {
|
|
303
|
+
const endpointUrl = await createLocalServer((socket) => {
|
|
304
|
+
socket.on("message", (data) => {
|
|
305
|
+
const msg = JSON.parse(data.toString());
|
|
306
|
+
socket.send(JSON.stringify({
|
|
307
|
+
type: "error",
|
|
308
|
+
done: true,
|
|
309
|
+
title: "Invalid model",
|
|
310
|
+
message: "The model is not valid.",
|
|
311
|
+
error_code: "model_not_found",
|
|
312
|
+
status_code: 400,
|
|
313
|
+
context_id: msg.context_id,
|
|
314
|
+
}));
|
|
315
|
+
});
|
|
316
|
+
});
|
|
317
|
+
const bus = new PipelineBusImpl();
|
|
318
|
+
const started = startBus(bus);
|
|
319
|
+
const plugin = new CartesiaTTSPlugin();
|
|
320
|
+
const errors: TtsErrorPacket[] = [];
|
|
321
|
+
const ends: TextToSpeechEndPacket[] = [];
|
|
322
|
+
bus.on("tts.error", (pkt) => {
|
|
323
|
+
errors.push(pkt as TtsErrorPacket);
|
|
324
|
+
});
|
|
325
|
+
bus.on("tts.end", (pkt) => {
|
|
326
|
+
ends.push(pkt as TextToSpeechEndPacket);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
await plugin.initialize(bus, {
|
|
330
|
+
api_key: "test-cartesia-key",
|
|
331
|
+
endpoint_url: endpointUrl,
|
|
332
|
+
voice_id: "voice-test",
|
|
333
|
+
model_id: "bad-model",
|
|
334
|
+
});
|
|
335
|
+
bus.push(Route.Main, {
|
|
336
|
+
kind: "tts.text",
|
|
337
|
+
contextId: "turn-error",
|
|
338
|
+
timestampMs: Date.now(),
|
|
339
|
+
text: "This will fail.",
|
|
340
|
+
});
|
|
341
|
+
await new Promise((resolve) => setTimeout(resolve, 40));
|
|
342
|
+
|
|
343
|
+
expect(errors).toEqual([
|
|
344
|
+
expect.objectContaining({
|
|
345
|
+
kind: "tts.error",
|
|
346
|
+
contextId: "turn-error",
|
|
347
|
+
component: "tts",
|
|
348
|
+
}),
|
|
349
|
+
]);
|
|
350
|
+
expect(errors[0]!.cause.message).toContain("Invalid model");
|
|
351
|
+
expect(ends).toEqual([expect.objectContaining({ contextId: "turn-error" })]);
|
|
352
|
+
|
|
353
|
+
await plugin.close();
|
|
354
|
+
bus.stop();
|
|
355
|
+
await started;
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it("surfaces Cartesia error field text in tts.error cause.message", async () => {
|
|
359
|
+
const endpointUrl = await createLocalServer((socket) => {
|
|
360
|
+
socket.on("message", (data) => {
|
|
361
|
+
const msg = JSON.parse(data.toString());
|
|
362
|
+
socket.send(JSON.stringify({
|
|
363
|
+
type: "error",
|
|
364
|
+
context_id: msg.context_id,
|
|
365
|
+
status_code: 400,
|
|
366
|
+
done: true,
|
|
367
|
+
error: "Model sunsetted: The requested model has been sunsetted and is no longer available.",
|
|
368
|
+
}));
|
|
369
|
+
});
|
|
370
|
+
});
|
|
371
|
+
const bus = new PipelineBusImpl();
|
|
372
|
+
const started = startBus(bus);
|
|
373
|
+
const plugin = new CartesiaTTSPlugin();
|
|
374
|
+
const errors: TtsErrorPacket[] = [];
|
|
375
|
+
bus.on("tts.error", (pkt) => {
|
|
376
|
+
errors.push(pkt as TtsErrorPacket);
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
await plugin.initialize(bus, {
|
|
380
|
+
api_key: "test-cartesia-key",
|
|
381
|
+
endpoint_url: endpointUrl,
|
|
382
|
+
voice_id: "voice-test",
|
|
383
|
+
model_id: "bad-model",
|
|
384
|
+
});
|
|
385
|
+
bus.push(Route.Main, {
|
|
386
|
+
kind: "tts.text",
|
|
387
|
+
contextId: "turn-x",
|
|
388
|
+
timestampMs: Date.now(),
|
|
389
|
+
text: "This will fail.",
|
|
390
|
+
});
|
|
391
|
+
await new Promise((resolve) => setTimeout(resolve, 40));
|
|
392
|
+
|
|
393
|
+
expect(errors).toEqual([
|
|
394
|
+
expect.objectContaining({
|
|
395
|
+
kind: "tts.error",
|
|
396
|
+
contextId: "turn-x",
|
|
397
|
+
component: "tts",
|
|
398
|
+
}),
|
|
399
|
+
]);
|
|
400
|
+
expect(errors[0]!.cause.message).toContain("Model sunsetted");
|
|
401
|
+
|
|
402
|
+
await plugin.close();
|
|
403
|
+
bus.stop();
|
|
404
|
+
await started;
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
it("turns malformed provider messages into TTS errors instead of throwing from the websocket listener", async () => {
|
|
408
|
+
const endpointUrl = await createLocalServer((socket) => {
|
|
409
|
+
socket.on("message", () => {
|
|
410
|
+
socket.send("{not-json");
|
|
411
|
+
});
|
|
412
|
+
});
|
|
413
|
+
const bus = new PipelineBusImpl();
|
|
414
|
+
const started = startBus(bus);
|
|
415
|
+
const plugin = new CartesiaTTSPlugin();
|
|
416
|
+
const errors: TtsErrorPacket[] = [];
|
|
417
|
+
bus.on("tts.error", (pkt) => {
|
|
418
|
+
errors.push(pkt as TtsErrorPacket);
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
await plugin.initialize(bus, {
|
|
422
|
+
api_key: "test-cartesia-key",
|
|
423
|
+
endpoint_url: endpointUrl,
|
|
424
|
+
voice_id: "voice-test",
|
|
425
|
+
model_id: "sonic-test",
|
|
426
|
+
});
|
|
427
|
+
bus.push(Route.Main, {
|
|
428
|
+
kind: "tts.text",
|
|
429
|
+
contextId: "turn-malformed",
|
|
430
|
+
timestampMs: Date.now(),
|
|
431
|
+
text: "Trigger malformed provider frame.",
|
|
432
|
+
});
|
|
433
|
+
await new Promise((resolve) => setTimeout(resolve, 40));
|
|
434
|
+
|
|
435
|
+
expect(errors).toEqual([
|
|
436
|
+
expect.objectContaining({
|
|
437
|
+
kind: "tts.error",
|
|
438
|
+
contextId: "turn-malformed",
|
|
439
|
+
component: "tts",
|
|
440
|
+
}),
|
|
441
|
+
]);
|
|
442
|
+
|
|
443
|
+
await plugin.close();
|
|
444
|
+
bus.stop();
|
|
445
|
+
await started;
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
it("rejects malformed Cartesia audio payloads before playback", async () => {
|
|
449
|
+
const endpointUrl = await createLocalServer((socket) => {
|
|
450
|
+
socket.on("message", (data) => {
|
|
451
|
+
const msg = JSON.parse(data.toString());
|
|
452
|
+
socket.send(JSON.stringify({
|
|
453
|
+
type: "chunk",
|
|
454
|
+
context_id: msg.context_id,
|
|
455
|
+
data: "not-base64",
|
|
456
|
+
done: false,
|
|
457
|
+
status_code: 206,
|
|
458
|
+
}));
|
|
459
|
+
});
|
|
460
|
+
});
|
|
461
|
+
const bus = new PipelineBusImpl();
|
|
462
|
+
const started = startBus(bus);
|
|
463
|
+
const plugin = new CartesiaTTSPlugin();
|
|
464
|
+
const audio: TextToSpeechAudioPacket[] = [];
|
|
465
|
+
const errors: TtsErrorPacket[] = [];
|
|
466
|
+
const ends: TextToSpeechEndPacket[] = [];
|
|
467
|
+
bus.on("tts.audio", (pkt) => {
|
|
468
|
+
audio.push(pkt as TextToSpeechAudioPacket);
|
|
469
|
+
});
|
|
470
|
+
bus.on("tts.error", (pkt) => {
|
|
471
|
+
errors.push(pkt as TtsErrorPacket);
|
|
472
|
+
});
|
|
473
|
+
bus.on("tts.end", (pkt) => {
|
|
474
|
+
ends.push(pkt as TextToSpeechEndPacket);
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
await plugin.initialize(bus, {
|
|
478
|
+
api_key: "test-cartesia-key",
|
|
479
|
+
endpoint_url: endpointUrl,
|
|
480
|
+
voice_id: "voice-test",
|
|
481
|
+
model_id: "sonic-test",
|
|
482
|
+
});
|
|
483
|
+
bus.push(Route.Main, {
|
|
484
|
+
kind: "tts.text",
|
|
485
|
+
contextId: "turn-malformed-audio",
|
|
486
|
+
timestampMs: Date.now(),
|
|
487
|
+
text: "Trigger malformed provider audio.",
|
|
488
|
+
});
|
|
489
|
+
await waitForCondition(() => errors.length > 0);
|
|
490
|
+
bus.push(Route.Main, {
|
|
491
|
+
kind: "tts.done",
|
|
492
|
+
contextId: "turn-malformed-audio",
|
|
493
|
+
timestampMs: Date.now(),
|
|
494
|
+
});
|
|
495
|
+
await waitForCondition(() => ends.length > 0);
|
|
496
|
+
|
|
497
|
+
expect(audio).toEqual([]);
|
|
498
|
+
expect(errors).toEqual([
|
|
499
|
+
expect.objectContaining({
|
|
500
|
+
kind: "tts.error",
|
|
501
|
+
contextId: "turn-malformed-audio",
|
|
502
|
+
component: "tts",
|
|
503
|
+
cause: expect.objectContaining({
|
|
504
|
+
message: "Cartesia TTS provider audio data must be valid base64",
|
|
505
|
+
}),
|
|
506
|
+
}),
|
|
507
|
+
]);
|
|
508
|
+
expect(ends).toEqual([expect.objectContaining({ contextId: "turn-malformed-audio" })]);
|
|
509
|
+
|
|
510
|
+
await plugin.close();
|
|
511
|
+
bus.stop();
|
|
512
|
+
await started;
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
it("emits tts.error for odd-length PCM16 provider audio without throwing into the bus pump", async () => {
|
|
516
|
+
const endpointUrl = await createLocalServer((socket) => {
|
|
517
|
+
socket.on("message", (data) => {
|
|
518
|
+
const msg = JSON.parse(data.toString());
|
|
519
|
+
socket.send(JSON.stringify({
|
|
520
|
+
type: "chunk",
|
|
521
|
+
context_id: msg.context_id,
|
|
522
|
+
data: Buffer.from(new Uint8Array([1, 2, 3])).toString("base64"),
|
|
523
|
+
done: false,
|
|
524
|
+
status_code: 206,
|
|
525
|
+
}));
|
|
526
|
+
});
|
|
527
|
+
});
|
|
528
|
+
const bus = new PipelineBusImpl();
|
|
529
|
+
const started = startBus(bus);
|
|
530
|
+
const plugin = new CartesiaTTSPlugin();
|
|
531
|
+
const audio: TextToSpeechAudioPacket[] = [];
|
|
532
|
+
const errors: TtsErrorPacket[] = [];
|
|
533
|
+
bus.on("tts.audio", (pkt) => {
|
|
534
|
+
audio.push(pkt as TextToSpeechAudioPacket);
|
|
535
|
+
});
|
|
536
|
+
bus.on("tts.error", (pkt) => {
|
|
537
|
+
errors.push(pkt as TtsErrorPacket);
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
await plugin.initialize(bus, {
|
|
541
|
+
api_key: "test-cartesia-key",
|
|
542
|
+
endpoint_url: endpointUrl,
|
|
543
|
+
voice_id: "voice-test",
|
|
544
|
+
model_id: "sonic-test",
|
|
545
|
+
});
|
|
546
|
+
bus.push(Route.Main, {
|
|
547
|
+
kind: "tts.text",
|
|
548
|
+
contextId: "turn-odd-pcm",
|
|
549
|
+
timestampMs: Date.now(),
|
|
550
|
+
text: "Trigger odd PCM.",
|
|
551
|
+
});
|
|
552
|
+
await waitForCondition(() => errors.length > 0);
|
|
553
|
+
|
|
554
|
+
expect(audio).toEqual([]);
|
|
555
|
+
expect(errors).toEqual([
|
|
556
|
+
expect.objectContaining({
|
|
557
|
+
kind: "tts.error",
|
|
558
|
+
contextId: "turn-odd-pcm",
|
|
559
|
+
component: "tts",
|
|
560
|
+
cause: expect.objectContaining({
|
|
561
|
+
message: expect.stringMatching(/even number of bytes/i),
|
|
562
|
+
}),
|
|
563
|
+
}),
|
|
564
|
+
]);
|
|
565
|
+
|
|
566
|
+
await plugin.close();
|
|
567
|
+
bus.stop();
|
|
568
|
+
await started;
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
it("ignores the empty-data flush_done acknowledgement instead of treating it as malformed audio", async () => {
|
|
572
|
+
const endpointUrl = await createLocalServer((socket) => {
|
|
573
|
+
socket.on("message", (data) => {
|
|
574
|
+
const msg = JSON.parse(data.toString());
|
|
575
|
+
if (msg.transcript === "Hello there.") {
|
|
576
|
+
socket.send(JSON.stringify({
|
|
577
|
+
type: "chunk",
|
|
578
|
+
context_id: msg.context_id,
|
|
579
|
+
data: Buffer.from(new Uint8Array([1, 2, 3, 4])).toString("base64"),
|
|
580
|
+
done: false,
|
|
581
|
+
status_code: 206,
|
|
582
|
+
}));
|
|
583
|
+
}
|
|
584
|
+
if (msg.context_id === "turn-flush" && msg.continue === false) {
|
|
585
|
+
// Real Cartesia answers a flush request with a flush_done control frame
|
|
586
|
+
// that carries an empty `data` string before the terminal done frame.
|
|
587
|
+
socket.send(JSON.stringify({
|
|
588
|
+
type: "flush_done",
|
|
589
|
+
context_id: "turn-flush",
|
|
590
|
+
data: "",
|
|
591
|
+
done: false,
|
|
592
|
+
status_code: 206,
|
|
593
|
+
flush_done: true,
|
|
594
|
+
}));
|
|
595
|
+
socket.send(JSON.stringify({
|
|
596
|
+
type: "done",
|
|
597
|
+
context_id: "turn-flush",
|
|
598
|
+
done: true,
|
|
599
|
+
status_code: 200,
|
|
600
|
+
}));
|
|
601
|
+
}
|
|
602
|
+
});
|
|
603
|
+
});
|
|
604
|
+
const bus = new PipelineBusImpl();
|
|
605
|
+
const started = startBus(bus);
|
|
606
|
+
const plugin = new CartesiaTTSPlugin();
|
|
607
|
+
const audio: TextToSpeechAudioPacket[] = [];
|
|
608
|
+
const errors: TtsErrorPacket[] = [];
|
|
609
|
+
const ends: TextToSpeechEndPacket[] = [];
|
|
610
|
+
bus.on("tts.audio", (pkt) => {
|
|
611
|
+
audio.push(pkt as TextToSpeechAudioPacket);
|
|
612
|
+
});
|
|
613
|
+
bus.on("tts.error", (pkt) => {
|
|
614
|
+
errors.push(pkt as TtsErrorPacket);
|
|
615
|
+
});
|
|
616
|
+
bus.on("tts.end", (pkt) => {
|
|
617
|
+
ends.push(pkt as TextToSpeechEndPacket);
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
await plugin.initialize(bus, {
|
|
621
|
+
api_key: "test-cartesia-key",
|
|
622
|
+
endpoint_url: endpointUrl,
|
|
623
|
+
voice_id: "voice-test",
|
|
624
|
+
model_id: "sonic-test",
|
|
625
|
+
sample_rate: 16000,
|
|
626
|
+
});
|
|
627
|
+
bus.push(Route.Main, {
|
|
628
|
+
kind: "tts.text",
|
|
629
|
+
contextId: "turn-flush",
|
|
630
|
+
timestampMs: Date.now(),
|
|
631
|
+
text: "Hello there.",
|
|
632
|
+
});
|
|
633
|
+
bus.push(Route.Main, {
|
|
634
|
+
kind: "tts.done",
|
|
635
|
+
contextId: "turn-flush",
|
|
636
|
+
timestampMs: Date.now(),
|
|
637
|
+
});
|
|
638
|
+
await waitForCondition(() => ends.length >= 1);
|
|
639
|
+
|
|
640
|
+
expect(errors).toEqual([]);
|
|
641
|
+
expect(audio).toEqual([
|
|
642
|
+
expect.objectContaining({
|
|
643
|
+
contextId: "turn-flush",
|
|
644
|
+
audio: new Uint8Array([1, 2, 3, 4]),
|
|
645
|
+
}),
|
|
646
|
+
]);
|
|
647
|
+
expect(ends).toEqual([expect.objectContaining({ contextId: "turn-flush" })]);
|
|
648
|
+
|
|
649
|
+
await plugin.close();
|
|
650
|
+
bus.stop();
|
|
651
|
+
await started;
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
it("fails active contexts when the Cartesia websocket closes before provider done", async () => {
|
|
655
|
+
const endpointUrl = await createLocalServer((socket) => {
|
|
656
|
+
socket.on("message", () => {
|
|
657
|
+
socket.close(1011, "provider restart");
|
|
658
|
+
});
|
|
659
|
+
});
|
|
660
|
+
const bus = new PipelineBusImpl();
|
|
661
|
+
const started = startBus(bus);
|
|
662
|
+
const plugin = new CartesiaTTSPlugin();
|
|
663
|
+
const errors: TtsErrorPacket[] = [];
|
|
664
|
+
bus.on("tts.error", (pkt) => {
|
|
665
|
+
errors.push(pkt as TtsErrorPacket);
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
await plugin.initialize(bus, {
|
|
669
|
+
api_key: "test-cartesia-key",
|
|
670
|
+
endpoint_url: endpointUrl,
|
|
671
|
+
voice_id: "voice-test",
|
|
672
|
+
model_id: "sonic-test",
|
|
673
|
+
});
|
|
674
|
+
bus.push(Route.Main, {
|
|
675
|
+
kind: "tts.text",
|
|
676
|
+
contextId: "turn-close",
|
|
677
|
+
timestampMs: Date.now(),
|
|
678
|
+
text: "This context should fail if the provider closes.",
|
|
679
|
+
});
|
|
680
|
+
await new Promise((resolve) => setTimeout(resolve, 60));
|
|
681
|
+
|
|
682
|
+
expect(errors).toEqual(expect.arrayContaining([
|
|
683
|
+
expect.objectContaining({
|
|
684
|
+
kind: "tts.error",
|
|
685
|
+
contextId: "turn-close",
|
|
686
|
+
component: "tts",
|
|
687
|
+
cause: expect.objectContaining({
|
|
688
|
+
message: expect.stringContaining("WebSocket closed unexpectedly"),
|
|
689
|
+
}),
|
|
690
|
+
}),
|
|
691
|
+
]));
|
|
692
|
+
|
|
693
|
+
await plugin.close();
|
|
694
|
+
bus.stop();
|
|
695
|
+
await started;
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
it("does not keep a Cartesia context active when the initial provider send fails", async () => {
|
|
699
|
+
const endpointUrl = await createLocalServer(() => {});
|
|
700
|
+
const bus = new PipelineBusImpl();
|
|
701
|
+
const started = startBus(bus);
|
|
702
|
+
const plugin = new CartesiaTTSPlugin();
|
|
703
|
+
const errors: TtsErrorPacket[] = [];
|
|
704
|
+
const ends: TextToSpeechEndPacket[] = [];
|
|
705
|
+
bus.on("tts.error", (pkt) => {
|
|
706
|
+
errors.push(pkt as TtsErrorPacket);
|
|
707
|
+
});
|
|
708
|
+
bus.on("tts.end", (pkt) => {
|
|
709
|
+
ends.push(pkt as TextToSpeechEndPacket);
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
await plugin.initialize(bus, {
|
|
713
|
+
api_key: "test-cartesia-key",
|
|
714
|
+
endpoint_url: endpointUrl,
|
|
715
|
+
voice_id: "voice-test",
|
|
716
|
+
model_id: "sonic-test",
|
|
717
|
+
});
|
|
718
|
+
// Simulate a closed socket: the managed connection's send throws.
|
|
719
|
+
const send = vi.fn(() => {
|
|
720
|
+
throw new Error("WebSocket is not open");
|
|
721
|
+
});
|
|
722
|
+
Object.assign(plugin as unknown as { conn: { ensureReady: () => Promise<void>; send: typeof send; close: () => Promise<void> } }, {
|
|
723
|
+
conn: { ensureReady: async () => undefined, send, close: async () => undefined },
|
|
724
|
+
});
|
|
725
|
+
bus.push(Route.Main, {
|
|
726
|
+
kind: "tts.text",
|
|
727
|
+
contextId: "turn-unsent",
|
|
728
|
+
timestampMs: Date.now(),
|
|
729
|
+
text: "This request never reaches Cartesia.",
|
|
730
|
+
});
|
|
731
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
732
|
+
bus.push(Route.Main, {
|
|
733
|
+
kind: "tts.done",
|
|
734
|
+
contextId: "turn-unsent",
|
|
735
|
+
timestampMs: Date.now(),
|
|
736
|
+
});
|
|
737
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
738
|
+
|
|
739
|
+
// The send was attempted but failed; the context must not stay active —
|
|
740
|
+
// an error fires and the turn ends instead of hanging.
|
|
741
|
+
expect(send).toHaveBeenCalled();
|
|
742
|
+
expect(errors).toEqual([
|
|
743
|
+
expect.objectContaining({
|
|
744
|
+
kind: "tts.error",
|
|
745
|
+
contextId: "turn-unsent",
|
|
746
|
+
component: "tts",
|
|
747
|
+
cause: expect.objectContaining({
|
|
748
|
+
message: "WebSocket is not open",
|
|
749
|
+
}),
|
|
750
|
+
}),
|
|
751
|
+
]);
|
|
752
|
+
expect(ends).toEqual([expect.objectContaining({ contextId: "turn-unsent" })]);
|
|
753
|
+
|
|
754
|
+
await plugin.close();
|
|
755
|
+
bus.stop();
|
|
756
|
+
await started;
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
it("emits tts.word_timestamps from Cartesia's real top-level parallel-array timestamp frame", async () => {
|
|
760
|
+
const audioChunk = new Uint8Array(3200);
|
|
761
|
+
const endpointUrl = await createLocalServer((socket) => {
|
|
762
|
+
socket.on("message", (data) => {
|
|
763
|
+
const msg = JSON.parse(data.toString());
|
|
764
|
+
if (msg.transcript === "Hello world.") {
|
|
765
|
+
socket.send(JSON.stringify({
|
|
766
|
+
type: "chunk",
|
|
767
|
+
context_id: msg.context_id,
|
|
768
|
+
data: Buffer.from(audioChunk).toString("base64"),
|
|
769
|
+
done: false,
|
|
770
|
+
status_code: 206,
|
|
771
|
+
}));
|
|
772
|
+
socket.send(JSON.stringify({
|
|
773
|
+
type: "timestamps",
|
|
774
|
+
context_id: msg.context_id,
|
|
775
|
+
word_timestamps: {
|
|
776
|
+
words: ["Hello", "world."],
|
|
777
|
+
start: [0.0, 0.5],
|
|
778
|
+
end: [0.4, 0.9],
|
|
779
|
+
},
|
|
780
|
+
}));
|
|
781
|
+
}
|
|
782
|
+
if (msg.continue === false) {
|
|
783
|
+
socket.send(JSON.stringify({ type: "done", context_id: msg.context_id, done: true, status_code: 200 }));
|
|
784
|
+
}
|
|
785
|
+
});
|
|
786
|
+
});
|
|
787
|
+
|
|
788
|
+
const bus = new PipelineBusImpl();
|
|
789
|
+
const started = startBus(bus);
|
|
790
|
+
const plugin = new CartesiaTTSPlugin();
|
|
791
|
+
const wordTsPackets: TextToSpeechWordTimestampsPacket[] = [];
|
|
792
|
+
bus.on("tts.word_timestamps", (pkt) => {
|
|
793
|
+
wordTsPackets.push(pkt as TextToSpeechWordTimestampsPacket);
|
|
794
|
+
});
|
|
795
|
+
|
|
796
|
+
await plugin.initialize(bus, {
|
|
797
|
+
api_key: "test-cartesia-key",
|
|
798
|
+
endpoint_url: endpointUrl,
|
|
799
|
+
voice_id: "voice-test",
|
|
800
|
+
model_id: "sonic-test",
|
|
801
|
+
sample_rate: 16000,
|
|
802
|
+
});
|
|
803
|
+
bus.push(Route.Main, { kind: "tts.text", contextId: "turn-ts", timestampMs: Date.now(), text: "Hello world." });
|
|
804
|
+
bus.push(Route.Main, { kind: "tts.done", contextId: "turn-ts", timestampMs: Date.now(), text: "Hello world." });
|
|
805
|
+
await waitForCondition(() => wordTsPackets.length >= 1);
|
|
806
|
+
|
|
807
|
+
expect(wordTsPackets[0]!.words).toEqual([
|
|
808
|
+
{ word: "Hello", startMs: 0, endMs: 400 },
|
|
809
|
+
{ word: "world.", startMs: 500, endMs: 900 },
|
|
810
|
+
]);
|
|
811
|
+
|
|
812
|
+
await plugin.close();
|
|
813
|
+
bus.stop();
|
|
814
|
+
await started;
|
|
815
|
+
});
|
|
816
|
+
|
|
817
|
+
it("does not keep a Cartesia context active when the terminal provider send fails", async () => {
|
|
818
|
+
const endpointUrl = await createLocalServer(() => {});
|
|
819
|
+
const bus = new PipelineBusImpl();
|
|
820
|
+
const started = startBus(bus);
|
|
821
|
+
const plugin = new CartesiaTTSPlugin();
|
|
822
|
+
const errors: TtsErrorPacket[] = [];
|
|
823
|
+
bus.on("tts.error", (pkt) => {
|
|
824
|
+
errors.push(pkt as TtsErrorPacket);
|
|
825
|
+
});
|
|
826
|
+
|
|
827
|
+
await plugin.initialize(bus, {
|
|
828
|
+
api_key: "test-cartesia-key",
|
|
829
|
+
endpoint_url: endpointUrl,
|
|
830
|
+
voice_id: "voice-test",
|
|
831
|
+
model_id: "sonic-test",
|
|
832
|
+
});
|
|
833
|
+
// The first send (the transcript) succeeds; the terminal flush send fails.
|
|
834
|
+
let failNext = false;
|
|
835
|
+
const send = vi.fn(() => {
|
|
836
|
+
if (failNext) throw new Error("WebSocket is not open");
|
|
837
|
+
});
|
|
838
|
+
Object.assign(plugin as unknown as { conn: { ensureReady: () => Promise<void>; send: typeof send; close: () => Promise<void> } }, {
|
|
839
|
+
conn: { ensureReady: async () => undefined, send, close: async () => undefined },
|
|
840
|
+
});
|
|
841
|
+
bus.push(Route.Main, {
|
|
842
|
+
kind: "tts.text",
|
|
843
|
+
contextId: "turn-terminal-unsent",
|
|
844
|
+
timestampMs: Date.now(),
|
|
845
|
+
text: "This request reaches Cartesia.",
|
|
846
|
+
});
|
|
847
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
848
|
+
expect(send).toHaveBeenCalledTimes(1);
|
|
849
|
+
|
|
850
|
+
failNext = true;
|
|
851
|
+
bus.push(Route.Main, {
|
|
852
|
+
kind: "tts.done",
|
|
853
|
+
contextId: "turn-terminal-unsent",
|
|
854
|
+
timestampMs: Date.now(),
|
|
855
|
+
});
|
|
856
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
857
|
+
expect(errors).toEqual([
|
|
858
|
+
expect.objectContaining({
|
|
859
|
+
kind: "tts.error",
|
|
860
|
+
contextId: "turn-terminal-unsent",
|
|
861
|
+
component: "tts",
|
|
862
|
+
cause: expect.objectContaining({
|
|
863
|
+
message: "WebSocket is not open",
|
|
864
|
+
}),
|
|
865
|
+
}),
|
|
866
|
+
]);
|
|
867
|
+
expect(send).toHaveBeenCalledTimes(2);
|
|
868
|
+
|
|
869
|
+
failNext = false;
|
|
870
|
+
bus.push(Route.Critical, {
|
|
871
|
+
kind: "interrupt.tts",
|
|
872
|
+
contextId: "turn-terminal-unsent",
|
|
873
|
+
timestampMs: Date.now(),
|
|
874
|
+
});
|
|
875
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
876
|
+
// The context was already removed, so the interrupt sends no Cancel.
|
|
877
|
+
expect(send).toHaveBeenCalledTimes(2);
|
|
878
|
+
|
|
879
|
+
await plugin.close();
|
|
880
|
+
bus.stop();
|
|
881
|
+
await started;
|
|
882
|
+
});
|
|
883
|
+
|
|
884
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
//
|
|
3
|
+
// Syrinx Kernel v2 — Cartesia TTS Plugin
|
|
4
|
+
|
|
5
|
+
import type { PipelineBus } from "@kuralle-syrinx/core";
|
|
6
|
+
import {
|
|
7
|
+
Route,
|
|
8
|
+
type AudioFormat,
|
|
9
|
+
type PluginConfig,
|
|
10
|
+
type RetryConfig,
|
|
11
|
+
type TextToSpeechAudioPacket,
|
|
12
|
+
type TextToSpeechEndPacket,
|
|
13
|
+
type TextToSpeechWordTimestampsPacket,
|
|
14
|
+
type TtsWordTimestamp,
|
|
15
|
+
type TtsErrorPacket,
|
|
16
|
+
type VoicePlugin,
|
|
17
|
+
assertAudioFormat,
|
|
18
|
+
assertAudioPayload,
|
|
19
|
+
categorizeTtsError,
|
|
20
|
+
isRecoverable,
|
|
21
|
+
optionalStringConfig,
|
|
22
|
+
readProviderRetryConfig,
|
|
23
|
+
requireStringConfig,
|
|
24
|
+
} from "@kuralle-syrinx/core";
|
|
25
|
+
import { WebSocketConnection, type SocketData, type SocketFactory } from "@kuralle-syrinx/ws";
|
|
26
|
+
|
|
27
|
+
const KEEP_ALIVE_INTERVAL_MS = 10_000;
|
|
28
|
+
|
|
29
|
+
export class CartesiaTTSPlugin implements VoicePlugin {
|
|
30
|
+
// socketFactory is injectable so the same plugin runs on Node (default) or
|
|
31
|
+
// Cloudflare Workers (pass createWorkersSocket).
|
|
32
|
+
constructor(private readonly socketFactory?: SocketFactory) {}
|
|
33
|
+
|
|
34
|
+
private bus: PipelineBus | null = null;
|
|
35
|
+
private conn: WebSocketConnection | null = null;
|
|
36
|
+
private apiKey = "";
|
|
37
|
+
private voiceId = "c2ac25f9-ecc4-4f56-9095-651354df60c0";
|
|
38
|
+
private modelId = "sonic-3";
|
|
39
|
+
private endpointUrl = "wss://api.cartesia.ai/tts/websocket";
|
|
40
|
+
private apiVersion = "2024-06-10";
|
|
41
|
+
private sampleRate = 16000;
|
|
42
|
+
private language = "en";
|
|
43
|
+
private retryConfig: RetryConfig = readProviderRetryConfig({});
|
|
44
|
+
private activeContexts = new Set<string>();
|
|
45
|
+
private cancelledContexts = new Set<string>();
|
|
46
|
+
private finishTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
47
|
+
private disposers: Array<() => void> = [];
|
|
48
|
+
private audioFormat: AudioFormat = { encoding: "pcm_s16le", sampleRateHz: 16000, channels: 1 };
|
|
49
|
+
|
|
50
|
+
async initialize(bus: PipelineBus, config: PluginConfig): Promise<void> {
|
|
51
|
+
this.bus = bus;
|
|
52
|
+
this.apiKey = requireStringConfig(config, "api_key");
|
|
53
|
+
this.voiceId = optionalStringConfig(config, "voice_id") ?? this.voiceId;
|
|
54
|
+
this.modelId = optionalStringConfig(config, "model_id") ?? this.modelId;
|
|
55
|
+
this.endpointUrl = optionalStringConfig(config, "endpoint_url") ?? this.endpointUrl;
|
|
56
|
+
this.apiVersion = optionalStringConfig(config, "cartesia_version") ?? this.apiVersion;
|
|
57
|
+
this.sampleRate = (config["sample_rate"] as number) ?? this.sampleRate;
|
|
58
|
+
this.language = optionalStringConfig(config, "language") ?? this.language;
|
|
59
|
+
this.retryConfig = readProviderRetryConfig(config);
|
|
60
|
+
const finishTimeoutMs = readNonNegativeInteger(config["finish_timeout_ms"], 2000);
|
|
61
|
+
this.audioFormat = { encoding: "pcm_s16le", sampleRateHz: this.sampleRate, channels: 1 };
|
|
62
|
+
assertAudioFormat(this.audioFormat);
|
|
63
|
+
|
|
64
|
+
this.conn = new WebSocketConnection({
|
|
65
|
+
url: () => {
|
|
66
|
+
const params = new URLSearchParams({ cartesia_version: this.apiVersion });
|
|
67
|
+
const separator = this.endpointUrl.includes("?") ? "&" : "?";
|
|
68
|
+
return `${this.endpointUrl}${separator}${params.toString()}`;
|
|
69
|
+
},
|
|
70
|
+
headers: { "X-API-Key": this.apiKey },
|
|
71
|
+
socketFactory: this.socketFactory ?? await defaultSocketFactory(),
|
|
72
|
+
retry: this.retryConfig,
|
|
73
|
+
replayBufferSize: (config["replay_buffer_size"] as number) ?? 32,
|
|
74
|
+
onReplay: (event, count) => {
|
|
75
|
+
this.emitMetric("", `tts.cartesia.reconnect_replay_${event}`, String(count));
|
|
76
|
+
},
|
|
77
|
+
keepAliveIntervalMs: KEEP_ALIVE_INTERVAL_MS,
|
|
78
|
+
onMessage: (data) => this.handleProviderMessage(data),
|
|
79
|
+
onConnectionLost: (err) => this.failActiveContexts(err),
|
|
80
|
+
onUnrecoverable: (err) => this.failActiveContexts(err),
|
|
81
|
+
});
|
|
82
|
+
await this.conn.connect();
|
|
83
|
+
|
|
84
|
+
this.disposers.push(
|
|
85
|
+
bus.on("tts.text", async (pkt: unknown) => {
|
|
86
|
+
const textPkt = pkt as { text: string; contextId: string };
|
|
87
|
+
await this.sendText(textPkt.text, textPkt.contextId);
|
|
88
|
+
}),
|
|
89
|
+
bus.on("tts.done", async (pkt: unknown) => {
|
|
90
|
+
const donePkt = pkt as { contextId: string };
|
|
91
|
+
if (!this.activeContexts.has(donePkt.contextId)) {
|
|
92
|
+
this.emitEnd(donePkt.contextId);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (finishTimeoutMs > 0) this.scheduleFinishTimeout(donePkt.contextId, finishTimeoutMs);
|
|
96
|
+
await this.finishContext(donePkt.contextId);
|
|
97
|
+
}),
|
|
98
|
+
bus.on("interrupt.tts", () => {
|
|
99
|
+
this.cancelActiveContexts().catch(() => {
|
|
100
|
+
// Best-effort interruption.
|
|
101
|
+
});
|
|
102
|
+
}),
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async sendText(text: string, contextId: string): Promise<void> {
|
|
107
|
+
if (!text.trim()) return;
|
|
108
|
+
if (this.cancelledContexts.has(contextId)) return;
|
|
109
|
+
this.activeContexts.add(contextId);
|
|
110
|
+
const sent = await this.trySend(
|
|
111
|
+
JSON.stringify({
|
|
112
|
+
model_id: this.modelId,
|
|
113
|
+
transcript: text,
|
|
114
|
+
voice: { mode: "id", id: this.voiceId },
|
|
115
|
+
output_format: {
|
|
116
|
+
container: "raw",
|
|
117
|
+
encoding: "pcm_s16le",
|
|
118
|
+
sample_rate: this.sampleRate,
|
|
119
|
+
},
|
|
120
|
+
language: this.language,
|
|
121
|
+
context_id: contextId || crypto.randomUUID(),
|
|
122
|
+
continue: true,
|
|
123
|
+
add_timestamps: true,
|
|
124
|
+
}),
|
|
125
|
+
contextId,
|
|
126
|
+
);
|
|
127
|
+
if (!sent) {
|
|
128
|
+
this.activeContexts.delete(contextId);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Flush/cancel current TTS generation (called on interrupt). */
|
|
133
|
+
async flush(contextId = ""): Promise<void> {
|
|
134
|
+
if (!contextId) {
|
|
135
|
+
await this.cancelActiveContexts();
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
await this.cancelContext(contextId);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async finishContext(contextId: string): Promise<void> {
|
|
142
|
+
if (this.cancelledContexts.has(contextId)) return;
|
|
143
|
+
const sent = await this.trySend(
|
|
144
|
+
JSON.stringify({
|
|
145
|
+
model_id: this.modelId,
|
|
146
|
+
transcript: "",
|
|
147
|
+
voice: { mode: "id", id: this.voiceId },
|
|
148
|
+
output_format: {
|
|
149
|
+
container: "raw",
|
|
150
|
+
encoding: "pcm_s16le",
|
|
151
|
+
sample_rate: this.sampleRate,
|
|
152
|
+
},
|
|
153
|
+
language: this.language,
|
|
154
|
+
context_id: contextId,
|
|
155
|
+
continue: false,
|
|
156
|
+
flush: true,
|
|
157
|
+
}),
|
|
158
|
+
contextId,
|
|
159
|
+
);
|
|
160
|
+
if (!sent) {
|
|
161
|
+
this.activeContexts.delete(contextId);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async close(): Promise<void> {
|
|
166
|
+
for (const dispose of this.disposers.splice(0)) dispose();
|
|
167
|
+
this.activeContexts.clear();
|
|
168
|
+
this.cancelledContexts.clear();
|
|
169
|
+
for (const timer of this.finishTimers.values()) clearTimeout(timer);
|
|
170
|
+
this.finishTimers.clear();
|
|
171
|
+
await this.conn?.close();
|
|
172
|
+
this.conn = null;
|
|
173
|
+
this.bus = null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Send a frame, ensuring the socket is ready; emit a typed error if it cannot be sent. */
|
|
177
|
+
private async trySend(payload: string, contextId: string): Promise<boolean> {
|
|
178
|
+
try {
|
|
179
|
+
await this.conn?.ensureReady();
|
|
180
|
+
this.conn?.send(payload);
|
|
181
|
+
return true;
|
|
182
|
+
} catch (err) {
|
|
183
|
+
this.emitError(contextId, err instanceof Error ? err : new Error(String(err)));
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
private async cancelActiveContexts(): Promise<void> {
|
|
189
|
+
const contextIds = [...this.activeContexts];
|
|
190
|
+
for (const contextId of contextIds) this.cancelledContexts.add(contextId);
|
|
191
|
+
this.activeContexts.clear();
|
|
192
|
+
await Promise.all(contextIds.map((contextId) => this.cancelContext(contextId)));
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
private async cancelContext(contextId: string): Promise<void> {
|
|
196
|
+
if (!contextId) return;
|
|
197
|
+
this.cancelledContexts.add(contextId);
|
|
198
|
+
this.activeContexts.delete(contextId);
|
|
199
|
+
this.clearFinishTimeout(contextId);
|
|
200
|
+
await this.trySend(
|
|
201
|
+
JSON.stringify({
|
|
202
|
+
context_id: contextId,
|
|
203
|
+
cancel: true,
|
|
204
|
+
}),
|
|
205
|
+
contextId,
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
private handleProviderMessage(data: SocketData): void {
|
|
210
|
+
if (typeof data !== "string") return; // Cartesia frames are JSON text
|
|
211
|
+
let msg: Record<string, unknown>;
|
|
212
|
+
try {
|
|
213
|
+
msg = JSON.parse(data) as Record<string, unknown>;
|
|
214
|
+
} catch (err) {
|
|
215
|
+
this.failActiveContexts(err instanceof Error ? err : new Error(String(err)));
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const contextId = typeof msg["context_id"] === "string" ? msg["context_id"] : "";
|
|
220
|
+
if (this.cancelledContexts.has(contextId)) {
|
|
221
|
+
if (msg["done"] === true || msg["type"] === "error" || isErrorStatusCode(msg["status_code"])) {
|
|
222
|
+
this.cancelledContexts.delete(contextId);
|
|
223
|
+
}
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (msg["type"] === "error" || isErrorStatusCode(msg["status_code"])) {
|
|
228
|
+
this.activeContexts.delete(contextId);
|
|
229
|
+
this.clearFinishTimeout(contextId);
|
|
230
|
+
this.emitError(contextId, cartesiaProviderError(msg));
|
|
231
|
+
if (msg["done"] === true) this.emitEnd(contextId);
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (msg["type"] === "timestamps") {
|
|
236
|
+
this.emitWordTimestamps(contextId, msg["word_timestamps"]);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Cartesia audio arrives as non-empty base64 `data`. Control frames such as
|
|
240
|
+
// `flush_done` (the acknowledgement of a `flush: true` request) carry an empty
|
|
241
|
+
// `data` string and must not be decoded as audio.
|
|
242
|
+
if (typeof msg["data"] === "string" && msg["data"].length > 0) {
|
|
243
|
+
try {
|
|
244
|
+
const audioBytes = decodeStrictBase64(msg["data"], "Cartesia TTS provider audio data");
|
|
245
|
+
assertAudioPayload(this.audioFormat, new Uint8Array(audioBytes));
|
|
246
|
+
const audioPacket: TextToSpeechAudioPacket = {
|
|
247
|
+
kind: "tts.audio",
|
|
248
|
+
contextId,
|
|
249
|
+
timestampMs: Date.now(),
|
|
250
|
+
audio: new Uint8Array(audioBytes),
|
|
251
|
+
sampleRateHz: this.sampleRate,
|
|
252
|
+
provider: { name: "cartesia", model: this.modelId, region: "global", cancelled: false },
|
|
253
|
+
};
|
|
254
|
+
this.bus?.push(Route.Main, audioPacket);
|
|
255
|
+
} catch (err) {
|
|
256
|
+
this.activeContexts.delete(contextId);
|
|
257
|
+
this.emitError(contextId, err instanceof Error ? err : new Error(String(err)));
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
if (msg["done"] === true) {
|
|
261
|
+
this.activeContexts.delete(contextId);
|
|
262
|
+
this.clearFinishTimeout(contextId);
|
|
263
|
+
this.emitEnd(contextId);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
private emitError(contextId: string, err: Error): void {
|
|
268
|
+
const category = categorizeTtsError(err);
|
|
269
|
+
const packet: TtsErrorPacket = {
|
|
270
|
+
kind: "tts.error",
|
|
271
|
+
contextId,
|
|
272
|
+
timestampMs: Date.now(),
|
|
273
|
+
component: "tts" as const,
|
|
274
|
+
category,
|
|
275
|
+
cause: err,
|
|
276
|
+
isRecoverable: isRecoverable(category),
|
|
277
|
+
};
|
|
278
|
+
this.bus?.push(Route.Critical, packet);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
private scheduleFinishTimeout(contextId: string, timeoutMs: number): void {
|
|
282
|
+
this.clearFinishTimeout(contextId);
|
|
283
|
+
const timer = setTimeout(() => {
|
|
284
|
+
this.finishTimers.delete(contextId);
|
|
285
|
+
if (!this.activeContexts.has(contextId)) return;
|
|
286
|
+
this.emitMetric(contextId, "tts.cartesia.finish_timeout", String(timeoutMs));
|
|
287
|
+
this.activeContexts.delete(contextId);
|
|
288
|
+
this.emitEnd(contextId);
|
|
289
|
+
}, timeoutMs);
|
|
290
|
+
this.finishTimers.set(contextId, timer);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
private clearFinishTimeout(contextId: string): void {
|
|
294
|
+
const timer = this.finishTimers.get(contextId);
|
|
295
|
+
if (!timer) return;
|
|
296
|
+
clearTimeout(timer);
|
|
297
|
+
this.finishTimers.delete(contextId);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
private emitMetric(contextId: string, name: string, value: string): void {
|
|
301
|
+
this.bus?.push(Route.Background, {
|
|
302
|
+
kind: "metric.conversation",
|
|
303
|
+
contextId,
|
|
304
|
+
timestampMs: Date.now(),
|
|
305
|
+
name,
|
|
306
|
+
value,
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
private failActiveContexts(err: Error): void {
|
|
311
|
+
const contextIds = [...this.activeContexts];
|
|
312
|
+
this.activeContexts.clear();
|
|
313
|
+
if (contextIds.length === 0) {
|
|
314
|
+
this.emitError("", err);
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
for (const contextId of contextIds) {
|
|
318
|
+
this.emitError(contextId, err);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
private emitEnd(contextId: string): void {
|
|
323
|
+
const packet: TextToSpeechEndPacket = {
|
|
324
|
+
kind: "tts.end",
|
|
325
|
+
contextId,
|
|
326
|
+
timestampMs: Date.now(),
|
|
327
|
+
};
|
|
328
|
+
this.bus?.push(Route.Main, packet);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
private emitWordTimestamps(contextId: string, value: unknown): void {
|
|
332
|
+
if (!contextId || value === null || typeof value !== "object") return;
|
|
333
|
+
const raw = value as Record<string, unknown>;
|
|
334
|
+
const rawWords = Array.isArray(raw["words"]) ? raw["words"] : [];
|
|
335
|
+
const rawStarts = Array.isArray(raw["start"]) ? raw["start"] : [];
|
|
336
|
+
const rawEnds = Array.isArray(raw["end"]) ? raw["end"] : [];
|
|
337
|
+
const count = Math.min(rawWords.length, rawStarts.length, rawEnds.length);
|
|
338
|
+
const words: TtsWordTimestamp[] = [];
|
|
339
|
+
for (let i = 0; i < count; i += 1) {
|
|
340
|
+
const word = rawWords[i];
|
|
341
|
+
const start = rawStarts[i];
|
|
342
|
+
const end = rawEnds[i];
|
|
343
|
+
if (typeof word !== "string" || typeof start !== "number" || typeof end !== "number") continue;
|
|
344
|
+
words.push({
|
|
345
|
+
word,
|
|
346
|
+
startMs: Math.round(start * 1000),
|
|
347
|
+
endMs: Math.round(end * 1000),
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
if (words.length === 0) return;
|
|
351
|
+
this.bus?.push(Route.Main, {
|
|
352
|
+
kind: "tts.word_timestamps",
|
|
353
|
+
contextId,
|
|
354
|
+
timestampMs: Date.now(),
|
|
355
|
+
words,
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
async function defaultSocketFactory(): Promise<SocketFactory> {
|
|
361
|
+
const mod = await import("@kuralle-syrinx/ws/node");
|
|
362
|
+
return mod.createNodeWsSocket;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function isErrorStatusCode(value: unknown): boolean {
|
|
366
|
+
return typeof value === "number" && value >= 400;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function cartesiaProviderError(msg: Record<string, unknown>): Error {
|
|
370
|
+
const title = typeof msg["title"] === "string" ? msg["title"] : "Cartesia TTS provider error";
|
|
371
|
+
const message = typeof msg["message"] === "string" ? msg["message"] : "";
|
|
372
|
+
const error = typeof msg["error"] === "string" ? msg["error"] : "";
|
|
373
|
+
const errorCode = typeof msg["error_code"] === "string" ? msg["error_code"] : "";
|
|
374
|
+
const statusCode = typeof msg["status_code"] === "number" ? `status ${String(msg["status_code"])}` : "";
|
|
375
|
+
const details = [message, error, errorCode, statusCode].filter((part) => part.length > 0).join(" ");
|
|
376
|
+
return new Error(details ? `${title}: ${details}` : title);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function decodeStrictBase64(value: string, name: string): Buffer {
|
|
380
|
+
const normalized = value.replace(/\s+/g, "");
|
|
381
|
+
if (normalized.length === 0 || normalized.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(normalized)) {
|
|
382
|
+
throw new Error(`${name} must be valid base64`);
|
|
383
|
+
}
|
|
384
|
+
const decoded = Buffer.from(normalized, "base64");
|
|
385
|
+
if (decoded.toString("base64") !== normalized) {
|
|
386
|
+
throw new Error(`${name} must be valid base64`);
|
|
387
|
+
}
|
|
388
|
+
return decoded;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function readNonNegativeInteger(value: unknown, fallback: number): number {
|
|
392
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
|
|
393
|
+
const integer = Math.floor(value);
|
|
394
|
+
return integer >= 0 ? integer : fallback;
|
|
395
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"lib": ["ES2022"],
|
|
7
|
+
"strict": true,
|
|
8
|
+
"noUncheckedIndexedAccess": true,
|
|
9
|
+
"noImplicitReturns": true,
|
|
10
|
+
"declaration": true,
|
|
11
|
+
"declarationMap": true,
|
|
12
|
+
"sourceMap": true,
|
|
13
|
+
"outDir": "./dist",
|
|
14
|
+
"rootDir": "./src",
|
|
15
|
+
"esModuleInterop": true,
|
|
16
|
+
"skipLibCheck": true,
|
|
17
|
+
"forceConsistentCasingInFileNames": true
|
|
18
|
+
},
|
|
19
|
+
"include": ["src/**/*.ts"],
|
|
20
|
+
"exclude": ["node_modules", "dist"]
|
|
21
|
+
}
|