@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,283 @@
1
+ // SPDX-License-Identifier: MIT
2
+ //
3
+ // CR-09 — explicit turn-taking state for barge-in decisions (G1 + VE-02).
4
+
5
+ import { Route, type PipelineBus } from "./pipeline-bus.js";
6
+ import type {
7
+ VadAudioPacket,
8
+ VadSpeechActivityPacket,
9
+ VadSpeechEndedPacket,
10
+ VadSpeechStartedPacket,
11
+ } from "./packets.js";
12
+ import { PrimarySpeakerGate } from "./primary-speaker-gate.js";
13
+ import { TtsPlayoutClock } from "./tts-playout-clock.js";
14
+ import * as make from "./packet-factories.js";
15
+
16
+ type TurnInterruptionState =
17
+ | { kind: "idle" }
18
+ | {
19
+ kind: "pending";
20
+ userContextId: string;
21
+ interruptedContextId: string;
22
+ firstSpeechMs: number;
23
+ awaitingAudio: boolean;
24
+ };
25
+
26
+ type PendingTurnInterruption = Extract<TurnInterruptionState, { kind: "pending" }>;
27
+
28
+ const BACKCHANNELS = new Set([
29
+ "yeah",
30
+ "yep",
31
+ "yup",
32
+ "uh huh",
33
+ "uhhuh",
34
+ "uh-huh",
35
+ "mhm",
36
+ "mm hmm",
37
+ "mm-hmm",
38
+ "mmhmm",
39
+ "okay",
40
+ "ok",
41
+ "right",
42
+ "sure",
43
+ "uh",
44
+ "um",
45
+ "hmm",
46
+ "i see",
47
+ "got it",
48
+ "gotcha",
49
+ "oh",
50
+ ]);
51
+
52
+ function isBackchannel(text: string): boolean {
53
+ const norm = text
54
+ .toLowerCase()
55
+ .replace(/[^a-z\s'-]/g, "")
56
+ .trim()
57
+ .replace(/\s+/g, " ");
58
+ if (!norm) return false;
59
+ return BACKCHANNELS.has(norm);
60
+ }
61
+
62
+ export interface TurnArbiterDeps {
63
+ readonly bus: PipelineBus;
64
+ readonly primarySpeakerGate: PrimarySpeakerGate;
65
+ readonly ttsPlayout: TtsPlayoutClock;
66
+ readonly minInterruptionMs: number;
67
+ }
68
+
69
+ export class TurnArbiter {
70
+ private turnInterruption: TurnInterruptionState = { kind: "idle" };
71
+ private latestInterimText = "";
72
+ private latestInterimConfidence: number | null = null;
73
+
74
+ constructor(private readonly deps: TurnArbiterDeps) {}
75
+
76
+ noteInterimEvidence(text: string, confidence?: number): void {
77
+ this.latestInterimText = text;
78
+ this.latestInterimConfidence = typeof confidence === "number" ? confidence : null;
79
+ }
80
+
81
+ onSpeechStarted(pkt: VadSpeechStartedPacket, interruptedContextId: string): void {
82
+ const { minInterruptionMs, bus, primarySpeakerGate } = this.deps;
83
+
84
+ // Idempotent for an already-pending barge-in: with both a local VAD and a
85
+ // provider STT emitting speech-start (vad_events), the later event must not
86
+ // reset firstSpeechMs and delay the commit.
87
+ if (this.pendingFor(pkt.contextId)) return;
88
+
89
+ if (minInterruptionMs <= 0) {
90
+ if (this.shouldDeferImmediateBargeInForSpeakerGate()) {
91
+ this.transitionToPending(pkt, interruptedContextId, true);
92
+ return;
93
+ }
94
+ bus.push(Route.Background, make.metric(interruptedContextId, "vaqi.interruption", "1"));
95
+ this.emitInterruptDetected(interruptedContextId);
96
+ return;
97
+ }
98
+
99
+ this.transitionToPending(pkt, interruptedContextId, false);
100
+ }
101
+
102
+ onSpeechActivity(pkt: VadSpeechActivityPacket): void {
103
+ const pending = this.pendingFor(pkt.contextId);
104
+ if (!pending) return;
105
+ if (pkt.timestampMs - pending.firstSpeechMs < this.deps.minInterruptionMs) return;
106
+ this.tryCommit(pkt.timestampMs);
107
+ }
108
+
109
+ // Barge-in evidence for deployments where the provider STT owns endpointing and
110
+ // no VAD plugin emits vad.speech_started: interim/final transcripts arriving
111
+ // while TTS playout is active are the speech signal. First evidence opens the
112
+ // pending window; later evidence commits once sustained past minInterruptionMs,
113
+ // through the same backchannel / low-confidence / speaker-gate suppression.
114
+ onProviderSttEvidence(userContextId: string, timestampMs: number, interruptedContextId: string): void {
115
+ const state = this.turnInterruption;
116
+ if (state.kind === "pending") {
117
+ if (state.userContextId !== userContextId) return;
118
+ this.tryCommit(timestampMs);
119
+ return;
120
+ }
121
+ this.transitionToPending({ contextId: userContextId, timestampMs }, interruptedContextId, false);
122
+ }
123
+
124
+ onSpeechEnded(pkt: VadSpeechEndedPacket, hasActiveTts: boolean): void {
125
+ const pending = this.pendingFor(pkt.contextId);
126
+ if (pending) {
127
+ const durationMs = pkt.timestampMs - pending.firstSpeechMs;
128
+ if (durationMs >= this.deps.minInterruptionMs) {
129
+ if (
130
+ this.deps.primarySpeakerGate.isEnabled() &&
131
+ this.deps.primarySpeakerGate.hasProfile() &&
132
+ !this.deps.primarySpeakerGate.shouldCommitBargeIn()
133
+ ) {
134
+ this.suppress(pending, "interrupt.suppressed_non_primary", durationMs);
135
+ } else {
136
+ this.tryCommit(pkt.timestampMs);
137
+ }
138
+ } else {
139
+ this.suppress(pending, "interrupt.suppressed_short_speech", durationMs);
140
+ }
141
+ return;
142
+ }
143
+
144
+ if (!hasActiveTts) {
145
+ this.deps.primarySpeakerGate.lockProfileFromFirstTurn();
146
+ }
147
+ }
148
+
149
+ observeBargeInAudio(pkt: VadAudioPacket): boolean {
150
+ const pending = this.pendingFor(pkt.contextId);
151
+ if (!pending) return false;
152
+
153
+ this.deps.primarySpeakerGate.observeBargeInChunk(pkt.audio);
154
+ if (pending.awaitingAudio) {
155
+ this.setAwaitingAudio(false);
156
+ this.tryCommit(pkt.timestampMs);
157
+ }
158
+ return true;
159
+ }
160
+
161
+ emitInterruptDetected(interruptedContextId: string): void {
162
+ this.deps.bus.push(Route.Critical, make.interruptDetected(interruptedContextId, Date.now(), "vad"));
163
+ }
164
+
165
+ commitClientInterrupt(interruptedContextId: string): void {
166
+ if (!this.deps.ttsPlayout.isActive(interruptedContextId)) return;
167
+ this.turnInterruption = { kind: "idle" };
168
+ this.deps.primarySpeakerGate.resetBargeInWindow();
169
+ this.deps.bus.push(Route.Background, make.metric(interruptedContextId, "interrupt.committed_after_ms", "0"));
170
+ this.deps.bus.push(Route.Background, make.metric(interruptedContextId, "vaqi.interruption", "1"));
171
+ this.deps.bus.push(Route.Background, make.metric(interruptedContextId, "interrupt.latency_ms", "0"));
172
+ this.deps.bus.push(Route.Critical, make.interruptDetected(interruptedContextId, Date.now(), "client"));
173
+ }
174
+
175
+ clear(): void {
176
+ this.turnInterruption = { kind: "idle" };
177
+ this.latestInterimText = "";
178
+ this.latestInterimConfidence = null;
179
+ }
180
+
181
+ private pendingFor(userContextId: string): PendingTurnInterruption | null {
182
+ const state = this.turnInterruption;
183
+ if (state.kind !== "pending" || state.userContextId !== userContextId) return null;
184
+ return state;
185
+ }
186
+
187
+ private transitionToPending(
188
+ pkt: Pick<VadSpeechStartedPacket, "contextId" | "timestampMs">,
189
+ interruptedContextId: string,
190
+ awaitingAudio: boolean,
191
+ ): void {
192
+ this.deps.primarySpeakerGate.beginBargeInWindow();
193
+ // Reset interim evidence so a stale low-confidence/backchannel interim from a
194
+ // previous turn cannot suppress this new turn's barge-in. The current turn's
195
+ // own interims (noteInterimEvidence) repopulate it before tryCommit reads it.
196
+ this.latestInterimText = "";
197
+ this.latestInterimConfidence = null;
198
+ this.turnInterruption = {
199
+ kind: "pending",
200
+ userContextId: pkt.contextId,
201
+ interruptedContextId,
202
+ firstSpeechMs: pkt.timestampMs,
203
+ awaitingAudio,
204
+ };
205
+ }
206
+
207
+ private setAwaitingAudio(awaitingAudio: boolean): void {
208
+ const state = this.turnInterruption;
209
+ if (state.kind !== "pending") return;
210
+ this.turnInterruption = { ...state, awaitingAudio };
211
+ }
212
+
213
+ private shouldDeferImmediateBargeInForSpeakerGate(): boolean {
214
+ const gate = this.deps.primarySpeakerGate;
215
+ return gate.isEnabled() && gate.hasProfile();
216
+ }
217
+
218
+ private tryCommit(nowMs: number): void {
219
+ const pending = this.turnInterruption.kind === "pending" ? this.turnInterruption : null;
220
+ if (!pending) return;
221
+ if (nowMs - pending.firstSpeechMs < this.deps.minInterruptionMs) return;
222
+
223
+ const gate = this.deps.primarySpeakerGate;
224
+ if (gate.isEnabled() && gate.hasProfile() && !gate.shouldCommitBargeIn()) {
225
+ this.suppress(pending, "interrupt.suppressed_non_primary", nowMs - pending.firstSpeechMs);
226
+ return;
227
+ }
228
+
229
+ const sustainedMs = nowMs - pending.firstSpeechMs;
230
+ if (this.latestInterimConfidence !== null && this.latestInterimConfidence < 0.5) {
231
+ this.suppress(pending, "interrupt.suppressed_low_confidence", sustainedMs);
232
+ return;
233
+ }
234
+ if (this.latestInterimText && isBackchannel(this.latestInterimText)) {
235
+ this.suppress(pending, "interrupt.suppressed_backchannel", sustainedMs);
236
+ return;
237
+ }
238
+ this.turnInterruption = { kind: "idle" };
239
+ gate.resetBargeInWindow();
240
+
241
+ const { bus, ttsPlayout } = this.deps;
242
+ if (!ttsPlayout.isActive(pending.interruptedContextId)) {
243
+ bus.push(
244
+ Route.Background,
245
+ make.metric(pending.interruptedContextId, "interrupt.gate_resolved_after_tts_end", String(sustainedMs)),
246
+ );
247
+ return;
248
+ }
249
+
250
+ bus.push(
251
+ Route.Background,
252
+ make.metric(pending.interruptedContextId, "interrupt.committed_after_ms", String(sustainedMs)),
253
+ );
254
+ bus.push(Route.Background, make.metric(pending.interruptedContextId, "vaqi.interruption", "1"));
255
+ bus.push(
256
+ Route.Background,
257
+ make.metric(pending.interruptedContextId, "interrupt.latency_ms", String(sustainedMs)),
258
+ );
259
+ this.emitInterruptDetected(pending.interruptedContextId);
260
+ this.latestInterimText = "";
261
+ this.latestInterimConfidence = null;
262
+ }
263
+
264
+ private suppress(
265
+ pending: PendingTurnInterruption,
266
+ metricName: string,
267
+ durationMs: number,
268
+ ): void {
269
+ if (metricName === "interrupt.suppressed_non_primary") {
270
+ this.deps.primarySpeakerGate.beginBargeInWindow();
271
+ this.turnInterruption = { ...pending };
272
+ } else {
273
+ this.turnInterruption = { kind: "idle" };
274
+ this.deps.primarySpeakerGate.resetBargeInWindow();
275
+ }
276
+ this.deps.bus.push(
277
+ Route.Background,
278
+ make.metric(pending.interruptedContextId, metricName, String(durationMs)),
279
+ );
280
+ this.latestInterimText = "";
281
+ this.latestInterimConfidence = null;
282
+ }
283
+ }
@@ -0,0 +1,240 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ import { Route, type PipelineBus } from "./pipeline-bus.js";
4
+ import type { SttResultPacket } from "./packets.js";
5
+ import { ErrorCategory, SessionState } from "./packets.js";
6
+ import type { VoicePlugin } from "./plugin-contract.js";
7
+ import { TimerScheduler, type Scheduler } from "./scheduler.js";
8
+ import { TtsPlayoutClock } from "./tts-playout-clock.js";
9
+ import * as make from "./packet-factories.js";
10
+
11
+ export function estimatePcm16Duration(audio: Uint8Array, sampleRate: number): number {
12
+ const samples = audio.length / 2;
13
+ return (samples / sampleRate) * 1000;
14
+ }
15
+
16
+ export function requireTtsAudioSampleRate(value: unknown): number {
17
+ if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) {
18
+ throw new Error("tts.audio sampleRateHz must be a positive integer");
19
+ }
20
+ return value;
21
+ }
22
+
23
+ export function languageFromTranscripts(transcripts: readonly SttResultPacket[]): string {
24
+ for (const transcript of transcripts) {
25
+ if (transcript.language) {
26
+ return transcript.language;
27
+ }
28
+ }
29
+ return "";
30
+ }
31
+
32
+ interface ForceFinalizableSttPlugin extends VoicePlugin {
33
+ forceFinalize(contextId?: string): void;
34
+ }
35
+
36
+ export function findForceFinalizableSttPlugin(
37
+ plugins: ReadonlyMap<string, VoicePlugin>,
38
+ ): ForceFinalizableSttPlugin | null {
39
+ for (const name of ["stt", "deepgram"]) {
40
+ const plugin = plugins.get(name);
41
+ if (isForceFinalizableSttPlugin(plugin)) {
42
+ return plugin;
43
+ }
44
+ }
45
+
46
+ for (const plugin of plugins.values()) {
47
+ if (isForceFinalizableSttPlugin(plugin)) {
48
+ return plugin;
49
+ }
50
+ }
51
+
52
+ return null;
53
+ }
54
+
55
+ function isForceFinalizableSttPlugin(
56
+ plugin: VoicePlugin | undefined,
57
+ ): plugin is ForceFinalizableSttPlugin {
58
+ return (
59
+ typeof plugin === "object" &&
60
+ plugin !== null &&
61
+ "forceFinalize" in plugin &&
62
+ typeof plugin.forceFinalize === "function"
63
+ );
64
+ }
65
+
66
+ export interface VoiceSessionWatchdogsDeps {
67
+ readonly bus: PipelineBus;
68
+ readonly plugins: ReadonlyMap<string, VoicePlugin>;
69
+ readonly ttsPlayout: TtsPlayoutClock;
70
+ readonly sttForceFinalizeTimeoutMs: number;
71
+ readonly vaqiMissedResponseMs: number;
72
+ readonly ttsStallMs: number;
73
+ readonly inputCadenceTimeoutMs: number;
74
+ readonly getSessionState: () => SessionState;
75
+ readonly isGenerationInterrupted: (contextId: string) => boolean;
76
+ readonly onVaqiMissedResponseFired: (contextId: string) => void;
77
+ readonly scheduler?: Scheduler;
78
+ }
79
+
80
+ export class VoiceSessionWatchdogs {
81
+ private readonly scheduler: Scheduler;
82
+ private sttForceFinalizeScheduled = false;
83
+ private pendingSttContextId = "";
84
+ private vaqiMissedResponseScheduled = false;
85
+ private vaqiMissedResponseContextId = "";
86
+ private vaqiMissedResponseStartMs = 0;
87
+ private ttsStallScheduled = false;
88
+ private ttsStallContextId = "";
89
+ private inputCadenceScheduled = false;
90
+ private inputCadenceContextId = "";
91
+
92
+ constructor(private readonly deps: VoiceSessionWatchdogsDeps) {
93
+ this.scheduler = deps.scheduler ?? new TimerScheduler();
94
+ }
95
+
96
+ dispose(): void {
97
+ this.clearSttForceFinalizeTimer();
98
+ this.clearVaqiMissedResponseTimer();
99
+ this.clearTtsStallTimer();
100
+ this.clearInputCadenceWatchdog();
101
+ }
102
+
103
+ scheduleSttForceFinalize(contextId: string): void {
104
+ if (this.deps.getSessionState() !== SessionState.Ready) return;
105
+ if (this.deps.sttForceFinalizeTimeoutMs <= 0) return;
106
+
107
+ this.pendingSttContextId = contextId;
108
+ this.clearSttForceFinalizeTimer(false);
109
+ this.sttForceFinalizeScheduled = true;
110
+ this.scheduler.schedule("voice.watchdog.stt_force_finalize", this.deps.sttForceFinalizeTimeoutMs, () => {
111
+ this.sttForceFinalizeScheduled = false;
112
+ const plugin = findForceFinalizableSttPlugin(this.deps.plugins);
113
+ plugin?.forceFinalize(contextId);
114
+ });
115
+ }
116
+
117
+ clearSttForceFinalizeIfContext(contextId: string): void {
118
+ if (this.pendingSttContextId === contextId) {
119
+ this.clearSttForceFinalizeTimer();
120
+ }
121
+ }
122
+
123
+ startVaqiMissedResponseTimer(contextId: string, startMs: number): void {
124
+ if (this.deps.vaqiMissedResponseMs <= 0) return;
125
+ this.clearVaqiMissedResponseTimer();
126
+ this.vaqiMissedResponseContextId = contextId;
127
+ this.vaqiMissedResponseStartMs = startMs;
128
+ this.vaqiMissedResponseScheduled = true;
129
+ this.scheduler.schedule("voice.watchdog.vaqi_missed_response", this.deps.vaqiMissedResponseMs, () => {
130
+ this.vaqiMissedResponseScheduled = false;
131
+ const cid = this.vaqiMissedResponseContextId;
132
+ const elapsedMs = Date.now() - this.vaqiMissedResponseStartMs;
133
+ this.vaqiMissedResponseContextId = "";
134
+ this.vaqiMissedResponseStartMs = 0;
135
+ this.deps.onVaqiMissedResponseFired(cid);
136
+ this.deps.bus.push(Route.Background, make.metric(cid, "vaqi.missed_response", String(elapsedMs)));
137
+ });
138
+ }
139
+
140
+ clearVaqiIfContext(contextId: string): void {
141
+ if (this.vaqiMissedResponseContextId === contextId) {
142
+ this.clearVaqiMissedResponseTimer();
143
+ }
144
+ }
145
+
146
+ armTtsStallTimer(contextId: string): void {
147
+ if (this.deps.ttsStallMs <= 0) return;
148
+ this.clearTtsStallTimer();
149
+ this.ttsStallContextId = contextId;
150
+ this.ttsStallScheduled = true;
151
+ this.scheduler.schedule("voice.watchdog.tts_stall", this.deps.ttsStallMs, () => {
152
+ this.ttsStallScheduled = false;
153
+ const cid = this.ttsStallContextId;
154
+ this.ttsStallContextId = "";
155
+ if (this.deps.isGenerationInterrupted(cid)) return;
156
+ if (!this.deps.ttsPlayout.isActive(cid)) return;
157
+ this.deps.ttsPlayout.release(cid);
158
+ this.deps.bus.push(Route.Background, make.metric(cid, "tts.stall_detected", String(this.deps.ttsStallMs)));
159
+ this.deps.bus.push(
160
+ Route.Critical,
161
+ make.ttsError(
162
+ cid,
163
+ Date.now(),
164
+ new Error(`TTS output stalled: no audio or tts.end for ${String(this.deps.ttsStallMs)}ms`),
165
+ ErrorCategory.NetworkTimeout,
166
+ true,
167
+ ),
168
+ );
169
+ });
170
+ }
171
+
172
+ clearTtsStallTimerFor(contextId: string): void {
173
+ if (this.ttsStallContextId === contextId) this.clearTtsStallTimer();
174
+ }
175
+
176
+ scheduleInputCadenceWatchdog(contextId: string): void {
177
+ if (this.deps.inputCadenceTimeoutMs <= 0) return;
178
+ if (this.deps.getSessionState() !== SessionState.Ready) return;
179
+
180
+ this.clearInputCadenceWatchdog();
181
+ this.inputCadenceContextId = contextId;
182
+ this.inputCadenceScheduled = true;
183
+ this.scheduler.schedule("voice.watchdog.input_cadence", this.deps.inputCadenceTimeoutMs, () => {
184
+ this.inputCadenceScheduled = false;
185
+ const cid = this.inputCadenceContextId;
186
+ if (this.deps.getSessionState() !== SessionState.Ready) return;
187
+
188
+ this.deps.bus.push(
189
+ Route.Background,
190
+ make.metric(cid, "input.cadence_stall_ms", String(this.deps.inputCadenceTimeoutMs)),
191
+ );
192
+ this.deps.bus.push(Route.Critical, {
193
+ kind: "pipeline.error",
194
+ contextId: cid,
195
+ timestampMs: Date.now(),
196
+ component: "pipeline",
197
+ category: ErrorCategory.NetworkTimeout,
198
+ cause: new Error("inbound audio stalled"),
199
+ isRecoverable: true,
200
+ });
201
+
202
+ this.scheduleInputCadenceWatchdog(cid);
203
+ });
204
+ }
205
+
206
+ clearInputCadenceWatchdog(): void {
207
+ if (this.inputCadenceScheduled) {
208
+ this.scheduler.cancel("voice.watchdog.input_cadence");
209
+ this.inputCadenceScheduled = false;
210
+ }
211
+ this.inputCadenceContextId = "";
212
+ }
213
+
214
+ private clearSttForceFinalizeTimer(clearContext = true): void {
215
+ if (this.sttForceFinalizeScheduled) {
216
+ this.scheduler.cancel("voice.watchdog.stt_force_finalize");
217
+ this.sttForceFinalizeScheduled = false;
218
+ }
219
+ if (clearContext) {
220
+ this.pendingSttContextId = "";
221
+ }
222
+ }
223
+
224
+ private clearVaqiMissedResponseTimer(): void {
225
+ if (this.vaqiMissedResponseScheduled) {
226
+ this.scheduler.cancel("voice.watchdog.vaqi_missed_response");
227
+ this.vaqiMissedResponseScheduled = false;
228
+ }
229
+ this.vaqiMissedResponseContextId = "";
230
+ this.vaqiMissedResponseStartMs = 0;
231
+ }
232
+
233
+ private clearTtsStallTimer(): void {
234
+ if (this.ttsStallScheduled) {
235
+ this.scheduler.cancel("voice.watchdog.tts_stall");
236
+ this.ttsStallScheduled = false;
237
+ }
238
+ this.ttsStallContextId = "";
239
+ }
240
+ }