@kuralle-syrinx/cartesia 4.4.1 → 4.5.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.
@@ -0,0 +1,38 @@
1
+ import { type AudioFormat, type PipelineBus, type PluginConfig, type VoicePlugin } from "@kuralle-syrinx/core";
2
+ import { type AttributionKey, type WireEvent, type WireProtocol } from "@kuralle-syrinx/tts-core";
3
+ import type { SocketData, SocketFactory } from "@kuralle-syrinx/ws";
4
+ interface CartesiaWireConfig {
5
+ readonly modelId: string;
6
+ readonly voiceId: string;
7
+ readonly sampleRate: number;
8
+ readonly language: string;
9
+ readonly audioFormat: AudioFormat;
10
+ readonly addTimestamps: boolean;
11
+ readonly outputContainer: string;
12
+ readonly outputEncoding: string;
13
+ readonly generationConfig?: Record<string, unknown>;
14
+ }
15
+ /** Exported for unit tests that exercise encode without a live socket. */
16
+ export declare class CartesiaWireProtocol implements WireProtocol {
17
+ private readonly cfg;
18
+ private readonly charactersByKey;
19
+ constructor(cfg: CartesiaWireConfig);
20
+ attributionFor(contextId: string): {
21
+ key: AttributionKey;
22
+ contextId: string;
23
+ };
24
+ encodeText(key: AttributionKey, text: string): SocketData[];
25
+ encodeFinish(contextId: string, activeKeys: readonly AttributionKey[]): SocketData[];
26
+ encodeCancel(key: AttributionKey): SocketData[];
27
+ encodeClose(): SocketData[];
28
+ decode(data: SocketData): WireEvent[];
29
+ }
30
+ export declare class CartesiaTTSPlugin implements VoicePlugin {
31
+ private readonly socketFactory?;
32
+ constructor(socketFactory?: SocketFactory | undefined);
33
+ private session;
34
+ initialize(bus: PipelineBus, config: PluginConfig): Promise<void>;
35
+ close(): Promise<void>;
36
+ }
37
+ export {};
38
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAUA,OAAO,EAOL,KAAK,WAAW,EAChB,KAAK,WAAW,EAChB,KAAK,YAAY,EAGjB,KAAK,WAAW,EACjB,MAAM,sBAAsB,CAAC;AAC9B,OAAO,EAIL,KAAK,cAAc,EAEnB,KAAK,SAAS,EACd,KAAK,YAAY,EAClB,MAAM,0BAA0B,CAAC;AAClC,OAAO,KAAK,EAAE,UAAU,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAC;AAIpE,UAAU,kBAAkB;IAC1B,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,WAAW,EAAE,WAAW,CAAC;IAClC,QAAQ,CAAC,aAAa,EAAE,OAAO,CAAC;IAChC,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAC;IACjC,QAAQ,CAAC,cAAc,EAAE,MAAM,CAAC;IAChC,QAAQ,CAAC,gBAAgB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACrD;AAED,0EAA0E;AAC1E,qBAAa,oBAAqB,YAAW,YAAY;IAG3C,OAAO,CAAC,QAAQ,CAAC,GAAG;IAFhC,OAAO,CAAC,QAAQ,CAAC,eAAe,CAAqC;gBAExC,GAAG,EAAE,kBAAkB;IAEpD,cAAc,CAAC,SAAS,EAAE,MAAM,GAAG;QAAE,GAAG,EAAE,cAAc,CAAC;QAAC,SAAS,EAAE,MAAM,CAAA;KAAE;IAI7E,UAAU,CAAC,GAAG,EAAE,cAAc,EAAE,IAAI,EAAE,MAAM,GAAG,UAAU,EAAE;IAoB3D,YAAY,CAAC,SAAS,EAAE,MAAM,EAAE,UAAU,EAAE,SAAS,cAAc,EAAE,GAAG,UAAU,EAAE;IAoBpF,YAAY,CAAC,GAAG,EAAE,cAAc,GAAG,UAAU,EAAE;IAK/C,WAAW,IAAI,UAAU,EAAE;IAI3B,MAAM,CAAC,IAAI,EAAE,UAAU,GAAG,SAAS,EAAE;CA4DtC;AAED,qBAAa,iBAAkB,YAAW,WAAW;IAGvC,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAC;gBAAd,aAAa,CAAC,EAAE,aAAa,YAAA;IAE1D,OAAO,CAAC,OAAO,CAAoC;IAE7C,UAAU,CAAC,GAAG,EAAE,WAAW,EAAE,MAAM,EAAE,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC;IA8CjE,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;CAI7B"}
package/dist/index.js ADDED
@@ -0,0 +1,240 @@
1
+ // SPDX-License-Identifier: MIT
2
+ //
3
+ // Syrinx Kernel v2 — Cartesia TTS Plugin
4
+ //
5
+ // The streaming lifecycle lives in @kuralle-syrinx/tts-core. This file is the Cartesia wire
6
+ // protocol: per-context attribution (the provider echoes `context_id`), the speak/flush/cancel
7
+ // JSON frames, and the inbound decode of base64 audio + word timestamps + the `done` flag.
8
+ // Cartesia ships whole PCM16 frames, so it validates alignment in `decode` (erroring on an
9
+ // odd-length payload) rather than relying on the engine's streaming carry.
10
+ import { Route, assertAudioFormat, assertAudioPayload, optionalStringConfig, readProviderRetryConfig, requireStringConfig, } from "@kuralle-syrinx/core";
11
+ import { attributionKey, defaultNodeSocketFactory, startStreamingTtsSession, } from "@kuralle-syrinx/tts-core";
12
+ const KEEP_ALIVE_INTERVAL_MS = 10_000;
13
+ /** Exported for unit tests that exercise encode without a live socket. */
14
+ export class CartesiaWireProtocol {
15
+ cfg;
16
+ charactersByKey = new Map();
17
+ constructor(cfg) {
18
+ this.cfg = cfg;
19
+ }
20
+ attributionFor(contextId) {
21
+ return { key: attributionKey(contextId), contextId };
22
+ }
23
+ encodeText(key, text) {
24
+ this.charactersByKey.set(key, (this.charactersByKey.get(key) ?? 0) + text.length);
25
+ const frame = {
26
+ model_id: this.cfg.modelId,
27
+ transcript: text,
28
+ voice: { mode: "id", id: this.cfg.voiceId },
29
+ output_format: {
30
+ container: this.cfg.outputContainer,
31
+ encoding: this.cfg.outputEncoding,
32
+ sample_rate: this.cfg.sampleRate,
33
+ },
34
+ language: this.cfg.language,
35
+ context_id: key || crypto.randomUUID(),
36
+ continue: true,
37
+ add_timestamps: this.cfg.addTimestamps,
38
+ ...this.cfg.generationConfig,
39
+ };
40
+ return [JSON.stringify(frame)];
41
+ }
42
+ encodeFinish(contextId, activeKeys) {
43
+ if (activeKeys.length === 0)
44
+ return [];
45
+ return [
46
+ JSON.stringify({
47
+ model_id: this.cfg.modelId,
48
+ transcript: "",
49
+ voice: { mode: "id", id: this.cfg.voiceId },
50
+ output_format: {
51
+ container: this.cfg.outputContainer,
52
+ encoding: this.cfg.outputEncoding,
53
+ sample_rate: this.cfg.sampleRate,
54
+ },
55
+ language: this.cfg.language,
56
+ context_id: contextId,
57
+ continue: false,
58
+ flush: true,
59
+ }),
60
+ ];
61
+ }
62
+ encodeCancel(key) {
63
+ this.charactersByKey.delete(key);
64
+ return [JSON.stringify({ context_id: key, cancel: true })];
65
+ }
66
+ encodeClose() {
67
+ return [];
68
+ }
69
+ decode(data) {
70
+ if (typeof data !== "string")
71
+ return []; // Cartesia frames are JSON text
72
+ const msg = JSON.parse(data); // parse failure → engine fails all contexts
73
+ const contextId = typeof msg["context_id"] === "string" ? msg["context_id"] : "";
74
+ const key = attributionKey(contextId);
75
+ if (msg["type"] === "error" || isErrorStatusCode(msg["status_code"])) {
76
+ // A `done:true` error frame both reports the error AND ends the context.
77
+ return [{ type: "error", key: contextId ? key : null, error: cartesiaProviderError(msg), endsContext: msg["done"] === true }];
78
+ }
79
+ const events = [];
80
+ if (msg["type"] === "timestamps") {
81
+ const words = parseWordTimestamps(msg["word_timestamps"]);
82
+ if (contextId && words.length > 0) {
83
+ events.push({
84
+ type: "sideband",
85
+ key,
86
+ route: Route.Main,
87
+ build: (ctxId, timestampMs) => ({ kind: "tts.word_timestamps", contextId: ctxId, timestampMs, words }),
88
+ });
89
+ }
90
+ }
91
+ // Audio arrives as non-empty base64 `data`; control frames such as `flush_done` carry an
92
+ // empty `data` and must not be decoded as audio.
93
+ if (typeof msg["data"] === "string" && msg["data"].length > 0) {
94
+ try {
95
+ const bytes = new Uint8Array(decodeStrictBase64(msg["data"], "Cartesia TTS provider audio data"));
96
+ assertAudioPayload(this.cfg.audioFormat, bytes);
97
+ events.push({ type: "audio", key, pcm: bytes });
98
+ }
99
+ catch (err) {
100
+ events.push({ type: "error", key, error: err instanceof Error ? err : new Error(String(err)) });
101
+ }
102
+ }
103
+ if (msg["done"] === true) {
104
+ const characters = this.charactersByKey.get(key) ?? 0;
105
+ this.charactersByKey.delete(key);
106
+ // Sideband before context_end so the engine still has the key→context mapping.
107
+ if (characters > 0) {
108
+ const modelId = this.cfg.modelId;
109
+ events.push({
110
+ type: "sideband",
111
+ key,
112
+ route: Route.Background,
113
+ build: (ctxId, timestampMs) => ({
114
+ kind: "usage.recorded",
115
+ contextId: ctxId,
116
+ timestampMs,
117
+ stage: "tts",
118
+ provider: "cartesia",
119
+ model: modelId,
120
+ characters,
121
+ }),
122
+ });
123
+ }
124
+ events.push({ type: "context_end", key });
125
+ }
126
+ return events;
127
+ }
128
+ }
129
+ export class CartesiaTTSPlugin {
130
+ socketFactory;
131
+ // socketFactory is injectable so the same plugin runs on Node (default) or
132
+ // Cloudflare Workers (pass createWorkersSocket).
133
+ constructor(socketFactory) {
134
+ this.socketFactory = socketFactory;
135
+ }
136
+ session = null;
137
+ async initialize(bus, config) {
138
+ const apiKey = requireStringConfig(config, "api_key");
139
+ const voiceId = optionalStringConfig(config, "voice_id") ?? "c2ac25f9-ecc4-4f56-9095-651354df60c0";
140
+ const modelId = optionalStringConfig(config, "model_id") ?? "sonic-3";
141
+ const endpointUrl = optionalStringConfig(config, "endpoint_url") ?? "wss://api.cartesia.ai/tts/websocket";
142
+ const apiVersion = optionalStringConfig(config, "cartesia_version") ?? "2024-06-10";
143
+ const sampleRate = config["sample_rate"] ?? 16000;
144
+ const language = optionalStringConfig(config, "language") ?? "en";
145
+ const addTimestamps = config["add_timestamps"] === undefined ? true : config["add_timestamps"] === true;
146
+ const outputContainer = optionalStringConfig(config, "output_container") ?? "raw";
147
+ const outputEncoding = optionalStringConfig(config, "output_encoding") ?? "pcm_s16le";
148
+ // Optional provider-specific passthrough merged into generation frames (speed, volume, etc.).
149
+ const generationConfig = readPlainObject(config["generation_config"]);
150
+ const audioFormat = { encoding: "pcm_s16le", sampleRateHz: sampleRate, channels: 1 };
151
+ assertAudioFormat(audioFormat);
152
+ this.session = await startStreamingTtsSession(bus, {
153
+ protocol: new CartesiaWireProtocol({
154
+ modelId,
155
+ voiceId,
156
+ sampleRate,
157
+ language,
158
+ audioFormat,
159
+ addTimestamps,
160
+ outputContainer,
161
+ outputEncoding,
162
+ generationConfig,
163
+ }),
164
+ provider: { name: "cartesia", model: modelId, region: "global" },
165
+ format: audioFormat,
166
+ sampleRateHz: sampleRate,
167
+ url: () => {
168
+ const params = new URLSearchParams({ cartesia_version: apiVersion });
169
+ const separator = endpointUrl.includes("?") ? "&" : "?";
170
+ return `${endpointUrl}${separator}${params.toString()}`;
171
+ },
172
+ headers: { "X-API-Key": apiKey },
173
+ retry: readProviderRetryConfig(config),
174
+ finishTimeoutMs: readNonNegativeInteger(config["finish_timeout_ms"], 2000),
175
+ metricPrefix: "tts.cartesia",
176
+ socketFactory: this.socketFactory ?? (await defaultNodeSocketFactory()),
177
+ replayBufferSize: config["replay_buffer_size"] ?? 32,
178
+ keepAliveIntervalMs: KEEP_ALIVE_INTERVAL_MS,
179
+ });
180
+ }
181
+ async close() {
182
+ await this.session?.dispose();
183
+ this.session = null;
184
+ }
185
+ }
186
+ function isErrorStatusCode(value) {
187
+ return typeof value === "number" && value >= 400;
188
+ }
189
+ function cartesiaProviderError(msg) {
190
+ const title = typeof msg["title"] === "string" ? msg["title"] : "Cartesia TTS provider error";
191
+ const message = typeof msg["message"] === "string" ? msg["message"] : "";
192
+ const error = typeof msg["error"] === "string" ? msg["error"] : "";
193
+ const errorCode = typeof msg["error_code"] === "string" ? msg["error_code"] : "";
194
+ const statusCode = typeof msg["status_code"] === "number" ? `status ${String(msg["status_code"])}` : "";
195
+ const details = [message, error, errorCode, statusCode].filter((part) => part.length > 0).join(" ");
196
+ return new Error(details ? `${title}: ${details}` : title);
197
+ }
198
+ function decodeStrictBase64(value, name) {
199
+ const normalized = value.replace(/\s+/g, "");
200
+ if (normalized.length === 0 || normalized.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(normalized)) {
201
+ throw new Error(`${name} must be valid base64`);
202
+ }
203
+ const decoded = Buffer.from(normalized, "base64");
204
+ if (decoded.toString("base64") !== normalized) {
205
+ throw new Error(`${name} must be valid base64`);
206
+ }
207
+ return decoded;
208
+ }
209
+ function parseWordTimestamps(value) {
210
+ if (value === null || typeof value !== "object")
211
+ return [];
212
+ const raw = value;
213
+ const rawWords = Array.isArray(raw["words"]) ? raw["words"] : [];
214
+ const rawStarts = Array.isArray(raw["start"]) ? raw["start"] : [];
215
+ const rawEnds = Array.isArray(raw["end"]) ? raw["end"] : [];
216
+ const count = Math.min(rawWords.length, rawStarts.length, rawEnds.length);
217
+ const words = [];
218
+ for (let i = 0; i < count; i += 1) {
219
+ const word = rawWords[i];
220
+ const start = rawStarts[i];
221
+ const end = rawEnds[i];
222
+ if (typeof word !== "string" || typeof start !== "number" || typeof end !== "number")
223
+ continue;
224
+ words.push({ word, startMs: Math.round(start * 1000), endMs: Math.round(end * 1000) });
225
+ }
226
+ return words;
227
+ }
228
+ function readNonNegativeInteger(value, fallback) {
229
+ if (typeof value !== "number" || !Number.isFinite(value))
230
+ return fallback;
231
+ const integer = Math.floor(value);
232
+ return integer >= 0 ? integer : fallback;
233
+ }
234
+ function readPlainObject(value) {
235
+ if (value !== null && typeof value === "object" && !Array.isArray(value)) {
236
+ return value;
237
+ }
238
+ return undefined;
239
+ }
240
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kuralle-syrinx/cartesia",
3
- "version": "4.4.1",
3
+ "version": "4.5.0",
4
4
  "private": false,
