@kuralle-syrinx/core 4.2.0 → 4.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/audio/alaw.ts +56 -0
- package/src/audio/g722.ts +329 -0
- package/src/audio/index.ts +12 -0
- package/src/audio/loudness.ts +87 -0
- package/src/idle-timeout.ts +1 -0
- package/src/index.ts +65 -3
- package/src/interaction-coordinator.ts +17 -2
- package/src/interaction-policy.ts +12 -0
- package/src/observability-observer.ts +8 -4
- package/src/observability.ts +30 -1
- package/src/packet-factories.ts +124 -3
- package/src/packets.ts +139 -1
- package/src/plugin-contract.ts +41 -0
- package/src/policies/rule-based.ts +17 -2
- package/src/pricing.ts +137 -0
- package/src/primary-speaker-gate.ts +23 -3
- package/src/reasoner.ts +28 -1
- package/src/spend-cap.ts +52 -0
- package/src/turn-arbiter.ts +34 -2
- package/src/voice-agent-session-util.ts +18 -0
- package/src/voice-agent-session.ts +469 -36
- package/src/voice-text.ts +69 -0
- package/src/audio/audio.test.ts +0 -285
- package/src/audio-envelope.test.ts +0 -167
- package/src/confidence-to-wait.test.ts +0 -21
- package/src/error-handler.test.ts +0 -56
- package/src/hedge-throwing-backend.test.ts +0 -46
- package/src/interaction-coordinator.test.ts +0 -425
- package/src/iu-ledger.test.ts +0 -276
- package/src/latency-filler.test.ts +0 -62
- package/src/observability-observer.test.ts +0 -245
- package/src/observability.test.ts +0 -85
- package/src/packet-factories.test.ts +0 -53
- package/src/pipeline-bus.g10.test.ts +0 -145
- package/src/pipeline-bus.test.ts +0 -211
- package/src/policies/defer.test.ts +0 -86
- package/src/policies/rule-based.test.ts +0 -269
- package/src/primary-speaker-gate.test.ts +0 -150
- package/src/provider-fallback.test.ts +0 -87
- package/src/reasoner-hedge.test.ts +0 -361
- package/src/reasoner-route.test.ts +0 -248
- package/src/reasoner.test.ts +0 -69
- package/src/retry.test.ts +0 -83
- package/src/route-throwing-spec.test.ts +0 -44
- package/src/tts-playout-clock.test.ts +0 -125
- package/src/turn-arbiter.characterization.test.ts +0 -477
- package/src/turn-arbiter.test.ts +0 -567
- package/src/voice-agent-session.test.ts +0 -3316
- package/src/voice-text.test.ts +0 -94
- package/tsconfig.json +0 -21
|
@@ -31,6 +31,10 @@ import type {
|
|
|
31
31
|
InterruptionDetectedPacket,
|
|
32
32
|
DelegateQueryPacket,
|
|
33
33
|
DelegateResultPacket,
|
|
34
|
+
ConversationMetricPacket,
|
|
35
|
+
AcousticSignalPacket,
|
|
36
|
+
UsageStage,
|
|
37
|
+
UsageRecordedPacket,
|
|
34
38
|
LlmDeltaPacket,
|
|
35
39
|
LlmResponseDonePacket,
|
|
36
40
|
LlmToolCallPacket,
|
|
@@ -50,6 +54,7 @@ import type {
|
|
|
50
54
|
EndOfSpeechPacket,
|
|
51
55
|
InterimEndOfSpeechPacket,
|
|
52
56
|
InjectMessagePacket,
|
|
57
|
+
SttReconfigurePacket,
|
|
53
58
|
DisconnectRequestedPacket,
|
|
54
59
|
InitializationFailedPacket,
|
|
55
60
|
ModeSwitchRequestedPacket,
|
|
@@ -63,12 +68,18 @@ import {
|
|
|
63
68
|
} from "./packets.js";
|
|
64
69
|
import { LatencyFillerController } from "./latency-filler.js";
|
|
65
70
|
import { PrimarySpeakerGate } from "./primary-speaker-gate.js";
|
|
66
|
-
import { takeCompleteVoiceText, isCompleteVoiceText, appendVoiceText } from "./voice-text.js";
|
|
71
|
+
import { takeCompleteVoiceText, isCompleteVoiceText, appendVoiceText, normalizeForSpeech } from "./voice-text.js";
|
|
67
72
|
import { TtsPlayoutClock } from "./tts-playout-clock.js";
|
|
68
73
|
import { TurnArbiter, isBackchannel } from "./turn-arbiter.js";
|
|
69
74
|
import { InteractionCoordinator } from "./interaction-coordinator.js";
|
|
70
75
|
import { isLifecycleInteractionPolicy, type InteractionPolicy, type WordTiming } from "./interaction-policy.js";
|
|
71
|
-
import { pcm16BytesToSamples } from "./audio/pcm.js";
|
|
76
|
+
import { pcm16BytesToSamples, pcm16SamplesToBytes } from "./audio/pcm.js";
|
|
77
|
+
import {
|
|
78
|
+
createLoudnessState,
|
|
79
|
+
normalizeLoudness,
|
|
80
|
+
type LoudnessConfig,
|
|
81
|
+
type LoudnessState,
|
|
82
|
+
} from "./audio/loudness.js";
|
|
72
83
|
import { DeferInteractionPolicy } from "./policies/defer.js";
|
|
73
84
|
import { RuleBasedInteractionPolicy } from "./policies/rule-based.js";
|
|
74
85
|
import * as make from "./packet-factories.js";
|
|
@@ -80,6 +91,7 @@ import {
|
|
|
80
91
|
VoiceSessionWatchdogs,
|
|
81
92
|
} from "./voice-agent-session-util.js";
|
|
82
93
|
import { noopMetricsExporter, type MetricsExporter } from "./observability.js";
|
|
94
|
+
import { localizeTurn, type ObservabilityLayer } from "./observability.js";
|
|
83
95
|
import { ObservabilityObserver } from "./observability-observer.js";
|
|
84
96
|
import { TimerScheduler, type Scheduler } from "./scheduler.js";
|
|
85
97
|
|
|
@@ -141,6 +153,8 @@ export interface VoiceAgentSessionConfig {
|
|
|
141
153
|
* instead of hanging. 0 disables. Default: 15000.
|
|
142
154
|
*/
|
|
143
155
|
ttsStallMs?: number;
|
|
156
|
+
/** Optional per-session Int16 RMS normalization for outbound assistant audio. Disabled by default. */
|
|
157
|
+
outboundLoudness?: LoudnessConfig;
|
|
144
158
|
/**
|
|
145
159
|
* Max ms of silence on inbound user audio while the session is Ready before a
|
|
146
160
|
* recoverable transport warning is emitted. Continuous streams (telephony, open
|
|
@@ -191,6 +205,7 @@ export interface VoiceAgentSessionConfig {
|
|
|
191
205
|
readonly provider?: string;
|
|
192
206
|
readonly model?: string;
|
|
193
207
|
readonly region?: string;
|
|
208
|
+
readonly layer?: ObservabilityLayer;
|
|
194
209
|
};
|
|
195
210
|
}
|
|
196
211
|
|
|
@@ -203,7 +218,18 @@ export interface VoiceAgentSessionEvents {
|
|
|
203
218
|
agent_tool_call: (event: { tsMs: number; turnId: string; id: string; name: string; args: Record<string, unknown> }) => void;
|
|
204
219
|
agent_tool_result: (event: { tsMs: number; turnId: string; id: string; result: string; durationMs: number }) => void;
|
|
205
220
|
delegate_query: (event: { tsMs: number; turnId: string; query: string; toolId?: string; toolName?: string }) => void;
|
|
206
|
-
delegate_result: (event: {
|
|
221
|
+
delegate_result: (event: {
|
|
222
|
+
tsMs: number;
|
|
223
|
+
turnId: string;
|
|
224
|
+
query: string;
|
|
225
|
+
answer: string;
|
|
226
|
+
durationMs: number;
|
|
227
|
+
grounded: boolean;
|
|
228
|
+
toolId?: string;
|
|
229
|
+
toolName?: string;
|
|
230
|
+
control?: { name: string; payload: unknown };
|
|
231
|
+
blocked?: { userFacingMessage: string; payload?: unknown };
|
|
232
|
+
}) => void;
|
|
207
233
|
/**
|
|
208
234
|
* G3: typed preamble/filler lifecycle for a pending tool call (Vapi-shaped:
|
|
209
235
|
* started / delayed / complete / failed). `delayed` is time-triggered by
|
|
@@ -213,30 +239,60 @@ export interface VoiceAgentSessionEvents {
|
|
|
213
239
|
*/
|
|
214
240
|
tool_call_cue: (event: { tsMs: number; turnId: string; phase: "started" | "delayed" | "complete" | "failed"; toolId: string; toolName: string; afterMs?: number }) => void;
|
|
215
241
|
/**
|
|
216
|
-
* Per-turn latency decomposition,
|
|
242
|
+
* Per-turn latency decomposition, timestamped at the turn's first TTS audio.
|
|
243
|
+
* When generation is still active at first audio, emission waits for `tts.end` so
|
|
244
|
+
* provider passes that follow a spoken tool preamble are included in the totals.
|
|
217
245
|
* `ttfaMs` is anchored to the real end of user speech (VAD speech-end, falling
|
|
218
246
|
* back to the endpoint decision) — `fillerUsed` flags turns where a latency filler spoke
|
|
219
247
|
* first, and `backchannelUsed` flags turns where a wait-gap cue played before the answer.
|
|
220
248
|
* Decomposition: eouDelayMs (speech end → endpoint) + llmTtftMs (endpoint →
|
|
221
|
-
* first LLM delta) +
|
|
249
|
+
* first LLM delta) + textAggregationMs (first LLM delta → first TTS text) +
|
|
250
|
+
* ttsTtfbMs (first TTS text dispatched → first audio). A stage is omitted when the
|
|
251
|
+
* front does not produce that Syrinx packet for this turn: realtime fronts may emit
|
|
252
|
+
* provider audio directly, and provider-owned endpointing may complete after audio.
|
|
253
|
+
* `unattributedMs` is the explicit residual after subtracting only the stages present.
|
|
222
254
|
*/
|
|
223
255
|
turn_latency: (event: {
|
|
224
256
|
tsMs: number;
|
|
225
257
|
turnId: string;
|
|
226
258
|
ttfaMs: number;
|
|
259
|
+
anchor: "speech_end" | "eos";
|
|
227
260
|
eouDelayMs?: number;
|
|
228
261
|
llmTtftMs?: number;
|
|
262
|
+
textAggregationMs?: number;
|
|
229
263
|
ttsTtfbMs?: number;
|
|
264
|
+
unattributedMs: number;
|
|
265
|
+
llmCallCount?: number;
|
|
266
|
+
llmPassTtftMs?: readonly number[];
|
|
230
267
|
fillerUsed: boolean;
|
|
231
268
|
backchannelUsed: boolean;
|
|
232
269
|
}) => void;
|
|
233
270
|
agent_first_audio: (event: { tsMs: number; turnId: string }) => void;
|
|
234
271
|
agent_finished: (event: { tsMs: number; turnId: string } & Record<string, unknown>) => void;
|
|
272
|
+
/**
|
|
273
|
+
* End-of-session usage manifest — the total billable resource this session consumed,
|
|
274
|
+
* summed per stage. Emitted once at close. This is the metering seam a host reads to
|
|
275
|
+
* bill, cap spend, or attribute cost to a tenant; today only the LLM stage is populated
|
|
276
|
+
* (STT/TTS producers are not yet wired), so `stages` may hold a single entry.
|
|
277
|
+
*/
|
|
278
|
+
usage: (event: { tsMs: number; stages: readonly SessionStageUsage[] }) => void;
|
|
235
279
|
error: (event: { tsMs: number; stage: string; category: string; message: string }) => void;
|
|
236
280
|
closed: (event: { tsMs: number; reason: string }) => void;
|
|
237
281
|
state_changed: (event: { tsMs: number; from: SessionState; to: SessionState }) => void;
|
|
238
282
|
}
|
|
239
283
|
|
|
284
|
+
/** Per-stage usage totals for a session. Absent fields were never reported by that stage. */
|
|
285
|
+
export interface SessionStageUsage {
|
|
286
|
+
readonly stage: UsageStage;
|
|
287
|
+
readonly inputTokens?: number;
|
|
288
|
+
readonly outputTokens?: number;
|
|
289
|
+
readonly totalTokens?: number;
|
|
290
|
+
readonly cachedInputTokens?: number;
|
|
291
|
+
readonly reasoningTokens?: number;
|
|
292
|
+
readonly audioSeconds?: number;
|
|
293
|
+
readonly characters?: number;
|
|
294
|
+
}
|
|
295
|
+
|
|
240
296
|
type EventHandler<T> = (event: T) => void;
|
|
241
297
|
|
|
242
298
|
interface TtsTextBuffer {
|
|
@@ -249,6 +305,8 @@ interface TurnTiming {
|
|
|
249
305
|
eosMs?: number;
|
|
250
306
|
firstLlmDeltaMs?: number;
|
|
251
307
|
firstTtsTextMs?: number;
|
|
308
|
+
llmCallCount?: number;
|
|
309
|
+
llmPassTtftMs?: number[];
|
|
252
310
|
fillerUsed?: boolean;
|
|
253
311
|
backchannelUsed?: boolean;
|
|
254
312
|
}
|
|
@@ -265,6 +323,25 @@ function interactionPlayoutTimerKey(contextId: string): string {
|
|
|
265
323
|
return `interaction.playout:${contextId}`;
|
|
266
324
|
}
|
|
267
325
|
|
|
326
|
+
function turnLocalizationTimerKey(contextId: string): string {
|
|
327
|
+
return `turn.localization:${contextId}`;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function isInfrastructureMetric(name: string): boolean {
|
|
331
|
+
return /(?:^|\.)(?:vad|stt|tts|tool|input|pipeline)\.(?:error|malfunction|timeout|stall|cadence)|(?:^|\.)(?:stage|error)\./i.test(name);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function isConversationEvaluationMetric(name: string, value: string): boolean {
|
|
335
|
+
if (!/(?:^|\.)(?:outcome|eval|evaluation|satisfaction|task_success|observer)(?:\.|$)/i.test(name)) {
|
|
336
|
+
return false;
|
|
337
|
+
}
|
|
338
|
+
return !/^(?:0|false|none|success|successful|passed|pass|good|healthy|ok)$/i.test(value.trim());
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function isConversationControlName(name: string): boolean {
|
|
342
|
+
return /^(?:conversation-outcome|outcome|escalation|handoff|flow-transition|node-enter|node-exit|flow-enter|flow-end)$/.test(name);
|
|
343
|
+
}
|
|
344
|
+
|
|
268
345
|
// =============================================================================
|
|
269
346
|
// Session Implementation
|
|
270
347
|
// =============================================================================
|
|
@@ -304,11 +381,23 @@ export class VoiceAgentSession {
|
|
|
304
381
|
private firstLlmDeltaReceived = new Set<string>();
|
|
305
382
|
private readonly vaqiMissedResponseMs: number;
|
|
306
383
|
private readonly ttsStallMs: number;
|
|
384
|
+
private readonly outboundLoudnessConfig: LoudnessConfig | null;
|
|
385
|
+
private readonly outboundLoudnessState: LoudnessState | null;
|
|
307
386
|
private readonly inputCadenceTimeoutMs: number;
|
|
308
387
|
private readonly watchdogs!: VoiceSessionWatchdogs;
|
|
309
388
|
private readonly observabilityObserver: ObservabilityObserver;
|
|
389
|
+
private readonly metricsExporter: MetricsExporter;
|
|
390
|
+
private readonly observabilityDims: {
|
|
391
|
+
provider: string;
|
|
392
|
+
model: string;
|
|
393
|
+
region: string;
|
|
394
|
+
layer?: ObservabilityLayer;
|
|
395
|
+
};
|
|
310
396
|
private turnUserStoppedAtMs = new Map<string, number>();
|
|
311
397
|
private turnTimings = new Map<string, TurnTiming>();
|
|
398
|
+
private pendingTurnLatency = new Map<string, number>();
|
|
399
|
+
/** Running usage totals per stage, summed across the session; emitted at close. */
|
|
400
|
+
private readonly usageByStage = new Map<UsageStage, SessionStageUsage>();
|
|
312
401
|
private speakerEnrollmentContextId: string | null = null;
|
|
313
402
|
private firstTtsAudioFired = new Set<string>();
|
|
314
403
|
private readonly pendingInteractionPlayoutTimers = new Set<string>();
|
|
@@ -323,6 +412,11 @@ export class VoiceAgentSession {
|
|
|
323
412
|
private userSpeaking = false;
|
|
324
413
|
private lastFinalizedContextId = "";
|
|
325
414
|
private readonly sttPartialWordTimings = new Map<string, readonly WordTiming[]>();
|
|
415
|
+
private readonly turnLocalizationStates = new Map<
|
|
416
|
+
string,
|
|
417
|
+
{ infrastructureBreached: boolean; conversationFlagged: boolean }
|
|
418
|
+
>();
|
|
419
|
+
private readonly emittedTurnLocalizations = new Set<string>();
|
|
326
420
|
|
|
327
421
|
constructor(config: VoiceAgentSessionConfig) {
|
|
328
422
|
const owner = config.endpointingOwner;
|
|
@@ -333,6 +427,8 @@ export class VoiceAgentSession {
|
|
|
333
427
|
this.fullDuplex = config.fullDuplex === true;
|
|
334
428
|
this.emitsBackchannel = config.emitsBackchannel === true;
|
|
335
429
|
this.config = config;
|
|
430
|
+
this.outboundLoudnessConfig = config.outboundLoudness ?? null;
|
|
431
|
+
this.outboundLoudnessState = this.outboundLoudnessConfig ? createLoudnessState() : null;
|
|
336
432
|
this.scheduler = config.scheduler ?? new TimerScheduler();
|
|
337
433
|
this.ttsPlayout = new TtsPlayoutClock(this.scheduler);
|
|
338
434
|
this.sttForceFinalizeTimeoutMs = config.sttForceFinalizeTimeoutMs ?? 7000;
|
|
@@ -340,6 +436,14 @@ export class VoiceAgentSession {
|
|
|
340
436
|
this.delayCueAfterMs = config.delayCueAfterMs ?? 2000;
|
|
341
437
|
this.primarySpeakerGate = new PrimarySpeakerGate({
|
|
342
438
|
enabled: config.primarySpeakerBargeInEnabled !== false,
|
|
439
|
+
onDecision: (decision) => {
|
|
440
|
+
this.emitAcousticSignal(decision.contextId, Date.now(), decision.signal, {
|
|
441
|
+
accepted: decision.accepted,
|
|
442
|
+
frameCount: decision.frameCount,
|
|
443
|
+
primaryHits: decision.primaryHits,
|
|
444
|
+
echoRejectedFrames: decision.echoRejectedFrames,
|
|
445
|
+
});
|
|
446
|
+
},
|
|
343
447
|
});
|
|
344
448
|
this.latencyFiller = new LatencyFillerController({
|
|
345
449
|
enabled: config.latencyFillerEnabled === true,
|
|
@@ -393,6 +497,9 @@ export class VoiceAgentSession {
|
|
|
393
497
|
? new DeferInteractionPolicy()
|
|
394
498
|
: (this.injectedInteractionPolicy ?? this.ruleBasedPolicy);
|
|
395
499
|
this.activeInteractionPolicy = coordinatorPolicy;
|
|
500
|
+
coordinatorPolicy.setAcousticSignalSink?.((signal) => {
|
|
501
|
+
this.emitAcousticSignal(signal.contextId, signal.timestampMs, signal.signal, signal.payload);
|
|
502
|
+
});
|
|
396
503
|
this.interaction = new InteractionCoordinator({
|
|
397
504
|
bus: this.bus,
|
|
398
505
|
policy: coordinatorPolicy,
|
|
@@ -404,6 +511,8 @@ export class VoiceAgentSession {
|
|
|
404
511
|
onBackchannelEmitted: (contextId) => {
|
|
405
512
|
this.timingFor(contextId).backchannelUsed = true;
|
|
406
513
|
},
|
|
514
|
+
endpointingOwner: this.endpointingOwner,
|
|
515
|
+
wasForceFinalized: (contextId) => this.watchdogs.wasForceFinalized(contextId),
|
|
407
516
|
});
|
|
408
517
|
this.watchdogs = new VoiceSessionWatchdogs({
|
|
409
518
|
bus: this.bus,
|
|
@@ -422,15 +531,18 @@ export class VoiceAgentSession {
|
|
|
422
531
|
});
|
|
423
532
|
|
|
424
533
|
const obs = config.observability;
|
|
534
|
+
this.metricsExporter = config.metricsExporter ?? noopMetricsExporter;
|
|
535
|
+
this.observabilityDims = {
|
|
536
|
+
provider: obs?.provider ?? "unknown",
|
|
537
|
+
model: obs?.model ?? "unknown",
|
|
538
|
+
region: obs?.region ?? "unknown",
|
|
539
|
+
layer: obs?.layer,
|
|
540
|
+
};
|
|
425
541
|
this.observabilityObserver = new ObservabilityObserver({
|
|
426
542
|
bus: this.bus,
|
|
427
|
-
exporter:
|
|
543
|
+
exporter: this.metricsExporter,
|
|
428
544
|
sessionId: obs?.sessionId ?? "",
|
|
429
|
-
dims:
|
|
430
|
-
provider: obs?.provider ?? "unknown",
|
|
431
|
-
model: obs?.model ?? "unknown",
|
|
432
|
-
region: obs?.region ?? "unknown",
|
|
433
|
-
},
|
|
545
|
+
dims: this.observabilityDims,
|
|
434
546
|
getContextId: () => this.currentContextId,
|
|
435
547
|
});
|
|
436
548
|
|
|
@@ -490,6 +602,28 @@ export class VoiceAgentSession {
|
|
|
490
602
|
this._state = SessionState.Ready;
|
|
491
603
|
}
|
|
492
604
|
|
|
605
|
+
/**
|
|
606
|
+
* Best-effort warm of registered plugins' remote/expensive resources. Call after start() to
|
|
607
|
+
* wake scaled-to-zero endpoints before the first user turn. The host decides when to invoke
|
|
608
|
+
* this — it is not called automatically from start(). Never throws; failed plugin prewarms
|
|
609
|
+
* are swallowed and may emit a prewarm.failed metric on the bus.
|
|
610
|
+
*/
|
|
611
|
+
async prewarm(): Promise<void> {
|
|
612
|
+
const entries = [...this.plugins.entries()];
|
|
613
|
+
const results = await Promise.allSettled(
|
|
614
|
+
entries.map(async ([, plugin]) => {
|
|
615
|
+
await plugin.prewarm?.();
|
|
616
|
+
}),
|
|
617
|
+
);
|
|
618
|
+
for (let i = 0; i < results.length; i++) {
|
|
619
|
+
const result = results[i];
|
|
620
|
+
if (result?.status === "rejected") {
|
|
621
|
+
const [name] = entries[i]!;
|
|
622
|
+
this.bus.push(Route.Background, make.metric("", "prewarm.failed", name));
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
493
627
|
/** Shut down the session. Runs finalize chain in reverse order. */
|
|
494
628
|
async close(): Promise<void> {
|
|
495
629
|
if (this._state === SessionState.Closed) return;
|
|
@@ -514,6 +648,13 @@ export class VoiceAgentSession {
|
|
|
514
648
|
this.turnArbiter.clear();
|
|
515
649
|
this.turnUserStoppedAtMs.clear();
|
|
516
650
|
this.turnTimings.clear();
|
|
651
|
+
for (const contextId of this.turnLocalizationStates.keys()) {
|
|
652
|
+
this.scheduler.cancel(turnLocalizationTimerKey(contextId));
|
|
653
|
+
this.emitTurnLocalization(contextId);
|
|
654
|
+
}
|
|
655
|
+
this.turnLocalizationStates.clear();
|
|
656
|
+
this.emittedTurnLocalizations.clear();
|
|
657
|
+
this.pendingTurnLatency.clear();
|
|
517
658
|
this.firstTtsAudioFired.clear();
|
|
518
659
|
this.fallbackInjectedContexts.clear();
|
|
519
660
|
this.ttsTextBuffers.clear();
|
|
@@ -528,6 +669,9 @@ export class VoiceAgentSession {
|
|
|
528
669
|
// 2. Run finalize chain (reverse order)
|
|
529
670
|
await runFinalizeChain(this.initSteps);
|
|
530
671
|
|
|
672
|
+
// 2b. Emit the end-of-session usage manifest before the bus stops — the metering seam.
|
|
673
|
+
this.emitSessionUsage();
|
|
674
|
+
|
|
531
675
|
// 3. Stop bus
|
|
532
676
|
this.bus.stop();
|
|
533
677
|
await this.busStartPromise;
|
|
@@ -618,6 +762,7 @@ export class VoiceAgentSession {
|
|
|
618
762
|
this.lastFinalizedContextId = "";
|
|
619
763
|
if (pkt.previousContextId) {
|
|
620
764
|
this.sttPartialWordTimings.delete(pkt.previousContextId);
|
|
765
|
+
this.carryTurnTimingAcrossContextChange(pkt.previousContextId, pkt.contextId);
|
|
621
766
|
}
|
|
622
767
|
});
|
|
623
768
|
this.bus.on("eos.turn_complete", this.handleTurnComplete.bind(this));
|
|
@@ -628,6 +773,8 @@ export class VoiceAgentSession {
|
|
|
628
773
|
this.bus.on("llm.done", this.handleLlmDone.bind(this));
|
|
629
774
|
this.bus.on("llm.tool_call", this.handleLlmToolCall.bind(this));
|
|
630
775
|
this.bus.on("llm.tool_result", this.handleLlmToolResult.bind(this));
|
|
776
|
+
this.bus.on("metric.conversation", this.handleConversationMetric.bind(this));
|
|
777
|
+
this.bus.on("usage.recorded", (pkt) => this.handleUsageRecorded(pkt as UsageRecordedPacket));
|
|
631
778
|
|
|
632
779
|
// Delegate (Responder-Thinker) observability — G2, RFC bimodel-delegate-seam
|
|
633
780
|
this.bus.on("delegate.query", this.handleDelegateQuery.bind(this));
|
|
@@ -662,6 +809,9 @@ export class VoiceAgentSession {
|
|
|
662
809
|
// Injected messages — push through LLM path for natural TTS
|
|
663
810
|
this.bus.on("inject.message", this.handleInjectMessage.bind(this));
|
|
664
811
|
|
|
812
|
+
// Per-turn STT reconfigure (keyterms / endpointing / EOT thresholds)
|
|
813
|
+
this.bus.on("stt.reconfigure", this.handleSttReconfigure.bind(this));
|
|
814
|
+
|
|
665
815
|
// Disconnect
|
|
666
816
|
this.bus.on("session.disconnect", this.handleDisconnect.bind(this));
|
|
667
817
|
|
|
@@ -726,8 +876,16 @@ export class VoiceAgentSession {
|
|
|
726
876
|
}
|
|
727
877
|
|
|
728
878
|
private handleUserText(pkt: UserTextReceivedPacket): void {
|
|
729
|
-
//
|
|
730
|
-
|
|
879
|
+
// Typed input is an immediate turn end — but nothing endpointed it and no one
|
|
880
|
+
// decided the caller stopped talking, because they typed. Label it as `text` so
|
|
881
|
+
// the timeline never claims a speech endpointer fired on a typed turn.
|
|
882
|
+
this.bus.push(
|
|
883
|
+
Route.Main,
|
|
884
|
+
make.eosTurnComplete(pkt.contextId, pkt.timestampMs, pkt.text, [], {
|
|
885
|
+
owner: "text",
|
|
886
|
+
reason: "typed",
|
|
887
|
+
}),
|
|
888
|
+
);
|
|
731
889
|
}
|
|
732
890
|
|
|
733
891
|
private handleSttPartial(pkt: SttPartialPacket): void {
|
|
@@ -901,6 +1059,23 @@ export class VoiceAgentSession {
|
|
|
901
1059
|
this.watchdogs.startVaqiMissedResponseTimer(pkt.contextId, pkt.timestampMs);
|
|
902
1060
|
}
|
|
903
1061
|
|
|
1062
|
+
/**
|
|
1063
|
+
* A realtime front rotates its contextId when the provider starts responding
|
|
1064
|
+
* (`RealtimeBridge.onResponseStarted`), so the user-side anchors (`speechEndedMs`, `eosMs`)
|
|
1065
|
+
* are recorded under the PREVIOUS context while `tts.audio` — and therefore the
|
|
1066
|
+
* `turn_latency` emit — lands under the new one. Without carrying the record across the
|
|
1067
|
+
* rotation, `emitTurnLatency` finds no anchor and silently drops every native turn.
|
|
1068
|
+
*
|
|
1069
|
+
* Only fills gaps: anything the new context already recorded wins.
|
|
1070
|
+
*/
|
|
1071
|
+
private carryTurnTimingAcrossContextChange(previousContextId: string, contextId: string): void {
|
|
1072
|
+
const previous = this.turnTimings.get(previousContextId);
|
|
1073
|
+
if (!previous) return;
|
|
1074
|
+
this.turnTimings.delete(previousContextId);
|
|
1075
|
+
const current = this.turnTimings.get(contextId);
|
|
1076
|
+
this.turnTimings.set(contextId, { ...previous, ...current });
|
|
1077
|
+
}
|
|
1078
|
+
|
|
904
1079
|
private timingFor(contextId: string): TurnTiming {
|
|
905
1080
|
let timing = this.turnTimings.get(contextId);
|
|
906
1081
|
if (!timing) {
|
|
@@ -910,28 +1085,82 @@ export class VoiceAgentSession {
|
|
|
910
1085
|
return timing;
|
|
911
1086
|
}
|
|
912
1087
|
|
|
1088
|
+
/** Emit a deferred turn_latency if one is pending, then clear it. Safe to call twice. */
|
|
1089
|
+
private flushPendingTurnLatency(contextId: string): void {
|
|
1090
|
+
const firstAudioMs = this.pendingTurnLatency.get(contextId);
|
|
1091
|
+
if (firstAudioMs === undefined) return;
|
|
1092
|
+
this.pendingTurnLatency.delete(contextId);
|
|
1093
|
+
this.emitTurnLatency(contextId, firstAudioMs);
|
|
1094
|
+
}
|
|
1095
|
+
|
|
913
1096
|
private emitTurnLatency(contextId: string, firstAudioMs: number): void {
|
|
914
|
-
const timing = this.
|
|
1097
|
+
const timing = this.timingFor(contextId);
|
|
915
1098
|
this.turnTimings.delete(contextId);
|
|
916
|
-
if (!timing) return;
|
|
917
1099
|
const anchorMs = timing.speechEndedMs ?? timing.eosMs;
|
|
918
1100
|
if (anchorMs === undefined) return; // text-injected or fallback turn — not a voice TTFA
|
|
1101
|
+
const anchor = timing.speechEndedMs !== undefined ? "speech_end" : "eos";
|
|
1102
|
+
const eouDelayMs =
|
|
1103
|
+
timing.speechEndedMs !== undefined &&
|
|
1104
|
+
timing.eosMs !== undefined &&
|
|
1105
|
+
timing.eosMs <= firstAudioMs
|
|
1106
|
+
? timing.eosMs - timing.speechEndedMs
|
|
1107
|
+
: undefined;
|
|
1108
|
+
const llmAnchorMs = timing.eosMs ?? timing.speechEndedMs;
|
|
1109
|
+
const llmTtftMs =
|
|
1110
|
+
llmAnchorMs !== undefined && timing.firstLlmDeltaMs !== undefined
|
|
1111
|
+
? timing.firstLlmDeltaMs - llmAnchorMs
|
|
1112
|
+
: undefined;
|
|
1113
|
+
const textAggregationMs =
|
|
1114
|
+
timing.firstLlmDeltaMs !== undefined && timing.firstTtsTextMs !== undefined
|
|
1115
|
+
? timing.firstTtsTextMs - timing.firstLlmDeltaMs
|
|
1116
|
+
: undefined;
|
|
1117
|
+
const ttsTtfbMs =
|
|
1118
|
+
timing.firstTtsTextMs !== undefined
|
|
1119
|
+
? firstAudioMs - timing.firstTtsTextMs
|
|
1120
|
+
: undefined;
|
|
1121
|
+
const attributedMs = [eouDelayMs, llmTtftMs, textAggregationMs, ttsTtfbMs]
|
|
1122
|
+
.filter((value): value is number => value !== undefined)
|
|
1123
|
+
.reduce((sum, value) => sum + value, 0);
|
|
1124
|
+
const ttfaMs = firstAudioMs - anchorMs;
|
|
1125
|
+
const unattributedMs = ttfaMs - attributedMs;
|
|
1126
|
+
if (unattributedMs >= 500) this.markInfrastructureBreach(contextId);
|
|
1127
|
+
const fillerUsed = timing.fillerUsed === true;
|
|
1128
|
+
const backchannelUsed = timing.backchannelUsed === true;
|
|
919
1129
|
this.emit("turn_latency", {
|
|
920
1130
|
tsMs: firstAudioMs,
|
|
921
1131
|
turnId: contextId,
|
|
922
|
-
ttfaMs
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
...(
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
...(timing.
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
backchannelUsed: timing.backchannelUsed === true,
|
|
1132
|
+
ttfaMs,
|
|
1133
|
+
anchor,
|
|
1134
|
+
...(eouDelayMs !== undefined ? { eouDelayMs } : {}),
|
|
1135
|
+
...(llmTtftMs !== undefined ? { llmTtftMs } : {}),
|
|
1136
|
+
...(textAggregationMs !== undefined ? { textAggregationMs } : {}),
|
|
1137
|
+
...(ttsTtfbMs !== undefined ? { ttsTtfbMs } : {}),
|
|
1138
|
+
unattributedMs,
|
|
1139
|
+
...(timing.llmCallCount !== undefined ? { llmCallCount: timing.llmCallCount } : {}),
|
|
1140
|
+
...(timing.llmPassTtftMs !== undefined ? { llmPassTtftMs: [...timing.llmPassTtftMs] } : {}),
|
|
1141
|
+
fillerUsed,
|
|
1142
|
+
backchannelUsed,
|
|
934
1143
|
});
|
|
1144
|
+
|
|
1145
|
+
const tags = {
|
|
1146
|
+
...this.observabilityDims,
|
|
1147
|
+
layer: "infrastructure" as const,
|
|
1148
|
+
cancelled: this.interruptedGenerationContextIds.has(contextId) ? "true" : "false",
|
|
1149
|
+
anchor,
|
|
1150
|
+
filler_used: String(fillerUsed),
|
|
1151
|
+
backchannel_used: String(backchannelUsed),
|
|
1152
|
+
};
|
|
1153
|
+
const histograms: Array<readonly [string, number | undefined]> = [
|
|
1154
|
+
["turn.ttfa_ms", ttfaMs],
|
|
1155
|
+
["turn.eou_delay_ms", eouDelayMs],
|
|
1156
|
+
["turn.llm_ttft_ms", llmTtftMs],
|
|
1157
|
+
["turn.text_aggregation_ms", textAggregationMs],
|
|
1158
|
+
["turn.tts_ttfb_ms", ttsTtfbMs],
|
|
1159
|
+
["turn.unattributed_ms", unattributedMs],
|
|
1160
|
+
];
|
|
1161
|
+
for (const [name, valueMs] of histograms) {
|
|
1162
|
+
if (valueMs !== undefined) this.metricsExporter.observeHistogram(name, valueMs, tags);
|
|
1163
|
+
}
|
|
935
1164
|
}
|
|
936
1165
|
|
|
937
1166
|
private handleTurnComplete(pkt: EndOfSpeechPacket): void {
|
|
@@ -950,11 +1179,20 @@ export class VoiceAgentSession {
|
|
|
950
1179
|
const otherTtsActive = this.ttsPlayout.activeContexts().some((c) => c !== pkt.contextId);
|
|
951
1180
|
if (otherTtsActive && isBackchannel(pkt.text)) {
|
|
952
1181
|
this.bus.push(Route.Background, make.metric(pkt.contextId, "turn.backchannel_dropped", pkt.text));
|
|
1182
|
+
this.emitAcousticSignal(pkt.contextId, pkt.timestampMs, "backchannel", { text: pkt.text });
|
|
953
1183
|
return;
|
|
954
1184
|
}
|
|
955
1185
|
|
|
1186
|
+
if (this.emittedTurnLocalizations.has(pkt.contextId)) {
|
|
1187
|
+
this.emittedTurnLocalizations.delete(pkt.contextId);
|
|
1188
|
+
this.turnLocalizationStates.delete(pkt.contextId);
|
|
1189
|
+
}
|
|
956
1190
|
this.lastFinalizedContextId = pkt.contextId;
|
|
957
1191
|
this.interaction.reset(pkt.contextId);
|
|
1192
|
+
// The force-finalize flag has now been read by whoever emitted this eos (and
|
|
1193
|
+
// captured by the metrics tracker via its metric) — retire it so the per-call
|
|
1194
|
+
// Set does not grow unbounded on a stable transport contextId.
|
|
1195
|
+
this.watchdogs.clearForceFinalized(pkt.contextId);
|
|
958
1196
|
|
|
959
1197
|
// Re-arm per-turn guard state for the next turn. Transports with a stable
|
|
960
1198
|
// per-call contextId (telephony callSid) reuse one id across turns, so these
|
|
@@ -963,6 +1201,7 @@ export class VoiceAgentSession {
|
|
|
963
1201
|
// - interruptedGenerationContextIds: else turn N+1's LLM/TTS packets are dropped after a prior barge-in
|
|
964
1202
|
// - fallbackInjectedContexts: else only one error fallback can ever be spoken per call
|
|
965
1203
|
this.firstTtsAudioFired.delete(pkt.contextId);
|
|
1204
|
+
this.pendingTurnLatency.delete(pkt.contextId);
|
|
966
1205
|
this.interruptedGenerationContextIds.delete(pkt.contextId);
|
|
967
1206
|
this.fallbackInjectedContexts.delete(pkt.contextId);
|
|
968
1207
|
|
|
@@ -1092,8 +1331,11 @@ export class VoiceAgentSession {
|
|
|
1092
1331
|
if (complete.text) {
|
|
1093
1332
|
const timing = this.turnTimings.get(contextId);
|
|
1094
1333
|
if (timing) timing.firstTtsTextMs ??= tsMs ?? Date.now();
|
|
1095
|
-
|
|
1096
|
-
|
|
1334
|
+
// Strip markdown before it reaches TTS, and track the SPOKEN form as the heard
|
|
1335
|
+
// prefix so barge-in truncation reconciles against what was actually said.
|
|
1336
|
+
const spoken = normalizeForSpeech(complete.text);
|
|
1337
|
+
this.bus.push(Route.Main, make.ttsText(contextId, Date.now(), spoken));
|
|
1338
|
+
buffer.emitted = appendVoiceText(buffer.emitted, spoken);
|
|
1097
1339
|
}
|
|
1098
1340
|
buffer.pending = complete.remaining;
|
|
1099
1341
|
this.ttsTextBuffers.set(contextId, buffer);
|
|
@@ -1102,7 +1344,7 @@ export class VoiceAgentSession {
|
|
|
1102
1344
|
private flushTtsText(contextId: string, tsMs?: number): string {
|
|
1103
1345
|
const buffer = this.ttsTextBuffers.get(contextId);
|
|
1104
1346
|
if (!buffer) return "";
|
|
1105
|
-
const tail = buffer.pending.trim();
|
|
1347
|
+
const tail = normalizeForSpeech(buffer.pending.trim());
|
|
1106
1348
|
if (tail) {
|
|
1107
1349
|
const timing = this.turnTimings.get(contextId);
|
|
1108
1350
|
if (timing) timing.firstTtsTextMs ??= tsMs ?? Date.now();
|
|
@@ -1224,6 +1466,9 @@ export class VoiceAgentSession {
|
|
|
1224
1466
|
}
|
|
1225
1467
|
|
|
1226
1468
|
private handleDelegateResult(pkt: DelegateResultPacket): void {
|
|
1469
|
+
if (pkt.control && isConversationControlName(pkt.control.name)) {
|
|
1470
|
+
this.markConversationFlag(pkt.contextId);
|
|
1471
|
+
}
|
|
1227
1472
|
this.emit("delegate_result", {
|
|
1228
1473
|
tsMs: pkt.timestampMs,
|
|
1229
1474
|
turnId: pkt.contextId,
|
|
@@ -1233,6 +1478,8 @@ export class VoiceAgentSession {
|
|
|
1233
1478
|
grounded: pkt.grounded,
|
|
1234
1479
|
toolId: pkt.toolId,
|
|
1235
1480
|
toolName: pkt.toolName,
|
|
1481
|
+
...(pkt.control ? { control: pkt.control } : {}),
|
|
1482
|
+
...(pkt.blocked ? { blocked: pkt.blocked } : {}),
|
|
1236
1483
|
});
|
|
1237
1484
|
this.debugPush({
|
|
1238
1485
|
component: "delegate",
|
|
@@ -1267,6 +1514,125 @@ export class VoiceAgentSession {
|
|
|
1267
1514
|
});
|
|
1268
1515
|
}
|
|
1269
1516
|
|
|
1517
|
+
private handleConversationMetric(pkt: ConversationMetricPacket): void {
|
|
1518
|
+
if (isInfrastructureMetric(pkt.name)) this.markInfrastructureBreach(pkt.contextId);
|
|
1519
|
+
if (isConversationEvaluationMetric(pkt.name, pkt.value)) this.markConversationFlag(pkt.contextId);
|
|
1520
|
+
if (pkt.name === "input.cadence_stall_ms") {
|
|
1521
|
+
this.emitAcousticSignal(pkt.contextId, pkt.timestampMs, "cadence", { value: pkt.value });
|
|
1522
|
+
}
|
|
1523
|
+
if (pkt.name === "llm.call_started") {
|
|
1524
|
+
const timing = this.timingFor(pkt.contextId);
|
|
1525
|
+
timing.llmCallCount = (timing.llmCallCount ?? 0) + 1;
|
|
1526
|
+
return;
|
|
1527
|
+
}
|
|
1528
|
+
if (pkt.name === "llm.pass_ttft_ms") {
|
|
1529
|
+
const ttftMs = Number(pkt.value);
|
|
1530
|
+
if (!Number.isFinite(ttftMs)) return;
|
|
1531
|
+
const timing = this.timingFor(pkt.contextId);
|
|
1532
|
+
(timing.llmPassTtftMs ??= []).push(ttftMs);
|
|
1533
|
+
}
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
private emitAcousticSignal(
|
|
1537
|
+
contextId: string,
|
|
1538
|
+
timestampMs: number,
|
|
1539
|
+
signal: AcousticSignalPacket["signal"],
|
|
1540
|
+
payload?: Readonly<Record<string, unknown>>,
|
|
1541
|
+
): void {
|
|
1542
|
+
this.bus.push(Route.Background, make.acousticSignal(contextId, timestampMs, signal, payload));
|
|
1543
|
+
this.metricsExporter.observeCounter?.(`acoustic.${signal}`, 1, {
|
|
1544
|
+
provider: this.observabilityDims.provider,
|
|
1545
|
+
model: this.observabilityDims.model,
|
|
1546
|
+
region: this.observabilityDims.region,
|
|
1547
|
+
layer: "conversation",
|
|
1548
|
+
});
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
private localizationStateFor(contextId: string): {
|
|
1552
|
+
infrastructureBreached: boolean;
|
|
1553
|
+
conversationFlagged: boolean;
|
|
1554
|
+
} {
|
|
1555
|
+
const existing = this.turnLocalizationStates.get(contextId);
|
|
1556
|
+
if (existing) return existing;
|
|
1557
|
+
const state = { infrastructureBreached: false, conversationFlagged: false };
|
|
1558
|
+
this.turnLocalizationStates.set(contextId, state);
|
|
1559
|
+
return state;
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
private markInfrastructureBreach(contextId: string): void {
|
|
1563
|
+
this.localizationStateFor(contextId).infrastructureBreached = true;
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
private markConversationFlag(contextId: string): void {
|
|
1567
|
+
this.localizationStateFor(contextId).conversationFlagged = true;
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
private emitTurnLocalization(contextId: string): void {
|
|
1571
|
+
if (this.emittedTurnLocalizations.has(contextId)) return;
|
|
1572
|
+
const state = this.localizationStateFor(contextId);
|
|
1573
|
+
this.emittedTurnLocalizations.add(contextId);
|
|
1574
|
+
this.bus.push(
|
|
1575
|
+
Route.Background,
|
|
1576
|
+
make.turnLocalization(
|
|
1577
|
+
contextId,
|
|
1578
|
+
Date.now(),
|
|
1579
|
+
localizeTurn(state),
|
|
1580
|
+
state.infrastructureBreached,
|
|
1581
|
+
state.conversationFlagged,
|
|
1582
|
+
),
|
|
1583
|
+
);
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
private scheduleTurnLocalization(contextId: string): void {
|
|
1587
|
+
this.scheduler.schedule(turnLocalizationTimerKey(contextId), 0, () => {
|
|
1588
|
+
this.emitTurnLocalization(contextId);
|
|
1589
|
+
});
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
private static readonly USAGE_FIELDS = [
|
|
1593
|
+
"inputTokens",
|
|
1594
|
+
"outputTokens",
|
|
1595
|
+
"totalTokens",
|
|
1596
|
+
"cachedInputTokens",
|
|
1597
|
+
"reasoningTokens",
|
|
1598
|
+
"audioSeconds",
|
|
1599
|
+
"characters",
|
|
1600
|
+
] as const;
|
|
1601
|
+
|
|
1602
|
+
private handleUsageRecorded(pkt: UsageRecordedPacket): void {
|
|
1603
|
+
// Accumulate into the per-stage running total. A missing field stays missing
|
|
1604
|
+
// (never coerced to 0) so an un-reported dimension is distinguishable from a real 0.
|
|
1605
|
+
const prev = this.usageByStage.get(pkt.stage) ?? { stage: pkt.stage };
|
|
1606
|
+
const next: Record<string, unknown> = { ...prev };
|
|
1607
|
+
for (const field of VoiceAgentSession.USAGE_FIELDS) {
|
|
1608
|
+
const add = pkt[field];
|
|
1609
|
+
if (typeof add === "number") next[field] = ((prev[field] as number | undefined) ?? 0) + add;
|
|
1610
|
+
}
|
|
1611
|
+
this.usageByStage.set(pkt.stage, next as unknown as SessionStageUsage);
|
|
1612
|
+
|
|
1613
|
+
// Export each recorded unit as a counter, tagged low-cardinality only — same
|
|
1614
|
+
// discipline as turn_latency. provider/model are exemplar-safe; contextId is not,
|
|
1615
|
+
// and must never become a metric dimension (LiveKit caps at model/provider).
|
|
1616
|
+
const exporter = this.metricsExporter;
|
|
1617
|
+
if (exporter.observeCounter) {
|
|
1618
|
+
const tags = {
|
|
1619
|
+
stage: pkt.stage,
|
|
1620
|
+
provider: pkt.provider ?? "",
|
|
1621
|
+
model: pkt.model ?? "",
|
|
1622
|
+
layer: "infrastructure" as const,
|
|
1623
|
+
};
|
|
1624
|
+
for (const field of VoiceAgentSession.USAGE_FIELDS) {
|
|
1625
|
+
const value = pkt[field];
|
|
1626
|
+
if (typeof value === "number") exporter.observeCounter(`usage.${field}`, value, tags);
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
private emitSessionUsage(): void {
|
|
1632
|
+
if (this.usageByStage.size === 0) return;
|
|
1633
|
+
this.emit("usage", { tsMs: Date.now(), stages: [...this.usageByStage.values()] });
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1270
1636
|
private handleTtsAudio(pkt: TextToSpeechAudioPacket): void {
|
|
1271
1637
|
if (this.interruptedGenerationContextIds.has(pkt.contextId)) {
|
|
1272
1638
|
this.bus.push(
|
|
@@ -1287,17 +1653,24 @@ export class VoiceAgentSession {
|
|
|
1287
1653
|
);
|
|
1288
1654
|
this.turnUserStoppedAtMs.delete(pkt.contextId);
|
|
1289
1655
|
}
|
|
1290
|
-
this.
|
|
1656
|
+
if (this.generatingContextIds.has(pkt.contextId)) {
|
|
1657
|
+
this.pendingTurnLatency.set(pkt.contextId, pkt.timestampMs);
|
|
1658
|
+
} else {
|
|
1659
|
+
this.emitTurnLatency(pkt.contextId, pkt.timestampMs);
|
|
1660
|
+
}
|
|
1291
1661
|
}
|
|
1292
1662
|
|
|
1293
|
-
this.
|
|
1663
|
+
const audio = this.normalizeOutboundAudio(pkt.audio);
|
|
1664
|
+
// Transport subscribers receive this same packet after the core handler.
|
|
1665
|
+
if (audio !== pkt.audio) Object.assign(pkt, { audio });
|
|
1666
|
+
this.primarySpeakerGate.observeAssistantPlayout(audio);
|
|
1294
1667
|
// Audio just arrived — (re)arm the stall watchdog for this turn's TTS output.
|
|
1295
1668
|
this.watchdogs.armTtsStallTimer(pkt.contextId);
|
|
1296
1669
|
|
|
1297
1670
|
// Mark active and advance this context's playout cursor by the chunk's
|
|
1298
1671
|
// realtime duration.
|
|
1299
1672
|
const sampleRateHz = requireTtsAudioSampleRate(pkt.sampleRateHz);
|
|
1300
|
-
const audioDurationMs = estimatePcm16Duration(
|
|
1673
|
+
const audioDurationMs = estimatePcm16Duration(audio, sampleRateHz);
|
|
1301
1674
|
const now = Date.now();
|
|
1302
1675
|
this.ttsPlayout.noteAudio(pkt.contextId, audioDurationMs, now);
|
|
1303
1676
|
this.interaction.observe({
|
|
@@ -1305,7 +1678,7 @@ export class VoiceAgentSession {
|
|
|
1305
1678
|
contextId: pkt.contextId,
|
|
1306
1679
|
timestampMs: pkt.timestampMs,
|
|
1307
1680
|
ttsActive: true,
|
|
1308
|
-
audio: pcm16BytesToSamples(
|
|
1681
|
+
audio: pcm16BytesToSamples(audio),
|
|
1309
1682
|
sampleRateHz,
|
|
1310
1683
|
});
|
|
1311
1684
|
|
|
@@ -1322,18 +1695,37 @@ export class VoiceAgentSession {
|
|
|
1322
1695
|
type: "audio",
|
|
1323
1696
|
data: {
|
|
1324
1697
|
context_id: pkt.contextId,
|
|
1325
|
-
bytes: String(
|
|
1698
|
+
bytes: String(audio.length),
|
|
1326
1699
|
},
|
|
1327
1700
|
timestampMs: pkt.timestampMs,
|
|
1328
1701
|
});
|
|
1329
1702
|
|
|
1330
|
-
this.bus.push(Route.Main, make.recordAssistantAudio(pkt.contextId, Date.now(),
|
|
1703
|
+
this.bus.push(Route.Main, make.recordAssistantAudio(pkt.contextId, Date.now(), audio, sampleRateHz));
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
private normalizeOutboundAudio(audio: Uint8Array): Uint8Array {
|
|
1707
|
+
if (
|
|
1708
|
+
!this.outboundLoudnessConfig ||
|
|
1709
|
+
!this.outboundLoudnessState ||
|
|
1710
|
+
audio.byteLength % 2 !== 0
|
|
1711
|
+
) {
|
|
1712
|
+
return audio;
|
|
1713
|
+
}
|
|
1714
|
+
const samples = pcm16BytesToSamples(audio);
|
|
1715
|
+
return pcm16SamplesToBytes(
|
|
1716
|
+
normalizeLoudness(samples, this.outboundLoudnessState, this.outboundLoudnessConfig),
|
|
1717
|
+
);
|
|
1331
1718
|
}
|
|
1332
1719
|
|
|
1333
1720
|
private handleTtsEnd(pkt: TextToSpeechEndPacket): void {
|
|
1334
1721
|
// Generation finished, but the streamed audio is still playing out. Keep the
|
|
1335
1722
|
// context interruptible until its playout estimate elapses, then release it.
|
|
1336
1723
|
this.generatingContextIds.delete(pkt.contextId);
|
|
1724
|
+
const firstAudioMs = this.pendingTurnLatency.get(pkt.contextId);
|
|
1725
|
+
if (firstAudioMs !== undefined) {
|
|
1726
|
+
this.pendingTurnLatency.delete(pkt.contextId);
|
|
1727
|
+
this.emitTurnLatency(pkt.contextId, firstAudioMs);
|
|
1728
|
+
}
|
|
1337
1729
|
const now = Date.now();
|
|
1338
1730
|
this.ttsPlayout.scheduleRelease(pkt.contextId, now);
|
|
1339
1731
|
const playoutEndMs = this.ttsPlayout.playoutEnd(pkt.contextId);
|
|
@@ -1346,6 +1738,7 @@ export class VoiceAgentSession {
|
|
|
1346
1738
|
data: {},
|
|
1347
1739
|
timestampMs: Date.now(),
|
|
1348
1740
|
});
|
|
1741
|
+
this.scheduleTurnLocalization(pkt.contextId);
|
|
1349
1742
|
}
|
|
1350
1743
|
|
|
1351
1744
|
private scheduleInteractionPlayoutTick(contextId: string, delayMs: number): void {
|
|
@@ -1384,6 +1777,15 @@ export class VoiceAgentSession {
|
|
|
1384
1777
|
this.interruptedGenerationContextIds.add(pkt.contextId);
|
|
1385
1778
|
this.failPendingToolCues(pkt.contextId); // G3: the aborted delegate's cue fails (R5)
|
|
1386
1779
|
this.latencyFiller.cancel(pkt.contextId);
|
|
1780
|
+
// A barged turn still produced first audio, and its TTFA is a real measurement.
|
|
1781
|
+
// Deferring emission to tts.end (so later tool passes are counted) must not make
|
|
1782
|
+
// interrupted turns vanish from the metric: barge-in correlates with slow turns,
|
|
1783
|
+
// so silently dropping them biases the sample toward the fast ones — the worst
|
|
1784
|
+
// turns would disappear from exactly the number meant to detect them.
|
|
1785
|
+
this.flushPendingTurnLatency(pkt.contextId);
|
|
1786
|
+
this.emitAcousticSignal(pkt.contextId, pkt.timestampMs, "interruption", {
|
|
1787
|
+
source: pkt.source,
|
|
1788
|
+
});
|
|
1387
1789
|
this.turnTimings.delete(pkt.contextId);
|
|
1388
1790
|
this.firstLlmDeltaReceived.delete(pkt.contextId);
|
|
1389
1791
|
this.ttsTextBuffers.delete(pkt.contextId);
|
|
@@ -1434,6 +1836,7 @@ export class VoiceAgentSession {
|
|
|
1434
1836
|
this.interruptedGenerationContextIds.add(contextId);
|
|
1435
1837
|
this.failPendingToolCues(contextId); // G3: a superseded turn's pending cue fails
|
|
1436
1838
|
this.generatingContextIds.delete(contextId);
|
|
1839
|
+
this.pendingTurnLatency.delete(contextId);
|
|
1437
1840
|
this.latencyFiller.cancel(contextId);
|
|
1438
1841
|
this.turnTimings.delete(contextId);
|
|
1439
1842
|
this.firstLlmDeltaReceived.delete(contextId);
|
|
@@ -1447,6 +1850,12 @@ export class VoiceAgentSession {
|
|
|
1447
1850
|
}
|
|
1448
1851
|
|
|
1449
1852
|
private handleComponentError(pkt: VoiceErrorPacket): void {
|
|
1853
|
+
this.markInfrastructureBreach(pkt.contextId);
|
|
1854
|
+
this.metricsExporter.observeCounter?.("error.stage", 1, {
|
|
1855
|
+
stage: pkt.component,
|
|
1856
|
+
category: pkt.category,
|
|
1857
|
+
layer: "infrastructure",
|
|
1858
|
+
});
|
|
1450
1859
|
this.emit("error", {
|
|
1451
1860
|
tsMs: pkt.timestampMs,
|
|
1452
1861
|
stage: `${pkt.component}.error`,
|
|
@@ -1486,6 +1895,7 @@ export class VoiceAgentSession {
|
|
|
1486
1895
|
// leave the caller in unexplained silence: if the reasoning layer failed the turn,
|
|
1487
1896
|
// speak a graceful fallback (G4 — Deepgram guide "never fail silently").
|
|
1488
1897
|
this.maybeSpeakErrorFallback(pkt);
|
|
1898
|
+
this.emitTurnLocalization(pkt.contextId);
|
|
1489
1899
|
}
|
|
1490
1900
|
|
|
1491
1901
|
private maybeSpeakErrorFallback(pkt: VoiceErrorPacket): void {
|
|
@@ -1514,11 +1924,34 @@ export class VoiceAgentSession {
|
|
|
1514
1924
|
}
|
|
1515
1925
|
|
|
1516
1926
|
private handleInjectMessage(pkt: InjectMessagePacket): void {
|
|
1927
|
+
if (pkt.mode === "context") {
|
|
1928
|
+
const bridge = this.plugins.get("bridge") as (VoicePlugin & {
|
|
1929
|
+
injectContext?: (text: string) => void;
|
|
1930
|
+
}) | undefined;
|
|
1931
|
+
if (bridge?.injectContext) {
|
|
1932
|
+
bridge.injectContext(pkt.text);
|
|
1933
|
+
} else {
|
|
1934
|
+
console.warn("VoiceAgentSession: context injection requested but the bridge does not support injectContext");
|
|
1935
|
+
}
|
|
1936
|
+
return;
|
|
1937
|
+
}
|
|
1938
|
+
|
|
1517
1939
|
// Inject as synthetic LLM output — goes through normal TTS path
|
|
1518
1940
|
this.bus.push(Route.Main, make.llmDelta(pkt.contextId, Date.now(), pkt.text));
|
|
1519
1941
|
this.bus.push(Route.Main, make.llmDone(pkt.contextId, Date.now(), pkt.text));
|
|
1520
1942
|
}
|
|
1521
1943
|
|
|
1944
|
+
private handleSttReconfigure(pkt: SttReconfigurePacket): void {
|
|
1945
|
+
const stt = this.plugins.get("stt");
|
|
1946
|
+
if (stt?.sttReconfigure) {
|
|
1947
|
+
stt.sttReconfigure.reconfigure(pkt.partial);
|
|
1948
|
+
return;
|
|
1949
|
+
}
|
|
1950
|
+
console.warn(
|
|
1951
|
+
"VoiceAgentSession: stt.reconfigure requested but the STT plugin does not support sttReconfigure",
|
|
1952
|
+
);
|
|
1953
|
+
}
|
|
1954
|
+
|
|
1522
1955
|
private handleDisconnect(pkt: DisconnectRequestedPacket): void {
|
|
1523
1956
|
this.emit("closed", { tsMs: pkt.timestampMs, reason: pkt.reason });
|
|
1524
1957
|
this.debugPush({
|