@kuralle-syrinx/tts-core 4.2.0 → 4.4.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/tts-core",
3
- "version": "4.2.0",
3
+ "version": "4.4.0",
4
4
  "private": false,
5
5
  "description": "Shared streaming-TTS lifecycle engine for Syrinx TTS adapters — carry, refcount, finish-timeout, cancel",
6
6
  "keywords": [
@@ -25,12 +25,12 @@
25
25
  "main": "./src/index.ts",
26
26
  "types": "./src/index.ts",
27
27
  "dependencies": {
28
- "@kuralle-syrinx/core": "4.2.0",
29
- "@kuralle-syrinx/ws": "4.2.0"
28
+ "@kuralle-syrinx/core": "4.4.0",
29
+ "@kuralle-syrinx/ws": "4.4.0"
30
30
  },
31
31
  "devDependencies": {
32
32
  "typescript": "^5.7.0",
33
- "vitest": "^2.1.0"
33
+ "vitest": "^3.2.6"
34
34
  },
35
35
  "scripts": {
36
36
  "typecheck": "tsc --noEmit",
@@ -1,330 +0,0 @@
1
- // SPDX-License-Identifier: MIT
2
-
3
- import { describe, expect, it } from "vitest";
4
- import { Route, type AudioFormat } from "@kuralle-syrinx/core";
5
- import type { SocketData } from "@kuralle-syrinx/ws";
6
- import { createTtsEngine } from "./engine.js";
7
- import { attributionKey, type AttributionKey, type TimerHandle, type TimerPort, type Transport, type WireEvent, type WireProtocol } from "./types.js";
8
-
9
- const FORMAT: AudioFormat = { encoding: "pcm_s16le", sampleRateHz: 24000, channels: 1 };
10
- const SESSION = attributionKey("session");
11
-
12
- class FakeTimer implements TimerPort {
13
- private readonly timers = new Map<number, { due: number; fn: () => void }>();
14
- private next = 0;
15
- private time = 0;
16
- set(ms: number, fn: () => void): TimerHandle {
17
- const handle = this.next++;
18
- this.timers.set(handle, { due: this.time + ms, fn });
19
- return handle;
20
- }
21
- clear(handle: TimerHandle): void {
22
- this.timers.delete(handle as number);
23
- }
24
- /** Advance the fake clock; fire any timers whose due time is now reached. */
25
- advance(ms: number): void {
26
- this.time += ms;
27
- this.fireDue();
28
- }
29
- /** Fire every pending timer immediately (legacy tests). */
30
- fire(): void {
31
- for (const [handle, entry] of [...this.timers]) {
32
- this.timers.delete(handle);
33
- entry.fn();
34
- }
35
- }
36
- private fireDue(): void {
37
- let progressed = true;
38
- while (progressed) {
39
- progressed = false;
40
- const due = [...this.timers.entries()]
41
- .filter(([, e]) => e.due <= this.time)
42
- .sort((a, b) => a[1].due - b[1].due);
43
- for (const [handle, entry] of due) {
44
- if (!this.timers.has(handle)) continue;
45
- this.timers.delete(handle);
46
- entry.fn();
47
- progressed = true;
48
- }
49
- }
50
- }
51
- }
52
-
53
- /** grok-shape: one constant key per session. */
54
- class SingleProtocol implements WireProtocol {
55
- attributionFor(contextId: string) {
56
- return { key: SESSION, contextId };
57
- }
58
- encodeText(_key: AttributionKey, text: string): SocketData[] {
59
- return [JSON.stringify({ op: "text", text })];
60
- }
61
- encodeFinish(): SocketData[] {
62
- return [JSON.stringify({ op: "finish" })];
63
- }
64
- encodeCancel(): SocketData[] {
65
- return [JSON.stringify({ op: "clear" })];
66
- }
67
- encodeClose(): SocketData[] {
68
- return [];
69
- }
70
- decode(data: SocketData): WireEvent[] {
71
- return decodeTestFrame(data, SESSION);
72
- }
73
- }
74
-
75
- /** epsilon-shape: per-request key `${ctx}:${seq}`, refcount-driven end (no finish frame). */
76
- class MultiplexProtocol implements WireProtocol {
77
- private readonly seq = new Map<string, number>();
78
- attributionFor(contextId: string) {
79
- const n = this.seq.get(contextId) ?? 0;
80
- this.seq.set(contextId, n + 1);
81
- return { key: attributionKey(`${contextId}:${n}`), contextId };
82
- }
83
- encodeText(key: AttributionKey, text: string): SocketData[] {
84
- return [JSON.stringify({ op: "speak", key, text })];
85
- }
86
- encodeFinish(): SocketData[] {
87
- return [];
88
- }
89
- encodeCancel(key: AttributionKey): SocketData[] {
90
- return [JSON.stringify({ op: "cancel", key })];
91
- }
92
- encodeClose(): SocketData[] {
93
- return [JSON.stringify({ op: "eos" })];
94
- }
95
- decode(data: SocketData): WireEvent[] {
96
- return decodeTestFrame(data, null);
97
- }
98
- }
99
-
100
- function decodeTestFrame(data: SocketData, fixedKey: AttributionKey | null): WireEvent[] {
101
- const m = JSON.parse(data as string) as { t?: string; key?: string; pcm?: number[]; msg?: string };
102
- const key = (m.key !== undefined ? attributionKey(m.key) : fixedKey) ?? SESSION;
103
- switch (m.t) {
104
- case "audio":
105
- return [{ type: "audio", key, pcm: Uint8Array.from(m.pcm ?? []) }];
106
- case "done":
107
- return [{ type: "utterance_end", key }];
108
- case "end":
109
- return [{ type: "context_end", key }];
110
- case "err":
111
- return [{ type: "error", key: m.key !== undefined ? key : null, error: new Error(m.msg ?? "err") }];
112
- case "boom":
113
- throw new Error("decode failure");
114
- default:
115
- return [];
116
- }
117
- }
118
-
119
- function harness(protocol: WireProtocol, finishTimeoutMs = 2000, opts: { sendThrows?: boolean } = {}) {
120
- const sent: SocketData[] = [];
121
- const pushed: Array<{ route: Route; packet: Record<string, unknown> }> = [];
122
- const timer = new FakeTimer();
123
- const transport: Transport = {
124
- ensureReady: async () => {},
125
- send: (frame) => {
126
- if (opts.sendThrows) throw new Error("WebSocket is not open");
127
- sent.push(frame);
128
- },
129
- close: async () => {},
130
- };
131
- const engine = createTtsEngine({
132
- protocol,
133
- transport,
134
- sink: { push: (route, packet) => pushed.push({ route, packet: packet as Record<string, unknown> }) },
135
- format: FORMAT,
136
- sampleRateHz: 24000,
137
- provider: { name: "fake", model: "m" },
138
- finishTimeoutMs,
139
- metricPrefix: "tts.fake",
140
- timer,
141
- now: () => 1000,
142
- });
143
- const byKind = (kind: string) => pushed.filter((p) => p.packet["kind"] === kind).map((p) => p.packet);
144
- return { engine, sent, pushed, timer, audio: () => byKind("tts.audio"), ends: () => byKind("tts.end"), errors: () => byKind("tts.error"), metrics: () => byKind("metric.conversation") };
145
- }
146
-
147
- describe("TtsEngine — single-context (grok-shape)", () => {
148
- it("carries an odd trailing byte across frames, then emits aligned PCM and ends on utterance_end", async () => {
149
- const h = harness(new SingleProtocol());
150
- await h.engine.onText("hello", "ctx1");
151
- expect(h.sent).toEqual([JSON.stringify({ op: "text", text: "hello" })]);
152
-
153
- h.engine.onMessage(JSON.stringify({ t: "audio", key: "session", pcm: [1] }), false);
154
- expect(h.audio()).toHaveLength(0); // 1 byte → odd, fully carried, nothing aligned yet
155
-
156
- h.engine.onMessage(JSON.stringify({ t: "audio", key: "session", pcm: [2, 3] }), false);
157
- expect(h.audio()).toHaveLength(1); // [1] + [2,3] = [1,2,3] → emit [1,2], carry [3]
158
- expect([...(h.audio()[0]!["audio"] as Uint8Array)]).toEqual([1, 2]);
159
-
160
- h.engine.onMessage(JSON.stringify({ t: "audio", key: "session", pcm: [4] }), false);
161
- expect(h.audio()).toHaveLength(2); // [3] + [4] = [3,4] → emit [3,4]
162
- expect([...(h.audio()[1]!["audio"] as Uint8Array)]).toEqual([3, 4]);
163
-
164
- await h.engine.onDone("ctx1");
165
- expect(h.sent).toContainEqual(JSON.stringify({ op: "finish" }));
166
- expect(h.ends()).toHaveLength(0); // still streaming
167
- h.engine.onMessage(JSON.stringify({ t: "done", key: "session" }), false);
168
- expect(h.ends()).toHaveLength(1);
169
- });
170
-
171
- it("emits tts.end immediately on a provider context_end, with no prior tts.done", async () => {
172
- const h = harness(new SingleProtocol());
173
- await h.engine.onText("a", "ctxCE");
174
- h.engine.onMessage(JSON.stringify({ t: "end", key: "session" }), false);
175
- expect(h.ends()).toHaveLength(1); // single-stream providers end on the provider's own done signal
176
- });
177
-
178
- it("drops audio after interrupt and sends the cancel frame", async () => {
179
- const h = harness(new SingleProtocol());
180
- await h.engine.onText("a", "ctxC");
181
- await h.engine.onInterrupt();
182
- expect(h.sent).toContainEqual(JSON.stringify({ op: "clear" }));
183
- h.engine.onMessage(JSON.stringify({ t: "audio", key: "session", pcm: [1, 2] }), false);
184
- expect(h.audio()).toHaveLength(0);
185
- });
186
- });
187
-
188
- describe("TtsEngine — multiplex (epsilon-shape)", () => {
189
- it("emits tts.end only after every per-request key for a context completes", async () => {
190
- const h = harness(new MultiplexProtocol());
191
- await h.engine.onText("a", "ctxM"); // ctxM:0
192
- await h.engine.onText("b", "ctxM"); // ctxM:1
193
- expect(h.sent).toHaveLength(2);
194
-
195
- await h.engine.onDone("ctxM"); // no finish frame; both requests still active
196
- expect(h.ends()).toHaveLength(0);
197
- h.engine.onMessage(JSON.stringify({ t: "done", key: "ctxM:0" }), false);
198
- expect(h.ends()).toHaveLength(0); // one still streaming
199
- h.engine.onMessage(JSON.stringify({ t: "done", key: "ctxM:1" }), false);
200
- expect(h.ends()).toHaveLength(1); // refcount hit zero
201
- });
202
-
203
- it("attributes audio per request key independently", async () => {
204
- const h = harness(new MultiplexProtocol());
205
- await h.engine.onText("a", "ctxM"); // ctxM:0
206
- await h.engine.onText("b", "ctxM"); // ctxM:1
207
- h.engine.onMessage(JSON.stringify({ t: "audio", key: "ctxM:0", pcm: [1, 2] }), false);
208
- h.engine.onMessage(JSON.stringify({ t: "audio", key: "ctxM:1", pcm: [3, 4] }), false);
209
- const audio = h.audio();
210
- expect(audio).toHaveLength(2);
211
- expect(audio.every((p) => p["contextId"] === "ctxM")).toBe(true);
212
- });
213
- });
214
-
215
- describe("TtsEngine — finish-timeout inactivity watchdog", () => {
216
- const FINISH_MS = 2000;
217
- const EPS = 500; // advance finishTimeoutMs - ε between audio frames
218
-
219
- it("does not end while audio keeps arriving after flush (even past finishTimeoutMs total)", async () => {
220
- const h = harness(new SingleProtocol(), FINISH_MS);
221
- await h.engine.onText("long turn", "ctxStream");
222
- await h.engine.onDone("ctxStream");
223
- expect(h.ends()).toHaveLength(0);
224
-
225
- // Three chunks spaced (FINISH_MS - ε) apart → wall time > FINISH_MS, but never silent that long.
226
- const chunks = [
227
- [1, 2],
228
- [3, 4],
229
- [5, 6],
230
- ] as const;
231
- for (let i = 0; i < chunks.length; i++) {
232
- if (i > 0) h.timer.advance(FINISH_MS - EPS);
233
- h.engine.onMessage(
234
- JSON.stringify({ t: "audio", key: "session", pcm: [...chunks[i]!] }),
235
- false,
236
- );
237
- expect(h.ends()).toHaveLength(0);
238
- }
239
-
240
- expect(h.audio()).toHaveLength(3);
241
- expect([...(h.audio()[0]!["audio"] as Uint8Array)]).toEqual([1, 2]);
242
- expect([...(h.audio()[1]!["audio"] as Uint8Array)]).toEqual([3, 4]);
243
- expect([...(h.audio()[2]!["audio"] as Uint8Array)]).toEqual([5, 6]);
244
- expect(h.metrics().some((m) => m["name"] === "tts.fake.finish_timeout")).toBe(false);
245
-
246
- // Natural provider end still closes the turn.
247
- h.engine.onMessage(JSON.stringify({ t: "done", key: "session" }), false);
248
- expect(h.ends()).toHaveLength(1);
249
- });
250
-
251
- it("still fires finish-timeout after finishTimeoutMs of silence post-flush (wedged provider)", async () => {
252
- const h = harness(new SingleProtocol(), FINISH_MS);
253
- await h.engine.onText("a", "ctxWedge");
254
- await h.engine.onDone("ctxWedge");
255
- expect(h.ends()).toHaveLength(0);
256
-
257
- h.timer.advance(FINISH_MS - 1);
258
- expect(h.ends()).toHaveLength(0);
259
-
260
- h.timer.advance(1);
261
- expect(h.ends()).toHaveLength(1);
262
- expect(h.metrics().some((m) => m["name"] === "tts.fake.finish_timeout")).toBe(true);
263
- });
264
-
265
- it("ends immediately on natural context_end even if finish-timeout is armed", async () => {
266
- const h = harness(new SingleProtocol(), FINISH_MS);
267
- await h.engine.onText("a", "ctxNatural");
268
- await h.engine.onDone("ctxNatural");
269
- expect(h.ends()).toHaveLength(0);
270
-
271
- h.engine.onMessage(JSON.stringify({ t: "end", key: "session" }), false);
272
- expect(h.ends()).toHaveLength(1);
273
- // Armed timer must not emit a second end or a finish_timeout metric.
274
- h.timer.advance(FINISH_MS * 2);
275
- expect(h.ends()).toHaveLength(1);
276
- expect(h.metrics().some((m) => m["name"] === "tts.fake.finish_timeout")).toBe(false);
277
- });
278
- });
279
-
280
- describe("TtsEngine — fallbacks and failures", () => {
281
- it("fires the finish-timeout: emits a metric and tts.end when the provider never reports done", async () => {
282
- const h = harness(new SingleProtocol(), 2000);
283
- await h.engine.onText("a", "ctxD");
284
- await h.engine.onDone("ctxD");
285
- expect(h.ends()).toHaveLength(0);
286
- h.timer.fire();
287
- expect(h.ends()).toHaveLength(1);
288
- expect(h.metrics().some((m) => m["name"] === "tts.fake.finish_timeout")).toBe(true);
289
- });
290
-
291
- it("maps a provider error frame to a categorized tts.error", async () => {
292
- const h = harness(new SingleProtocol());
293
- await h.engine.onText("a", "ctxE");
294
- h.engine.onMessage(JSON.stringify({ t: "err", key: "session", msg: "provider boom" }), false);
295
- const errors = h.errors();
296
- expect(errors).toHaveLength(1);
297
- expect((errors[0]!["cause"] as Error).message).toBe("provider boom");
298
- expect(typeof errors[0]!["isRecoverable"]).toBe("boolean");
299
- });
300
-
301
- it("treats a decode throw as fatal and fails all active contexts", async () => {
302
- const h = harness(new SingleProtocol());
303
- await h.engine.onText("a", "ctxF");
304
- h.engine.onMessage(JSON.stringify({ t: "boom" }), false);
305
- expect(h.errors().some((e) => e["contextId"] === "ctxF")).toBe(true);
306
- });
307
-
308
- it("emits tts.error and does not leave the context active when a send fails", async () => {
309
- const h = harness(new SingleProtocol(), 2000, { sendThrows: true });
310
- await h.engine.onText("a", "ctxSend");
311
- expect(h.errors().some((e) => e["contextId"] === "ctxSend")).toBe(true); // send failure → typed error
312
- await h.engine.onDone("ctxSend");
313
- expect(h.ends().some((e) => e["contextId"] === "ctxSend")).toBe(true); // context drained, turn ends (no hang)
314
- });
315
-
316
- it("fails active contexts on connection loss", async () => {
317
- const h = harness(new MultiplexProtocol());
318
- await h.engine.onText("a", "ctxG");
319
- h.engine.onConnectionLost(new Error("dropped"));
320
- expect(h.errors().some((e) => e["contextId"] === "ctxG")).toBe(true);
321
- });
322
-
323
- it("does not error a cancelled context when the connection then drops (barge-in race)", async () => {
324
- const h = harness(new MultiplexProtocol());
325
- await h.engine.onText("a", "ctxCancelled");
326
- await h.engine.onInterrupt(); // cancel sent; key still tracked pending the provider ack
327
- h.engine.onConnectionLost(new Error("dropped"));
328
- expect(h.errors().some((e) => e["contextId"] === "ctxCancelled")).toBe(false);
329
- });
330
- });
package/tsconfig.json DELETED
@@ -1,13 +0,0 @@
1
- {
2
- "compilerOptions": {
3
- "target": "ES2022",
4
- "module": "NodeNext",
5
- "moduleResolution": "NodeNext",
6
- "strict": true,
7
- "esModuleInterop": true,
8
- "skipLibCheck": true,
9
- "types": ["vitest/globals"],
10
- "noEmit": true
11
- },
12
- "include": ["src/**/*.ts"]
13
- }