@kuralle-syrinx/tts-core 4.1.0 → 4.2.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 +22 -4
- package/src/engine.test.ts +92 -5
- package/src/engine.ts +7 -0
package/package.json
CHANGED
|
@@ -1,14 +1,32 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kuralle-syrinx/tts-core",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.2.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.2.0",
|
|
29
|
+
"@kuralle-syrinx/ws": "4.2.0"
|
|
12
30
|
},
|
|
13
31
|
"devDependencies": {
|
|
14
32
|
"typescript": "^5.7.0",
|
package/src/engine.test.ts
CHANGED
|
@@ -10,20 +10,42 @@ const FORMAT: AudioFormat = { encoding: "pcm_s16le", sampleRateHz: 24000, channe
|
|
|
10
10
|
const SESSION = attributionKey("session");
|
|
11
11
|
|
|
12
12
|
class FakeTimer implements TimerPort {
|
|
13
|
-
private readonly timers = new Map<number, () => void>();
|
|
13
|
+
private readonly timers = new Map<number, { due: number; fn: () => void }>();
|
|
14
14
|
private next = 0;
|
|
15
|
-
|
|
15
|
+
private time = 0;
|
|
16
|
+
set(ms: number, fn: () => void): TimerHandle {
|
|
16
17
|
const handle = this.next++;
|
|
17
|
-
this.timers.set(handle, fn);
|
|
18
|
+
this.timers.set(handle, { due: this.time + ms, fn });
|
|
18
19
|
return handle;
|
|
19
20
|
}
|
|
20
21
|
clear(handle: TimerHandle): void {
|
|
21
22
|
this.timers.delete(handle as number);
|
|
22
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). */
|
|
23
30
|
fire(): void {
|
|
24
|
-
for (const [handle,
|
|
31
|
+
for (const [handle, entry] of [...this.timers]) {
|
|
25
32
|
this.timers.delete(handle);
|
|
26
|
-
fn();
|
|
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
|
+
}
|
|
27
49
|
}
|
|
28
50
|
}
|
|
29
51
|
}
|
|
@@ -190,6 +212,71 @@ describe("TtsEngine — multiplex (epsilon-shape)", () => {
|
|
|
190
212
|
});
|
|
191
213
|
});
|
|
192
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
|
+
|
|
193
280
|
describe("TtsEngine — fallbacks and failures", () => {
|
|
194
281
|
it("fires the finish-timeout: emits a metric and tts.end when the provider never reports done", async () => {
|
|
195
282
|
const h = harness(new SingleProtocol(), 2000);
|
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);
|