@kuralle-syrinx/tts-core 4.1.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,18 +1,36 @@
1
1
  {
2
2
  "name": "@kuralle-syrinx/tts-core",
3
- "version": "4.1.0",
3
+ "version": "4.3.0",
4
4
  "private": false,
5
- "type": "module",
5
+ "description": "Shared streaming-TTS lifecycle engine for Syrinx TTS adapters — carry, refcount, finish-timeout, cancel",
6
+ "keywords": [
7
+ "voice",
8
+ "voice-agent",
9
+ "speech",
10
+ "syrinx",
11
+ "text-to-speech",
12
+ "streaming"
13
+ ],
6
14
  "license": "MIT",
15
+ "homepage": "https://github.com/kuralle/syrinx#readme",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/kuralle/syrinx.git",
19
+ "directory": "packages/tts-core"
20
+ },
21
+ "bugs": {
22
+ "url": "https://github.com/kuralle/syrinx/issues"
23
+ },
24
+ "type": "module",
7
25
  "main": "./src/index.ts",
8
26
  "types": "./src/index.ts",
9
27
  "dependencies": {
10
- "@kuralle-syrinx/core": "4.1.0",
11
- "@kuralle-syrinx/ws": "4.1.0"
28
+ "@kuralle-syrinx/core": "4.3.0",
29
+ "@kuralle-syrinx/ws": "4.3.0"
12
30
  },
13
31
  "devDependencies": {
14
32
  "typescript": "^5.7.0",
15
- "vitest": "^2.1.0"
33
+ "vitest": "^3.2.6"
16
34
  },