5
5
  "description": "Cartesia streaming TTS adapter for Syrinx",
6
6
  "keywords": [
@@ -22,21 +22,28 @@
22
22
  "url": "https://github.com/kuralle/syrinx/issues"
23
23
  },
24
24
  "type": "module",
25
- "main": "./src/index.ts",
26
- "types": "./src/index.ts",
25
+ "main": "./dist/index.js",
26
+ "types": "./dist/index.d.ts",
27
27
  "dependencies": {
28
- "@kuralle-syrinx/core": "4.4.1",
29
- "@kuralle-syrinx/tts-core": "4.4.1",
30
- "@kuralle-syrinx/ws": "4.4.1"
28
+ "@kuralle-syrinx/core": "4.5.0",
29
+ "@kuralle-syrinx/tts-core": "4.5.0",
30
+ "@kuralle-syrinx/ws": "4.5.0"
31
31
  },
32
32
  "devDependencies": {
33
+ "@types/node": "^22.0.0",
33
34
  "@types/ws": "^8.5.0",
34
35
  "typescript": "^5.7.0",
35
36
  "vitest": "^3.2.6",
36
37
  "ws": "^8.21.0"
37
38
  },
39
+ "files": [
40
+ "dist",
41
+ "src",
42
+ "README.md"
43
+ ],
38
44
  "scripts": {
39
45
  "typecheck": "tsc --noEmit",
40
- "test": "vitest run"
46
+ "test": "vitest run",
47
+ "build": "tsc -p tsconfig.build.json"
41
48
  }
