@kuralle-syrinx/cartesia 4.2.0 → 4.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kuralle-syrinx/cartesia",
3
- "version": "4.2.0",
3
+ "version": "4.3.0",
4
4
  "private": false,
5
5
  "description": "Cartesia streaming TTS adapter for Syrinx",
6
6
  "keywords": [
@@ -25,15 +25,15 @@
25
25
  "main": "./src/index.ts",
26
26
  "types": "./src/index.ts",
27
27
  "dependencies": {
28
- "@kuralle-syrinx/tts-core": "4.2.0",
29
- "@kuralle-syrinx/core": "4.2.0",
30
- "@kuralle-syrinx/ws": "4.2.0"
28
+ "@kuralle-syrinx/core": "4.3.0",
29
+ "@kuralle-syrinx/tts-core": "4.3.0",
30
+ "@kuralle-syrinx/ws": "4.3.0"
31
31
  },
32
32
  "devDependencies": {
33
33
  "@types/ws": "^8.5.0",
34
34
  "typescript": "^5.7.0",
35
- "vitest": "^2.1.0",
36
- "ws": "^8.18.0"
35
+ "vitest": "^3.2.6",
36
+ "ws": "^8.21.0"
37
37
  },
38
38
  "scripts": {
39
39
  "typecheck": "tsc --noEmit",
package/src/index.ts CHANGED
@@ -41,9 +41,16 @@ interface CartesiaWireConfig {
41
41
  readonly sampleRate: number;
42
42
  readonly language: string;
43
43
  readonly audioFormat: AudioFormat;
44
+ readonly addTimestamps: boolean;
45
+ readonly outputContainer: string;
46
+ readonly outputEncoding: string;
47
+ readonly generationConfig?: Record<string, unknown>;
44
48
  }
45
49
 
46
- class CartesiaWireProtocol implements WireProtocol {
50
+ /** Exported for unit tests that exercise encode without a live socket. */
51
+ export class CartesiaWireProtocol implements WireProtocol {
52
+ private readonly charactersByKey = new Map<AttributionKey, number>();
53
+
47
54
  constructor(private readonly cfg: CartesiaWireConfig) {}
48
55
 
49
56
  attributionFor(contextId: string): { key: AttributionKey; contextId: string } {
@@ -51,18 +58,23 @@ class CartesiaWireProtocol implements WireProtocol {
51
58
  }
52
59
 
53
60
  encodeText(key: AttributionKey, text: string): SocketData[] {
54
- return [
55
- JSON.stringify({
56
- model_id: this.cfg.modelId,
57
- transcript: text,
58
- voice: { mode: "id", id: this.cfg.voiceId },
59
- output_format: { container: "raw", encoding: "pcm_s16le", sample_rate: this.cfg.sampleRate },
60
- language: this.cfg.language,
61
- context_id: key || crypto.randomUUID(),
62
- continue: true,
63
- add_timestamps: true,
64
- }),
65
- ];
61
+ this.charactersByKey.set(key, (this.charactersByKey.get(key) ?? 0) + text.length);
62
+ const frame: Record<string, unknown> = {
63
+ model_id: this.cfg.modelId,
64
+ transcript: text,
65
+ voice: { mode: "id", id: this.cfg.voiceId },
66
+ output_format: {
67
+ container: this.cfg.outputContainer,
68
+ encoding: this.cfg.outputEncoding,
69
+ sample_rate: this.cfg.sampleRate,
70
+ },
71
+ language: this.cfg.language,
72
+ context_id: key || crypto.randomUUID(),
73
+ continue: true,
74
+ add_timestamps: this.cfg.addTimestamps,
75
+ ...this.cfg.generationConfig,
76
+ };
77
+ return [JSON.stringify(frame)];
66
78
  }
67
79
 
68
80
  encodeFinish(contextId: string, activeKeys: readonly AttributionKey[]): SocketData[] {
@@ -72,7 +84,11 @@ class CartesiaWireProtocol implements WireProtocol {
72
84
  model_id: this.cfg.modelId,
73
85
  transcript: "",
74
86
  voice: { mode: "id", id: this.cfg.voiceId },
75
- output_format: { container: "raw", encoding: "pcm_s16le", sample_rate: this.cfg.sampleRate },
87
+ output_format: {
88
+ container: this.cfg.outputContainer,
89
+ encoding: this.cfg.outputEncoding,
90
+ sample_rate: this.cfg.sampleRate,
91
+ },
76
92
  language: this.cfg.language,
77
93
  context_id: contextId,
78
94
  continue: false,
@@ -82,6 +98,7 @@ class CartesiaWireProtocol implements WireProtocol {
82
98
  }
83
99
 
84
100
  encodeCancel(key: AttributionKey): SocketData[] {
101
+ this.charactersByKey.delete(key);
85
102
  return [JSON.stringify({ context_id: key, cancel: true })];
86
103
  }
87
104
 
@@ -124,7 +141,29 @@ class CartesiaWireProtocol implements WireProtocol {
124
141
  events.push({ type: "error", key, error: err instanceof Error ? err : new Error(String(err)) });
125
142
  }
126
143
  }
127
- if (msg["done"] === true) events.push({ type: "context_end", key });
144
+ if (msg["done"] === true) {
145
+ const characters = this.charactersByKey.get(key) ?? 0;
146
+ this.charactersByKey.delete(key);
147
+ // Sideband before context_end so the engine still has the key→context mapping.
148
+ if (characters > 0) {
149
+ const modelId = this.cfg.modelId;
150
+ events.push({
151
+ type: "sideband",
152
+ key,
153
+ route: Route.Background,
154
+ build: (ctxId, timestampMs) => ({
155
+ kind: "usage.recorded",
156
+ contextId: ctxId,
157
+ timestampMs,
158
+ stage: "tts" as const,
159
+ provider: "cartesia",
160
+ model: modelId,
161
+ characters,
162
+ }),
163
+ });
164
+ }
165
+ events.push({ type: "context_end", key });
166
+ }
128
167
  return events;
129
168
  }
130
169
  }
@@ -144,11 +183,26 @@ export class CartesiaTTSPlugin implements VoicePlugin {
144
183
  const apiVersion = optionalStringConfig(config, "cartesia_version") ?? "2024-06-10";
145
184
  const sampleRate = (config["sample_rate"] as number) ?? 16000;
146
185
  const language = optionalStringConfig(config, "language") ?? "en";
186
+ const addTimestamps = config["add_timestamps"] === undefined ? true : config["add_timestamps"] === true;
187
+ const outputContainer = optionalStringConfig(config, "output_container") ?? "raw";
188
+ const outputEncoding = optionalStringConfig(config, "output_encoding") ?? "pcm_s16le";
189
+ // Optional provider-specific passthrough merged into generation frames (speed, volume, etc.).
190
+ const generationConfig = readPlainObject(config["generation_config"]);
147
191
  const audioFormat: AudioFormat = { encoding: "pcm_s16le", sampleRateHz: sampleRate, channels: 1 };
148
192
  assertAudioFormat(audioFormat);
149
193
 
150
194
  this.session = await startStreamingTtsSession(bus, {
151
- protocol: new CartesiaWireProtocol({ modelId, voiceId, sampleRate, language, audioFormat }),
195
+ protocol: new CartesiaWireProtocol({
196
+ modelId,
197
+ voiceId,
198
+ sampleRate,
199
+ language,
200
+ audioFormat,
201
+ addTimestamps,
202
+ outputContainer,
203
+ outputEncoding,
204
+ generationConfig,
205
+ }),
152
206
  provider: { name: "cartesia", model: modelId, region: "global" },
153
207
  format: audioFormat,
154
208
  sampleRateHz: sampleRate,
@@ -222,3 +276,10 @@ function readNonNegativeInteger(value: unknown, fallback: number): number {
222
276
  const integer = Math.floor(value);
223
277
  return integer >= 0 ? integer : fallback;
224
278
  }
279
+
280
+ function readPlainObject(value: unknown): Record<string, unknown> | undefined {
281
+ if (value !== null && typeof value === "object" && !Array.isArray(value)) {
282
+ return value as Record<string, unknown>;
283
+ }
284
+ return undefined;
285
+ }
package/src/index.test.ts DELETED
@@ -1,756 +0,0 @@
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("emits tts.word_timestamps from Cartesia's real top-level parallel-array timestamp frame", async () => {
699
- const audioChunk = new Uint8Array(3200);
700
- const endpointUrl = await createLocalServer((socket) => {
701
- socket.on("message", (data) => {
702
- const msg = JSON.parse(data.toString());
703
- if (msg.transcript === "Hello world.") {
704
- socket.send(JSON.stringify({
705
- type: "chunk",
706
- context_id: msg.context_id,
707
- data: Buffer.from(audioChunk).toString("base64"),
708
- done: false,
709
- status_code: 206,
710
- }));
711
- socket.send(JSON.stringify({
712
- type: "timestamps",
713
- context_id: msg.context_id,
714
- word_timestamps: {
715
- words: ["Hello", "world."],
716
- start: [0.0, 0.5],
717
- end: [0.4, 0.9],
718
- },
719
- }));
720
- }
721
- if (msg.continue === false) {
722
- socket.send(JSON.stringify({ type: "done", context_id: msg.context_id, done: true, status_code: 200 }));
723
- }
724
- });
725
- });
726
-
727
- const bus = new PipelineBusImpl();
728
- const started = startBus(bus);
729
- const plugin = new CartesiaTTSPlugin();
730
- const wordTsPackets: TextToSpeechWordTimestampsPacket[] = [];
731
- bus.on("tts.word_timestamps", (pkt) => {
732
- wordTsPackets.push(pkt as TextToSpeechWordTimestampsPacket);
733
- });
734
-
735
- await plugin.initialize(bus, {
736
- api_key: "test-cartesia-key",
737
- endpoint_url: endpointUrl,
738
- voice_id: "voice-test",
739
- model_id: "sonic-test",
740
- sample_rate: 16000,
741
- });
742
- bus.push(Route.Main, { kind: "tts.text", contextId: "turn-ts", timestampMs: Date.now(), text: "Hello world." });
743
- bus.push(Route.Main, { kind: "tts.done", contextId: "turn-ts", timestampMs: Date.now(), text: "Hello world." });
744
- await waitForCondition(() => wordTsPackets.length >= 1);
745
-
746
- expect(wordTsPackets[0]!.words).toEqual([
747
- { word: "Hello", startMs: 0, endMs: 400 },
748
- { word: "world.", startMs: 500, endMs: 900 },
749
- ]);
750
-
751
- await plugin.close();
752
- bus.stop();
753
- await started;
754
- });
755
-
756
- });
package/tsconfig.json DELETED
@@ -1,21 +0,0 @@
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
- }