@kuralle-syrinx/tts-core 3.0.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/LICENSE +22 -0
- package/package.json +21 -0
- package/src/engine.test.ts +243 -0
- package/src/engine.ts +410 -0
- package/src/index.ts +19 -0
- package/src/plugin.ts +109 -0
- package/src/types.ts +91 -0
- package/tsconfig.json +13 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kuralle
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|
package/package.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@kuralle-syrinx/tts-core",
|
|
3
|
+
"version": "3.0.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"main": "./src/index.ts",
|
|
8
|
+
"types": "./src/index.ts",
|
|
9
|
+
"dependencies": {
|
|
10
|
+
"@kuralle-syrinx/core": "3.0.0",
|
|
11
|
+
"@kuralle-syrinx/ws": "3.0.0"
|
|
12
|
+
},
|
|
13
|
+
"devDependencies": {
|
|
14
|
+
"typescript": "^5.7.0",
|
|
15
|
+
"vitest": "^2.1.0"
|
|
16
|
+
},
|
|
17
|
+
"scripts": {
|
|
18
|
+
"typecheck": "tsc --noEmit",
|
|
19
|
+
"test": "vitest run"
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
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/src/engine.ts
ADDED
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
//
|
|
3
|
+
// The streaming-TTS deep module. Owns the entire lifecycle that the cartesia/grok/epsilon
|
|
4
|
+
// plugins each used to duplicate: per-key PCM16 carry, active/cancelled/pending-end
|
|
5
|
+
// bookkeeping keyed on the provider's opaque attribution key, finish-timeout, error
|
|
6
|
+
// categorization, packet emission, and connection-loss failure. Socket-free: depends only
|
|
7
|
+
// on the injected ports (`WireProtocol`, `Transport`, `PacketSink`, `TimerPort`), so the
|
|
8
|
+
// tricky behavior (odd-byte carry, multiplex refcount, cancel races, finish-timeout) is
|
|
9
|
+
// unit-testable without a real WebSocket.
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
Route,
|
|
13
|
+
assertAudioPayload,
|
|
14
|
+
categorizeTtsError,
|
|
15
|
+
isRecoverable,
|
|
16
|
+
type AudioFormat,
|
|
17
|
+
type TextToSpeechAudioPacket,
|
|
18
|
+
type TextToSpeechEndPacket,
|
|
19
|
+
type TtsErrorPacket,
|
|
20
|
+
} from "@kuralle-syrinx/core";
|
|
21
|
+
import type { SocketData } from "@kuralle-syrinx/ws";
|
|
22
|
+
|
|
23
|
+
import type { AttributionKey, PacketSink, TimerHandle, TimerPort, Transport, WireEvent, WireProtocol } from "./types.js";
|
|
24
|
+
|
|
25
|
+
const EMPTY = new Uint8Array(0);
|
|
26
|
+
|
|
27
|
+
const defaultTimer: TimerPort = {
|
|
28
|
+
set: (ms, fn) => setTimeout(fn, ms),
|
|
29
|
+
clear: (handle) => {
|
|
30
|
+
clearTimeout(handle as ReturnType<typeof setTimeout>);
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export interface TtsEngineDeps {
|
|
35
|
+
readonly protocol: WireProtocol;
|
|
36
|
+
readonly transport: Transport;
|
|
37
|
+
readonly sink: PacketSink;
|
|
38
|
+
readonly format: AudioFormat;
|
|
39
|
+
readonly sampleRateHz: number;
|
|
40
|
+
readonly provider: { readonly name: string; readonly model: string; readonly region?: string };
|
|
41
|
+
/** 0 disables the finish-timeout fallback. */
|
|
42
|
+
readonly finishTimeoutMs: number;
|
|
43
|
+
/** Namespace for engine-emitted metrics, e.g. "tts.epsilon". */
|
|
44
|
+
readonly metricPrefix: string;
|
|
45
|
+
readonly timer?: TimerPort;
|
|
46
|
+
readonly now?: () => number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface TtsEngine {
|
|
50
|
+
onText(text: string, contextId: string): Promise<void>;
|
|
51
|
+
onDone(contextId: string): Promise<void>;
|
|
52
|
+
onInterrupt(): Promise<void>;
|
|
53
|
+
onMessage(data: SocketData, isBinary: boolean): void;
|
|
54
|
+
onConnectionLost(error: Error): void;
|
|
55
|
+
close(): Promise<void>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function createTtsEngine(deps: TtsEngineDeps): TtsEngine {
|
|
59
|
+
return new TtsEngineImpl(deps);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
class TtsEngineImpl implements TtsEngine {
|
|
63
|
+
private readonly keyToContext = new Map<AttributionKey, string>();
|
|
64
|
+
private readonly contextKeys = new Map<string, Set<AttributionKey>>();
|
|
65
|
+
private readonly cancelledContexts = new Set<string>();
|
|
66
|
+
private readonly carry = new Map<AttributionKey, Uint8Array>();
|
|
67
|
+
private readonly pendingEnd = new Set<string>();
|
|
68
|
+
private readonly finishTimers = new Map<string, TimerHandle>();
|
|
69
|
+
private readonly timer: TimerPort;
|
|
70
|
+
private readonly now: () => number;
|
|
71
|
+
|
|
72
|
+
constructor(private readonly deps: TtsEngineDeps) {
|
|
73
|
+
this.timer = deps.timer ?? defaultTimer;
|
|
74
|
+
this.now = deps.now ?? (() => Date.now());
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async onText(text: string, contextId: string): Promise<void> {
|
|
78
|
+
if (!text.trim()) return;
|
|
79
|
+
if (this.cancelledContexts.has(contextId)) return;
|
|
80
|
+
const { key } = this.deps.protocol.attributionFor(contextId);
|
|
81
|
+
this.track(key, contextId);
|
|
82
|
+
const frames = this.deps.protocol.encodeText(key, text);
|
|
83
|
+
if (frames.length === 0) {
|
|
84
|
+
this.untrack(key);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
for (const frame of frames) {
|
|
88
|
+
if (!(await this.trySend(frame, contextId))) {
|
|
89
|
+
this.untrack(key);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async onDone(contextId: string): Promise<void> {
|
|
96
|
+
if (this.cancelledContexts.has(contextId)) return;
|
|
97
|
+
const activeKeys = [...(this.contextKeys.get(contextId) ?? [])];
|
|
98
|
+
for (const frame of this.deps.protocol.encodeFinish(contextId, activeKeys)) {
|
|
99
|
+
await this.trySend(frame, contextId);
|
|
100
|
+
}
|
|
101
|
+
this.pendingEnd.add(contextId);
|
|
102
|
+
if (this.deps.finishTimeoutMs > 0 && this.hasActiveKeys(contextId)) {
|
|
103
|
+
this.scheduleFinishTimeout(contextId);
|
|
104
|
+
}
|
|
105
|
+
this.tryEmitEnd(contextId);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async onInterrupt(): Promise<void> {
|
|
109
|
+
const contexts = new Set<string>([...this.contextKeys.keys(), ...this.pendingEnd]);
|
|
110
|
+
for (const contextId of contexts) await this.cancelContext(contextId);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
onMessage(data: SocketData, isBinary: boolean): void {
|
|
114
|
+
let events: readonly WireEvent[];
|
|
115
|
+
try {
|
|
116
|
+
events = this.deps.protocol.decode(data, isBinary);
|
|
117
|
+
} catch (err) {
|
|
118
|
+
this.failAll(err instanceof Error ? err : new Error(String(err)));
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
for (const event of events) this.dispatch(event);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
private dispatch(event: WireEvent): void {
|
|
125
|
+
switch (event.type) {
|
|
126
|
+
case "audio":
|
|
127
|
+
this.handleAudio(event.key, event.pcm);
|
|
128
|
+
return;
|
|
129
|
+
case "utterance_end":
|
|
130
|
+
this.handleUtteranceEnd(event.key);
|
|
131
|
+
return;
|
|
132
|
+
case "context_end":
|
|
133
|
+
this.handleContextEnd(event.key);
|
|
134
|
+
return;
|
|
135
|
+
case "cancelled":
|
|
136
|
+
this.handleCancelled(event.key);
|
|
137
|
+
return;
|
|
138
|
+
case "error":
|
|
139
|
+
this.handleError(event.key, event.error, event.endsContext ?? false);
|
|
140
|
+
return;
|
|
141
|
+
case "sideband": {
|
|
142
|
+
const contextId = this.keyToContext.get(event.key);
|
|
143
|
+
if (contextId === undefined) return;
|
|
144
|
+
this.deps.sink.push(event.route, event.build(contextId, this.now()));
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
case "ignore":
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
onConnectionLost(error: Error): void {
|
|
153
|
+
this.failAll(error);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async close(): Promise<void> {
|
|
157
|
+
this.pendingEnd.clear();
|
|
158
|
+
this.cancelledContexts.clear();
|
|
159
|
+
this.keyToContext.clear();
|
|
160
|
+
this.contextKeys.clear();
|
|
161
|
+
this.carry.clear();
|
|
162
|
+
for (const handle of this.finishTimers.values()) this.timer.clear(handle);
|
|
163
|
+
this.finishTimers.clear();
|
|
164
|
+
try {
|
|
165
|
+
for (const frame of this.deps.protocol.encodeClose()) {
|
|
166
|
+
await this.deps.transport.ensureReady();
|
|
167
|
+
this.deps.transport.send(frame);
|
|
168
|
+
}
|
|
169
|
+
} catch {
|
|
170
|
+
// Best-effort session shutdown.
|
|
171
|
+
}
|
|
172
|
+
await this.deps.transport.close();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ── inbound handling ──────────────────────────────────────────────────────
|
|
176
|
+
|
|
177
|
+
private handleAudio(key: AttributionKey, pcm: Uint8Array): void {
|
|
178
|
+
const contextId = this.keyToContext.get(key);
|
|
179
|
+
if (contextId === undefined || this.cancelledContexts.has(contextId)) return;
|
|
180
|
+
if (pcm.byteLength === 0) return;
|
|
181
|
+
|
|
182
|
+
const prev = this.carry.get(key) ?? EMPTY;
|
|
183
|
+
const buf = prev.byteLength === 0 ? pcm : concatBytes(prev, pcm);
|
|
184
|
+
const evenLen = buf.byteLength - (buf.byteLength % 2);
|
|
185
|
+
if (evenLen > 0) {
|
|
186
|
+
const audio = buf.subarray(0, evenLen);
|
|
187
|
+
try {
|
|
188
|
+
assertAudioPayload(this.deps.format, audio);
|
|
189
|
+
} catch (err) {
|
|
190
|
+
this.emitError(contextId, err instanceof Error ? err : new Error(String(err)));
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
const packet: TextToSpeechAudioPacket = {
|
|
194
|
+
kind: "tts.audio",
|
|
195
|
+
contextId,
|
|
196
|
+
timestampMs: this.now(),
|
|
197
|
+
audio,
|
|
198
|
+
sampleRateHz: this.deps.sampleRateHz,
|
|
199
|
+
provider: {
|
|
200
|
+
name: this.deps.provider.name,
|
|
201
|
+
model: this.deps.provider.model,
|
|
202
|
+
region: this.deps.provider.region ?? "global",
|
|
203
|
+
cancelled: false,
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
this.deps.sink.push(Route.Main, packet);
|
|
207
|
+
}
|
|
208
|
+
this.carry.set(key, evenLen < buf.byteLength ? buf.subarray(evenLen) : EMPTY);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// One attribution unit finished. The context ends only when all its units are done AND
|
|
212
|
+
// `tts.done` has been seen (the refcount/multiplex model, e.g. epsilon).
|
|
213
|
+
private handleUtteranceEnd(key: AttributionKey): void {
|
|
214
|
+
const contextId = this.keyToContext.get(key);
|
|
215
|
+
this.untrack(key);
|
|
216
|
+
if (contextId === undefined) return;
|
|
217
|
+
if (this.cancelledContexts.has(contextId)) {
|
|
218
|
+
this.clearCancelledIfDrained(contextId);
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
this.tryEmitEnd(contextId);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// The provider declared the whole context complete — end now, regardless of `tts.done`
|
|
225
|
+
// (the single-stream model: cartesia `done:true`, grok `audio.done`).
|
|
226
|
+
private handleContextEnd(key: AttributionKey): void {
|
|
227
|
+
const contextId = this.keyToContext.get(key);
|
|
228
|
+
this.untrack(key);
|
|
229
|
+
if (contextId === undefined) return;
|
|
230
|
+
if (this.cancelledContexts.has(contextId)) {
|
|
231
|
+
this.clearCancelledIfDrained(contextId);
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
this.endContextNow(contextId);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
private handleCancelled(key: AttributionKey): void {
|
|
238
|
+
const contextId = this.keyToContext.get(key);
|
|
239
|
+
this.untrack(key);
|
|
240
|
+
if (contextId !== undefined && this.cancelledContexts.has(contextId)) this.clearCancelledIfDrained(contextId);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
private handleError(key: AttributionKey | null, error: Error, endsContext: boolean): void {
|
|
244
|
+
const contextId = key !== null ? this.keyToContext.get(key) : undefined;
|
|
245
|
+
if (key !== null) this.untrack(key);
|
|
246
|
+
if (contextId !== undefined && this.cancelledContexts.has(contextId)) {
|
|
247
|
+
this.clearCancelledIfDrained(contextId);
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
this.emitError(contextId ?? "", error);
|
|
251
|
+
if (contextId === undefined) return;
|
|
252
|
+
// A terminal error (provider coupled it with `done`) ends the context now, using the
|
|
253
|
+
// contextId captured above before untrack; otherwise end only if the refcount allows.
|
|
254
|
+
if (endsContext) this.endContextNow(contextId);
|
|
255
|
+
else this.tryEmitEnd(contextId);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Once a cancelled context fully drains, drop the cancelled flag so the id can be reused. */
|
|
259
|
+
private clearCancelledIfDrained(contextId: string): void {
|
|
260
|
+
if (!this.hasActiveKeys(contextId)) this.cancelledContexts.delete(contextId);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Emit `tts.end` for a context immediately and clear its pending/timeout state. */
|
|
264
|
+
private endContextNow(contextId: string): void {
|
|
265
|
+
this.pendingEnd.delete(contextId);
|
|
266
|
+
this.clearFinishTimeout(contextId);
|
|
267
|
+
this.emitEnd(contextId);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// ── bookkeeping ───────────────────────────────────────────────────────────
|
|
271
|
+
|
|
272
|
+
private track(key: AttributionKey, contextId: string): void {
|
|
273
|
+
this.keyToContext.set(key, contextId);
|
|
274
|
+
let keys = this.contextKeys.get(contextId);
|
|
275
|
+
if (!keys) {
|
|
276
|
+
keys = new Set();
|
|
277
|
+
this.contextKeys.set(contextId, keys);
|
|
278
|
+
}
|
|
279
|
+
keys.add(key);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
private untrack(key: AttributionKey): void {
|
|
283
|
+
const contextId = this.keyToContext.get(key);
|
|
284
|
+
this.keyToContext.delete(key);
|
|
285
|
+
this.carry.delete(key);
|
|
286
|
+
if (contextId === undefined) return;
|
|
287
|
+
const keys = this.contextKeys.get(contextId);
|
|
288
|
+
if (!keys) return;
|
|
289
|
+
keys.delete(key);
|
|
290
|
+
if (keys.size === 0) this.contextKeys.delete(contextId);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
private hasActiveKeys(contextId: string): boolean {
|
|
294
|
+
return (this.contextKeys.get(contextId)?.size ?? 0) > 0;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
private async cancelContext(contextId: string): Promise<void> {
|
|
298
|
+
this.cancelledContexts.add(contextId);
|
|
299
|
+
this.pendingEnd.delete(contextId);
|
|
300
|
+
this.clearFinishTimeout(contextId);
|
|
301
|
+
for (const key of [...(this.contextKeys.get(contextId) ?? [])]) {
|
|
302
|
+
for (const frame of this.deps.protocol.encodeCancel(key, contextId)) {
|
|
303
|
+
await this.trySend(frame, contextId);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
private tryEmitEnd(contextId: string): void {
|
|
309
|
+
if (!this.pendingEnd.has(contextId)) return;
|
|
310
|
+
if (this.hasActiveKeys(contextId)) return;
|
|
311
|
+
this.pendingEnd.delete(contextId);
|
|
312
|
+
this.clearFinishTimeout(contextId);
|
|
313
|
+
this.cancelledContexts.delete(contextId);
|
|
314
|
+
this.emitEnd(contextId);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
private failAll(error: Error): void {
|
|
318
|
+
const contexts = new Set<string>([...this.contextKeys.keys(), ...this.pendingEnd]);
|
|
319
|
+
this.pendingEnd.clear();
|
|
320
|
+
this.contextKeys.clear();
|
|
321
|
+
this.keyToContext.clear();
|
|
322
|
+
this.carry.clear();
|
|
323
|
+
let emitted = 0;
|
|
324
|
+
for (const contextId of contexts) {
|
|
325
|
+
this.clearFinishTimeout(contextId);
|
|
326
|
+
// Don't surface an error for a context the caller deliberately cancelled (e.g. a
|
|
327
|
+
// barge-in) when the socket drops before the provider acks the cancel.
|
|
328
|
+
if (this.cancelledContexts.has(contextId)) continue;
|
|
329
|
+
this.emitError(contextId, error);
|
|
330
|
+
emitted += 1;
|
|
331
|
+
}
|
|
332
|
+
this.cancelledContexts.clear();
|
|
333
|
+
if (emitted === 0 && contexts.size === 0) this.emitError("", error);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// ── timers ────────────────────────────────────────────────────────────────
|
|
337
|
+
|
|
338
|
+
private scheduleFinishTimeout(contextId: string): void {
|
|
339
|
+
this.clearFinishTimeout(contextId);
|
|
340
|
+
const handle = this.timer.set(this.deps.finishTimeoutMs, () => {
|
|
341
|
+
this.finishTimers.delete(contextId);
|
|
342
|
+
if (!this.pendingEnd.has(contextId) && !this.hasActiveKeys(contextId)) return;
|
|
343
|
+
this.emitMetric(contextId, `${this.deps.metricPrefix}.finish_timeout`, String(this.deps.finishTimeoutMs));
|
|
344
|
+
this.pendingEnd.delete(contextId);
|
|
345
|
+
for (const key of [...(this.contextKeys.get(contextId) ?? [])]) this.untrack(key);
|
|
346
|
+
this.cancelledContexts.delete(contextId);
|
|
347
|
+
this.emitEnd(contextId);
|
|
348
|
+
});
|
|
349
|
+
this.finishTimers.set(contextId, handle);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
private clearFinishTimeout(contextId: string): void {
|
|
353
|
+
const handle = this.finishTimers.get(contextId);
|
|
354
|
+
if (handle === undefined) return;
|
|
355
|
+
this.timer.clear(handle);
|
|
356
|
+
this.finishTimers.delete(contextId);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// ── emission ──────────────────────────────────────────────────────────────
|
|
360
|
+
|
|
361
|
+
private async trySend(frame: SocketData, contextId: string): Promise<boolean> {
|
|
362
|
+
try {
|
|
363
|
+
await this.deps.transport.ensureReady();
|
|
364
|
+
this.deps.transport.send(frame);
|
|
365
|
+
return true;
|
|
366
|
+
} catch (err) {
|
|
367
|
+
this.emitError(contextId, err instanceof Error ? err : new Error(String(err)));
|
|
368
|
+
return false;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
private emitEnd(contextId: string): void {
|
|
373
|
+
this.deps.sink.push(Route.Main, {
|
|
374
|
+
kind: "tts.end",
|
|
375
|
+
contextId,
|
|
376
|
+
timestampMs: this.now(),
|
|
377
|
+
} satisfies TextToSpeechEndPacket);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
private emitError(contextId: string, err: Error): void {
|
|
381
|
+
const category = categorizeTtsError(err);
|
|
382
|
+
const packet: TtsErrorPacket = {
|
|
383
|
+
kind: "tts.error",
|
|
384
|
+
contextId,
|
|
385
|
+
timestampMs: this.now(),
|
|
386
|
+
component: "tts",
|
|
387
|
+
category,
|
|
388
|
+
cause: err,
|
|
389
|
+
isRecoverable: isRecoverable(category),
|
|
390
|
+
};
|
|
391
|
+
this.deps.sink.push(Route.Critical, packet);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
private emitMetric(contextId: string, name: string, value: string): void {
|
|
395
|
+
this.deps.sink.push(Route.Background, {
|
|
396
|
+
kind: "metric.conversation",
|
|
397
|
+
contextId,
|
|
398
|
+
timestampMs: this.now(),
|
|
399
|
+
name,
|
|
400
|
+
value,
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function concatBytes(a: Uint8Array, b: Uint8Array): Uint8Array {
|
|
406
|
+
const out = new Uint8Array(a.byteLength + b.byteLength);
|
|
407
|
+
out.set(a, 0);
|
|
408
|
+
out.set(b, a.byteLength);
|
|
409
|
+
return out;
|
|
410
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
|
|
3
|
+
export { createTtsEngine, type TtsEngine, type TtsEngineDeps } from "./engine.js";
|
|
4
|
+
export {
|
|
5
|
+
startStreamingTtsSession,
|
|
6
|
+
defaultNodeSocketFactory,
|
|
7
|
+
type StreamingTtsSpec,
|
|
8
|
+
type StreamingTtsSession,
|
|
9
|
+
} from "./plugin.js";
|
|
10
|
+
export {
|
|
11
|
+
attributionKey,
|
|
12
|
+
type AttributionKey,
|
|
13
|
+
type WireEvent,
|
|
14
|
+
type WireProtocol,
|
|
15
|
+
type Transport,
|
|
16
|
+
type TimerPort,
|
|
17
|
+
type TimerHandle,
|
|
18
|
+
type PacketSink,
|
|
19
|
+
} from "./types.js";
|
package/src/plugin.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
//
|
|
3
|
+
// Ergonomic factory: wires a provider `WireProtocol` into a running streaming-TTS session
|
|
4
|
+
// over a `WebSocketConnection`-backed transport, with the standard PipelineBus wiring
|
|
5
|
+
// (tts.text→onText / tts.done→onDone / interrupt.tts→onInterrupt). A provider's published
|
|
6
|
+
// `*TTSPlugin` class delegates `initialize`/`close` to this — its public surface is unchanged.
|
|
7
|
+
|
|
8
|
+
import { Route, type AudioFormat, type PipelineBus, type RetryConfig } from "@kuralle-syrinx/core";
|
|
9
|
+
import { WebSocketConnection, type SocketData, type SocketFactory } from "@kuralle-syrinx/ws";
|
|
10
|
+
|
|
11
|
+
import { createTtsEngine } from "./engine.js";
|
|
12
|
+
import type { WireProtocol } from "./types.js";
|
|
13
|
+
|
|
14
|
+
export interface StreamingTtsSpec {
|
|
15
|
+
readonly protocol: WireProtocol;
|
|
16
|
+
readonly provider: { readonly name: string; readonly model: string; readonly region?: string };
|
|
17
|
+
readonly format: AudioFormat;
|
|
18
|
+
readonly sampleRateHz: number;
|
|
19
|
+
readonly url: () => string;
|
|
20
|
+
readonly headers?: Record<string, string>;
|
|
21
|
+
readonly retry: RetryConfig;
|
|
22
|
+
readonly finishTimeoutMs: number;
|
|
23
|
+
readonly metricPrefix: string;
|
|
24
|
+
readonly socketFactory: SocketFactory;
|
|
25
|
+
readonly maxReconnectAttempts?: number;
|
|
26
|
+
readonly connectTimeoutMs?: number;
|
|
27
|
+
readonly replayBufferSize?: number;
|
|
28
|
+
readonly keepAliveIntervalMs?: number;
|
|
29
|
+
readonly keepAliveMessage?: () => SocketData;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface StreamingTtsSession {
|
|
33
|
+
dispose(): Promise<void>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Open the provider socket, wire the bus, and return a handle whose `dispose()` tears it all down. */
|
|
37
|
+
export async function startStreamingTtsSession(
|
|
38
|
+
bus: PipelineBus,
|
|
39
|
+
spec: StreamingTtsSpec,
|
|
40
|
+
): Promise<StreamingTtsSession> {
|
|
41
|
+
let conn: WebSocketConnection;
|
|
42
|
+
const engine = createTtsEngine({
|
|
43
|
+
protocol: spec.protocol,
|
|
44
|
+
transport: {
|
|
45
|
+
ensureReady: () => conn.ensureReady(),
|
|
46
|
+
send: (frame) => conn.send(frame),
|
|
47
|
+
close: () => conn.close(),
|
|
48
|
+
},
|
|
49
|
+
sink: { push: (route, packet) => bus.push(route, packet as Parameters<PipelineBus["push"]>[1]) },
|
|
50
|
+
format: spec.format,
|
|
51
|
+
sampleRateHz: spec.sampleRateHz,
|
|
52
|
+
provider: spec.provider,
|
|
53
|
+
finishTimeoutMs: spec.finishTimeoutMs,
|
|
54
|
+
metricPrefix: spec.metricPrefix,
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
conn = new WebSocketConnection({
|
|
58
|
+
url: spec.url,
|
|
59
|
+
headers: spec.headers,
|
|
60
|
+
socketFactory: spec.socketFactory,
|
|
61
|
+
retry: spec.retry,
|
|
62
|
+
maxReconnectAttempts: spec.maxReconnectAttempts,
|
|
63
|
+
connectTimeoutMs: spec.connectTimeoutMs,
|
|
64
|
+
replayBufferSize: spec.replayBufferSize,
|
|
65
|
+
keepAliveIntervalMs: spec.keepAliveIntervalMs,
|
|
66
|
+
keepAliveMessage: spec.keepAliveMessage,
|
|
67
|
+
onMessage: (data, isBinary) => engine.onMessage(data, isBinary),
|
|
68
|
+
onConnectionLost: (err) => engine.onConnectionLost(err),
|
|
69
|
+
onUnrecoverable: (err) => engine.onConnectionLost(err),
|
|
70
|
+
onReplay: (event, count) =>
|
|
71
|
+
bus.push(Route.Background, {
|
|
72
|
+
kind: "metric.conversation",
|
|
73
|
+
contextId: "",
|
|
74
|
+
timestampMs: Date.now(),
|
|
75
|
+
name: `${spec.metricPrefix}.reconnect_replay_${event}`,
|
|
76
|
+
value: String(count),
|
|
77
|
+
}),
|
|
78
|
+
});
|
|
79
|
+
await conn.connect();
|
|
80
|
+
|
|
81
|
+
const disposers: Array<() => void> = [
|
|
82
|
+
bus.on("tts.text", async (pkt: unknown) => {
|
|
83
|
+
const textPkt = pkt as { text: string; contextId: string };
|
|
84
|
+
await engine.onText(textPkt.text, textPkt.contextId);
|
|
85
|
+
}),
|
|
86
|
+
bus.on("tts.done", async (pkt: unknown) => {
|
|
87
|
+
const donePkt = pkt as { contextId: string };
|
|
88
|
+
await engine.onDone(donePkt.contextId);
|
|
89
|
+
}),
|
|
90
|
+
bus.on("interrupt.tts", () => {
|
|
91
|
+
engine.onInterrupt().catch(() => {
|
|
92
|
+
// Best-effort interruption.
|
|
93
|
+
});
|
|
94
|
+
}),
|
|
95
|
+
];
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
dispose: async () => {
|
|
99
|
+
for (const dispose of disposers.splice(0)) dispose();
|
|
100
|
+
await engine.close();
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Default Node socket factory — lazily imported so the heavy `ws` dep only loads when used. */
|
|
106
|
+
export async function defaultNodeSocketFactory(): Promise<SocketFactory> {
|
|
107
|
+
const mod = await import("@kuralle-syrinx/ws/node");
|
|
108
|
+
return mod.createNodeWsSocket;
|
|
109
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
//
|
|
3
|
+
// Ports for the shared streaming-TTS deep module. The domain core (`engine.ts`) depends
|
|
4
|
+
// only on these interfaces — never on a concrete socket or a specific provider. Each
|
|
5
|
+
// provider supplies a `WireProtocol`; the runtime supplies a `Transport`.
|
|
6
|
+
|
|
7
|
+
import type { Route } from "@kuralle-syrinx/core";
|
|
8
|
+
import type { SocketData } from "@kuralle-syrinx/ws";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Opaque attribution key. The engine NEVER parses it — it only uses it as a map key for
|
|
12
|
+
* carry/active/cancel bookkeeping. Providers mint it: a per-context provider returns the
|
|
13
|
+
* contextId; a single-stream provider returns a constant; a multiplexed provider returns
|
|
14
|
+
* `${contextId}:${seq}`. The single/per-context/per-request distinction is entirely a
|
|
15
|
+
* function of what key `WireProtocol.attributionFor` returns — there is no mode flag.
|
|
16
|
+
*/
|
|
17
|
+
export type AttributionKey = string & { readonly __brand: "TtsAttributionKey" };
|
|
18
|
+
|
|
19
|
+
export function attributionKey(value: string): AttributionKey {
|
|
20
|
+
return value as AttributionKey;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Decoded result of one inbound provider frame — a list, because a single frame can carry
|
|
25
|
+
* several aspects (e.g. cartesia ships audio + word-timestamps + a done flag together).
|
|
26
|
+
*
|
|
27
|
+
* `utterance_end` vs `context_end` is the one real semantic split between providers:
|
|
28
|
+
* - `utterance_end` — one attribution unit finished; the context ends only once every unit
|
|
29
|
+
* is done AND the engine has seen `tts.done` (the multiplexed/refcount model, e.g. epsilon).
|
|
30
|
+
* - `context_end` — the provider declares the whole context complete; emit `tts.end` now,
|
|
31
|
+
* regardless of `tts.done` (the single-stream model, e.g. cartesia `done:true`, grok `audio.done`).
|
|
32
|
+
*/
|
|
33
|
+
export type WireEvent =
|
|
34
|
+
| { readonly type: "audio"; readonly key: AttributionKey; readonly pcm: Uint8Array }
|
|
35
|
+
| { readonly type: "utterance_end"; readonly key: AttributionKey }
|
|
36
|
+
| { readonly type: "context_end"; readonly key: AttributionKey }
|
|
37
|
+
| { readonly type: "cancelled"; readonly key: AttributionKey }
|
|
38
|
+
| {
|
|
39
|
+
readonly type: "error";
|
|
40
|
+
readonly key: AttributionKey | null;
|
|
41
|
+
readonly error: Error;
|
|
42
|
+
/** When true, this error is also the context's terminal signal → emit `tts.end` too
|
|
43
|
+
* (e.g. cartesia's `{type:"error", done:true}`). Default false: report the error only. */
|
|
44
|
+
readonly endsContext?: boolean;
|
|
45
|
+
}
|
|
46
|
+
| {
|
|
47
|
+
readonly type: "sideband";
|
|
48
|
+
readonly key: AttributionKey;
|
|
49
|
+
readonly route: Route;
|
|
50
|
+
readonly build: (contextId: string, timestampMs: number) => unknown;
|
|
51
|
+
}
|
|
52
|
+
| { readonly type: "ignore" };
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* DRIVEN PORT — the provider wire protocol. Pure of sockets, timers, and the bus. This is the
|
|
56
|
+
* only surface a provider implements; everything else lives in the engine.
|
|
57
|
+
*/
|
|
58
|
+
export interface WireProtocol {
|
|
59
|
+
/** Mint the attribution key (and echo the contextId) for a new utterance request. */
|
|
60
|
+
attributionFor(contextId: string): { readonly key: AttributionKey; readonly contextId: string };
|
|
61
|
+
/** Encode a text chunk into the exact wire frame(s) to send. Return `[]` to send nothing. */
|
|
62
|
+
encodeText(key: AttributionKey, text: string): readonly SocketData[];
|
|
63
|
+
/** Encode the "no more text for this context" frame(s). `[]` if the provider has none. */
|
|
64
|
+
encodeFinish(contextId: string, activeKeys: readonly AttributionKey[]): readonly SocketData[];
|
|
65
|
+
/** Encode the cancel frame(s) for one attribution key. `[]` if the provider has no cancel. */
|
|
66
|
+
encodeCancel(key: AttributionKey, contextId: string): readonly SocketData[];
|
|
67
|
+
/** Optional session-teardown frame(s) sent best-effort on close (e.g. `{type:"eos"}`). */
|
|
68
|
+
encodeClose(): readonly SocketData[];
|
|
69
|
+
/** Decode one inbound socket frame into domain events (0+). Throwing is treated as fatal. */
|
|
70
|
+
decode(data: SocketData, isBinary: boolean): readonly WireEvent[];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** DRIVEN PORT — transport. Production wraps a `WebSocketConnection`; tests pass a fake. */
|
|
74
|
+
export interface Transport {
|
|
75
|
+
ensureReady(): Promise<void>;
|
|
76
|
+
send(frame: SocketData): void;
|
|
77
|
+
close(): Promise<void>;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export type TimerHandle = unknown;
|
|
81
|
+
|
|
82
|
+
/** Injectable timers so finish-timeout behavior is deterministic in tests. */
|
|
83
|
+
export interface TimerPort {
|
|
84
|
+
set(ms: number, fn: () => void): TimerHandle;
|
|
85
|
+
clear(handle: TimerHandle): void;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The bus, narrowed to the one method the engine needs. Injectable for socket-free tests. */
|
|
89
|
+
export interface PacketSink {
|
|
90
|
+
push(route: Route, packet: unknown): void;
|
|
91
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
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
|
+
}
|