@kuralle-syrinx/core 2.1.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.
Files changed (52) hide show
  1. package/README.md +41 -0
  2. package/package.json +22 -0
  3. package/src/audio/audio.test.ts +285 -0
  4. package/src/audio/index.ts +5 -0
  5. package/src/audio/mulaw.ts +42 -0
  6. package/src/audio/pcm.ts +43 -0
  7. package/src/audio/resample.ts +177 -0
  8. package/src/audio-envelope.test.ts +167 -0
  9. package/src/audio-envelope.ts +143 -0
  10. package/src/conversation-event.ts +62 -0
  11. package/src/error-handler.test.ts +56 -0
  12. package/src/error-handler.ts +149 -0
  13. package/src/idle-timeout.ts +210 -0
  14. package/src/index.ts +215 -0
  15. package/src/init-chain.ts +137 -0
  16. package/src/init-stage-order.ts +79 -0
  17. package/src/latency-filler-fixtures.ts +16 -0
  18. package/src/latency-filler.test.ts +62 -0
  19. package/src/latency-filler.ts +125 -0
  20. package/src/mode-switcher.ts +110 -0
  21. package/src/observability-observer.test.ts +245 -0
  22. package/src/observability-observer.ts +195 -0
  23. package/src/observability.test.ts +85 -0
  24. package/src/observability.ts +93 -0
  25. package/src/packet-factories.test.ts +34 -0
  26. package/src/packet-factories.ts +243 -0
  27. package/src/packets.ts +553 -0
  28. package/src/pipeline-bus.g10.test.ts +145 -0
  29. package/src/pipeline-bus.test.ts +197 -0
  30. package/src/pipeline-bus.ts +369 -0
  31. package/src/plugin-contract.ts +79 -0
  32. package/src/primary-speaker-fixtures.ts +45 -0
  33. package/src/primary-speaker-gate.test.ts +150 -0
  34. package/src/primary-speaker-gate.ts +186 -0
  35. package/src/provider-fallback.test.ts +87 -0
  36. package/src/provider-fallback.ts +88 -0
  37. package/src/reasoner.test.ts +69 -0
  38. package/src/reasoner.ts +54 -0
  39. package/src/retry.test.ts +83 -0
  40. package/src/retry.ts +106 -0
  41. package/src/scheduler.ts +28 -0
  42. package/src/tts-playout-clock.test.ts +125 -0
  43. package/src/tts-playout-clock.ts +116 -0
  44. package/src/turn-arbiter.characterization.test.ts +477 -0
  45. package/src/turn-arbiter.test.ts +567 -0
  46. package/src/turn-arbiter.ts +283 -0
  47. package/src/voice-agent-session-util.ts +240 -0
  48. package/src/voice-agent-session.test.ts +2487 -0
  49. package/src/voice-agent-session.ts +1175 -0
  50. package/src/voice-text.test.ts +81 -0
  51. package/src/voice-text.ts +102 -0
  52. package/tsconfig.json +21 -0