17
35
  "scripts": {
18
36
  "typecheck": "tsc --noEmit",
package/src/engine.ts CHANGED
@@ -191,6 +191,13 @@ class TtsEngineImpl implements TtsEngine {
191
191
  if (contextId === undefined || this.cancelledContexts.has(contextId)) return;
192
192
  if (pcm.byteLength === 0) return;
193
193
 
194
+ // After flush, finish-timeout is an inactivity watchdog: keep it armed only
195
+ // while the provider is silent. Active audio must reschedule so long turns
196
+ // (e.g. half-cascade dumping full text then tts.done early) are not cut mid-stream.
197
+ if (this.pendingEnd.has(contextId) && this.deps.finishTimeoutMs > 0) {
198
+ this.scheduleFinishTimeout(contextId);
199
+ }
200
+
194
201
  const prev = this.carry.get(key) ?? EMPTY;
195
202
  const buf = prev.byteLength === 0 ? pcm : concatBytes(prev, pcm);
196
203
  const evenLen = buf.byteLength - (buf.byteLength % 2);
@@ -1,243 +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, () => void>();
14
- private next = 0;
15
- set(_ms: number, fn: () => void): TimerHandle {
16
- const handle = this.next++;
17
- this.timers.set(handle, fn);
18
- return handle;
19
- }
20
- clear(handle: TimerHandle): void {
21
- this.timers.delete(handle as number);
22
- }
23
- fire(): void {
24
- for (const [handle, fn] of [...this.timers]) {
25
- this.timers.delete(handle);
26
- fn();
27
- }
28
- }
29
- }
30
-
31
- /** grok-shape: one constant key per session. */
32
- class SingleProtocol implements WireProtocol {
33
- attributionFor(contextId: string) {
34
- return { key: SESSION, contextId };
35
- }
36
- encodeText(_key: AttributionKey, text: string): SocketData[] {
37
- return [JSON.stringify({ op: "text", text })];
38
- }
39
- encodeFinish(): SocketData[] {
40
- return [JSON.stringify({ op: "finish" })];
41
- }
42
- encodeCancel(): SocketData[] {
43
- return [JSON.stringify({ op: "clear" })];
44
- }
45
- encodeClose(): SocketData[] {
46
- return [];
47
- }
48
- decode(data: SocketData): WireEvent[] {
49
- return decodeTestFrame(data, SESSION);
50
- }
51
- }
52
-
53
- /** epsilon-shape: per-request key `${ctx}:${seq}`, refcount-driven end (no finish frame). */
54
- class MultiplexProtocol implements WireProtocol {
55
- private readonly seq = new Map<string, number>();
56
- attributionFor(contextId: string) {
57
- const n = this.seq.get(contextId) ?? 0;
58
- this.seq.set(contextId, n + 1);
59
- return { key: attributionKey(`${contextId}:${n}`), contextId };
60
- }
61
- encodeText(key: AttributionKey, text: string): SocketData[] {
62
- return [JSON.stringify({ op: "speak", key, text })];
63
- }
64
- encodeFinish(): SocketData[] {
65
- return [];
66
- }
67
- encodeCancel(key: AttributionKey): SocketData[] {
68
- return [JSON.stringify({ op: "cancel", key })];
69
- }
70
- encodeClose(): SocketData[] {
71
- return [JSON.stringify({ op: "eos" })];
72
- }
73
- decode(data: SocketData): WireEvent[] {
74
- return decodeTestFrame(data, null);
75
- }
76
- }
77
-
78
- function decodeTestFrame(data: SocketData, fixedKey: AttributionKey | null): WireEvent[] {
79
- const m = JSON.parse(data as string) as { t?: string; key?: string; pcm?: number[]; msg?: string };
80
- const key = (m.key !== undefined ? attributionKey(m.key) : fixedKey) ?? SESSION;
81
- switch (m.t) {
82
- case "audio":
83
- return [{ type: "audio", key, pcm: Uint8Array.from(m.pcm ?? []) }];
84
- case "done":
85
- return [{ type: "utterance_end", key }];
86
- case "end":
87
- return [{ type: "context_end", key }];
88
- case "err":
89
- return [{ type: "error", key: m.key !== undefined ? key : null, error: new Error(m.msg ?? "err") }];
90
- case "boom":
91
- throw new Error("decode failure");
92
- default:
93
- return [];
94
- }
95
- }
96
-
97
- function harness(protocol: WireProtocol, finishTimeoutMs = 2000, opts: { sendThrows?: boolean } = {}) {
98
- const sent: SocketData[] = [];
99
- const pushed: Array<{ route: Route; packet: Record<string, unknown> }> = [];
100
- const timer = new FakeTimer();
101
- const transport: Transport = {
102
- ensureReady: async () => {},
103
- send: (frame) => {
104
- if (opts.sendThrows) throw new Error("WebSocket is not open");
105
- sent.push(frame);
106
- },
107
- close: async () => {},
108
- };
109
- const engine = createTtsEngine({
110
- protocol,
111
- transport,
112
- sink: { push: (route, packet) => pushed.push({ route, packet: packet as Record<string, unknown> }) },
113
- format: FORMAT,
114
- sampleRateHz: 24000,
115
- provider: { name: "fake", model: "m" },
116
- finishTimeoutMs,
117
- metricPrefix: "tts.fake",
118
- timer,
119
- now: () => 1000,
120
- });
121
- const byKind = (kind: string) => pushed.filter((p) => p.packet["kind"] === kind).map((p) => p.packet);
122
- return { engine, sent, pushed, timer, audio: () => byKind("tts.audio"), ends: () => byKind("tts.end"), errors: () => byKind("tts.error"), metrics: () => byKind("metric.conversation") };
123
- }
124
-
125
- describe("TtsEngine — single-context (grok-shape)", () => {
126
- it("carries an odd trailing byte across frames, then emits aligned PCM and ends on utterance_end", async () => {
127
- const h = harness(new SingleProtocol());
128
- await h.engine.onText("hello", "ctx1");
129
- expect(h.sent).toEqual([JSON.stringify({ op: "text", text: "hello" })]);
130
-
131
- h.engine.onMessage(JSON.stringify({ t: "audio", key: "session", pcm: [1] }), false);
132
- expect(h.audio()).toHaveLength(0); // 1 byte → odd, fully carried, nothing aligned yet
133
-
134
- h.engine.onMessage(JSON.stringify({ t: "audio", key: "session", pcm: [2, 3] }), false);
135
- expect(h.audio()).toHaveLength(1); // [1] + [2,3] = [1,2,3] → emit [1,2], carry [3]
136
- expect([...(h.audio()[0]!["audio"] as Uint8Array)]).toEqual([1, 2]);
137
-
138
- h.engine.onMessage(JSON.stringify({ t: "audio", key: "session", pcm: [4] }), false);
139
- expect(h.audio()).toHaveLength(2); // [3] + [4] = [3,4] → emit [3,4]
140
- expect([...(h.audio()[1]!["audio"] as Uint8Array)]).toEqual([3, 4]);
141
-
142
- await h.engine.onDone("ctx1");
143
- expect(h.sent).toContainEqual(JSON.stringify({ op: "finish" }));
144
- expect(h.ends()).toHaveLength(0); // still streaming
145
- h.engine.onMessage(JSON.stringify({ t: "done", key: "session" }), false);
146
- expect(h.ends()).toHaveLength(1);
147
- });
148
-
149
- it("emits tts.end immediately on a provider context_end, with no prior tts.done", async () => {
150
- const h = harness(new SingleProtocol());
151
- await h.engine.onText("a", "ctxCE");
152
- h.engine.onMessage(JSON.stringify({ t: "end", key: "session" }), false);
153
- expect(h.ends()).toHaveLength(1); // single-stream providers end on the provider's own done signal
154
- });
155
-
156
- it("drops audio after interrupt and sends the cancel frame", async () => {
157
- const h = harness(new SingleProtocol());
158
- await h.engine.onText("a", "ctxC");
159
- await h.engine.onInterrupt();
160
- expect(h.sent).toContainEqual(JSON.stringify({ op: "clear" }));
161
- h.engine.onMessage(JSON.stringify({ t: "audio", key: "session", pcm: [1, 2] }), false);
162
- expect(h.audio()).toHaveLength(0);
163
- });
164
- });
165
-
166
- describe("TtsEngine — multiplex (epsilon-shape)", () => {
167
- it("emits tts.end only after every per-request key for a context completes", async () => {
168
- const h = harness(new MultiplexProtocol());
169
- await h.engine.onText("a", "ctxM"); // ctxM:0
170
- await h.engine.onText("b", "ctxM"); // ctxM:1
171
- expect(h.sent).toHaveLength(2);
172
-
173
- await h.engine.onDone("ctxM"); // no finish frame; both requests still active
174
- expect(h.ends()).toHaveLength(0);
175
- h.engine.onMessage(JSON.stringify({ t: "done", key: "ctxM:0" }), false);
176
- expect(h.ends()).toHaveLength(0); // one still streaming
177
- h.engine.onMessage(JSON.stringify({ t: "done", key: "ctxM:1" }), false);
178
- expect(h.ends()).toHaveLength(1); // refcount hit zero
179
- });
180
-
181
- it("attributes audio per request key independently", async () => {
182
- const h = harness(new MultiplexProtocol());
183
- await h.engine.onText("a", "ctxM"); // ctxM:0
184
- await h.engine.onText("b", "ctxM"); // ctxM:1
185
- h.engine.onMessage(JSON.stringify({ t: "audio", key: "ctxM:0", pcm: [1, 2] }), false);
186
- h.engine.onMessage(JSON.stringify({ t: "audio", key: "ctxM:1", pcm: [3, 4] }), false);
187
- const audio = h.audio();
188
- expect(audio).toHaveLength(2);
189
- expect(audio.every((p) => p["contextId"] === "ctxM")).toBe(true);
190
- });
191
- });
192
-
193
- describe("TtsEngine — fallbacks and failures", () => {
194
- it("fires the finish-timeout: emits a metric and tts.end when the provider never reports done", async () => {
195
- const h = harness(new SingleProtocol(), 2000);
196
- await h.engine.onText("a", "ctxD");
197
- await h.engine.onDone("ctxD");
198
- expect(h.ends()).toHaveLength(0);
199
- h.timer.fire();
200
- expect(h.ends()).toHaveLength(1);
201
- expect(h.metrics().some((m) => m["name"] === "tts.fake.finish_timeout")).toBe(true);
202
- });
203
-
204
- it("maps a provider error frame to a categorized tts.error", async () => {
205
- const h = harness(new SingleProtocol());
206
- await h.engine.onText("a", "ctxE");
207
- h.engine.onMessage(JSON.stringify({ t: "err", key: "session", msg: "provider boom" }), false);
208
- const errors = h.errors();
209
- expect(errors).toHaveLength(1);
210
- expect((errors[0]!["cause"] as Error).message).toBe("provider boom");
211
- expect(typeof errors[0]!["isRecoverable"]).toBe("boolean");
212
- });
213
-
214
- it("treats a decode throw as fatal and fails all active contexts", async () => {
215
- const h = harness(new SingleProtocol());
216
- await h.engine.onText("a", "ctxF");
217
- h.engine.onMessage(JSON.stringify({ t: "boom" }), false);
218
- expect(h.errors().some((e) => e["contextId"] === "ctxF")).toBe(true);
219
- });
220
-
221
- it("emits tts.error and does not leave the context active when a send fails", async () => {
222
- const h = harness(new SingleProtocol(), 2000, { sendThrows: true });
223
- await h.engine.onText("a", "ctxSend");
224
- expect(h.errors().some((e) => e["contextId"] === "ctxSend")).toBe(true); // send failure → typed error
225
- await h.engine.onDone("ctxSend");
226
- expect(h.ends().some((e) => e["contextId"] === "ctxSend")).toBe(true); // context drained, turn ends (no hang)
227
- });
228
-
229
- it("fails active contexts on connection loss", async () => {
230
- const h = harness(new MultiplexProtocol());
231
- await h.engine.onText("a", "ctxG");
232
- h.engine.onConnectionLost(new Error("dropped"));
233
- expect(h.errors().some((e) => e["contextId"] === "ctxG")).toBe(true);
234
- });
235
-
236
- it("does not error a cancelled context when the connection then drops (barge-in race)", async () => {
237
- const h = harness(new MultiplexProtocol());
238
- await h.engine.onText("a", "ctxCancelled");
239
- await h.engine.onInterrupt(); // cancel sent; key still tracked pending the provider ack
240
- h.engine.onConnectionLost(new Error("dropped"));
241
- expect(h.errors().some((e) => e["contextId"] === "ctxCancelled")).toBe(false);
242
- });
243
- });
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
- }