42
49
  }
@@ -0,0 +1,914 @@
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
+ type UsageRecordedPacket,
13
+ } from "@kuralle-syrinx/core";
14
+ import { attributionKey } from "@kuralle-syrinx/tts-core";
15
+
16
+ import { CartesiaTTSPlugin, CartesiaWireProtocol } from "./index.js";
17
+
18
+ let servers: WebSocketServer[] = [];
19
+
20
+ afterEach(async () => {
21
+ await Promise.all(
22
+ servers.splice(0).map(
23
+ (server) =>
24
+ new Promise<void>((resolve) => {
25
+ for (const client of server.clients) client.terminate();
26
+ server.close(() => resolve());
27
+ }),
28
+ ),
29
+ );
30
+ });
31
+
32
+ async function createLocalServer(onConnection: (socket: WebSocket, requestUrl: string, apiKeyHeader: string) => void): Promise<string> {
33
+ const server = await new Promise<WebSocketServer>((resolve) => {
34
+ let nextServer: WebSocketServer;
35
+ nextServer = new WebSocketServer({ port: 0 }, () => {
36
+ resolve(nextServer);
37
+ });
38
+ });
39
+ servers.push(server);
40
+ server.on("connection", (socket, request) => {
41
+ const header = request.headers["x-api-key"];
42
+ onConnection(socket, request.url ?? "", Array.isArray(header) ? header[0] ?? "" : header ?? "");
43
+ });
44
+ const address = server.address();
45
+ if (!address || typeof address === "string") throw new Error("Expected TCP server address");
46
+ return `ws://127.0.0.1:${address.port}/tts/websocket`;
47
+ }
48
+
49
+ function startBus(bus: PipelineBusImpl): Promise<void> {
50
+ return bus.start();
51
+ }
52
+
53
+ async function waitForCondition(predicate: () => boolean, timeoutMs = 1000): Promise<void> {
54
+ const startedAt = Date.now();
55
+ while (Date.now() - startedAt < timeoutMs) {
56
+ if (predicate()) return;
57
+ await new Promise((resolve) => setTimeout(resolve, 10));
58
+ }
59
+ throw new Error("Timed out waiting for Cartesia test condition");
60
+ }
61
+
62
+ describe("CartesiaTTSPlugin", () => {
63
+ it("emits usage.recorded with stage tts and characters equal to synthesized text length", async () => {
64
+ const text = "Hello there.";
65
+ const endpointUrl = await createLocalServer((socket) => {
66
+ socket.on("message", (data) => {
67
+ const msg = JSON.parse(data.toString());
68
+ if (msg.transcript === text) {
69
+ socket.send(JSON.stringify({
70
+ type: "chunk",
71
+ context_id: msg.context_id,
72
+ data: Buffer.from(new Uint8Array([1, 2, 3, 4])).toString("base64"),
73
+ done: false,
74
+ status_code: 206,
75
+ }));
76
+ }
77
+ if (msg.context_id === "turn-usage" && msg.continue === false) {
78
+ socket.send(JSON.stringify({
79
+ type: "done",
80
+ context_id: "turn-usage",
81
+ done: true,
82
+ status_code: 206,
83
+ }));
84
+ }
85
+ });
86
+ });
87
+ const bus = new PipelineBusImpl();
88
+ const started = startBus(bus);
89
+ const plugin = new CartesiaTTSPlugin();
90
+ const ends: TextToSpeechEndPacket[] = [];
91
+ const usage: UsageRecordedPacket[] = [];
92
+ bus.on("tts.end", (pkt) => {
93
+ ends.push(pkt as TextToSpeechEndPacket);
94
+ });
95
+ bus.on("usage.recorded", (pkt) => {
96
+ usage.push(pkt as UsageRecordedPacket);
97
+ });
98
+
99
+ await plugin.initialize(bus, {
100
+ api_key: "test-cartesia-key",
101
+ endpoint_url: endpointUrl,
102
+ voice_id: "voice-test",
103
+ model_id: "sonic-3",
104
+ sample_rate: 16000,
105
+ });
106
+ bus.push(Route.Main, {
107
+ kind: "tts.text",
108
+ contextId: "turn-usage",
109
+ timestampMs: Date.now(),
110
+ text,
111
+ });
112
+ bus.push(Route.Main, {
113
+ kind: "tts.done",
114
+ contextId: "turn-usage",
115
+ timestampMs: Date.now(),
116
+ text,
117
+ });
118
+ await waitForCondition(() => ends.length >= 1 && usage.length >= 1);
119
+
120
+ expect(usage).toEqual([
121
+ expect.objectContaining({
122
+ kind: "usage.recorded",
123
+ contextId: "turn-usage",
124
+ stage: "tts",
125
+ provider: "cartesia",
126
+ model: "sonic-3",
127
+ characters: text.length,
128
+ }),
129
+ ]);
130
+
131
+ await plugin.close();
132
+ bus.stop();
133
+ await started;
134
+ });
135
+
136
+ it("streams text over one authenticated websocket without leaking the API key in the URL", async () => {
137
+ const receivedRequests: any[] = [];
138
+ const endpointUrl = await createLocalServer((socket, requestUrl, apiKeyHeader) => {
139
+ expect(requestUrl).toContain("cartesia_version=");
140
+ expect(requestUrl).not.toContain("test-cartesia-key");
141
+ expect(apiKeyHeader).toBe("test-cartesia-key");
142
+ socket.on("message", (data) => {
143
+ const msg = JSON.parse(data.toString());
144
+ receivedRequests.push(msg);
145
+ if (msg.transcript === "Hello there.") {
146
+ socket.send(JSON.stringify({
147
+ type: "chunk",
148
+ context_id: msg.context_id,
149
+ data: Buffer.from(new Uint8Array([1, 2, 3, 4])).toString("base64"),
150
+ done: false,
151
+ status_code: 206,
152
+ }));
153
+ }
154
+ if (msg.context_id === "turn-1" && msg.continue === false) {
155
+ socket.send(JSON.stringify({
156
+ type: "done",
157
+ context_id: "turn-1",
158
+ done: true,
159
+ status_code: 206,
160
+ }));
161
+ }
162
+ });
163
+ });
164
+ const bus = new PipelineBusImpl();
165
+ const started = startBus(bus);
166
+ const plugin = new CartesiaTTSPlugin();
167
+ const audio: TextToSpeechAudioPacket[] = [];
168
+ const ends: TextToSpeechEndPacket[] = [];
169
+ bus.on("tts.audio", (pkt) => {
170
+ audio.push(pkt as TextToSpeechAudioPacket);
171
+ });
172
+ bus.on("tts.end", (pkt) => {
173
+ ends.push(pkt as TextToSpeechEndPacket);
174
+ });
175
+
176
+ await plugin.initialize(bus, {
177
+ api_key: "test-cartesia-key",
178
+ endpoint_url: endpointUrl,
179
+ voice_id: "voice-test",
180
+ model_id: "sonic-test",
181
+ sample_rate: 16000,
182
+ });
183
+ bus.push(Route.Main, {
184
+ kind: "tts.text",
185
+ contextId: "turn-1",
186
+ timestampMs: Date.now(),
187
+ text: "Hello there.",
188
+ });
189
+ bus.push(Route.Main, {
190
+ kind: "tts.done",
191
+ contextId: "turn-1",
192
+ timestampMs: Date.now(),
193
+ text: "Hello there.",
194
+ });
195
+ await waitForCondition(() => receivedRequests.length >= 2 && audio.length >= 1 && ends.length >= 1);
196
+
197
+ expect(receivedRequests).toEqual([
198
+ expect.objectContaining({
199
+ context_id: "turn-1",
200
+ continue: true,
201
+ transcript: "Hello there.",
202
+ }),
203
+ expect.objectContaining({
204
+ context_id: "turn-1",
205
+ continue: false,
206
+ flush: true,
207
+ transcript: "",
208
+ }),
209
+ ]);
210
+ expect(audio).toEqual([
211
+ expect.objectContaining({
212
+ contextId: "turn-1",
213
+ audio: new Uint8Array([1, 2, 3, 4]),
214
+ }),
215
+ ]);
216
+ expect(ends).toEqual([expect.objectContaining({ contextId: "turn-1" })]);
217
+
218
+ await plugin.close();
219
+ bus.stop();
220
+ await started;
221
+ });
222
+
223
+ it("cancels active Cartesia contexts on TTS interruption", async () => {
224
+ const receivedRequests: any[] = [];
225
+ const endpointUrl = await createLocalServer((socket) => {
226
+ socket.on("message", (data) => {
227
+ receivedRequests.push(JSON.parse(data.toString()));
228
+ });
229
+ });
230
+ const bus = new PipelineBusImpl();
231
+ const started = startBus(bus);
232
+ const plugin = new CartesiaTTSPlugin();
233
+
234
+ await plugin.initialize(bus, {
235
+ api_key: "test-cartesia-key",
236
+ endpoint_url: endpointUrl,
237
+ voice_id: "voice-test",
238
+ model_id: "sonic-test",
239
+ });
240
+ bus.push(Route.Main, {
241
+ kind: "tts.text",
242
+ contextId: "turn-interrupt",
243
+ timestampMs: Date.now(),
244
+ text: "This will be interrupted.",
245
+ });
246
+ await new Promise((resolve) => setTimeout(resolve, 20));
247
+ bus.push(Route.Critical, {
248
+ kind: "interrupt.tts",
249
+ contextId: "turn-interrupt",
250
+ timestampMs: Date.now(),
251
+ });
252
+ await new Promise((resolve) => setTimeout(resolve, 40));
253
+
254
+ expect(receivedRequests).toEqual([
255
+ expect.objectContaining({
256
+ context_id: "turn-interrupt",
257
+ continue: true,
258
+ }),
259
+ {
260
+ context_id: "turn-interrupt",
261
+ cancel: true,
262
+ },
263
+ ]);
264
+
265
+ await plugin.close();
266
+ bus.stop();
267
+ await started;
268
+ });
269
+
270
+ it("drops late Cartesia audio and done frames for cancelled contexts", async () => {
271
+ const endpointUrl = await createLocalServer((socket) => {
272
+ socket.on("message", (data) => {
273
+ const msg = JSON.parse(data.toString());
274
+ if (msg.cancel === true) {
275
+ socket.send(JSON.stringify({
276
+ context_id: msg.context_id,
277
+ data: Buffer.from([9, 8, 7, 6]).toString("base64"),
278
+ }));
279
+ socket.send(JSON.stringify({
280
+ context_id: msg.context_id,
281
+ done: true,
282
+ }));
283
+ }
284
+ });
285
+ });
286
+ const bus = new PipelineBusImpl();
287
+ const started = startBus(bus);
288
+ const plugin = new CartesiaTTSPlugin();
289
+ const audio: TextToSpeechAudioPacket[] = [];
290
+ const ends: TextToSpeechEndPacket[] = [];
291
+
292
+ bus.on("tts.audio", (pkt) => {
293
+ audio.push(pkt as TextToSpeechAudioPacket);
294
+ });
295
+ bus.on("tts.end", (pkt) => {
296
+ ends.push(pkt as TextToSpeechEndPacket);
297
+ });
298
+
299
+ await plugin.initialize(bus, {
300
+ api_key: "test-cartesia-key",
301
+ endpoint_url: endpointUrl,
302
+ voice_id: "voice-test",
303
+ model_id: "sonic-test",
304
+ });
305
+ bus.push(Route.Main, {
306
+ kind: "tts.text",
307
+ contextId: "turn-cancelled",
308
+ timestampMs: Date.now(),
309
+ text: "This generation will be cancelled.",
310
+ });
311
+ await new Promise((resolve) => setTimeout(resolve, 20));
312
+ bus.push(Route.Critical, {
313
+ kind: "interrupt.tts",
314
+ contextId: "turn-cancelled",
315
+ timestampMs: Date.now(),
316
+ });
317
+ await new Promise((resolve) => setTimeout(resolve, 50));
318
+
319
+ expect(audio).toEqual([]);
320
+ expect(ends).toEqual([]);
321
+
322
+ await plugin.close();
323
+ bus.stop();
324
+ await started;
325
+ });
326
+
327
+ it("emits tts.end when Cartesia streams audio but never acknowledges flush", async () => {
328
+ const endpointUrl = await createLocalServer((socket) => {
329
+ socket.on("message", (data) => {
330
+ const msg = JSON.parse(data.toString());
331
+ if (msg.transcript !== "Hello there.") return;
332
+ socket.send(JSON.stringify({
333
+ type: "chunk",
334
+ context_id: msg.context_id,
335
+ data: Buffer.from(new Uint8Array([1, 2, 3, 4])).toString("base64"),
336
+ done: false,
337
+ status_code: 206,
338
+ }));
339
+ });
340
+ });
341
+ const bus = new PipelineBusImpl();
342
+ const started = startBus(bus);
343
+ const plugin = new CartesiaTTSPlugin();
344
+ const ends: TextToSpeechEndPacket[] = [];
345
+ bus.on("tts.end", (pkt) => {
346
+ ends.push(pkt as TextToSpeechEndPacket);
347
+ });
348
+
349
+ await plugin.initialize(bus, {
350
+ api_key: "test-cartesia-key",
351
+ endpoint_url: endpointUrl,
352
+ voice_id: "voice-test",
353
+ model_id: "sonic-test",
354
+ finish_timeout_ms: 20,
355
+ });
356
+ bus.push(Route.Main, {
357
+ kind: "tts.text",
358
+ contextId: "turn-timeout-end",
359
+ timestampMs: Date.now(),
360
+ text: "Hello there.",
361
+ });
362
+ bus.push(Route.Main, {
363
+ kind: "tts.done",
364
+ contextId: "turn-timeout-end",
365
+ timestampMs: Date.now(),
366
+ text: "Hello there.",
367
+ });
368
+ await waitForCondition(() => ends.length >= 1);
369
+
370
+ expect(ends).toEqual([expect.objectContaining({ contextId: "turn-timeout-end" })]);
371
+
372
+ await plugin.close();
373
+ bus.stop();
374
+ await started;
375
+ });
376
+
377
+ it("emits a typed TTS error and closes the context when Cartesia returns an error frame", async () => {
378
+ const endpointUrl = await createLocalServer((socket) => {
379
+ socket.on("message", (data) => {
380
+ const msg = JSON.parse(data.toString());
381
+ socket.send(JSON.stringify({
382
+ type: "error",
383
+ done: true,
384
+ title: "Invalid model",
385
+ message: "The model is not valid.",
386
+ error_code: "model_not_found",
387
+ status_code: 400,
388
+ context_id: msg.context_id,
389
+ }));
390
+ });
391
+ });
392
+ const bus = new PipelineBusImpl();
393
+ const started = startBus(bus);
394
+ const plugin = new CartesiaTTSPlugin();
395
+ const errors: TtsErrorPacket[] = [];
396
+ const ends: TextToSpeechEndPacket[] = [];
397
+ bus.on("tts.error", (pkt) => {
398
+ errors.push(pkt as TtsErrorPacket);
399
+ });
400
+ bus.on("tts.end", (pkt) => {
401
+ ends.push(pkt as TextToSpeechEndPacket);
402
+ });
403
+
404
+ await plugin.initialize(bus, {
405
+ api_key: "test-cartesia-key",
406
+ endpoint_url: endpointUrl,
407
+ voice_id: "voice-test",
408
+ model_id: "bad-model",
409
+ });
410
+ bus.push(Route.Main, {
411
+ kind: "tts.text",
412
+ contextId: "turn-error",
413
+ timestampMs: Date.now(),
414
+ text: "This will fail.",
415
+ });
416
+ await new Promise((resolve) => setTimeout(resolve, 40));
417
+
418
+ expect(errors).toEqual([
419
+ expect.objectContaining({
420
+ kind: "tts.error",
421
+ contextId: "turn-error",
422
+ component: "tts",
423
+ }),
424
+ ]);
425
+ expect(errors[0]!.cause.message).toContain("Invalid model");
426
+ expect(ends).toEqual([expect.objectContaining({ contextId: "turn-error" })]);
427
+
428
+ await plugin.close();
429
+ bus.stop();
430
+ await started;
431
+ });
432
+
433
+ it("surfaces Cartesia error field text in tts.error cause.message", async () => {
434
+ const endpointUrl = await createLocalServer((socket) => {
435
+ socket.on("message", (data) => {
436
+ const msg = JSON.parse(data.toString());
437
+ socket.send(JSON.stringify({
438
+ type: "error",
439
+ context_id: msg.context_id,
440
+ status_code: 400,
441
+ done: true,
442
+ error: "Model sunsetted: The requested model has been sunsetted and is no longer available.",
443
+ }));
444
+ });
445
+ });
446
+ const bus = new PipelineBusImpl();
447
+ const started = startBus(bus);
448
+ const plugin = new CartesiaTTSPlugin();
449
+ const errors: TtsErrorPacket[] = [];
450
+ bus.on("tts.error", (pkt) => {
451
+ errors.push(pkt as TtsErrorPacket);
452
+ });
453
+
454
+ await plugin.initialize(bus, {
455
+ api_key: "test-cartesia-key",
456
+ endpoint_url: endpointUrl,
457
+ voice_id: "voice-test",
458
+ model_id: "bad-model",
459
+ });
460
+ bus.push(Route.Main, {
461
+ kind: "tts.text",
462
+ contextId: "turn-x",
463
+ timestampMs: Date.now(),
464
+ text: "This will fail.",
465
+ });
466
+ await new Promise((resolve) => setTimeout(resolve, 40));
467
+
468
+ expect(errors).toEqual([
469
+ expect.objectContaining({
470
+ kind: "tts.error",
471
+ contextId: "turn-x",
472
+ component: "tts",
473
+ }),
474
+ ]);
475
+ expect(errors[0]!.cause.message).toContain("Model sunsetted");
476
+
477
+ await plugin.close();
478
+ bus.stop();
479
+ await started;
480
+ });
481
+
482
+ it("turns malformed provider messages into TTS errors instead of throwing from the websocket listener", async () => {
483
+ const endpointUrl = await createLocalServer((socket) => {
484
+ socket.on("message", () => {
485
+ socket.send("{not-json");
486
+ });
487
+ });
488
+ const bus = new PipelineBusImpl();
489
+ const started = startBus(bus);
490
+ const plugin = new CartesiaTTSPlugin();
491
+ const errors: TtsErrorPacket[] = [];
492
+ bus.on("tts.error", (pkt) => {
493
+ errors.push(pkt as TtsErrorPacket);
494
+ });
495
+
496
+ await plugin.initialize(bus, {
497
+ api_key: "test-cartesia-key",
498
+ endpoint_url: endpointUrl,
499
+ voice_id: "voice-test",
500
+ model_id: "sonic-test",
501
+ });
502
+ bus.push(Route.Main, {
503
+ kind: "tts.text",
504
+ contextId: "turn-malformed",
505
+ timestampMs: Date.now(),
506
+ text: "Trigger malformed provider frame.",
507
+ });
508
+ await new Promise((resolve) => setTimeout(resolve, 40));
509
+
510
+ expect(errors).toEqual([
511
+ expect.objectContaining({
512
+ kind: "tts.error",
513
+ contextId: "turn-malformed",
514
+ component: "tts",
515
+ }),
516
+ ]);
517
+
518
+ await plugin.close();
519
+ bus.stop();
520
+ await started;
521
+ });
522
+
523
+ it("rejects malformed Cartesia audio payloads before playback", async () => {
524
+ const endpointUrl = await createLocalServer((socket) => {
525
+ socket.on("message", (data) => {
526
+ const msg = JSON.parse(data.toString());
527
+ socket.send(JSON.stringify({
528
+ type: "chunk",
529
+ context_id: msg.context_id,
530
+ data: "not-base64",
531
+ done: false,
532
+ status_code: 206,
533
+ }));
534
+ });
535
+ });
536
+ const bus = new PipelineBusImpl();
537
+ const started = startBus(bus);
538
+ const plugin = new CartesiaTTSPlugin();
539
+ const audio: TextToSpeechAudioPacket[] = [];
540
+ const errors: TtsErrorPacket[] = [];
541
+ const ends: TextToSpeechEndPacket[] = [];
542
+ bus.on("tts.audio", (pkt) => {
543
+ audio.push(pkt as TextToSpeechAudioPacket);
544
+ });
545
+ bus.on("tts.error", (pkt) => {
546
+ errors.push(pkt as TtsErrorPacket);
547
+ });
548
+ bus.on("tts.end", (pkt) => {
549
+ ends.push(pkt as TextToSpeechEndPacket);
550
+ });
551
+
552
+ await plugin.initialize(bus, {
553
+ api_key: "test-cartesia-key",
554
+ endpoint_url: endpointUrl,
555
+ voice_id: "voice-test",
556
+ model_id: "sonic-test",
557
+ });
558
+ bus.push(Route.Main, {
559
+ kind: "tts.text",
560
+ contextId: "turn-malformed-audio",
561
+ timestampMs: Date.now(),
562
+ text: "Trigger malformed provider audio.",
563
+ });
564
+ await waitForCondition(() => errors.length > 0);
565
+ bus.push(Route.Main, {
566
+ kind: "tts.done",
567
+ contextId: "turn-malformed-audio",
568
+ timestampMs: Date.now(),
569
+ });
570
+ await waitForCondition(() => ends.length > 0);
571
+
572
+ expect(audio).toEqual([]);
573
+ expect(errors).toEqual([
574
+ expect.objectContaining({
575
+ kind: "tts.error",
576
+ contextId: "turn-malformed-audio",
577
+ component: "tts",
578
+ cause: expect.objectContaining({
579
+ message: "Cartesia TTS provider audio data must be valid base64",
580
+ }),
581
+ }),
582
+ ]);
583
+ expect(ends).toEqual([expect.objectContaining({ contextId: "turn-malformed-audio" })]);
584
+
585
+ await plugin.close();
586
+ bus.stop();
587
+ await started;
588
+ });
589
+
590
+ it("emits tts.error for odd-length PCM16 provider audio without throwing into the bus pump", async () => {
591
+ const endpointUrl = await createLocalServer((socket) => {
592
+ socket.on("message", (data) => {
593
+ const msg = JSON.parse(data.toString());
594
+ socket.send(JSON.stringify({
595
+ type: "chunk",
596
+ context_id: msg.context_id,
597
+ data: Buffer.from(new Uint8Array([1, 2, 3])).toString("base64"),
598
+ done: false,
599
+ status_code: 206,
600
+ }));
601
+ });
602
+ });
603
+ const bus = new PipelineBusImpl();
604
+ const started = startBus(bus);
605
+ const plugin = new CartesiaTTSPlugin();
606
+ const audio: TextToSpeechAudioPacket[] = [];
607
+ const errors: TtsErrorPacket[] = [];
608
+ bus.on("tts.audio", (pkt) => {
609
+ audio.push(pkt as TextToSpeechAudioPacket);
610
+ });
611
+ bus.on("tts.error", (pkt) => {
612
+ errors.push(pkt as TtsErrorPacket);
613
+ });
614
+
615
+ await plugin.initialize(bus, {
616
+ api_key: "test-cartesia-key",
617
+ endpoint_url: endpointUrl,
618
+ voice_id: "voice-test",
619
+ model_id: "sonic-test",
620
+ });
621
+ bus.push(Route.Main, {
622
+ kind: "tts.text",
623
+ contextId: "turn-odd-pcm",
624
+ timestampMs: Date.now(),
625
+ text: "Trigger odd PCM.",
626
+ });
627
+ await waitForCondition(() => errors.length > 0);
628
+
629
+ expect(audio).toEqual([]);
630
+ expect(errors).toEqual([
631
+ expect.objectContaining({
632
+ kind: "tts.error",
633
+ contextId: "turn-odd-pcm",
634
+ component: "tts",
635
+ cause: expect.objectContaining({
636
+ message: expect.stringMatching(/even number of bytes/i),
637
+ }),
638
+ }),
639
+ ]);
640
+
641
+ await plugin.close();
642
+ bus.stop();
643
+ await started;
644
+ });
645
+
646
+ it("ignores the empty-data flush_done acknowledgement instead of treating it as malformed audio", async () => {
647
+ const endpointUrl = await createLocalServer((socket) => {
648
+ socket.on("message", (data) => {
649
+ const msg = JSON.parse(data.toString());
650
+ if (msg.transcript === "Hello there.") {
651
+ socket.send(JSON.stringify({
652
+ type: "chunk",
653
+ context_id: msg.context_id,
654
+ data: Buffer.from(new Uint8Array([1, 2, 3, 4])).toString("base64"),
655
+ done: false,
656
+ status_code: 206,
657
+ }));
658
+ }
659
+ if (msg.context_id === "turn-flush" && msg.continue === false) {
660
+ // Real Cartesia answers a flush request with a flush_done control frame
661
+ // that carries an empty `data` string before the terminal done frame.
662
+ socket.send(JSON.stringify({
663
+ type: "flush_done",
664
+ context_id: "turn-flush",
665
+ data: "",
666
+ done: false,
667
+ status_code: 206,
668
+ flush_done: true,
669
+ }));
670
+ socket.send(JSON.stringify({
671
+ type: "done",
672
+ context_id: "turn-flush",
673
+ done: true,
674
+ status_code: 200,
675
+ }));
676
+ }
677
+ });
678
+ });
679
+ const bus = new PipelineBusImpl();
680
+ const started = startBus(bus);
681
+ const plugin = new CartesiaTTSPlugin();
682
+ const audio: TextToSpeechAudioPacket[] = [];
683
+ const errors: TtsErrorPacket[] = [];
684
+ const ends: TextToSpeechEndPacket[] = [];
685
+ bus.on("tts.audio", (pkt) => {
686
+ audio.push(pkt as TextToSpeechAudioPacket);
687
+ });
688
+ bus.on("tts.error", (pkt) => {
689
+ errors.push(pkt as TtsErrorPacket);
690
+ });
691
+ bus.on("tts.end", (pkt) => {
692
+ ends.push(pkt as TextToSpeechEndPacket);
693
+ });
694
+
695
+ await plugin.initialize(bus, {
696
+ api_key: "test-cartesia-key",
697
+ endpoint_url: endpointUrl,
698
+ voice_id: "voice-test",
699
+ model_id: "sonic-test",
700
+ sample_rate: 16000,
701
+ });
702
+ bus.push(Route.Main, {
703
+ kind: "tts.text",
704
+ contextId: "turn-flush",
705
+ timestampMs: Date.now(),
706
+ text: "Hello there.",
707
+ });
708
+ bus.push(Route.Main, {
709
+ kind: "tts.done",
710
+ contextId: "turn-flush",
711
+ timestampMs: Date.now(),
712
+ });
713
+ await waitForCondition(() => ends.length >= 1);
714
+
715
+ expect(errors).toEqual([]);
716
+ expect(audio).toEqual([
717
+ expect.objectContaining({
718
+ contextId: "turn-flush",
719
+ audio: new Uint8Array([1, 2, 3, 4]),
720
+ }),
721
+ ]);
722
+ expect(ends).toEqual([expect.objectContaining({ contextId: "turn-flush" })]);
723
+
724
+ await plugin.close();
725
+ bus.stop();
726
+ await started;
727
+ });
728
+
729
+ it("fails active contexts when the Cartesia websocket closes before provider done", async () => {
730
+ const endpointUrl = await createLocalServer((socket) => {
731
+ socket.on("message", () => {
732
+ socket.close(1011, "provider restart");
733
+ });
734
+ });
735
+ const bus = new PipelineBusImpl();
736
+ const started = startBus(bus);
737
+ const plugin = new CartesiaTTSPlugin();
738
+ const errors: TtsErrorPacket[] = [];
739
+ bus.on("tts.error", (pkt) => {
740
+ errors.push(pkt as TtsErrorPacket);
741
+ });
742
+
743
+ await plugin.initialize(bus, {
744
+ api_key: "test-cartesia-key",
745
+ endpoint_url: endpointUrl,
746
+ voice_id: "voice-test",
747
+ model_id: "sonic-test",
748
+ });
749
+ bus.push(Route.Main, {
750
+ kind: "tts.text",
751
+ contextId: "turn-close",
752
+ timestampMs: Date.now(),
753
+ text: "This context should fail if the provider closes.",
754
+ });
755
+ await new Promise((resolve) => setTimeout(resolve, 60));
756
+
757
+ expect(errors).toEqual(expect.arrayContaining([
758
+ expect.objectContaining({
759
+ kind: "tts.error",
760
+ contextId: "turn-close",
761
+ component: "tts",
762
+ cause: expect.objectContaining({
763
+ message: expect.stringContaining("WebSocket closed unexpectedly"),
764
+ }),
765
+ }),
766
+ ]));
767
+
768
+ await plugin.close();
769
+ bus.stop();
770
+ await started;
771
+ });
772
+
773
+ it("honors add_timestamps, output encoding/container overrides, and generation_config passthrough on speak frames", async () => {
774
+ const protocol = new CartesiaWireProtocol({
775
+ modelId: "sonic-3",
776
+ voiceId: "voice-x",
777
+ sampleRate: 16000,
778
+ language: "en",
779
+ audioFormat: { encoding: "pcm_s16le", sampleRateHz: 16000, channels: 1 },
780
+ addTimestamps: false,
781
+ outputContainer: "raw",
782
+ outputEncoding: "pcm_f32le",
783
+ generationConfig: { speed: 1.2, volume: 0.9 },
784
+ });
785
+ const key = attributionKey("turn-cfg");
786
+ const frames = protocol.encodeText(key, "Hello.").map((f) => JSON.parse(String(f)));
787
+ expect(frames).toEqual([
788
+ expect.objectContaining({
789
+ model_id: "sonic-3",
790
+ transcript: "Hello.",
791
+ context_id: key,
792
+ continue: true,
793
+ add_timestamps: false,
794
+ speed: 1.2,
795
+ volume: 0.9,
796
+ output_format: {
797
+ container: "raw",
798
+ encoding: "pcm_f32le",
799
+ sample_rate: 16000,
800
+ },
801
+ }),
802
+ ]);
803
+ });
804
+
805
+ it("defaults add_timestamps true and pcm_s16le/raw on speak frames when unset", async () => {
806
+ const received: Array<Record<string, unknown>> = [];
807
+ const endpointUrl = await createLocalServer((socket) => {
808
+ socket.on("message", (data) => {
809
+ const msg = JSON.parse(data.toString()) as Record<string, unknown>;
810
+ received.push(msg);
811
+ if (msg["transcript"] === "Hi.") {
812
+ socket.send(JSON.stringify({
813
+ type: "chunk",
814
+ context_id: msg["context_id"],
815
+ data: Buffer.from(new Uint8Array([1, 2, 3, 4])).toString("base64"),
816
+ done: false,
817
+ status_code: 206,
818
+ }));
819
+ }
820
+ if (msg["continue"] === false) {
821
+ socket.send(JSON.stringify({ type: "done", context_id: msg["context_id"], done: true, status_code: 200 }));
822
+ }
823
+ });
824
+ });
825
+ const bus = new PipelineBusImpl();
826
+ const started = startBus(bus);
827
+ const plugin = new CartesiaTTSPlugin();
828
+ const ends: TextToSpeechEndPacket[] = [];
829
+ bus.on("tts.end", (pkt) => {
830
+ ends.push(pkt as TextToSpeechEndPacket);
831
+ });
832
+
833
+ await plugin.initialize(bus, {
834
+ api_key: "test-cartesia-key",
835
+ endpoint_url: endpointUrl,
836
+ voice_id: "voice-test",
837
+ model_id: "sonic-test",
838
+ sample_rate: 16000,
839
+ });
840
+ bus.push(Route.Main, { kind: "tts.text", contextId: "turn-default-cfg", timestampMs: Date.now(), text: "Hi." });
841
+ bus.push(Route.Main, { kind: "tts.done", contextId: "turn-default-cfg", timestampMs: Date.now() });
842
+ await waitForCondition(() => ends.length >= 1);
843
+
844
+ expect(received[0]).toEqual(
845
+ expect.objectContaining({
846
+ add_timestamps: true,
847
+ output_format: { container: "raw", encoding: "pcm_s16le", sample_rate: 16000 },
848
+ }),
849
+ );
850
+
851
+ await plugin.close();
852
+ bus.stop();
853
+ await started;
854
+ });
855
+
856
+ it("emits tts.word_timestamps from Cartesia's real top-level parallel-array timestamp frame", async () => {
857
+ const audioChunk = new Uint8Array(3200);
858
+ const endpointUrl = await createLocalServer((socket) => {
859
+ socket.on("message", (data) => {
860
+ const msg = JSON.parse(data.toString());
861
+ if (msg.transcript === "Hello world.") {
862
+ socket.send(JSON.stringify({
863
+ type: "chunk",
864
+ context_id: msg.context_id,
865
+ data: Buffer.from(audioChunk).toString("base64"),
866
+ done: false,
867
+ status_code: 206,
868
+ }));
869
+ socket.send(JSON.stringify({
870
+ type: "timestamps",
871
+ context_id: msg.context_id,
872
+ word_timestamps: {
873
+ words: ["Hello", "world."],
874
+ start: [0.0, 0.5],
875
+ end: [0.4, 0.9],
876
+ },
877
+ }));
878
+ }
879
+ if (msg.continue === false) {
880
+ socket.send(JSON.stringify({ type: "done", context_id: msg.context_id, done: true, status_code: 200 }));
881
+ }
882
+ });
883
+ });
884
+
885
+ const bus = new PipelineBusImpl();
886
+ const started = startBus(bus);
887
+ const plugin = new CartesiaTTSPlugin();
888
+ const wordTsPackets: TextToSpeechWordTimestampsPacket[] = [];
889
+ bus.on("tts.word_timestamps", (pkt) => {
890
+ wordTsPackets.push(pkt as TextToSpeechWordTimestampsPacket);
891
+ });
892
+
893
+ await plugin.initialize(bus, {
894
+ api_key: "test-cartesia-key",
895
+ endpoint_url: endpointUrl,
896
+ voice_id: "voice-test",
897
+ model_id: "sonic-test",
898
+ sample_rate: 16000,
899
+ });
900
+ bus.push(Route.Main, { kind: "tts.text", contextId: "turn-ts", timestampMs: Date.now(), text: "Hello world." });
901
+ bus.push(Route.Main, { kind: "tts.done", contextId: "turn-ts", timestampMs: Date.now(), text: "Hello world." });
902
+ await waitForCondition(() => wordTsPackets.length >= 1);
903
+
904
+ expect(wordTsPackets[0]!.words).toEqual([
905
+ { word: "Hello", startMs: 0, endMs: 400 },
906
+ { word: "world.", startMs: 500, endMs: 900 },
907
+ ]);
908
+
909
+ await plugin.close();
910
+ bus.stop();
911
+ await started;
912
+ });
913
+
914
+ });