@kuralle-syrinx/tts-core 4.4.0 → 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,30 @@
1
+ import { type AudioFormat } from "@kuralle-syrinx/core";
2
+ import type { SocketData } from "@kuralle-syrinx/ws";
3
+ import type { PacketSink, TimerPort, Transport, WireProtocol } from "./types.js";
4
+ export interface TtsEngineDeps {
5
+ readonly protocol: WireProtocol;
6
+ readonly transport: Transport;
7
+ readonly sink: PacketSink;
8
+ readonly format: AudioFormat;
9
+ readonly sampleRateHz: number;
10
+ readonly provider: {
11
+ readonly name: string;
12
+ readonly model: string;
13
+ readonly region?: string;
14
+ };
15
+ /** 0 disables the finish-timeout fallback. */
16
+ readonly finishTimeoutMs: number;
17
+ /** Namespace for engine-emitted metrics, e.g. "tts.epsilon". */
18
+ readonly metricPrefix: string;
19
+ readonly timer?: TimerPort;
20
+ readonly now?: () => number;
21
+ }
22
+ export interface TtsEngine {
23
+ onText(text: string, contextId: string): Promise<void>;
24
+ onDone(contextId: string): Promise<void>;
25
+ onInterrupt(): Promise<void>;
26
+ onMessage(data: SocketData, isBinary: boolean): void;
27
+ onConnectionLost(error: Error): void;
28
+ close(): Promise<void>;
29
+ }
30
+ export declare function createTtsEngine(deps: TtsEngineDeps): TtsEngine;
package/dist/engine.js ADDED
@@ -0,0 +1,387 @@
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
+ import { Route, assertAudioPayload, categorizeTtsError, isRecoverable, } from "@kuralle-syrinx/core";
11
+ const EMPTY = new Uint8Array(0);
12
+ // Cancelled-context guard is per-turn (telephony rotates contextIds), so it must
13
+ // stay bounded to a recent-turns window instead of growing for the whole call.
14
+ const MAX_CANCELLED_CONTEXTS = 256;
15
+ function boundedAdd(set, value, cap) {
16
+ set.add(value);
17
+ while (set.size > cap) {
18
+ const oldest = set.values().next().value;
19
+ if (oldest === undefined)
20
+ break;
21
+ set.delete(oldest);
22
+ }
23
+ }
24
+ const defaultTimer = {
25
+ set: (ms, fn) => setTimeout(fn, ms),
26
+ clear: (handle) => {
27
+ clearTimeout(handle);
28
+ },
29
+ };
30
+ export function createTtsEngine(deps) {
31
+ return new TtsEngineImpl(deps);
32
+ }
33
+ class TtsEngineImpl {
34
+ deps;
35
+ keyToContext = new Map();
36
+ contextKeys = new Map();
37
+ cancelledContexts = new Set();
38
+ carry = new Map();
39
+ pendingEnd = new Set();
40
+ finishTimers = new Map();
41
+ timer;
42
+ now;
43
+ constructor(deps) {
44
+ this.deps = deps;
45
+ this.timer = deps.timer ?? defaultTimer;
46
+ this.now = deps.now ?? (() => Date.now());
47
+ }
48
+ async onText(text, contextId) {
49
+ if (!text.trim())
50
+ return;
51
+ if (this.cancelledContexts.has(contextId))
52
+ return;
53
+ const { key } = this.deps.protocol.attributionFor(contextId);
54
+ this.track(key, contextId);
55
+ const frames = this.deps.protocol.encodeText(key, text);
56
+ if (frames.length === 0) {
57
+ this.untrack(key);
58
+ return;
59
+ }
60
+ for (const frame of frames) {
61
+ if (!(await this.trySend(frame, contextId))) {
62
+ this.untrack(key);
63
+ return;
64
+ }
65
+ }
66
+ }
67
+ async onDone(contextId) {
68
+ if (this.cancelledContexts.has(contextId))
69
+ return;
70
+ const activeKeys = [...(this.contextKeys.get(contextId) ?? [])];
71
+ for (const frame of this.deps.protocol.encodeFinish(contextId, activeKeys)) {
72
+ await this.trySend(frame, contextId);
73
+ }
74
+ this.pendingEnd.add(contextId);
75
+ if (this.deps.finishTimeoutMs > 0 && this.hasActiveKeys(contextId)) {
76
+ this.scheduleFinishTimeout(contextId);
77
+ }
78
+ this.tryEmitEnd(contextId);
79
+ }
80
+ async onInterrupt() {
81
+ const contexts = new Set([...this.contextKeys.keys(), ...this.pendingEnd]);
82
+ for (const contextId of contexts)
83
+ await this.cancelContext(contextId);
84
+ }
85
+ onMessage(data, isBinary) {
86
+ let events;
87
+ try {
88
+ events = this.deps.protocol.decode(data, isBinary);
89
+ }
90
+ catch (err) {
91
+ this.failAll(err instanceof Error ? err : new Error(String(err)));
92
+ return;
93
+ }
94
+ for (const event of events)
95
+ this.dispatch(event);
96
+ }
97
+ dispatch(event) {
98
+ switch (event.type) {
99
+ case "audio":
100
+ this.handleAudio(event.key, event.pcm);
101
+ return;
102
+ case "utterance_end":
103
+ this.handleUtteranceEnd(event.key);
104
+ return;
105
+ case "context_end":
106
+ this.handleContextEnd(event.key);
107
+ return;
108
+ case "cancelled":
109
+ this.handleCancelled(event.key);
110
+ return;
111
+ case "error":
112
+ this.handleError(event.key, event.error, event.endsContext ?? false);
113
+ return;
114
+ case "sideband": {
115
+ const contextId = this.keyToContext.get(event.key);
116
+ if (contextId === undefined)
117
+ return;
118
+ this.deps.sink.push(event.route, event.build(contextId, this.now()));
119
+ return;
120
+ }
121
+ case "ignore":
122
+ return;
123
+ }
124
+ }
125
+ onConnectionLost(error) {
126
+ this.failAll(error);
127
+ }
128
+ async close() {
129
+ this.pendingEnd.clear();
130
+ this.cancelledContexts.clear();
131
+ this.keyToContext.clear();
132
+ this.contextKeys.clear();
133
+ this.carry.clear();
134
+ for (const handle of this.finishTimers.values())
135
+ this.timer.clear(handle);
136
+ this.finishTimers.clear();
137
+ try {
138
+ for (const frame of this.deps.protocol.encodeClose()) {
139
+ await this.deps.transport.ensureReady();
140
+ this.deps.transport.send(frame);
141
+ }
142
+ }
143
+ catch {
144
+ // Best-effort session shutdown.
145
+ }
146
+ await this.deps.transport.close();
147
+ }
148
+ // ── inbound handling ──────────────────────────────────────────────────────
149
+ handleAudio(key, pcm) {
150
+ const contextId = this.keyToContext.get(key);
151
+ if (contextId === undefined || this.cancelledContexts.has(contextId))
152
+ return;
153
+ if (pcm.byteLength === 0)
154
+ return;
155
+ // After flush, finish-timeout is an inactivity watchdog: keep it armed only
156
+ // while the provider is silent. Active audio must reschedule so long turns
157
+ // (e.g. half-cascade dumping full text then tts.done early) are not cut mid-stream.
158
+ if (this.pendingEnd.has(contextId) && this.deps.finishTimeoutMs > 0) {
159
+ this.scheduleFinishTimeout(contextId);
160
+ }
161
+ const prev = this.carry.get(key) ?? EMPTY;
162
+ const buf = prev.byteLength === 0 ? pcm : concatBytes(prev, pcm);
163
+ const evenLen = buf.byteLength - (buf.byteLength % 2);
164
+ if (evenLen > 0) {
165
+ const audio = buf.subarray(0, evenLen);
166
+ try {
167
+ assertAudioPayload(this.deps.format, audio);
168
+ }
169
+ catch (err) {
170
+ this.emitError(contextId, err instanceof Error ? err : new Error(String(err)));
171
+ return;
172
+ }
173
+ const packet = {
174
+ kind: "tts.audio",
175
+ contextId,
176
+ timestampMs: this.now(),
177
+ audio,
178
+ sampleRateHz: this.deps.sampleRateHz,
179
+ provider: {
180
+ name: this.deps.provider.name,
181
+ model: this.deps.provider.model,
182
+ region: this.deps.provider.region ?? "global",
183
+ cancelled: false,
184
+ },
185
+ };
186
+ this.deps.sink.push(Route.Main, packet);
187
+ }
188
+ this.carry.set(key, evenLen < buf.byteLength ? buf.subarray(evenLen) : EMPTY);
189
+ }
190
+ // One attribution unit finished. The context ends only when all its units are done AND
191
+ // `tts.done` has been seen (the refcount/multiplex model, e.g. epsilon).
192
+ handleUtteranceEnd(key) {
193
+ const contextId = this.keyToContext.get(key);
194
+ this.untrack(key);
195
+ if (contextId === undefined)
196
+ return;
197
+ if (this.cancelledContexts.has(contextId)) {
198
+ this.clearCancelledIfDrained(contextId);
199
+ return;
200
+ }
201
+ this.tryEmitEnd(contextId);
202
+ }
203
+ // The provider declared the whole context complete — end now, regardless of `tts.done`
204
+ // (the single-stream model: cartesia `done:true`, grok `audio.done`).
205
+ handleContextEnd(key) {
206
+ const contextId = this.keyToContext.get(key);
207
+ this.untrack(key);
208
+ if (contextId === undefined)
209
+ return;
210
+ if (this.cancelledContexts.has(contextId)) {
211
+ this.clearCancelledIfDrained(contextId);
212
+ return;
213
+ }
214
+ this.endContextNow(contextId);
215
+ }
216
+ handleCancelled(key) {
217
+ const contextId = this.keyToContext.get(key);
218
+ this.untrack(key);
219
+ if (contextId !== undefined && this.cancelledContexts.has(contextId))
220
+ this.clearCancelledIfDrained(contextId);
221
+ }
222
+ handleError(key, error, endsContext) {
223
+ const contextId = key !== null ? this.keyToContext.get(key) : undefined;
224
+ if (key !== null)
225
+ this.untrack(key);
226
+ if (contextId !== undefined && this.cancelledContexts.has(contextId)) {
227
+ this.clearCancelledIfDrained(contextId);
228
+ return;
229
+ }
230
+ this.emitError(contextId ?? "", error);
231
+ if (contextId === undefined)
232
+ return;
233
+ // A terminal error (provider coupled it with `done`) ends the context now, using the
234
+ // contextId captured above before untrack; otherwise end only if the refcount allows.
235
+ if (endsContext)
236
+ this.endContextNow(contextId);
237
+ else
238
+ this.tryEmitEnd(contextId);
239
+ }
240
+ /** Once a cancelled context fully drains, drop the cancelled flag so the id can be reused. */
241
+ clearCancelledIfDrained(contextId) {
242
+ if (!this.hasActiveKeys(contextId))
243
+ this.cancelledContexts.delete(contextId);
244
+ }
245
+ /** Emit `tts.end` for a context immediately and clear its pending/timeout state. */
246
+ endContextNow(contextId) {
247
+ this.pendingEnd.delete(contextId);
248
+ this.clearFinishTimeout(contextId);
249
+ this.emitEnd(contextId);
250
+ }
251
+ // ── bookkeeping ───────────────────────────────────────────────────────────
252
+ track(key, contextId) {
253
+ this.keyToContext.set(key, contextId);
254
+ let keys = this.contextKeys.get(contextId);
255
+ if (!keys) {
256
+ keys = new Set();
257
+ this.contextKeys.set(contextId, keys);
258
+ }
259
+ keys.add(key);
260
+ }
261
+ untrack(key) {
262
+ const contextId = this.keyToContext.get(key);
263
+ this.keyToContext.delete(key);
264
+ this.carry.delete(key);
265
+ if (contextId === undefined)
266
+ return;
267
+ const keys = this.contextKeys.get(contextId);
268
+ if (!keys)
269
+ return;
270
+ keys.delete(key);
271
+ if (keys.size === 0)
272
+ this.contextKeys.delete(contextId);
273
+ }
274
+ hasActiveKeys(contextId) {
275
+ return (this.contextKeys.get(contextId)?.size ?? 0) > 0;
276
+ }
277
+ async cancelContext(contextId) {
278
+ boundedAdd(this.cancelledContexts, contextId, MAX_CANCELLED_CONTEXTS);
279
+ this.pendingEnd.delete(contextId);
280
+ this.clearFinishTimeout(contextId);
281
+ for (const key of [...(this.contextKeys.get(contextId) ?? [])]) {
282
+ for (const frame of this.deps.protocol.encodeCancel(key, contextId)) {
283
+ await this.trySend(frame, contextId);
284
+ }
285
+ }
286
+ }
287
+ tryEmitEnd(contextId) {
288
+ if (!this.pendingEnd.has(contextId))
289
+ return;
290
+ if (this.hasActiveKeys(contextId))
291
+ return;
292
+ this.pendingEnd.delete(contextId);
293
+ this.clearFinishTimeout(contextId);
294
+ this.cancelledContexts.delete(contextId);
295
+ this.emitEnd(contextId);
296
+ }
297
+ failAll(error) {
298
+ const contexts = new Set([...this.contextKeys.keys(), ...this.pendingEnd]);
299
+ this.pendingEnd.clear();
300
+ this.contextKeys.clear();
301
+ this.keyToContext.clear();
302
+ this.carry.clear();
303
+ let emitted = 0;
304
+ for (const contextId of contexts) {
305
+ this.clearFinishTimeout(contextId);
306
+ // Don't surface an error for a context the caller deliberately cancelled (e.g. a
307
+ // barge-in) when the socket drops before the provider acks the cancel.
308
+ if (this.cancelledContexts.has(contextId))
309
+ continue;
310
+ this.emitError(contextId, error);
311
+ emitted += 1;
312
+ }
313
+ this.cancelledContexts.clear();
314
+ if (emitted === 0 && contexts.size === 0)
315
+ this.emitError("", error);
316
+ }
317
+ // ── timers ────────────────────────────────────────────────────────────────
318
+ scheduleFinishTimeout(contextId) {
319
+ this.clearFinishTimeout(contextId);
320
+ const handle = this.timer.set(this.deps.finishTimeoutMs, () => {
321
+ this.finishTimers.delete(contextId);
322
+ if (!this.pendingEnd.has(contextId) && !this.hasActiveKeys(contextId))
323
+ return;
324
+ this.emitMetric(contextId, `${this.deps.metricPrefix}.finish_timeout`, String(this.deps.finishTimeoutMs));
325
+ this.pendingEnd.delete(contextId);
326
+ for (const key of [...(this.contextKeys.get(contextId) ?? [])])
327
+ this.untrack(key);
328
+ this.cancelledContexts.delete(contextId);
329
+ this.emitEnd(contextId);
330
+ });
331
+ this.finishTimers.set(contextId, handle);
332
+ }
333
+ clearFinishTimeout(contextId) {
334
+ const handle = this.finishTimers.get(contextId);
335
+ if (handle === undefined)
336
+ return;
337
+ this.timer.clear(handle);
338
+ this.finishTimers.delete(contextId);
339
+ }
340
+ // ── emission ──────────────────────────────────────────────────────────────
341
+ async trySend(frame, contextId) {
342
+ try {
343
+ await this.deps.transport.ensureReady();
344
+ this.deps.transport.send(frame);
345
+ return true;
346
+ }
347
+ catch (err) {
348
+ this.emitError(contextId, err instanceof Error ? err : new Error(String(err)));
349
+ return false;
350
+ }
351
+ }
352
+ emitEnd(contextId) {
353
+ this.deps.sink.push(Route.Main, {
354
+ kind: "tts.end",
355
+ contextId,
356
+ timestampMs: this.now(),
357
+ });
358
+ }
359
+ emitError(contextId, err) {
360
+ const category = categorizeTtsError(err);
361
+ const packet = {
362
+ kind: "tts.error",
363
+ contextId,
364
+ timestampMs: this.now(),
365
+ component: "tts",
366
+ category,
367
+ cause: err,
368
+ isRecoverable: isRecoverable(category),
369
+ };
370
+ this.deps.sink.push(Route.Critical, packet);
371
+ }
372
+ emitMetric(contextId, name, value) {
373
+ this.deps.sink.push(Route.Background, {
374
+ kind: "metric.conversation",
375
+ contextId,
376
+ timestampMs: this.now(),
377
+ name,
378
+ value,
379
+ });
380
+ }
381
+ }
382
+ function concatBytes(a, b) {
383
+ const out = new Uint8Array(a.byteLength + b.byteLength);
384
+ out.set(a, 0);
385
+ out.set(b, a.byteLength);
386
+ return out;
387
+ }
@@ -0,0 +1,3 @@
1
+ export { createTtsEngine, type TtsEngine, type TtsEngineDeps } from "./engine.js";
2
+ export { startStreamingTtsSession, defaultNodeSocketFactory, type StreamingTtsSpec, type StreamingTtsSession, } from "./plugin.js";
3
+ export { attributionKey, type AttributionKey, type WireEvent, type WireProtocol, type Transport, type TimerPort, type TimerHandle, type PacketSink, } from "./types.js";
package/dist/index.js ADDED
@@ -0,0 +1,4 @@
1
+ // SPDX-License-Identifier: MIT
2
+ export { createTtsEngine } from "./engine.js";
3
+ export { startStreamingTtsSession, defaultNodeSocketFactory, } from "./plugin.js";
4
+ export { attributionKey, } from "./types.js";
@@ -0,0 +1,31 @@
1
+ import { type AudioFormat, type PipelineBus, type RetryConfig } from "@kuralle-syrinx/core";
2
+ import { type SocketData, type SocketFactory } from "@kuralle-syrinx/ws";
3
+ import type { WireProtocol } from "./types.js";
4
+ export interface StreamingTtsSpec {
5
+ readonly protocol: WireProtocol;
6
+ readonly provider: {
7
+ readonly name: string;
8
+ readonly model: string;
9
+ readonly region?: string;
10
+ };
11
+ readonly format: AudioFormat;
12
+ readonly sampleRateHz: number;
13
+ readonly url: () => string;
14
+ readonly headers?: Record<string, string>;
15
+ readonly retry: RetryConfig;
16
+ readonly finishTimeoutMs: number;
17
+ readonly metricPrefix: string;
18
+ readonly socketFactory: SocketFactory;
19
+ readonly maxReconnectAttempts?: number;
20
+ readonly connectTimeoutMs?: number;
21
+ readonly replayBufferSize?: number;
22
+ readonly keepAliveIntervalMs?: number;
23
+ readonly keepAliveMessage?: () => SocketData;
24
+ }
25
+ export interface StreamingTtsSession {
26
+ dispose(): Promise<void>;
27
+ }
28
+ /** Open the provider socket, wire the bus, and return a handle whose `dispose()` tears it all down. */
29
+ export declare function startStreamingTtsSession(bus: PipelineBus, spec: StreamingTtsSpec): Promise<StreamingTtsSession>;
30
+ /** Default Node socket factory — lazily imported so the heavy `ws` dep only loads when used. */
31
+ export declare function defaultNodeSocketFactory(): Promise<SocketFactory>;
package/dist/plugin.js ADDED
@@ -0,0 +1,76 @@
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
+ import { Route } from "@kuralle-syrinx/core";
8
+ import { WebSocketConnection } from "@kuralle-syrinx/ws";
9
+ import { createTtsEngine } from "./engine.js";
10
+ /** Open the provider socket, wire the bus, and return a handle whose `dispose()` tears it all down. */
11
+ export async function startStreamingTtsSession(bus, spec) {
12
+ let conn;
13
+ const engine = createTtsEngine({
14
+ protocol: spec.protocol,
15
+ transport: {
16
+ ensureReady: () => conn.ensureReady(),
17
+ send: (frame) => conn.send(frame),
18
+ close: () => conn.close(),
19
+ },
20
+ sink: { push: (route, packet) => bus.push(route, packet) },
21
+ format: spec.format,
22
+ sampleRateHz: spec.sampleRateHz,
23
+ provider: spec.provider,
24
+ finishTimeoutMs: spec.finishTimeoutMs,
25
+ metricPrefix: spec.metricPrefix,
26
+ });
27
+ conn = new WebSocketConnection({
28
+ url: spec.url,
29
+ headers: spec.headers,
30
+ socketFactory: spec.socketFactory,
31
+ retry: spec.retry,
32
+ maxReconnectAttempts: spec.maxReconnectAttempts,
33
+ connectTimeoutMs: spec.connectTimeoutMs,
34
+ replayBufferSize: spec.replayBufferSize,
35
+ keepAliveIntervalMs: spec.keepAliveIntervalMs,
36
+ keepAliveMessage: spec.keepAliveMessage,
37
+ onMessage: (data, isBinary) => engine.onMessage(data, isBinary),
38
+ onConnectionLost: (err) => engine.onConnectionLost(err),
39
+ onUnrecoverable: (err) => engine.onConnectionLost(err),
40
+ onReplay: (event, count) => bus.push(Route.Background, {
41
+ kind: "metric.conversation",
42
+ contextId: "",
43
+ timestampMs: Date.now(),
44
+ name: `${spec.metricPrefix}.reconnect_replay_${event}`,
45
+ value: String(count),
46
+ }),
47
+ });
48
+ await conn.connect();
49
+ const disposers = [
50
+ bus.on("tts.text", async (pkt) => {
51
+ const textPkt = pkt;
52
+ await engine.onText(textPkt.text, textPkt.contextId);
53
+ }),
54
+ bus.on("tts.done", async (pkt) => {
55
+ const donePkt = pkt;
56
+ await engine.onDone(donePkt.contextId);
57
+ }),
58
+ bus.on("interrupt.tts", () => {
59
+ engine.onInterrupt().catch(() => {
60
+ // Best-effort interruption.
61
+ });
62
+ }),
63
+ ];
64
+ return {
65
+ dispose: async () => {
66
+ for (const dispose of disposers.splice(0))
67
+ dispose();
68
+ await engine.close();
69
+ },
70
+ };
71
+ }
72
+ /** Default Node socket factory — lazily imported so the heavy `ws` dep only loads when used. */
73
+ export async function defaultNodeSocketFactory() {
74
+ const mod = await import("@kuralle-syrinx/ws/node");
75
+ return mod.createNodeWsSocket;
76
+ }
@@ -0,0 +1,88 @@
1
+ import type { Route } from "@kuralle-syrinx/core";
2
+ import type { SocketData } from "@kuralle-syrinx/ws";
3
+ /**
4
+ * Opaque attribution key. The engine NEVER parses it — it only uses it as a map key for
5
+ * carry/active/cancel bookkeeping. Providers mint it: a per-context provider returns the
6
+ * contextId; a single-stream provider returns a constant; a multiplexed provider returns
7
+ * `${contextId}:${seq}`. The single/per-context/per-request distinction is entirely a
8
+ * function of what key `WireProtocol.attributionFor` returns — there is no mode flag.
9
+ */
10
+ export type AttributionKey = string & {
11
+ readonly __brand: "TtsAttributionKey";
12
+ };
13
+ export declare function attributionKey(value: string): AttributionKey;
14
+ /**
15
+ * Decoded result of one inbound provider frame — a list, because a single frame can carry
16
+ * several aspects (e.g. cartesia ships audio + word-timestamps + a done flag together).
17
+ *
18
+ * `utterance_end` vs `context_end` is the one real semantic split between providers:
19
+ * - `utterance_end` — one attribution unit finished; the context ends only once every unit
20
+ * is done AND the engine has seen `tts.done` (the multiplexed/refcount model, e.g. epsilon).
21
+ * - `context_end` — the provider declares the whole context complete; emit `tts.end` now,
22
+ * regardless of `tts.done` (the single-stream model, e.g. cartesia `done:true`, grok `audio.done`).
23
+ */
24
+ export type WireEvent = {
25
+ readonly type: "audio";
26
+ readonly key: AttributionKey;
27
+ readonly pcm: Uint8Array;
28
+ } | {
29
+ readonly type: "utterance_end";
30
+ readonly key: AttributionKey;
31
+ } | {
32
+ readonly type: "context_end";
33
+ readonly key: AttributionKey;
34
+ } | {
35
+ readonly type: "cancelled";
36
+ readonly key: AttributionKey;
37
+ } | {
38
+ readonly type: "error";
39
+ readonly key: AttributionKey | null;
40
+ readonly error: Error;
41
+ /** When true, this error is also the context's terminal signal → emit `tts.end` too
42
+ * (e.g. cartesia's `{type:"error", done:true}`). Default false: report the error only. */
43
+ readonly endsContext?: boolean;
44
+ } | {
45
+ readonly type: "sideband";
46
+ readonly key: AttributionKey;
47
+ readonly route: Route;
48
+ readonly build: (contextId: string, timestampMs: number) => unknown;
49
+ } | {
50
+ readonly type: "ignore";
51
+ };
52
+ /**
53
+ * DRIVEN PORT — the provider wire protocol. Pure of sockets, timers, and the bus. This is the
54
+ * only surface a provider implements; everything else lives in the engine.
55
+ */
56
+ export interface WireProtocol {
57
+ /** Mint the attribution key (and echo the contextId) for a new utterance request. */
58
+ attributionFor(contextId: string): {
59
+ readonly key: AttributionKey;
60
+ readonly contextId: string;
61
+ };
62
+ /** Encode a text chunk into the exact wire frame(s) to send. Return `[]` to send nothing. */
63
+ encodeText(key: AttributionKey, text: string): readonly SocketData[];
64
+ /** Encode the "no more text for this context" frame(s). `[]` if the provider has none. */
65
+ encodeFinish(contextId: string, activeKeys: readonly AttributionKey[]): readonly SocketData[];
66
+ /** Encode the cancel frame(s) for one attribution key. `[]` if the provider has no cancel. */
67
+ encodeCancel(key: AttributionKey, contextId: string): readonly SocketData[];
68
+ /** Optional session-teardown frame(s) sent best-effort on close (e.g. `{type:"eos"}`). */
69
+ encodeClose(): readonly SocketData[];
70
+ /** Decode one inbound socket frame into domain events (0+). Throwing is treated as fatal. */
71
+ decode(data: SocketData, isBinary: boolean): readonly WireEvent[];
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
+ export type TimerHandle = unknown;
80
+ /** Injectable timers so finish-timeout behavior is deterministic in tests. */
81
+ export interface TimerPort {
82
+ set(ms: number, fn: () => void): TimerHandle;
83
+ clear(handle: TimerHandle): void;
84
+ }
85
+ /** The bus, narrowed to the one method the engine needs. Injectable for socket-free tests. */
86
+ export interface PacketSink {
87
+ push(route: Route, packet: unknown): void;
88
+ }
package/dist/types.js ADDED
@@ -0,0 +1,8 @@
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
+ export function attributionKey(value) {
7
+ return value;
8
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kuralle-syrinx/tts-core",
3
- "version": "4.4.0",
3
+ "version": "4.5.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": [
@@ -22,18 +22,25 @@
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.0",
29
- "@kuralle-syrinx/ws": "4.4.0"
28
+ "@kuralle-syrinx/core": "4.5.0",
29
+ "@kuralle-syrinx/ws": "4.5.0"
30
30
  },
31
31
  "devDependencies": {
32
+ "@types/node": "^22.0.0",
32
33
  "typescript": "^5.7.0",
33
34
  "vitest": "^3.2.6"
34
35
  },
36
+ "files": [
37
+ "dist",
38
+ "src",
39
+ "README.md"
40
+ ],
35
41
  "scripts": {
36
42
  "typecheck": "tsc --noEmit",
37
- "test": "vitest run"
43
+ "test": "vitest run",
44
+ "build": "tsc -p tsconfig.build.json"
38
45
  }
39
46
  }
@@ -0,0 +1,330 @@
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
+ });