@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.
- package/README.md +41 -0
- package/package.json +22 -0
- package/src/audio/audio.test.ts +285 -0
- package/src/audio/index.ts +5 -0
- package/src/audio/mulaw.ts +42 -0
- package/src/audio/pcm.ts +43 -0
- package/src/audio/resample.ts +177 -0
- package/src/audio-envelope.test.ts +167 -0
- package/src/audio-envelope.ts +143 -0
- package/src/conversation-event.ts +62 -0
- package/src/error-handler.test.ts +56 -0
- package/src/error-handler.ts +149 -0
- package/src/idle-timeout.ts +210 -0
- package/src/index.ts +215 -0
- package/src/init-chain.ts +137 -0
- package/src/init-stage-order.ts +79 -0
- package/src/latency-filler-fixtures.ts +16 -0
- package/src/latency-filler.test.ts +62 -0
- package/src/latency-filler.ts +125 -0
- package/src/mode-switcher.ts +110 -0
- package/src/observability-observer.test.ts +245 -0
- package/src/observability-observer.ts +195 -0
- package/src/observability.test.ts +85 -0
- package/src/observability.ts +93 -0
- package/src/packet-factories.test.ts +34 -0
- package/src/packet-factories.ts +243 -0
- package/src/packets.ts +553 -0
- package/src/pipeline-bus.g10.test.ts +145 -0
- package/src/pipeline-bus.test.ts +197 -0
- package/src/pipeline-bus.ts +369 -0
- package/src/plugin-contract.ts +79 -0
- package/src/primary-speaker-fixtures.ts +45 -0
- package/src/primary-speaker-gate.test.ts +150 -0
- package/src/primary-speaker-gate.ts +186 -0
- package/src/provider-fallback.test.ts +87 -0
- package/src/provider-fallback.ts +88 -0
- package/src/reasoner.test.ts +69 -0
- package/src/reasoner.ts +54 -0
- package/src/retry.test.ts +83 -0
- package/src/retry.ts +106 -0
- package/src/scheduler.ts +28 -0
- package/src/tts-playout-clock.test.ts +125 -0
- package/src/tts-playout-clock.ts +116 -0
- package/src/turn-arbiter.characterization.test.ts +477 -0
- package/src/turn-arbiter.test.ts +567 -0
- package/src/turn-arbiter.ts +283 -0
- package/src/voice-agent-session-util.ts +240 -0
- package/src/voice-agent-session.test.ts +2487 -0
- package/src/voice-agent-session.ts +1175 -0
- package/src/voice-text.test.ts +81 -0
- package/src/voice-text.ts +102 -0
- package/tsconfig.json +21 -0
|
@@ -0,0 +1,1175 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
//
|
|
3
|
+
// Syrinx Kernel v2 — Voice Agent Session
|
|
4
|
+
//
|
|
5
|
+
// The central orchestrator. Wires together PipelineBus, plugins, init chain,
|
|
6
|
+
// error handler, idle timeout, mode switcher, and debug event stream.
|
|
7
|
+
//
|
|
8
|
+
// Breaking changes from v0.1:
|
|
9
|
+
// - Plugins accept PipelineBus directly (no callbacks).
|
|
10
|
+
// - Explicit init/finalize chains with stage-level error reporting.
|
|
11
|
+
// - Categorized errors with recoverable flag.
|
|
12
|
+
// - Priority bus with Critical/Main/Background routes.
|
|
13
|
+
// - Unified ConversationEvent debug stream.
|
|
14
|
+
// - Idle timeout with consecutive backoff.
|
|
15
|
+
// - Mode switching (text ↔ audio).
|
|
16
|
+
//
|
|
17
|
+
// Backward compat: on/off event emitter stays for client-side consumers
|
|
18
|
+
// (bridges bus packets to legacy event names like "user_started_speaking").
|
|
19
|
+
|
|
20
|
+
import { PipelineBusImpl, Route, type PipelineBus } from "./pipeline-bus.js";
|
|
21
|
+
import { runInitChain, runFinalizeChain, type InitStep } from "./init-chain.js";
|
|
22
|
+
import type { VoicePlugin, PluginConfig, EndpointingOwner } from "./plugin-contract.js";
|
|
23
|
+
import { IdleTimeoutManager, type IdleTimeoutConfig } from "./idle-timeout.js";
|
|
24
|
+
import { ModeSwitcher } from "./mode-switcher.js";
|
|
25
|
+
import { createConversationEventStream, type ConversationEvent } from "./conversation-event.js";
|
|
26
|
+
import { isRecoverable } from "./error-handler.js";
|
|
27
|
+
import type {
|
|
28
|
+
VoiceErrorPacket,
|
|
29
|
+
UserAudioReceivedPacket,
|
|
30
|
+
UserTextReceivedPacket,
|
|
31
|
+
InterruptionDetectedPacket,
|
|
32
|
+
LlmDeltaPacket,
|
|
33
|
+
LlmResponseDonePacket,
|
|
34
|
+
LlmToolCallPacket,
|
|
35
|
+
LlmToolResultPacket,
|
|
36
|
+
TextToSpeechAudioPacket,
|
|
37
|
+
TextToSpeechEndPacket,
|
|
38
|
+
TextToSpeechPlayoutProgressPacket,
|
|
39
|
+
SpeechToTextAudioPacket,
|
|
40
|
+
SttResultPacket,
|
|
41
|
+
SttInterimPacket,
|
|
42
|
+
VadAudioPacket,
|
|
43
|
+
VadSpeechStartedPacket,
|
|
44
|
+
VadSpeechActivityPacket,
|
|
45
|
+
VadSpeechEndedPacket,
|
|
46
|
+
EndOfSpeechPacket,
|
|
47
|
+
InterimEndOfSpeechPacket,
|
|
48
|
+
InjectMessagePacket,
|
|
49
|
+
DisconnectRequestedPacket,
|
|
50
|
+
InitializationFailedPacket,
|
|
51
|
+
ModeSwitchRequestedPacket,
|
|
52
|
+
StartIdleTimeoutPacket,
|
|
53
|
+
StopIdleTimeoutPacket,
|
|
54
|
+
} from "./packets.js";
|
|
55
|
+
import {
|
|
56
|
+
SessionState,
|
|
57
|
+
InitStage,
|
|
58
|
+
ErrorCategory,
|
|
59
|
+
} from "./packets.js";
|
|
60
|
+
import { LatencyFillerController } from "./latency-filler.js";
|
|
61
|
+
import { PrimarySpeakerGate } from "./primary-speaker-gate.js";
|
|
62
|
+
import { takeCompleteVoiceText, isCompleteVoiceText, appendVoiceText } from "./voice-text.js";
|
|
63
|
+
import { TtsPlayoutClock } from "./tts-playout-clock.js";
|
|
64
|
+
import { TurnArbiter } from "./turn-arbiter.js";
|
|
65
|
+
import * as make from "./packet-factories.js";
|
|
66
|
+
import { pluginStage, stageOrder, isAudioStage } from "./init-stage-order.js";
|
|
67
|
+
import {
|
|
68
|
+
estimatePcm16Duration,
|
|
69
|
+
languageFromTranscripts,
|
|
70
|
+
requireTtsAudioSampleRate,
|
|
71
|
+
VoiceSessionWatchdogs,
|
|
72
|
+
} from "./voice-agent-session-util.js";
|
|
73
|
+
import { noopMetricsExporter, type MetricsExporter } from "./observability.js";
|
|
74
|
+
import { ObservabilityObserver } from "./observability-observer.js";
|
|
75
|
+
import { TimerScheduler, type Scheduler } from "./scheduler.js";
|
|
76
|
+
|
|
77
|
+
// =============================================================================
|
|
78
|
+
// Types
|
|
79
|
+
// =============================================================================
|
|
80
|
+
|
|
81
|
+
export interface VoiceAgentSessionConfig {
|
|
82
|
+
/** Plugin configurations, keyed by plugin name. */
|
|
83
|
+
plugins: Record<string, PluginConfig>;
|
|
84
|
+
/** Idle timeout configuration. */
|
|
85
|
+
idleTimeout?: Partial<IdleTimeoutConfig>;
|
|
86
|
+
/** PipelineBus configuration. */
|
|
87
|
+
busConfig?: {
|
|
88
|
+
mainCapacity?: number;
|
|
89
|
+
bgCapacity?: number;
|
|
90
|
+
criticalBatchSize?: number;
|
|
91
|
+
};
|
|
92
|
+
/**
|
|
93
|
+
* Maximum ms to wait for an STT final transcript after audio injection stops.
|
|
94
|
+
* When this timer fires, asks the streaming STT provider to flush buffered audio.
|
|
95
|
+
* Default: 7000 (endpointing + 2s grace)
|
|
96
|
+
*/
|
|
97
|
+
sttForceFinalizeTimeoutMs?: number;
|
|
98
|
+
/**
|
|
99
|
+
* Minimum sustained user-speech duration (ms) during assistant playback before a
|
|
100
|
+
* barge-in is committed. Filters transient noise, clicks, and very short blips that
|
|
101
|
+
* would otherwise falsely cut off the agent. The agent keeps speaking until the
|
|
102
|
+
* user's speech is sustained past this threshold, then interruption fires
|
|
103
|
+
* immediately. Set to 0 to disable the gate and interrupt on the first VAD speech
|
|
104
|
+
* frame (legacy behavior). Default: 280 ms.
|
|
105
|
+
*/
|
|
106
|
+
minInterruptionMs?: number;
|
|
107
|
+
/**
|
|
108
|
+
* When true (default), barge-in requires sustained speech from the enrolled
|
|
109
|
+
* primary speaker (first user turn fingerprint) in addition to G1's time gate.
|
|
110
|
+
* Non-primary / echo speech emits `interrupt.suppressed_non_primary`. When no
|
|
111
|
+
* profile is enrolled yet, falls back to G1-only behavior.
|
|
112
|
+
*/
|
|
113
|
+
primarySpeakerBargeInEnabled?: boolean;
|
|
114
|
+
/**
|
|
115
|
+
* When true, emit a short discourse connective via TTS at endpoint (before LLM
|
|
116
|
+
* TTFB) and splice the real response in when it arrives. Off by default.
|
|
117
|
+
*/
|
|
118
|
+
latencyFillerEnabled?: boolean;
|
|
119
|
+
/**
|
|
120
|
+
* Maximum ms after a user turn ends to wait for first assistant audio before
|
|
121
|
+
* emitting a vaqi.missed_response metric (VAQI-M). 0 disables the check.
|
|
122
|
+
* Default: 4000.
|
|
123
|
+
*/
|
|
124
|
+
vaqiMissedResponseMs?: number;
|
|
125
|
+
/**
|
|
126
|
+
* Max ms of silence from the TTS provider AFTER it has begun producing audio for a
|
|
127
|
+
* turn before the output is treated as a stalled provider. Guards against a TTS
|
|
128
|
+
* provider that goes silent mid-utterance without `tts.end` or an error (dead air).
|
|
129
|
+
* Armed only after the first `tts.audio`, so first-audio latency (which can be many
|
|
130
|
+
* seconds on some providers, e.g. Gemini) is never watchdogged. On breach, a
|
|
131
|
+
* recoverable `tts.error` (NetworkTimeout) is emitted so the turn fails visibly
|
|
132
|
+
* instead of hanging. 0 disables. Default: 15000.
|
|
133
|
+
*/
|
|
134
|
+
ttsStallMs?: number;
|
|
135
|
+
/**
|
|
136
|
+
* Max ms of silence on inbound user audio while the session is Ready before a
|
|
137
|
+
* recoverable transport warning is emitted. Continuous streams (telephony, open
|
|
138
|
+
* mic) should set this; push-to-talk / headless sessions leave at 0 (disabled).
|
|
139
|
+
* Default: 0.
|
|
140
|
+
*/
|
|
141
|
+
inputCadenceTimeoutMs?: number;
|
|
142
|
+
/**
|
|
143
|
+
* Spoken fallback when the reasoning (LLM) layer fails a turn with a recoverable
|
|
144
|
+
* error. "Never fail silently" (Deepgram guide Ch3): rather than ending the turn in
|
|
145
|
+
* unexplained silence, the agent speaks this line via the normal TTS path (which is
|
|
146
|
+
* unaffected by an LLM failure). Empty string disables. Default: a brief apology.
|
|
147
|
+
* (TTS/STT-failure fallback needs canned audio / a clarification prompt — out of scope.)
|
|
148
|
+
*/
|
|
149
|
+
errorFallbackText?: string;
|
|
150
|
+
/**
|
|
151
|
+
* Which component owns turn boundary (EOS) for this session. Defaults to
|
|
152
|
+
* provider STT ownership; Smart Turn sessions must opt in explicitly.
|
|
153
|
+
*/
|
|
154
|
+
endpointingOwner?: "provider_stt" | "smart_turn" | "timer";
|
|
155
|
+
readonly metricsExporter?: MetricsExporter;
|
|
156
|
+
readonly scheduler?: Scheduler;
|
|
157
|
+
readonly observability?: {
|
|
158
|
+
readonly sessionId?: string;
|
|
159
|
+
readonly provider?: string;
|
|
160
|
+
readonly model?: string;
|
|
161
|
+
readonly region?: string;
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export interface VoiceAgentSessionEvents {
|
|
166
|
+
user_started_speaking: (event: { tsMs: number; turnId: string }) => void;
|
|
167
|
+
user_stopped_speaking: (event: { tsMs: number; turnId: string }) => void;
|
|
168
|
+
user_input_partial: (event: { tsMs: number; turnId: string; text: string }) => void;
|
|
169
|
+
user_input_final: (event: { tsMs: number; turnId: string; text: string; confidence: number }) => void;
|
|
170
|
+
agent_text_delta: (event: { tsMs: number; turnId: string; delta: string }) => void;
|
|
171
|
+
agent_tool_call: (event: { tsMs: number; turnId: string; id: string; name: string; args: Record<string, unknown> }) => void;
|
|
172
|
+
agent_tool_result: (event: { tsMs: number; turnId: string; id: string; result: string; durationMs: number }) => void;
|
|
173
|
+
agent_first_audio: (event: { tsMs: number; turnId: string }) => void;
|
|
174
|
+
agent_finished: (event: { tsMs: number; turnId: string } & Record<string, unknown>) => void;
|
|
175
|
+
error: (event: { tsMs: number; stage: string; category: string; message: string }) => void;
|
|
176
|
+
closed: (event: { tsMs: number; reason: string }) => void;
|
|
177
|
+
state_changed: (event: { tsMs: number; from: SessionState; to: SessionState }) => void;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
type EventHandler<T> = (event: T) => void;
|
|
181
|
+
|
|
182
|
+
interface TtsTextBuffer {
|
|
183
|
+
pending: string;
|
|
184
|
+
emitted: string;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Suffix marking a context created to speak an error fallback, so it never recurses. */
|
|
188
|
+
const FALLBACK_CONTEXT_SUFFIX = ":error-fallback";
|
|
189
|
+
|
|
190
|
+
// =============================================================================
|
|
191
|
+
// Session Implementation
|
|
192
|
+
// =============================================================================
|
|
193
|
+
|
|
194
|
+
export class VoiceAgentSession {
|
|
195
|
+
readonly bus: PipelineBus;
|
|
196
|
+
readonly debugEvents: ReadableStream<ConversationEvent>;
|
|
197
|
+
private readonly config: VoiceAgentSessionConfig;
|
|
198
|
+
private readonly sttForceFinalizeTimeoutMs: number;
|
|
199
|
+
private _state: SessionState = SessionState.Uninitialized;
|
|
200
|
+
private plugins: Map<string, VoicePlugin> = new Map();
|
|
201
|
+
private initSteps: InitStep[] = [];
|
|
202
|
+
private idleTimeout: IdleTimeoutManager;
|
|
203
|
+
private modeSwitcher: ModeSwitcher;
|
|
204
|
+
private debugPush: (event: ConversationEvent) => void;
|
|
205
|
+
private eventListeners = new Map<string, Set<EventHandler<unknown>>>();
|
|
206
|
+
private currentTurnId = "";
|
|
207
|
+
private busStartPromise: Promise<void> | null = null;
|
|
208
|
+
private closePromise: Promise<void> | null = null;
|
|
209
|
+
// Tracks which contexts are still playing out their TTS audio; turn-taking and
|
|
210
|
+
// the stall watchdog key on this. Pure state — see TtsPlayoutClock.
|
|
211
|
+
private readonly scheduler: Scheduler;
|
|
212
|
+
private readonly ttsPlayout: TtsPlayoutClock;
|
|
213
|
+
private interruptedGenerationContextIds = new Set<string>();
|
|
214
|
+
private ttsTextBuffers = new Map<string, TtsTextBuffer>();
|
|
215
|
+
private readonly minInterruptionMs: number;
|
|
216
|
+
private readonly primarySpeakerGate: PrimarySpeakerGate;
|
|
217
|
+
private readonly turnArbiter!: TurnArbiter;
|
|
218
|
+
private readonly latencyFiller: LatencyFillerController;
|
|
219
|
+
private firstLlmDeltaReceived = new Set<string>();
|
|
220
|
+
private readonly vaqiMissedResponseMs: number;
|
|
221
|
+
private readonly ttsStallMs: number;
|
|
222
|
+
private readonly inputCadenceTimeoutMs: number;
|
|
223
|
+
private readonly watchdogs!: VoiceSessionWatchdogs;
|
|
224
|
+
private readonly observabilityObserver: ObservabilityObserver;
|
|
225
|
+
private turnUserStoppedAtMs = new Map<string, number>();
|
|
226
|
+
private speakerEnrollmentContextId: string | null = null;
|
|
227
|
+
private firstTtsAudioFired = new Set<string>();
|
|
228
|
+
private readonly errorFallbackText: string;
|
|
229
|
+
private fallbackInjectedContexts = new Set<string>();
|
|
230
|
+
private readonly endpointingOwner: "provider_stt" | "smart_turn" | "timer";
|
|
231
|
+
private lastFinalizedContextId = "";
|
|
232
|
+
|
|
233
|
+
constructor(config: VoiceAgentSessionConfig) {
|
|
234
|
+
const owner = config.endpointingOwner;
|
|
235
|
+
if (owner !== undefined && owner !== "provider_stt" && owner !== "smart_turn" && owner !== "timer") {
|
|
236
|
+
throw new Error(`Unsupported endpointingOwner: ${owner}`);
|
|
237
|
+
}
|
|
238
|
+
this.endpointingOwner = owner ?? "provider_stt";
|
|
239
|
+
this.config = config;
|
|
240
|
+
this.scheduler = config.scheduler ?? new TimerScheduler();
|
|
241
|
+
this.ttsPlayout = new TtsPlayoutClock(this.scheduler);
|
|
242
|
+
this.sttForceFinalizeTimeoutMs = config.sttForceFinalizeTimeoutMs ?? 7000;
|
|
243
|
+
this.minInterruptionMs = config.minInterruptionMs ?? 280;
|
|
244
|
+
this.primarySpeakerGate = new PrimarySpeakerGate({
|
|
245
|
+
enabled: config.primarySpeakerBargeInEnabled !== false,
|
|
246
|
+
});
|
|
247
|
+
this.latencyFiller = new LatencyFillerController({
|
|
248
|
+
enabled: config.latencyFillerEnabled === true,
|
|
249
|
+
});
|
|
250
|
+
this.vaqiMissedResponseMs = config.vaqiMissedResponseMs ?? 4000;
|
|
251
|
+
this.ttsStallMs = config.ttsStallMs ?? 15000;
|
|
252
|
+
this.inputCadenceTimeoutMs = config.inputCadenceTimeoutMs ?? 0;
|
|
253
|
+
this.errorFallbackText = config.errorFallbackText ?? "Sorry, I'm having trouble right now. Could you try again?";
|
|
254
|
+
|
|
255
|
+
// Debug events
|
|
256
|
+
const [stream, push] = createConversationEventStream();
|
|
257
|
+
this.debugEvents = stream;
|
|
258
|
+
this.debugPush = push;
|
|
259
|
+
|
|
260
|
+
// PipelineBus
|
|
261
|
+
this.bus = new PipelineBusImpl({
|
|
262
|
+
...config.busConfig,
|
|
263
|
+
onPacket: (route, packet) => {
|
|
264
|
+
this.debugPush({
|
|
265
|
+
component: "bus",
|
|
266
|
+
type: "packet",
|
|
267
|
+
data: {
|
|
268
|
+
context_id: packet.contextId,
|
|
269
|
+
route: Route[route] ?? String(route),
|
|
270
|
+
kind: packet.kind,
|
|
271
|
+
},
|
|
272
|
+
timestampMs: packet.timestampMs,
|
|
273
|
+
});
|
|
274
|
+
},
|
|
275
|
+
onBackgroundDrop: (dropped) => {
|
|
276
|
+
this.debugPush({
|
|
277
|
+
component: "pipeline",
|
|
278
|
+
type: "background_dropped",
|
|
279
|
+
data: {
|
|
280
|
+
context_id: dropped.contextId,
|
|
281
|
+
kind: dropped.kind,
|
|
282
|
+
},
|
|
283
|
+
timestampMs: Date.now(),
|
|
284
|
+
});
|
|
285
|
+
},
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
this.turnArbiter = new TurnArbiter({
|
|
289
|
+
bus: this.bus,
|
|
290
|
+
primarySpeakerGate: this.primarySpeakerGate,
|
|
291
|
+
ttsPlayout: this.ttsPlayout,
|
|
292
|
+
minInterruptionMs: this.minInterruptionMs,
|
|
293
|
+
});
|
|
294
|
+
this.watchdogs = new VoiceSessionWatchdogs({
|
|
295
|
+
bus: this.bus,
|
|
296
|
+
plugins: this.plugins,
|
|
297
|
+
ttsPlayout: this.ttsPlayout,
|
|
298
|
+
sttForceFinalizeTimeoutMs: this.sttForceFinalizeTimeoutMs,
|
|
299
|
+
vaqiMissedResponseMs: this.vaqiMissedResponseMs,
|
|
300
|
+
ttsStallMs: this.ttsStallMs,
|
|
301
|
+
inputCadenceTimeoutMs: this.inputCadenceTimeoutMs,
|
|
302
|
+
getSessionState: () => this._state,
|
|
303
|
+
isGenerationInterrupted: (contextId) => this.interruptedGenerationContextIds.has(contextId),
|
|
304
|
+
onVaqiMissedResponseFired: (contextId) => {
|
|
305
|
+
this.turnUserStoppedAtMs.delete(contextId);
|
|
306
|
+
},
|
|
307
|
+
scheduler: this.scheduler,
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
const obs = config.observability;
|
|
311
|
+
this.observabilityObserver = new ObservabilityObserver({
|
|
312
|
+
bus: this.bus,
|
|
313
|
+
exporter: config.metricsExporter ?? noopMetricsExporter,
|
|
314
|
+
sessionId: obs?.sessionId ?? "",
|
|
315
|
+
dims: {
|
|
316
|
+
provider: obs?.provider ?? "unknown",
|
|
317
|
+
model: obs?.model ?? "unknown",
|
|
318
|
+
region: obs?.region ?? "unknown",
|
|
319
|
+
},
|
|
320
|
+
getContextId: () => this.currentContextId,
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
// Idle timeout — starts after bus handlers are wired
|
|
324
|
+
this.idleTimeout = new IdleTimeoutManager(this.bus, config.idleTimeout, this.scheduler);
|
|
325
|
+
|
|
326
|
+
// Mode switcher
|
|
327
|
+
this.modeSwitcher = new ModeSwitcher(this.bus);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// =========================================================================
|
|
331
|
+
// Public API
|
|
332
|
+
// =========================================================================
|
|
333
|
+
|
|
334
|
+
get state(): SessionState {
|
|
335
|
+
return this._state;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
get currentContextId(): string {
|
|
339
|
+
return this.currentTurnId;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** Register a plugin. Must be called before start(). */
|
|
343
|
+
registerPlugin(name: string, plugin: VoicePlugin): void {
|
|
344
|
+
this.plugins.set(name, plugin);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** Start the session. Runs init chain, starts bus draining. */
|
|
348
|
+
async start(): Promise<void> {
|
|
349
|
+
if (this._state !== SessionState.Uninitialized) {
|
|
350
|
+
throw new Error(`Cannot start session in state ${this._state}`);
|
|
351
|
+
}
|
|
352
|
+
this._state = SessionState.Initializing;
|
|
353
|
+
|
|
354
|
+
// 1. Wire all bus handlers
|
|
355
|
+
this.wireBusHandlers();
|
|
356
|
+
|
|
357
|
+
// 2. Start bus drain loop
|
|
358
|
+
this.busStartPromise = this.bus.start();
|
|
359
|
+
|
|
360
|
+
// 3. Build init chain from registered plugins
|
|
361
|
+
this.buildInitChain();
|
|
362
|
+
|
|
363
|
+
// 4. Run init chain
|
|
364
|
+
try {
|
|
365
|
+
await runInitChain(this.bus, this.initSteps);
|
|
366
|
+
} catch (err) {
|
|
367
|
+
this._state = SessionState.Failed;
|
|
368
|
+
throw err;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
this._state = SessionState.Ready;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** Shut down the session. Runs finalize chain in reverse order. */
|
|
375
|
+
async close(): Promise<void> {
|
|
376
|
+
if (this._state === SessionState.Closed) return;
|
|
377
|
+
if (this.closePromise) return await this.closePromise;
|
|
378
|
+
this.closePromise = this.closeOnce();
|
|
379
|
+
return await this.closePromise;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
private async closeOnce(): Promise<void> {
|
|
383
|
+
this._state = SessionState.Finalizing;
|
|
384
|
+
|
|
385
|
+
// 1. Stop idle timeout
|
|
386
|
+
this.idleTimeout.dispose();
|
|
387
|
+
this.watchdogs.dispose();
|
|
388
|
+
this.observabilityObserver.dispose();
|
|
389
|
+
this.ttsPlayout.clear();
|
|
390
|
+
this.turnArbiter.clear();
|
|
391
|
+
this.turnUserStoppedAtMs.clear();
|
|
392
|
+
this.firstTtsAudioFired.clear();
|
|
393
|
+
this.fallbackInjectedContexts.clear();
|
|
394
|
+
this.ttsTextBuffers.clear();
|
|
395
|
+
this.interruptedGenerationContextIds.clear();
|
|
396
|
+
this.firstLlmDeltaReceived.clear();
|
|
397
|
+
|
|
398
|
+
// 2. Run finalize chain (reverse order)
|
|
399
|
+
await runFinalizeChain(this.initSteps);
|
|
400
|
+
|
|
401
|
+
// 3. Stop bus
|
|
402
|
+
this.bus.stop();
|
|
403
|
+
await this.busStartPromise;
|
|
404
|
+
|
|
405
|
+
// 4. Emit debug event
|
|
406
|
+
this.emitDebug("session", "disconnected", { reason: "close" });
|
|
407
|
+
|
|
408
|
+
this._state = SessionState.Closed;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/** Switch between text and audio mode. */
|
|
412
|
+
async switchMode(mode: "text" | "audio"): Promise<void> {
|
|
413
|
+
this.bus.push(Route.Main, make.modeSwitchRequested(this.currentTurnId, Date.now(), mode));
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
requestClientInterrupt(contextId: string): void {
|
|
417
|
+
this.turnArbiter.commitClientInterrupt(contextId);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// =========================================================================
|
|
421
|
+
// Legacy Event Emitter (client-side consumers)
|
|
422
|
+
// =========================================================================
|
|
423
|
+
|
|
424
|
+
on<K extends keyof VoiceAgentSessionEvents>(
|
|
425
|
+
event: K,
|
|
426
|
+
handler: VoiceAgentSessionEvents[K],
|
|
427
|
+
): void {
|
|
428
|
+
if (!this.eventListeners.has(event)) {
|
|
429
|
+
this.eventListeners.set(event, new Set());
|
|
430
|
+
}
|
|
431
|
+
this.eventListeners.get(event)!.add(handler as EventHandler<unknown>);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
off<K extends keyof VoiceAgentSessionEvents>(
|
|
435
|
+
event: K,
|
|
436
|
+
handler: VoiceAgentSessionEvents[K],
|
|
437
|
+
): void {
|
|
438
|
+
this.eventListeners.get(event)?.delete(handler as EventHandler<unknown>);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
private emit<K extends keyof VoiceAgentSessionEvents>(
|
|
442
|
+
event: K,
|
|
443
|
+
payload: Parameters<VoiceAgentSessionEvents[K]>[0],
|
|
444
|
+
): void {
|
|
445
|
+
const listeners = this.eventListeners.get(event);
|
|
446
|
+
if (!listeners) return;
|
|
447
|
+
for (const fn of listeners) {
|
|
448
|
+
try {
|
|
449
|
+
(fn as EventHandler<unknown>)(payload);
|
|
450
|
+
} catch {
|
|
451
|
+
// Don't let listener errors crash the session
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// =========================================================================
|
|
457
|
+
// Bus Handler Wiring
|
|
458
|
+
// =========================================================================
|
|
459
|
+
|
|
460
|
+
private wireBusHandlers(): void {
|
|
461
|
+
// Input pipeline
|
|
462
|
+
this.bus.on("user.audio_received", this.handleUserAudio.bind(this));
|
|
463
|
+
this.bus.on("user.text_received", this.handleUserText.bind(this));
|
|
464
|
+
|
|
465
|
+
// STT results
|
|
466
|
+
this.bus.on("stt.audio", this.handleSttAudio.bind(this));
|
|
467
|
+
this.bus.on("stt.interim", this.handleSttInterim.bind(this));
|
|
468
|
+
this.bus.on("stt.result", this.handleSttResult.bind(this));
|
|
469
|
+
|
|
470
|
+
// VAD
|
|
471
|
+
this.bus.on("vad.speech_started", this.handleVadSpeechStarted.bind(this));
|
|
472
|
+
this.bus.on("vad.speech_activity", this.handleVadSpeechActivity.bind(this));
|
|
473
|
+
this.bus.on("vad.speech_ended", this.handleVadSpeechEnded.bind(this));
|
|
474
|
+
this.bus.on("vad.audio", this.handleVadAudioForSpeakerGate.bind(this));
|
|
475
|
+
|
|
476
|
+
// EOS
|
|
477
|
+
this.bus.on("turn.change", () => {
|
|
478
|
+
this.lastFinalizedContextId = "";
|
|
479
|
+
});
|
|
480
|
+
this.bus.on("eos.turn_complete", this.handleTurnComplete.bind(this));
|
|
481
|
+
this.bus.on("eos.interim", this.handleEosInterim.bind(this));
|
|
482
|
+
|
|
483
|
+
// LLM
|
|
484
|
+
this.bus.on("llm.delta", this.handleLlmDelta.bind(this));
|
|
485
|
+
this.bus.on("llm.done", this.handleLlmDone.bind(this));
|
|
486
|
+
this.bus.on("llm.tool_call", this.handleLlmToolCall.bind(this));
|
|
487
|
+
this.bus.on("llm.tool_result", this.handleLlmToolResult.bind(this));
|
|
488
|
+
|
|
489
|
+
// TTS
|
|
490
|
+
this.bus.on("tts.audio", this.handleTtsAudio.bind(this));
|
|
491
|
+
this.bus.on("tts.end", this.handleTtsEnd.bind(this));
|
|
492
|
+
this.bus.on("tts.playout_progress", this.handleTtsPlayoutProgress.bind(this));
|
|
493
|
+
|
|
494
|
+
// Interrupts
|
|
495
|
+
this.bus.on("interrupt.detected", this.handleInterruptDetected.bind(this));
|
|
496
|
+
|
|
497
|
+
// Errors
|
|
498
|
+
this.bus.on("stt.error", this.handleComponentError.bind(this));
|
|
499
|
+
this.bus.on("tts.error", this.handleComponentError.bind(this));
|
|
500
|
+
this.bus.on("vad.error", this.handleComponentError.bind(this));
|
|
501
|
+
this.bus.on("llm.error", this.handleComponentError.bind(this));
|
|
502
|
+
this.bus.on("pipeline.error", this.handleComponentError.bind(this));
|
|
503
|
+
|
|
504
|
+
// Lifecycle
|
|
505
|
+
this.bus.on("init.failed", this.handleInitFailed.bind(this));
|
|
506
|
+
|
|
507
|
+
// Behavior
|
|
508
|
+
this.bus.on<StartIdleTimeoutPacket>("behavior.idle_timeout_start", (pkt) => {
|
|
509
|
+
this.idleTimeout.handleStart(pkt);
|
|
510
|
+
});
|
|
511
|
+
this.bus.on<StopIdleTimeoutPacket>("behavior.idle_timeout_stop", (pkt) => {
|
|
512
|
+
this.idleTimeout.handleStop(pkt);
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
// Injected messages — push through LLM path for natural TTS
|
|
516
|
+
this.bus.on("inject.message", this.handleInjectMessage.bind(this));
|
|
517
|
+
|
|
518
|
+
// Disconnect
|
|
519
|
+
this.bus.on("session.disconnect", this.handleDisconnect.bind(this));
|
|
520
|
+
|
|
521
|
+
// Mode switching
|
|
522
|
+
this.bus.on<ModeSwitchRequestedPacket>("mode.switch_requested", async (pkt) => {
|
|
523
|
+
await this.modeSwitcher.handleSwitchRequested(pkt);
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
this.observabilityObserver.wire();
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// =========================================================================
|
|
530
|
+
// Handler Implementations
|
|
531
|
+
// =========================================================================
|
|
532
|
+
|
|
533
|
+
private handleUserAudio(pkt: UserAudioReceivedPacket): void {
|
|
534
|
+
if (this.shouldEnrollPrimarySpeaker(pkt.contextId)) {
|
|
535
|
+
this.primarySpeakerGate.enrollUserTurnChunk(pkt.audio);
|
|
536
|
+
}
|
|
537
|
+
if (this.endpointingOwner === "provider_stt") {
|
|
538
|
+
this.bus.push(
|
|
539
|
+
Route.Main,
|
|
540
|
+
make.recordUserAudio(pkt.contextId, pkt.timestampMs, pkt.audio),
|
|
541
|
+
make.vadAudio(pkt.contextId, pkt.timestampMs, pkt.audio),
|
|
542
|
+
make.sttAudio(pkt.contextId, pkt.timestampMs, pkt.audio),
|
|
543
|
+
);
|
|
544
|
+
} else {
|
|
545
|
+
this.bus.push(
|
|
546
|
+
Route.Main,
|
|
547
|
+
make.recordUserAudio(pkt.contextId, pkt.timestampMs, pkt.audio),
|
|
548
|
+
make.vadAudio(pkt.contextId, pkt.timestampMs, pkt.audio),
|
|
549
|
+
make.sttAudio(pkt.contextId, pkt.timestampMs, pkt.audio),
|
|
550
|
+
make.eosAudio(pkt.contextId, pkt.timestampMs, pkt.audio),
|
|
551
|
+
);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
this.debugPush({
|
|
555
|
+
component: "input",
|
|
556
|
+
type: "audio_received",
|
|
557
|
+
data: { context_id: pkt.contextId, bytes: String(pkt.audio.length) },
|
|
558
|
+
timestampMs: pkt.timestampMs,
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
this.watchdogs.scheduleInputCadenceWatchdog(pkt.contextId);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
private handleSttAudio(pkt: SpeechToTextAudioPacket): void {
|
|
565
|
+
this.watchdogs.scheduleSttForceFinalize(pkt.contextId);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
private handleUserText(pkt: UserTextReceivedPacket): void {
|
|
569
|
+
// Treat text input as an immediate EOS turn complete
|
|
570
|
+
this.bus.push(Route.Main, make.eosTurnComplete(pkt.contextId, pkt.timestampMs, pkt.text, []));
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
private handleSttInterim(pkt: SttInterimPacket): void {
|
|
574
|
+
this.turnArbiter.noteInterimEvidence(pkt.text);
|
|
575
|
+
this.maybeBargeInFromProviderStt(pkt.contextId, pkt.text, pkt.timestampMs);
|
|
576
|
+
this.currentTurnId = pkt.contextId;
|
|
577
|
+
this.emit("user_input_partial", {
|
|
578
|
+
tsMs: pkt.timestampMs,
|
|
579
|
+
turnId: pkt.contextId,
|
|
580
|
+
text: pkt.text,
|
|
581
|
+
});
|
|
582
|
+
this.debugPush({
|
|
583
|
+
component: "stt",
|
|
584
|
+
type: "interim",
|
|
585
|
+
data: { context_id: pkt.contextId, text: pkt.text },
|
|
586
|
+
timestampMs: pkt.timestampMs,
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
private handleSttResult(pkt: SttResultPacket): void {
|
|
591
|
+
this.watchdogs.clearSttForceFinalizeIfContext(pkt.contextId);
|
|
592
|
+
this.turnArbiter.noteInterimEvidence(pkt.text, pkt.confidence);
|
|
593
|
+
this.maybeBargeInFromProviderStt(pkt.contextId, pkt.text, pkt.timestampMs);
|
|
594
|
+
this.currentTurnId = pkt.contextId;
|
|
595
|
+
this.emit("user_input_final", {
|
|
596
|
+
tsMs: pkt.timestampMs,
|
|
597
|
+
turnId: pkt.contextId,
|
|
598
|
+
text: pkt.text,
|
|
599
|
+
confidence: pkt.confidence,
|
|
600
|
+
});
|
|
601
|
+
this.debugPush({
|
|
602
|
+
component: "stt",
|
|
603
|
+
type: "final",
|
|
604
|
+
data: {
|
|
605
|
+
context_id: pkt.contextId,
|
|
606
|
+
text: pkt.text,
|
|
607
|
+
confidence: String(pkt.confidence),
|
|
608
|
+
},
|
|
609
|
+
timestampMs: pkt.timestampMs,
|
|
610
|
+
});
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
private handleVadAudioForSpeakerGate(pkt: VadAudioPacket): void {
|
|
614
|
+
if (this.turnArbiter.observeBargeInAudio(pkt)) return;
|
|
615
|
+
|
|
616
|
+
if (this.shouldEnrollPrimarySpeaker(pkt.contextId)) {
|
|
617
|
+
this.primarySpeakerGate.enrollUserTurnChunk(pkt.audio);
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
private shouldEnrollPrimarySpeaker(contextId: string): boolean {
|
|
622
|
+
return (
|
|
623
|
+
!this.latestActiveTtsContextId() &&
|
|
624
|
+
this.speakerEnrollmentContextId === contextId
|
|
625
|
+
);
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// Deployments that delegate endpointing to the provider STT register no VAD
|
|
629
|
+
// plugin, so vad.speech_started never fires and barge-in would stay dormant.
|
|
630
|
+
// Provider transcripts arriving while TTS playout is active are the speech
|
|
631
|
+
// evidence instead (echo of our own playout is mitigated by client AEC plus
|
|
632
|
+
// the arbiter's backchannel / low-confidence suppression).
|
|
633
|
+
private maybeBargeInFromProviderStt(contextId: string, text: string, timestampMs: number): void {
|
|
634
|
+
if (this.endpointingOwner !== "provider_stt") return;
|
|
635
|
+
if (!text.trim()) return;
|
|
636
|
+
const interruptedContextId = this.latestActiveTtsContextId();
|
|
637
|
+
if (!interruptedContextId) return;
|
|
638
|
+
this.turnArbiter.onProviderSttEvidence(contextId, timestampMs, interruptedContextId);
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
private handleVadSpeechStarted(pkt: VadSpeechStartedPacket): void {
|
|
642
|
+
this.lastFinalizedContextId = "";
|
|
643
|
+
|
|
644
|
+
if (this.latencyFiller.isFillerOnly(this.currentTurnId)) {
|
|
645
|
+
this.cancelLatencyFillerTurn(this.currentTurnId, pkt.timestampMs);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
this.emit("user_started_speaking", {
|
|
649
|
+
tsMs: pkt.timestampMs,
|
|
650
|
+
turnId: pkt.contextId,
|
|
651
|
+
});
|
|
652
|
+
this.debugPush({
|
|
653
|
+
component: "vad",
|
|
654
|
+
type: "speech_started",
|
|
655
|
+
data: {
|
|
656
|
+
context_id: pkt.contextId,
|
|
657
|
+
confidence: String(pkt.confidence),
|
|
658
|
+
},
|
|
659
|
+
timestampMs: pkt.timestampMs,
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
const interruptedContextId = this.latestActiveTtsContextId();
|
|
663
|
+
if (!interruptedContextId) {
|
|
664
|
+
this.speakerEnrollmentContextId = pkt.contextId;
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
this.turnArbiter.onSpeechStarted(pkt, interruptedContextId);
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
private handleVadSpeechActivity(pkt: VadSpeechActivityPacket): void {
|
|
672
|
+
this.turnArbiter.onSpeechActivity(pkt);
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
private handleVadSpeechEnded(pkt: VadSpeechEndedPacket): void {
|
|
676
|
+
this.emit("user_stopped_speaking", {
|
|
677
|
+
tsMs: pkt.timestampMs,
|
|
678
|
+
turnId: pkt.contextId,
|
|
679
|
+
});
|
|
680
|
+
this.debugPush({
|
|
681
|
+
component: "vad",
|
|
682
|
+
type: "speech_ended",
|
|
683
|
+
data: { context_id: pkt.contextId },
|
|
684
|
+
timestampMs: pkt.timestampMs,
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
if (this.speakerEnrollmentContextId === pkt.contextId) {
|
|
688
|
+
this.speakerEnrollmentContextId = null;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
this.turnArbiter.onSpeechEnded(pkt, Boolean(this.latestActiveTtsContextId()));
|
|
692
|
+
|
|
693
|
+
this.turnUserStoppedAtMs.set(pkt.contextId, pkt.timestampMs);
|
|
694
|
+
this.watchdogs.startVaqiMissedResponseTimer(pkt.contextId, pkt.timestampMs);
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
private handleTurnComplete(pkt: EndOfSpeechPacket): void {
|
|
698
|
+
if (this.lastFinalizedContextId === pkt.contextId) {
|
|
699
|
+
this.bus.push(Route.Background, make.metric(pkt.contextId, "eos.duplicate_dropped", "1"));
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
this.lastFinalizedContextId = pkt.contextId;
|
|
703
|
+
|
|
704
|
+
// Re-arm per-turn guard state for the next turn. Transports with a stable
|
|
705
|
+
// per-call contextId (telephony callSid) reuse one id across turns, so these
|
|
706
|
+
// Sets must be cleared at the turn boundary or turn 2+ inherits stale flags:
|
|
707
|
+
// - firstTtsAudioFired: else vaqi.latency_ms is never emitted again
|
|
708
|
+
// - interruptedGenerationContextIds: else turn N+1's LLM/TTS packets are dropped after a prior barge-in
|
|
709
|
+
// - fallbackInjectedContexts: else only one error fallback can ever be spoken per call
|
|
710
|
+
this.firstTtsAudioFired.delete(pkt.contextId);
|
|
711
|
+
this.interruptedGenerationContextIds.delete(pkt.contextId);
|
|
712
|
+
this.fallbackInjectedContexts.delete(pkt.contextId);
|
|
713
|
+
|
|
714
|
+
this.currentTurnId = pkt.contextId;
|
|
715
|
+
this.idleTimeout.setContextId(pkt.contextId);
|
|
716
|
+
|
|
717
|
+
this.emit("user_input_final", {
|
|
718
|
+
tsMs: pkt.timestampMs,
|
|
719
|
+
turnId: pkt.contextId,
|
|
720
|
+
text: pkt.text,
|
|
721
|
+
confidence: 1.0,
|
|
722
|
+
});
|
|
723
|
+
this.debugPush({
|
|
724
|
+
component: "eos",
|
|
725
|
+
type: "turn_complete",
|
|
726
|
+
data: { context_id: pkt.contextId, text: pkt.text },
|
|
727
|
+
timestampMs: pkt.timestampMs,
|
|
728
|
+
});
|
|
729
|
+
|
|
730
|
+
// Stop idle timeout while LLM is processing
|
|
731
|
+
this.bus.push(Route.Main, make.stopIdleTimeout(pkt.contextId, Date.now(), false));
|
|
732
|
+
|
|
733
|
+
const fillerText = this.latencyFiller.start(pkt.contextId, pkt.text, pkt.timestampMs);
|
|
734
|
+
if (fillerText) {
|
|
735
|
+
this.bus.push(Route.Main, make.ttsText(pkt.contextId, Date.now(), fillerText));
|
|
736
|
+
this.bus.push(Route.Background, make.metric(pkt.contextId, "filler.started", fillerText));
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
this.bus.push(
|
|
740
|
+
Route.Main,
|
|
741
|
+
make.userInput(pkt.contextId, Date.now(), pkt.text, languageFromTranscripts(pkt.transcripts)),
|
|
742
|
+
);
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
private handleEosInterim(pkt: InterimEndOfSpeechPacket): void {
|
|
746
|
+
this.debugPush({
|
|
747
|
+
component: "eos",
|
|
748
|
+
type: "interim",
|
|
749
|
+
data: { context_id: pkt.contextId, text: pkt.text },
|
|
750
|
+
timestampMs: pkt.timestampMs,
|
|
751
|
+
});
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
private handleLlmDelta(pkt: LlmDeltaPacket): void {
|
|
755
|
+
if (this.interruptedGenerationContextIds.has(pkt.contextId)) {
|
|
756
|
+
this.bus.push(
|
|
757
|
+
Route.Background,
|
|
758
|
+
make.metric(pkt.contextId, "llm.delta_ignored_after_interrupt", String(pkt.text.length)),
|
|
759
|
+
);
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
let deltaText = pkt.text;
|
|
764
|
+
if (!this.firstLlmDeltaReceived.has(pkt.contextId)) {
|
|
765
|
+
this.firstLlmDeltaReceived.add(pkt.contextId);
|
|
766
|
+
if (this.latencyFiller.isActive(pkt.contextId)) {
|
|
767
|
+
deltaText = this.latencyFiller.spliceLlmDelta(pkt.contextId, deltaText);
|
|
768
|
+
this.bus.push(Route.Background, make.metric(pkt.contextId, "filler.spliced", "1"));
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
this.emit("agent_text_delta", {
|
|
773
|
+
tsMs: pkt.timestampMs,
|
|
774
|
+
turnId: pkt.contextId,
|
|
775
|
+
delta: deltaText,
|
|
776
|
+
});
|
|
777
|
+
this.debugPush({
|
|
778
|
+
component: "llm",
|
|
779
|
+
type: "delta",
|
|
780
|
+
data: { context_id: pkt.contextId, text: deltaText },
|
|
781
|
+
timestampMs: pkt.timestampMs,
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
this.bufferTtsText(pkt.contextId, deltaText);
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
private handleLlmDone(pkt: LlmResponseDonePacket): void {
|
|
788
|
+
if (this.interruptedGenerationContextIds.has(pkt.contextId)) {
|
|
789
|
+
this.ttsTextBuffers.delete(pkt.contextId);
|
|
790
|
+
this.bus.push(Route.Background, make.metric(pkt.contextId, "llm.done_ignored_after_interrupt", "1"));
|
|
791
|
+
return;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
const spokenText = this.flushTtsText(pkt.contextId);
|
|
795
|
+
this.emit("agent_finished", {
|
|
796
|
+
tsMs: pkt.timestampMs,
|
|
797
|
+
turnId: pkt.contextId,
|
|
798
|
+
});
|
|
799
|
+
this.debugPush({
|
|
800
|
+
component: "llm",
|
|
801
|
+
type: "done",
|
|
802
|
+
data: { context_id: pkt.contextId },
|
|
803
|
+
timestampMs: pkt.timestampMs,
|
|
804
|
+
});
|
|
805
|
+
|
|
806
|
+
// Start idle timeout after agent finishes
|
|
807
|
+
this.bus.push(Route.Main, make.startIdleTimeout(pkt.contextId, Date.now()));
|
|
808
|
+
|
|
809
|
+
this.bus.push(Route.Main, make.ttsDone(pkt.contextId, Date.now(), spokenText));
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
private bufferTtsText(contextId: string, text: string): void {
|
|
813
|
+
const buffer = this.ttsTextBuffers.get(contextId) ?? { pending: "", emitted: "" };
|
|
814
|
+
buffer.pending += text;
|
|
815
|
+
const complete = takeCompleteVoiceText(buffer.pending);
|
|
816
|
+
if (complete.text) {
|
|
817
|
+
this.bus.push(Route.Main, make.ttsText(contextId, Date.now(), complete.text));
|
|
818
|
+
buffer.emitted = appendVoiceText(buffer.emitted, complete.text);
|
|
819
|
+
}
|
|
820
|
+
buffer.pending = complete.remaining;
|
|
821
|
+
this.ttsTextBuffers.set(contextId, buffer);
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
private flushTtsText(contextId: string): string {
|
|
825
|
+
const buffer = this.ttsTextBuffers.get(contextId);
|
|
826
|
+
if (!buffer) return "";
|
|
827
|
+
const tail = buffer.pending.trim();
|
|
828
|
+
if (tail) {
|
|
829
|
+
this.bus.push(Route.Main, make.ttsText(contextId, Date.now(), tail));
|
|
830
|
+
buffer.emitted = appendVoiceText(buffer.emitted, tail);
|
|
831
|
+
buffer.pending = "";
|
|
832
|
+
this.bus.push(
|
|
833
|
+
Route.Background,
|
|
834
|
+
make.metric(contextId, isCompleteVoiceText(tail) ? "tts.final_text_flushed" : "tts.final_tail_flushed", tail),
|
|
835
|
+
);
|
|
836
|
+
}
|
|
837
|
+
this.ttsTextBuffers.delete(contextId);
|
|
838
|
+
this.latencyFiller.clear(contextId);
|
|
839
|
+
this.firstLlmDeltaReceived.delete(contextId);
|
|
840
|
+
return buffer.emitted.trim();
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
private cancelLatencyFillerTurn(contextId: string, timestampMs: number): void {
|
|
844
|
+
const cancelled = this.latencyFiller.cancel(contextId);
|
|
845
|
+
if (!cancelled) return;
|
|
846
|
+
this.bus.push(Route.Background, make.metric(contextId, "filler.cancelled", cancelled.text, timestampMs));
|
|
847
|
+
this.turnArbiter.emitInterruptDetected(contextId);
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
private handleLlmToolCall(pkt: LlmToolCallPacket): void {
|
|
851
|
+
this.emit("agent_tool_call", {
|
|
852
|
+
tsMs: pkt.timestampMs,
|
|
853
|
+
turnId: pkt.contextId,
|
|
854
|
+
id: pkt.toolId,
|
|
855
|
+
name: pkt.toolName,
|
|
856
|
+
args: pkt.toolArgs,
|
|
857
|
+
});
|
|
858
|
+
this.debugPush({
|
|
859
|
+
component: "tool",
|
|
860
|
+
type: "call_started",
|
|
861
|
+
data: {
|
|
862
|
+
context_id: pkt.contextId,
|
|
863
|
+
tool_id: pkt.toolId,
|
|
864
|
+
tool_name: pkt.toolName,
|
|
865
|
+
},
|
|
866
|
+
timestampMs: pkt.timestampMs,
|
|
867
|
+
});
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
private handleLlmToolResult(pkt: LlmToolResultPacket): void {
|
|
871
|
+
this.emit("agent_tool_result", {
|
|
872
|
+
tsMs: pkt.timestampMs,
|
|
873
|
+
turnId: pkt.contextId,
|
|
874
|
+
id: pkt.toolId,
|
|
875
|
+
result: pkt.result,
|
|
876
|
+
durationMs: 0,
|
|
877
|
+
});
|
|
878
|
+
this.debugPush({
|
|
879
|
+
component: "tool",
|
|
880
|
+
type: "call_completed",
|
|
881
|
+
data: {
|
|
882
|
+
context_id: pkt.contextId,
|
|
883
|
+
tool_id: pkt.toolId,
|
|
884
|
+
tool_name: pkt.toolName,
|
|
885
|
+
},
|
|
886
|
+
timestampMs: pkt.timestampMs,
|
|
887
|
+
});
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
private handleTtsAudio(pkt: TextToSpeechAudioPacket): void {
|
|
891
|
+
if (this.interruptedGenerationContextIds.has(pkt.contextId)) {
|
|
892
|
+
this.bus.push(
|
|
893
|
+
Route.Background,
|
|
894
|
+
make.metric(pkt.contextId, "tts.audio_ignored_after_interrupt", String(pkt.audio.byteLength)),
|
|
895
|
+
);
|
|
896
|
+
return;
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
if (!this.firstTtsAudioFired.has(pkt.contextId)) {
|
|
900
|
+
this.firstTtsAudioFired.add(pkt.contextId);
|
|
901
|
+
this.watchdogs.clearVaqiIfContext(pkt.contextId);
|
|
902
|
+
const userStoppedMs = this.turnUserStoppedAtMs.get(pkt.contextId);
|
|
903
|
+
if (userStoppedMs !== undefined) {
|
|
904
|
+
this.bus.push(
|
|
905
|
+
Route.Background,
|
|
906
|
+
make.metric(pkt.contextId, "vaqi.latency_ms", String(pkt.timestampMs - userStoppedMs)),
|
|
907
|
+
);
|
|
908
|
+
this.turnUserStoppedAtMs.delete(pkt.contextId);
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
this.primarySpeakerGate.observeAssistantPlayout(pkt.audio);
|
|
913
|
+
// Audio just arrived — (re)arm the stall watchdog for this turn's TTS output.
|
|
914
|
+
this.watchdogs.armTtsStallTimer(pkt.contextId);
|
|
915
|
+
|
|
916
|
+
// Extend idle timeout by audio duration to prevent timeout during playback.
|
|
917
|
+
const sampleRateHz = requireTtsAudioSampleRate(pkt.sampleRateHz);
|
|
918
|
+
const audioDurationMs = estimatePcm16Duration(pkt.audio, sampleRateHz);
|
|
919
|
+
this.idleTimeout.extend(audioDurationMs);
|
|
920
|
+
|
|
921
|
+
// Mark active and advance this context's playout cursor by the chunk's
|
|
922
|
+
// realtime duration.
|
|
923
|
+
this.ttsPlayout.noteAudio(pkt.contextId, audioDurationMs, Date.now());
|
|
924
|
+
|
|
925
|
+
this.debugPush({
|
|
926
|
+
component: "tts",
|
|
927
|
+
type: "audio",
|
|
928
|
+
data: {
|
|
929
|
+
context_id: pkt.contextId,
|
|
930
|
+
bytes: String(pkt.audio.length),
|
|
931
|
+
},
|
|
932
|
+
timestampMs: pkt.timestampMs,
|
|
933
|
+
});
|
|
934
|
+
|
|
935
|
+
this.bus.push(Route.Main, make.recordAssistantAudio(pkt.contextId, Date.now(), pkt.audio, sampleRateHz));
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
private handleTtsEnd(pkt: TextToSpeechEndPacket): void {
|
|
939
|
+
// Generation finished, but the streamed audio is still playing out. Keep the
|
|
940
|
+
// context interruptible until its playout estimate elapses, then release it.
|
|
941
|
+
this.ttsPlayout.scheduleRelease(pkt.contextId, Date.now());
|
|
942
|
+
this.watchdogs.clearTtsStallTimerFor(pkt.contextId);
|
|
943
|
+
this.debugPush({
|
|
944
|
+
component: "tts",
|
|
945
|
+
type: "end",
|
|
946
|
+
data: {},
|
|
947
|
+
timestampMs: Date.now(),
|
|
948
|
+
});
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
private handleTtsPlayoutProgress(pkt: TextToSpeechPlayoutProgressPacket): void {
|
|
952
|
+
this.ttsPlayout.noteProgress(pkt.contextId, pkt.complete, pkt.playedOutMs);
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
private handleInterruptDetected(pkt: InterruptionDetectedPacket): void {
|
|
956
|
+
this.interruptedGenerationContextIds.add(pkt.contextId);
|
|
957
|
+
this.latencyFiller.cancel(pkt.contextId);
|
|
958
|
+
this.firstLlmDeltaReceived.delete(pkt.contextId);
|
|
959
|
+
this.ttsTextBuffers.delete(pkt.contextId);
|
|
960
|
+
this.ttsPlayout.release(pkt.contextId);
|
|
961
|
+
this.watchdogs.clearTtsStallTimerFor(pkt.contextId);
|
|
962
|
+
this.debugPush({
|
|
963
|
+
component: "turn",
|
|
964
|
+
type: "interrupt_detected",
|
|
965
|
+
data: {
|
|
966
|
+
context_id: pkt.contextId,
|
|
967
|
+
source: pkt.source,
|
|
968
|
+
},
|
|
969
|
+
timestampMs: pkt.timestampMs,
|
|
970
|
+
});
|
|
971
|
+
|
|
972
|
+
this.bus.push(
|
|
973
|
+
Route.Background,
|
|
974
|
+
make.metric(
|
|
975
|
+
pkt.contextId,
|
|
976
|
+
"interrupt.onset_to_logic_cancel_ms",
|
|
977
|
+
String(Math.max(0, Date.now() - pkt.timestampMs)),
|
|
978
|
+
),
|
|
979
|
+
);
|
|
980
|
+
|
|
981
|
+
// Stop idle timeout
|
|
982
|
+
this.bus.push(Route.Critical, make.stopIdleTimeout(pkt.contextId, Date.now(), true));
|
|
983
|
+
|
|
984
|
+
this.bus.push(Route.Critical, make.recordAssistantTruncate(pkt.contextId, Date.now()));
|
|
985
|
+
|
|
986
|
+
// Interrupt TTS, then LLM
|
|
987
|
+
this.bus.push(Route.Critical, make.interruptTts(pkt.contextId, pkt.timestampMs));
|
|
988
|
+
this.bus.push(Route.Critical, make.interruptLlm(pkt.contextId, pkt.timestampMs));
|
|
989
|
+
// Reset STT transcript state too, so a barge-in cannot leak stale finalized
|
|
990
|
+
// segments into the next turn when a client reuses the same contextId
|
|
991
|
+
// (the provider STT plugins listen for interrupt.stt; previously unfired).
|
|
992
|
+
this.bus.push(Route.Critical, make.interruptStt(pkt.contextId, pkt.timestampMs));
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
private handleComponentError(pkt: VoiceErrorPacket): void {
|
|
996
|
+
this.emit("error", {
|
|
997
|
+
tsMs: pkt.timestampMs,
|
|
998
|
+
stage: `${pkt.component}.error`,
|
|
999
|
+
category: pkt.category,
|
|
1000
|
+
message: pkt.cause.message,
|
|
1001
|
+
});
|
|
1002
|
+
|
|
1003
|
+
this.debugPush({
|
|
1004
|
+
component: pkt.component,
|
|
1005
|
+
type: "error",
|
|
1006
|
+
data: {
|
|
1007
|
+
category: pkt.category,
|
|
1008
|
+
message: pkt.cause.message,
|
|
1009
|
+
recoverable: String(pkt.isRecoverable),
|
|
1010
|
+
},
|
|
1011
|
+
timestampMs: pkt.timestampMs,
|
|
1012
|
+
});
|
|
1013
|
+
|
|
1014
|
+
this.latencyFiller.clear(pkt.contextId);
|
|
1015
|
+
// The packet's own recoverability verdict is authoritative when present: the
|
|
1016
|
+
// bus marks handler exceptions (pipeline.error) recoverable by design — one
|
|
1017
|
+
// misbehaving handler must degrade the turn, not kill the whole call.
|
|
1018
|
+
const recoverable = typeof pkt.isRecoverable === "boolean" ? pkt.isRecoverable : isRecoverable(pkt.category);
|
|
1019
|
+
if (!recoverable) {
|
|
1020
|
+
// Fatal error — close session
|
|
1021
|
+
void this.close().catch(() => {
|
|
1022
|
+
// Best effort
|
|
1023
|
+
});
|
|
1024
|
+
return;
|
|
1025
|
+
}
|
|
1026
|
+
// Recoverable errors are handled by individual component retry logic. But never
|
|
1027
|
+
// leave the caller in unexplained silence: if the reasoning layer failed the turn,
|
|
1028
|
+
// speak a graceful fallback (G4 — Deepgram guide "never fail silently").
|
|
1029
|
+
this.maybeSpeakErrorFallback(pkt);
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
private maybeSpeakErrorFallback(pkt: VoiceErrorPacket): void {
|
|
1033
|
+
if (!this.errorFallbackText) return;
|
|
1034
|
+
// Only the reasoning layer: a TTS/STT failure can't reliably use the same TTS path
|
|
1035
|
+
// for a fallback (that needs canned audio / a clarification prompt — out of scope).
|
|
1036
|
+
if (pkt.component !== "llm") return;
|
|
1037
|
+
const contextId = pkt.contextId;
|
|
1038
|
+
if (contextId.endsWith(FALLBACK_CONTEXT_SUFFIX)) return; // never fall back for a fallback
|
|
1039
|
+
if (this.fallbackInjectedContexts.has(contextId)) return; // at most once per turn
|
|
1040
|
+
this.fallbackInjectedContexts.add(contextId);
|
|
1041
|
+
this.bus.push(Route.Background, make.metric(contextId, "error.fallback_spoken", pkt.component));
|
|
1042
|
+
this.bus.push(
|
|
1043
|
+
Route.Main,
|
|
1044
|
+
make.injectMessage(`${contextId}${FALLBACK_CONTEXT_SUFFIX}`, Date.now(), this.errorFallbackText),
|
|
1045
|
+
);
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
private handleInitFailed(pkt: InitializationFailedPacket): void {
|
|
1049
|
+
this.emit("error", {
|
|
1050
|
+
tsMs: pkt.timestampMs,
|
|
1051
|
+
stage: `init.${pkt.stage}`,
|
|
1052
|
+
category: ErrorCategory.InternalFault,
|
|
1053
|
+
message: `Initialization failed: ${pkt.stage}/${pkt.component} — ${pkt.cause.message}`,
|
|
1054
|
+
});
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
private handleInjectMessage(pkt: InjectMessagePacket): void {
|
|
1058
|
+
// Inject as synthetic LLM output — goes through normal TTS path
|
|
1059
|
+
this.bus.push(Route.Main, make.llmDelta(pkt.contextId, Date.now(), pkt.text));
|
|
1060
|
+
this.bus.push(Route.Main, make.llmDone(pkt.contextId, Date.now(), pkt.text));
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
private handleDisconnect(pkt: DisconnectRequestedPacket): void {
|
|
1064
|
+
this.emit("closed", { tsMs: pkt.timestampMs, reason: pkt.reason });
|
|
1065
|
+
this.debugPush({
|
|
1066
|
+
component: "session",
|
|
1067
|
+
type: "disconnect_requested",
|
|
1068
|
+
data: { reason: pkt.reason },
|
|
1069
|
+
timestampMs: pkt.timestampMs,
|
|
1070
|
+
});
|
|
1071
|
+
void this.close().catch(() => {
|
|
1072
|
+
// Best effort
|
|
1073
|
+
});
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
// =========================================================================
|
|
1077
|
+
// Init Chain
|
|
1078
|
+
// =========================================================================
|
|
1079
|
+
|
|
1080
|
+
private buildInitChain(): void {
|
|
1081
|
+
const steps: InitStep[] = [];
|
|
1082
|
+
this.applyEndpointingOwnerInvariant();
|
|
1083
|
+
|
|
1084
|
+
for (const [name, plugin] of this.plugins) {
|
|
1085
|
+
if (!this.shouldInitializePlugin(plugin)) continue;
|
|
1086
|
+
steps.push({
|
|
1087
|
+
name,
|
|
1088
|
+
stage: pluginStage(name),
|
|
1089
|
+
run: () => {
|
|
1090
|
+
return plugin.initialize(this.bus, this.config.plugins[name] ?? {});
|
|
1091
|
+
},
|
|
1092
|
+
cleanup: () => plugin.close(),
|
|
1093
|
+
});
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
const orderedPluginSteps = steps.sort(
|
|
1097
|
+
(a, b) => stageOrder(a.stage) - stageOrder(b.stage),
|
|
1098
|
+
);
|
|
1099
|
+
this.modeSwitcher.register({
|
|
1100
|
+
textToAudioSteps: orderedPluginSteps,
|
|
1101
|
+
audioToTextCleanups: [...orderedPluginSteps]
|
|
1102
|
+
.reverse()
|
|
1103
|
+
.filter((step) => isAudioStage(step.stage))
|
|
1104
|
+
.map((step) => async () => {
|
|
1105
|
+
await step.cleanup?.();
|
|
1106
|
+
}),
|
|
1107
|
+
});
|
|
1108
|
+
|
|
1109
|
+
// Behavior is always last (idle timeout, max session)
|
|
1110
|
+
orderedPluginSteps.push({
|
|
1111
|
+
name: "idle_timeout",
|
|
1112
|
+
stage: InitStage.Behavior,
|
|
1113
|
+
run: async () => {
|
|
1114
|
+
this.idleTimeout.start();
|
|
1115
|
+
},
|
|
1116
|
+
cleanup: async () => {
|
|
1117
|
+
this.idleTimeout.dispose();
|
|
1118
|
+
},
|
|
1119
|
+
});
|
|
1120
|
+
|
|
1121
|
+
this.initSteps = orderedPluginSteps;
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
private applyEndpointingOwnerInvariant(): void {
|
|
1125
|
+
if (this.endpointingOwner === "timer") return;
|
|
1126
|
+
const owner: EndpointingOwner = this.endpointingOwner;
|
|
1127
|
+
const finalizers = [...this.plugins.entries()]
|
|
1128
|
+
.filter(([, plugin]) => plugin.endpointingCapability !== undefined);
|
|
1129
|
+
const enabledFinalizers = finalizers
|
|
1130
|
+
.filter(([, plugin]) => plugin.endpointingCapability?.owner === owner);
|
|
1131
|
+
if (finalizers.length > 0 && enabledFinalizers.length !== 1) {
|
|
1132
|
+
throw new Error(
|
|
1133
|
+
`endpointingOwner=${owner} requires exactly one registered ${owner} EOS finalizer; found ${String(enabledFinalizers.length)}`,
|
|
1134
|
+
);
|
|
1135
|
+
}
|
|
1136
|
+
for (const [name, plugin] of finalizers) {
|
|
1137
|
+
if (plugin.endpointingCapability?.owner === owner) continue;
|
|
1138
|
+
const disabled = plugin.endpointingCapability?.disableConfig;
|
|
1139
|
+
if (!disabled) continue;
|
|
1140
|
+
this.config.plugins[name] = {
|
|
1141
|
+
...(this.config.plugins[name] ?? {}),
|
|
1142
|
+
...disabled,
|
|
1143
|
+
};
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
private shouldInitializePlugin(plugin: VoicePlugin): boolean {
|
|
1148
|
+
const capability = plugin.endpointingCapability;
|
|
1149
|
+
if (!capability) return true;
|
|
1150
|
+
if (this.endpointingOwner === "timer") return false;
|
|
1151
|
+
if (capability.owner === "smart_turn") return capability.owner === this.endpointingOwner;
|
|
1152
|
+
return true;
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
// =========================================================================
|
|
1156
|
+
// Helpers
|
|
1157
|
+
// =========================================================================
|
|
1158
|
+
|
|
1159
|
+
private emitDebug(
|
|
1160
|
+
component: string,
|
|
1161
|
+
type: string,
|
|
1162
|
+
data: Record<string, string>,
|
|
1163
|
+
): void {
|
|
1164
|
+
this.debugPush({
|
|
1165
|
+
component,
|
|
1166
|
+
type,
|
|
1167
|
+
data,
|
|
1168
|
+
timestampMs: Date.now(),
|
|
1169
|
+
});
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
private latestActiveTtsContextId(): string {
|
|
1173
|
+
return this.ttsPlayout.latestActive();
|
|
1174
|
+
}
|
|
1175
|
+
}
|