@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 +23 -5
- package/src/engine.ts +7 -0
- package/src/engine.test.ts +0 -243
- package/tsconfig.json +0 -13
package/package.json
CHANGED
|
@@ -1,18 +1,36 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kuralle-syrinx/tts-core",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.3.0",
|
|
4
4
|
"private": false,
|
|
5
|
-
"
|
|
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.
|
|
11
|
-
"@kuralle-syrinx/ws": "4.
|
|
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.
|
|
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);
|
package/src/engine.test.ts
DELETED
|
@@ -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
|
-
}
|