@@ -0,0 +1,83 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ import { describe, expect, it, vi } from "vitest";
4
+
5
+ import { DEFAULT_RETRY_CONFIG, VOICE_PROVIDER_OUTAGE_RETRY_CONFIG, VOICE_PROVIDER_RETRY_CONFIG, readProviderRetryConfig, readRetryConfig, retryDelayMs, retryDelayWithJitterMs, waitForRetryDelay } from "./retry.js";
6
+
7
+ describe("retry helpers", () => {
8
+ it("reads bounded retry config from plugin config", () => {
9
+ expect(
10
+ readRetryConfig({
11
+ retry_max_attempts: 5,
12
+ retry_base_delay_ms: 100,
13
+ retry_max_delay_ms: 900,
14
+ }),
15
+ ).toEqual({
16
+ maxAttempts: 5,
17
+ baseDelayMs: 100,
18
+ maxDelayMs: 900,
19
+ });
20
+ });
21
+
22
+ it("backs off exponentially up to the configured cap", () => {
23
+ const config = {
24
+ maxAttempts: 5,
25
+ baseDelayMs: 100,
26
+ maxDelayMs: 250,
27
+ };
28
+ expect(retryDelayMs(1, config)).toBe(100);
29
+ expect(retryDelayMs(2, config)).toBe(200);
30
+ expect(retryDelayMs(3, config)).toBe(250);
31
+ });
32
+
33
+ it("applies equal jitter: half the deterministic delay plus a random half", () => {
34
+ const config = { maxAttempts: 5, baseDelayMs: 100, maxDelayMs: 1000 };
35
+ // attempt 2 deterministic = 200 → equal-jitter range [100, 200].
36
+ expect(retryDelayWithJitterMs(2, config, () => 0)).toBe(100);
37
+ expect(retryDelayWithJitterMs(2, config, () => 1)).toBe(200);
38
+ expect(retryDelayWithJitterMs(2, config, () => 0.5)).toBe(150);
39
+ });
40
+
41
+ it("can be aborted while waiting", async () => {
42
+ vi.useFakeTimers();
43
+ const controller = new AbortController();
44
+ const wait = waitForRetryDelay(
45
+ 1,
46
+ {
47
+ maxAttempts: 3,
48
+ baseDelayMs: 1000,
49
+ maxDelayMs: 1000,
50
+ },
51
+ controller.signal,
52
+ );
53
+
54
+ controller.abort();
55
+ await expect(wait).rejects.toMatchObject({ name: "AbortError" });
56
+ vi.useRealTimers();
57
+ });
58
+
59
+ it("voice-provider profile keeps fast first reconnect but raises the backoff cap", () => {
60
+ // Fast first reconnect (no multi-second floor → no dead air on transient blips)...
61
+ expect(retryDelayMs(1, VOICE_PROVIDER_RETRY_CONFIG)).toBe(250);
62
+ expect(retryDelayMs(1, VOICE_PROVIDER_RETRY_CONFIG)).toBe(DEFAULT_RETRY_CONFIG.baseDelayMs);
63
+ // ...but a patient cap for persistent provider failure (vs the 2s default).
64
+ expect(retryDelayMs(10, VOICE_PROVIDER_RETRY_CONFIG)).toBe(10_000);
65
+ expect(VOICE_PROVIDER_RETRY_CONFIG.maxAttempts).toBeGreaterThan(DEFAULT_RETRY_CONFIG.maxAttempts);
66
+ });
67
+
68
+ it("readProviderRetryConfig defaults to the voice-provider profile but honors overrides", () => {
69
+ expect(readProviderRetryConfig({})).toEqual(VOICE_PROVIDER_RETRY_CONFIG);
70
+ expect(readProviderRetryConfig({ retry_max_delay_ms: 3000 }).maxDelayMs).toBe(3000);
71
+ // The plain default profile is unchanged (intra-turn low-latency retries).
72
+ expect(readRetryConfig({})).toEqual(DEFAULT_RETRY_CONFIG);
73
+ });
74
+
75
+ it("offers a provider-outage profile with a 4s floor and 10s cap", () => {
76
+ expect(retryDelayMs(1, VOICE_PROVIDER_OUTAGE_RETRY_CONFIG)).toBe(4_000);
77
+ expect(retryDelayMs(2, VOICE_PROVIDER_OUTAGE_RETRY_CONFIG)).toBe(8_000);
78
+ expect(retryDelayMs(3, VOICE_PROVIDER_OUTAGE_RETRY_CONFIG)).toBe(10_000);
79
+ expect(readProviderRetryConfig({ retry_profile: "provider_outage" })).toEqual(VOICE_PROVIDER_OUTAGE_RETRY_CONFIG);
80
+ expect(readProviderRetryConfig({ retry_profile: "provider_outage", retry_base_delay_ms: 5000 }).baseDelayMs).toBe(5000);
81
+ expect(readRetryConfig({})).toEqual(DEFAULT_RETRY_CONFIG);
82
+ });
83
+ });
package/src/retry.ts ADDED
@@ -0,0 +1,106 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ export interface RetryConfig {
4
+ readonly maxAttempts: number;
5
+ readonly baseDelayMs: number;
6
+ readonly maxDelayMs: number;
7
+ }
8
+
9
+ export const DEFAULT_RETRY_CONFIG: RetryConfig = {
10
+ maxAttempts: 3,
11
+ baseDelayMs: 250,
12
+ maxDelayMs: 2000,
13
+ };
14
+
15
+ /**
16
+ * Retry profile for long-lived voice-provider WebSockets (STT/TTS).
17
+ *
18
+ * Keeps the SAME fast first reconnect as the default (~125-250 ms) so a transient
19
+ * blip during a live call recovers without dead air, but raises the backoff cap to
20
+ * 10 s and allows more attempts so a persistent provider failure (rate limit /
21
+ * outage) is retried patiently instead of giving up after ~2 s. This intentionally
22
+ * does NOT use a multi-second *floor* (a 4 s first-retry would mean seconds of dead
23
+ * air on a live call); "every 10 ms matters" applies to the first reconnect, patient
24
+ * backoff applies only after repeated failures.
25
+ */
26
+ export const VOICE_PROVIDER_RETRY_CONFIG: RetryConfig = {
27
+ maxAttempts: 6,
28
+ baseDelayMs: 250,
29
+ maxDelayMs: 10_000,
30
+ };
31
+
32
+ export const VOICE_PROVIDER_OUTAGE_RETRY_CONFIG: RetryConfig = {
33
+ maxAttempts: 4,
34
+ baseDelayMs: 4_000,
35
+ maxDelayMs: 10_000,
36
+ };
37
+
38
+ export function readRetryConfig(config: Record<string, unknown>): RetryConfig {
39
+ return readRetryConfigWithDefaults(config, DEFAULT_RETRY_CONFIG);
40
+ }
41
+
42
+ /** Read retry config for a voice-provider WebSocket; defaults to the patient-backoff profile. */
43
+ export function readProviderRetryConfig(config: Record<string, unknown>): RetryConfig {
44
+ const profile = typeof config["retry_profile"] === "string" ? config["retry_profile"] : "";
45
+ return readRetryConfigWithDefaults(
46
+ config,
47
+ profile === "provider_outage" ? VOICE_PROVIDER_OUTAGE_RETRY_CONFIG : VOICE_PROVIDER_RETRY_CONFIG,
48
+ );
49
+ }
50
+
51
+ function readRetryConfigWithDefaults(config: Record<string, unknown>, defaults: RetryConfig): RetryConfig {
52
+ return {
53
+ maxAttempts: readPositiveInteger(config["retry_max_attempts"], defaults.maxAttempts),
54
+ baseDelayMs: readPositiveInteger(config["retry_base_delay_ms"], defaults.baseDelayMs),
55
+ maxDelayMs: readPositiveInteger(config["retry_max_delay_ms"], defaults.maxDelayMs),
56
+ };
57
+ }
58
+
59
+ export function retryDelayMs(attemptIndex: number, config: RetryConfig): number {
60
+ const exponential = config.baseDelayMs * 2 ** Math.max(0, attemptIndex - 1);
61
+ return Math.min(config.maxDelayMs, exponential);
62
+ }
63
+
64
+ /**
65
+ * Equal-jitter backoff: half the deterministic exponential delay plus a random half.
66
+ * Spreads out retries so many sessions hitting the same provider rate/concurrency limit
67
+ * at once don't reconnect in lockstep (thundering herd). `rng` is injectable for tests.
68
+ */
69
+ export function retryDelayWithJitterMs(
70
+ attemptIndex: number,
71
+ config: RetryConfig,
72
+ rng: () => number = Math.random,
73
+ ): number {
74
+ const base = retryDelayMs(attemptIndex, config);
75
+ const half = base / 2;
76
+ return Math.round(half + rng() * half);
77
+ }
78
+
79
+ export async function waitForRetryDelay(attemptIndex: number, config: RetryConfig, signal?: AbortSignal): Promise<void> {
80
+ const delayMs = retryDelayWithJitterMs(attemptIndex, config);
81
+ await new Promise<void>((resolve, reject) => {
82
+ if (signal?.aborted) {
83
+ reject(abortError());
84
+ return;
85
+ }
86
+
87
+ const timeout = setTimeout(resolve, delayMs);
88
+ const onAbort = (): void => {
89
+ clearTimeout(timeout);
90
+ reject(abortError());
91
+ };
92
+ signal?.addEventListener("abort", onAbort, { once: true });
93
+ });
94
+ }
95
+
96
+ function abortError(): Error {
97
+ const err = new Error("Retry aborted");
98
+ err.name = "AbortError";
99
+ return err;
100
+ }
101
+
102
+ function readPositiveInteger(value: unknown, fallback: number): number {
103
+ if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
104
+ const integer = Math.floor(value);
105
+ return integer > 0 ? integer : fallback;
106
+ }
@@ -0,0 +1,28 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ export type ScheduledCallback = () => void | Promise<void>;
4
+
5
+ export interface Scheduler {
6
+ schedule(key: string, delayMs: number, cb: ScheduledCallback): void;
7
+ cancel(key: string): void;
8
+ }
9
+
10
+ export class TimerScheduler implements Scheduler {
11
+ private readonly timers = new Map<string, ReturnType<typeof setTimeout>>();
12
+
13
+ schedule(key: string, delayMs: number, cb: ScheduledCallback): void {
14
+ this.cancel(key);
15
+ const timer = setTimeout(() => {
16
+ this.timers.delete(key);
17
+ void cb();
18
+ }, Math.max(0, delayMs));
19
+ this.timers.set(key, timer);
20
+ }
21
+
22
+ cancel(key: string): void {
23
+ const timer = this.timers.get(key);
24
+ if (!timer) return;
25
+ clearTimeout(timer);
26
+ this.timers.delete(key);
27
+ }
28
+ }
@@ -0,0 +1,125 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
+ import { TtsPlayoutClock } from "./tts-playout-clock.js";
5
+
6
+ describe("TtsPlayoutClock", () => {
7
+ beforeEach(() => {
8
+ vi.useFakeTimers();
9
+ vi.setSystemTime(0);
10
+ });
11
+ afterEach(() => {
12
+ vi.useRealTimers();
13
+ });
14
+
15
+ it("marks a context active on the first audio chunk", () => {
16
+ const clock = new TtsPlayoutClock();
17
+ expect(clock.isActive("c1")).toBe(false);
18
+ clock.noteAudio("c1", 100, Date.now());
19
+ expect(clock.isActive("c1")).toBe(true);
20
+ expect(clock.latestActive()).toBe("c1");
21
+ });
22
+
23
+ it("keeps a context interruptible until its playout estimate elapses, then releases", () => {
24
+ const clock = new TtsPlayoutClock();
25
+ clock.noteAudio("c1", 200, Date.now()); // playout ends at t=200
26
+ clock.scheduleRelease("c1", Date.now()); // 200ms remaining
27
+ expect(clock.isActive("c1")).toBe(true);
28
+ vi.advanceTimersByTime(199);
29
+ expect(clock.isActive("c1")).toBe(true);
30
+ vi.advanceTimersByTime(1);
31
+ expect(clock.isActive("c1")).toBe(false);
32
+ });
33
+
34
+ it("releases immediately when the playout estimate has already passed", () => {
35
+ const clock = new TtsPlayoutClock();
36
+ clock.noteAudio("c1", 50, Date.now());
37
+ vi.setSystemTime(100); // well past the 50ms estimate
38
+ clock.scheduleRelease("c1", Date.now());
39
+ expect(clock.isActive("c1")).toBe(false);
40
+ });
41
+
42
+ it("lets real transport playout override the estimate: the timer must not pre-empt", () => {
43
+ const clock = new TtsPlayoutClock();
44
+ clock.noteAudio("c1", 100, Date.now());
45
+ clock.scheduleRelease("c1", Date.now()); // estimate release at t=100
46
+ clock.noteProgress("c1", false); // transport now authoritative, not yet complete
47
+ vi.advanceTimersByTime(500); // estimate timer fires but must defer
48
+ expect(clock.isActive("c1")).toBe(true);
49
+ clock.noteProgress("c1", true); // transport says done
50
+ expect(clock.isActive("c1")).toBe(false);
51
+ });
52
+
53
+ it("advances the playout cursor across chunks instead of resetting it", () => {
54
+ const clock = new TtsPlayoutClock();
55
+ clock.noteAudio("c1", 100, Date.now()); // ends at 100
56
+ clock.noteAudio("c1", 100, Date.now()); // still t=0, extends to 200
57
+ clock.scheduleRelease("c1", Date.now());
58
+ vi.advanceTimersByTime(150);
59
+ expect(clock.isActive("c1")).toBe(true); // would have released at 100 if reset
60
+ vi.advanceTimersByTime(50);
61
+ expect(clock.isActive("c1")).toBe(false);
62
+ });
63
+
64
+ it("reports the most-recently-added active context as latest", () => {
65
+ const clock = new TtsPlayoutClock();
66
+ clock.noteAudio("a", 10, Date.now());
67
+ clock.noteAudio("b", 10, Date.now());
68
+ expect(clock.latestActive()).toBe("b");
69
+ clock.release("b");
70
+ expect(clock.latestActive()).toBe("a");
71
+ clock.release("a");
72
+ expect(clock.latestActive()).toBe("");
73
+ });
74
+
75
+ it("records playout position and returns it via positionMs", () => {
76
+ const clock = new TtsPlayoutClock();
77
+ expect(clock.positionMs("c1")).toBeUndefined();
78
+ clock.noteAudio("c1", 100, Date.now());
79
+ clock.noteProgress("c1", false, 42);
80
+ expect(clock.positionMs("c1")).toBe(42);
81
+ });
82
+
83
+ it("keeps the latest playout position across multiple progress updates", () => {
84
+ const clock = new TtsPlayoutClock();
85
+ clock.noteAudio("c1", 100, Date.now());
86
+ clock.noteProgress("c1", false, 10);
87
+ clock.noteProgress("c1", false, 55);
88
+ expect(clock.positionMs("c1")).toBe(55);
89
+ });
90
+
91
+ it("drops recorded position on release and clear", () => {
92
+ const clock = new TtsPlayoutClock();
93
+ clock.noteAudio("c1", 100, Date.now());
94
+ clock.noteProgress("c1", false, 30);
95
+ clock.release("c1");
96
+ expect(clock.positionMs("c1")).toBeUndefined();
97
+
98
+ clock.noteAudio("c2", 100, Date.now());
99
+ clock.noteProgress("c2", false, 20);
100
+ clock.clear();
101
+ expect(clock.positionMs("c2")).toBeUndefined();
102
+ });
103
+
104
+ it("noteProgress without position still releases on complete", () => {
105
+ const clock = new TtsPlayoutClock();
106
+ clock.noteAudio("c1", 100, Date.now());
107
+ clock.noteProgress("c1", false);
108
+ expect(clock.positionMs("c1")).toBeUndefined();
109
+ expect(clock.isActive("c1")).toBe(true);
110
+ clock.noteProgress("c1", true);
111
+ expect(clock.isActive("c1")).toBe(false);
112
+ expect(clock.positionMs("c1")).toBeUndefined();
113
+ });
114
+
115
+ it("clear() drops all state and cancels pending release timers", () => {
116
+ const clock = new TtsPlayoutClock();
117
+ clock.noteAudio("c1", 100, Date.now());
118
+ clock.scheduleRelease("c1", Date.now());
119
+ clock.clear();
120
+ expect(clock.isActive("c1")).toBe(false);
121
+ // A leaked timer would throw "release of cleared context" — assert none fire.
122
+ expect(() => vi.advanceTimersByTime(1000)).not.toThrow();
123
+ expect(clock.latestActive()).toBe("");
124
+ });
125
+ });
@@ -0,0 +1,116 @@
1
+ // SPDX-License-Identifier: MIT
2
+ //
3
+ // Syrinx Kernel v2 — TTS Playout Clock
4
+ //
5
+ // Tracks when each context's streamed TTS audio finishes *playing out* (not just
6
+ // generating). TTS streams faster than realtime, so generation (tts.end) ends
7
+ // well before playout; turn-taking keys on these estimates so the assistant
8
+ // stays interruptible until it is actually done being heard.
9
+ //
10
+ // Pure state + timers — no bus, no other session coupling. The orchestrator owns
11
+ // wiring; this owns the playout bookkeeping.
12
+
13
+ import { TimerScheduler, type Scheduler } from "./scheduler.js";
14
+
15
+ export class TtsPlayoutClock {
16
+ private readonly active = new Set<string>();
17
+ // Wall-clock estimate of when each context's audio finishes playing out.
18
+ private readonly playoutEndMs = new Map<string, number>();
19
+ private readonly releaseTimers = new Set<string>();
20
+ // Contexts for which the output transport has reported real playout progress.
21
+ // When present, the transport's `complete` signal is authoritative and the
22
+ // sample-duration estimate defers to it (it is only a fallback for transports
23
+ // without a paced-playout layer, e.g. headless).
24
+ private readonly realPlayoutContexts = new Set<string>();
25
+ private readonly playedOutMsByContext = new Map<string, number>();
26
+
27
+ constructor(private readonly scheduler: Scheduler = new TimerScheduler()) {}
28
+
29
+ /**
30
+ * A TTS audio chunk arrived: mark the context active and advance its playout
31
+ * cursor by the chunk's realtime duration, anchored to `nowMs` if the previous
32
+ * estimate already lapsed (a gap in delivery).
33
+ */
34
+ noteAudio(contextId: string, audioDurationMs: number, nowMs: number): void {
35
+ this.cancelRelease(contextId);
36
+ this.active.add(contextId);
37
+ const base = Math.max(nowMs, this.playoutEndMs.get(contextId) ?? nowMs);
38
+ this.playoutEndMs.set(contextId, base + audioDurationMs);
39
+ }
40
+
41
+ /**
42
+ * Generation finished (tts.end). Keep the context interruptible until its
43
+ * playout estimate elapses, then release it. Releases immediately if the
44
+ * estimate has already passed.
45
+ */
46
+ scheduleRelease(contextId: string, nowMs: number): void {
47
+ const playoutEndMs = this.playoutEndMs.get(contextId);
48
+ const remainingMs = playoutEndMs === undefined ? 0 : playoutEndMs - nowMs;
49
+ if (remainingMs <= 0) {
50
+ this.release(contextId);
51
+ return;
52
+ }
53
+ this.cancelRelease(contextId);
54
+ this.releaseTimers.add(contextId);
55
+ this.scheduler.schedule(releaseKey(contextId), remainingMs, () => {
56
+ this.releaseTimers.delete(contextId);
57
+ // If a paced transport is reporting real playout for this context, it
58
+ // owns the release via noteProgress({complete}) — the estimate must not
59
+ // pre-empt it (real playout can run past the audio length under
60
+ // send-buffer backpressure). Session close() is the backstop.
61
+ if (this.realPlayoutContexts.has(contextId)) return;
62
+ this.release(contextId);
63
+ });
64
+ }
65
+
66
+ /**
67
+ * Transport reported realtime playout progress for a context. Real playout is
68
+ * authoritative once seen; release the context when it reports complete.
69
+ */
70
+ noteProgress(contextId: string, complete: boolean, playedOutMs?: number): void {
71
+ this.realPlayoutContexts.add(contextId);
72
+ if (playedOutMs !== undefined) this.playedOutMsByContext.set(contextId, playedOutMs);
73
+ if (complete) this.release(contextId);
74
+ }
75
+
76
+ positionMs(contextId: string): number | undefined {
77
+ return this.playedOutMsByContext.get(contextId);
78
+ }
79
+
80
+ release(contextId: string): void {
81
+ this.cancelRelease(contextId);
82
+ this.active.delete(contextId);
83
+ this.playoutEndMs.delete(contextId);
84
+ this.realPlayoutContexts.delete(contextId);
85
+ this.playedOutMsByContext.delete(contextId);
86
+ }
87
+
88
+ isActive(contextId: string): boolean {
89
+ return this.active.has(contextId);
90
+ }
91
+
92
+ /** The most-recently-added still-active context (insertion order), or "" if none. */
93
+ latestActive(): string {
94
+ let latest = "";
95
+ for (const contextId of this.active) latest = contextId;
96
+ return latest;
97
+ }
98
+
99
+ clear(): void {
100
+ for (const contextId of [...this.releaseTimers]) this.cancelRelease(contextId);
101
+ this.active.clear();
102
+ this.playoutEndMs.clear();
103
+ this.realPlayoutContexts.clear();
104
+ this.playedOutMsByContext.clear();
105
+ }
106
+
107
+ private cancelRelease(contextId: string): void {
108
+ if (!this.releaseTimers.has(contextId)) return;
109
+ this.scheduler.cancel(releaseKey(contextId));
110
+ this.releaseTimers.delete(contextId);
111
+ }
112
+ }
113
+
114
+ function releaseKey(contextId: string): string {
115
+ return `voice.tts_playout.release:${contextId}`;
116
+ }