@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 CHANGED
@@ -1,14 +1,32 @@
1
1
  {
2
2
  "name": "@kuralle-syrinx/tts-core",
3
- "version": "4.1.0",
3
+ "version": "4.2.0",
4
4
  "private": false,
5
- "type": "module",
5
+ "description": "Shared streaming-TTS lifecycle engine for Syrinx TTS adapters — carry, refcount, finish-timeout, cancel",
6
+ "keywords": [
7
+ "voice",
8
+ "voice-agent",
9
+ "speech",
10
+ "syrinx",
11
+ "text-to-speech",
12
+ "streaming"
13
+ ],
6
14
  "license": "MIT",
15
+ "homepage": "https://github.com/kuralle/syrinx#readme",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/kuralle/syrinx.git",
19
+ "directory": "packages/tts-core"
20
+ },
21
+ "bugs": {
22
+ "url": "https://github.com/kuralle/syrinx/issues"
23
+ },
24
+ "type": "module",
7
25
  "main": "./src/index.ts",
8
26
  "types": "./src/index.ts",
9
27
  "dependencies": {
10
- "@kuralle-syrinx/core": "4.1.0",
11
- "@kuralle-syrinx/ws": "4.1.0"
28
+ "@kuralle-syrinx/core": "4.2.0",
29
+ "@kuralle-syrinx/ws": "4.2.0"
12
30
  },
13
31
  "devDependencies": {
14
32
  "typescript": "^5.7.0",
@@ -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
- set(_ms: number, fn: () => void): TimerHandle {
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, fn] of [...this.timers]) {
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);