@kuralle-syrinx/core 4.2.0 → 4.3.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 +63 -3
- 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 +110 -2
- package/src/packets.ts +116 -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.ts +453 -34
- 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,
|
|
@@ -422,15 +529,18 @@ export class VoiceAgentSession {
|
|
|
422
529
|
});
|
|
423
530
|
|
|
424
531
|
const obs = config.observability;
|
|
532
|
+
this.metricsExporter = config.metricsExporter ?? noopMetricsExporter;
|
|
533
|
+
this.observabilityDims = {
|
|
534
|
+
provider: obs?.provider ?? "unknown",
|
|
535
|
+
model: obs?.model ?? "unknown",
|
|
536
|
+
region: obs?.region ?? "unknown",
|
|
537
|
+
layer: obs?.layer,
|
|
538
|
+
};
|
|
425
539
|
this.observabilityObserver = new ObservabilityObserver({
|
|
426
540
|
bus: this.bus,
|
|
427
|
-
exporter:
|
|
541
|
+
exporter: this.metricsExporter,
|
|
428
542
|
sessionId: obs?.sessionId ?? "",
|
|
429
|
-
dims:
|
|
430
|
-
provider: obs?.provider ?? "unknown",
|
|
431
|
-
model: obs?.model ?? "unknown",
|
|
432
|
-
region: obs?.region ?? "unknown",
|
|
433
|
-
},
|
|
543
|
+
dims: this.observabilityDims,
|
|
434
544
|
getContextId: () => this.currentContextId,
|
|
435
545
|
});
|
|
436
546
|
|
|
@@ -490,6 +600,28 @@ export class VoiceAgentSession {
|
|
|
490
600
|
this._state = SessionState.Ready;
|
|
491
601
|
}
|
|
492
602
|
|
|
603
|
+
/**
|
|
604
|
+
* Best-effort warm of registered plugins' remote/expensive resources. Call after start() to
|
|
605
|
+
* wake scaled-to-zero endpoints before the first user turn. The host decides when to invoke
|
|
606
|
+
* this — it is not called automatically from start(). Never throws; failed plugin prewarms
|
|
607
|
+
* are swallowed and may emit a prewarm.failed metric on the bus.
|
|
608
|
+
*/
|
|
609
|
+
async prewarm(): Promise<void> {
|
|
610
|
+
const entries = [...this.plugins.entries()];
|
|
611
|
+
const results = await Promise.allSettled(
|
|
612
|
+
entries.map(async ([, plugin]) => {
|
|
613
|
+
await plugin.prewarm?.();
|
|
614
|
+
}),
|
|
615
|
+
);
|
|
616
|
+
for (let i = 0; i < results.length; i++) {
|
|
617
|
+
const result = results[i];
|
|
618
|
+
if (result?.status === "rejected") {
|
|
619
|
+
const [name] = entries[i]!;
|
|
620
|
+
this.bus.push(Route.Background, make.metric("", "prewarm.failed", name));
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
493
625
|
/** Shut down the session. Runs finalize chain in reverse order. */
|
|
494
626
|
async close(): Promise<void> {
|
|
495
627
|
if (this._state === SessionState.Closed) return;
|
|
@@ -514,6 +646,13 @@ export class VoiceAgentSession {
|
|
|
514
646
|
this.turnArbiter.clear();
|
|
515
647
|
this.turnUserStoppedAtMs.clear();
|
|
516
648
|
this.turnTimings.clear();
|
|
649
|
+
for (const contextId of this.turnLocalizationStates.keys()) {
|
|
650
|
+
this.scheduler.cancel(turnLocalizationTimerKey(contextId));
|
|
651
|
+
this.emitTurnLocalization(contextId);
|
|
652
|
+
}
|
|
653
|
+
this.turnLocalizationStates.clear();
|
|
654
|
+
this.emittedTurnLocalizations.clear();
|
|
655
|
+
this.pendingTurnLatency.clear();
|
|
517
656
|
this.firstTtsAudioFired.clear();
|
|
518
657
|
this.fallbackInjectedContexts.clear();
|
|
519
658
|
this.ttsTextBuffers.clear();
|
|
@@ -528,6 +667,9 @@ export class VoiceAgentSession {
|
|
|
528
667
|
// 2. Run finalize chain (reverse order)
|
|
529
668
|
await runFinalizeChain(this.initSteps);
|
|
530
669
|
|
|
670
|
+
// 2b. Emit the end-of-session usage manifest before the bus stops — the metering seam.
|
|
671
|
+
this.emitSessionUsage();
|
|
672
|
+
|
|
531
673
|
// 3. Stop bus
|
|
532
674
|
this.bus.stop();
|
|
533
675
|
await this.busStartPromise;
|
|
@@ -618,6 +760,7 @@ export class VoiceAgentSession {
|
|
|
618
760
|
this.lastFinalizedContextId = "";
|
|
619
761
|
if (pkt.previousContextId) {
|
|
620
762
|
this.sttPartialWordTimings.delete(pkt.previousContextId);
|
|
763
|
+
this.carryTurnTimingAcrossContextChange(pkt.previousContextId, pkt.contextId);
|
|
621
764
|
}
|
|
622
765
|
});
|
|
623
766
|
this.bus.on("eos.turn_complete", this.handleTurnComplete.bind(this));
|
|
@@ -628,6 +771,8 @@ export class VoiceAgentSession {
|
|
|
628
771
|
this.bus.on("llm.done", this.handleLlmDone.bind(this));
|
|
629
772
|
this.bus.on("llm.tool_call", this.handleLlmToolCall.bind(this));
|
|
630
773
|
this.bus.on("llm.tool_result", this.handleLlmToolResult.bind(this));
|
|
774
|
+
this.bus.on("metric.conversation", this.handleConversationMetric.bind(this));
|
|
775
|
+
this.bus.on("usage.recorded", (pkt) => this.handleUsageRecorded(pkt as UsageRecordedPacket));
|
|
631
776
|
|
|
632
777
|
// Delegate (Responder-Thinker) observability — G2, RFC bimodel-delegate-seam
|
|
633
778
|
this.bus.on("delegate.query", this.handleDelegateQuery.bind(this));
|
|
@@ -662,6 +807,9 @@ export class VoiceAgentSession {
|
|
|
662
807
|
// Injected messages — push through LLM path for natural TTS
|
|
663
808
|
this.bus.on("inject.message", this.handleInjectMessage.bind(this));
|
|
664
809
|
|
|
810
|
+
// Per-turn STT reconfigure (keyterms / endpointing / EOT thresholds)
|
|
811
|
+
this.bus.on("stt.reconfigure", this.handleSttReconfigure.bind(this));
|
|
812
|
+
|
|
665
813
|
// Disconnect
|
|
666
814
|
this.bus.on("session.disconnect", this.handleDisconnect.bind(this));
|
|
667
815
|
|
|
@@ -901,6 +1049,23 @@ export class VoiceAgentSession {
|
|
|
901
1049
|
this.watchdogs.startVaqiMissedResponseTimer(pkt.contextId, pkt.timestampMs);
|
|
902
1050
|
}
|
|
903
1051
|
|
|
1052
|
+
/**
|
|
1053
|
+
* A realtime front rotates its contextId when the provider starts responding
|
|
1054
|
+
* (`RealtimeBridge.onResponseStarted`), so the user-side anchors (`speechEndedMs`, `eosMs`)
|
|
1055
|
+
* are recorded under the PREVIOUS context while `tts.audio` — and therefore the
|
|
1056
|
+
* `turn_latency` emit — lands under the new one. Without carrying the record across the
|
|
1057
|
+
* rotation, `emitTurnLatency` finds no anchor and silently drops every native turn.
|
|
1058
|
+
*
|
|
1059
|
+
* Only fills gaps: anything the new context already recorded wins.
|
|
1060
|
+
*/
|
|
1061
|
+
private carryTurnTimingAcrossContextChange(previousContextId: string, contextId: string): void {
|
|
1062
|
+
const previous = this.turnTimings.get(previousContextId);
|
|
1063
|
+
if (!previous) return;
|
|
1064
|
+
this.turnTimings.delete(previousContextId);
|
|
1065
|
+
const current = this.turnTimings.get(contextId);
|
|
1066
|
+
this.turnTimings.set(contextId, { ...previous, ...current });
|
|
1067
|
+
}
|
|
1068
|
+
|
|
904
1069
|
private timingFor(contextId: string): TurnTiming {
|
|
905
1070
|
let timing = this.turnTimings.get(contextId);
|
|
906
1071
|
if (!timing) {
|
|
@@ -910,28 +1075,82 @@ export class VoiceAgentSession {
|
|
|
910
1075
|
return timing;
|
|
911
1076
|
}
|
|
912
1077
|
|
|
1078
|
+
/** Emit a deferred turn_latency if one is pending, then clear it. Safe to call twice. */
|
|
1079
|
+
private flushPendingTurnLatency(contextId: string): void {
|
|
1080
|
+
const firstAudioMs = this.pendingTurnLatency.get(contextId);
|
|
1081
|
+
if (firstAudioMs === undefined) return;
|
|
1082
|
+
this.pendingTurnLatency.delete(contextId);
|
|
1083
|
+
this.emitTurnLatency(contextId, firstAudioMs);
|
|
1084
|
+
}
|
|
1085
|
+
|
|
913
1086
|
private emitTurnLatency(contextId: string, firstAudioMs: number): void {
|
|
914
|
-
const timing = this.
|
|
1087
|
+
const timing = this.timingFor(contextId);
|
|
915
1088
|
this.turnTimings.delete(contextId);
|
|
916
|
-
if (!timing) return;
|
|
917
1089
|
const anchorMs = timing.speechEndedMs ?? timing.eosMs;
|
|
918
1090
|
if (anchorMs === undefined) return; // text-injected or fallback turn — not a voice TTFA
|
|
1091
|
+
const anchor = timing.speechEndedMs !== undefined ? "speech_end" : "eos";
|
|
1092
|
+
const eouDelayMs =
|
|
1093
|
+
timing.speechEndedMs !== undefined &&
|
|
1094
|
+
timing.eosMs !== undefined &&
|
|
1095
|
+
timing.eosMs <= firstAudioMs
|
|
1096
|
+
? timing.eosMs - timing.speechEndedMs
|
|
1097
|
+
: undefined;
|
|
1098
|
+
const llmAnchorMs = timing.eosMs ?? timing.speechEndedMs;
|
|
1099
|
+
const llmTtftMs =
|
|
1100
|
+
llmAnchorMs !== undefined && timing.firstLlmDeltaMs !== undefined
|
|
1101
|
+
? timing.firstLlmDeltaMs - llmAnchorMs
|
|
1102
|
+
: undefined;
|
|
1103
|
+
const textAggregationMs =
|
|
1104
|
+
timing.firstLlmDeltaMs !== undefined && timing.firstTtsTextMs !== undefined
|
|
1105
|
+
? timing.firstTtsTextMs - timing.firstLlmDeltaMs
|
|
1106
|
+
: undefined;
|
|
1107
|
+
const ttsTtfbMs =
|
|
1108
|
+
timing.firstTtsTextMs !== undefined
|
|
1109
|
+
? firstAudioMs - timing.firstTtsTextMs
|
|
1110
|
+
: undefined;
|
|
1111
|
+
const attributedMs = [eouDelayMs, llmTtftMs, textAggregationMs, ttsTtfbMs]
|
|
1112
|
+
.filter((value): value is number => value !== undefined)
|
|
1113
|
+
.reduce((sum, value) => sum + value, 0);
|
|
1114
|
+
const ttfaMs = firstAudioMs - anchorMs;
|
|
1115
|
+
const unattributedMs = ttfaMs - attributedMs;
|
|
1116
|
+
if (unattributedMs >= 500) this.markInfrastructureBreach(contextId);
|
|
1117
|
+
const fillerUsed = timing.fillerUsed === true;
|
|
1118
|
+
const backchannelUsed = timing.backchannelUsed === true;
|
|
919
1119
|
this.emit("turn_latency", {
|
|
920
1120
|
tsMs: firstAudioMs,
|
|
921
1121
|
turnId: contextId,
|
|
922
|
-
ttfaMs
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
...(
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
...(timing.
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
backchannelUsed: timing.backchannelUsed === true,
|
|
1122
|
+
ttfaMs,
|
|
1123
|
+
anchor,
|
|
1124
|
+
...(eouDelayMs !== undefined ? { eouDelayMs } : {}),
|
|
1125
|
+
...(llmTtftMs !== undefined ? { llmTtftMs } : {}),
|
|
1126
|
+
...(textAggregationMs !== undefined ? { textAggregationMs } : {}),
|
|
1127
|
+
...(ttsTtfbMs !== undefined ? { ttsTtfbMs } : {}),
|
|
1128
|
+
unattributedMs,
|
|
1129
|
+
...(timing.llmCallCount !== undefined ? { llmCallCount: timing.llmCallCount } : {}),
|
|
1130
|
+
...(timing.llmPassTtftMs !== undefined ? { llmPassTtftMs: [...timing.llmPassTtftMs] } : {}),
|
|
1131
|
+
fillerUsed,
|
|
1132
|
+
backchannelUsed,
|
|
934
1133
|
});
|
|
1134
|
+
|
|
1135
|
+
const tags = {
|
|
1136
|
+
...this.observabilityDims,
|
|
1137
|
+
layer: "infrastructure" as const,
|
|
1138
|
+
cancelled: this.interruptedGenerationContextIds.has(contextId) ? "true" : "false",
|
|
1139
|
+
anchor,
|
|
1140
|
+
filler_used: String(fillerUsed),
|
|
1141
|
+
backchannel_used: String(backchannelUsed),
|
|
1142
|
+
};
|
|
1143
|
+
const histograms: Array<readonly [string, number | undefined]> = [
|
|
1144
|
+
["turn.ttfa_ms", ttfaMs],
|
|
1145
|
+
["turn.eou_delay_ms", eouDelayMs],
|
|
1146
|
+
["turn.llm_ttft_ms", llmTtftMs],
|
|
1147
|
+
["turn.text_aggregation_ms", textAggregationMs],
|
|
1148
|
+
["turn.tts_ttfb_ms", ttsTtfbMs],
|
|
1149
|
+
["turn.unattributed_ms", unattributedMs],
|
|
1150
|
+
];
|
|
1151
|
+
for (const [name, valueMs] of histograms) {
|
|
1152
|
+
if (valueMs !== undefined) this.metricsExporter.observeHistogram(name, valueMs, tags);
|
|
1153
|
+
}
|
|
935
1154
|
}
|
|
936
1155
|
|
|
937
1156
|
private handleTurnComplete(pkt: EndOfSpeechPacket): void {
|
|
@@ -950,9 +1169,14 @@ export class VoiceAgentSession {
|
|
|
950
1169
|
const otherTtsActive = this.ttsPlayout.activeContexts().some((c) => c !== pkt.contextId);
|
|
951
1170
|
if (otherTtsActive && isBackchannel(pkt.text)) {
|
|
952
1171
|
this.bus.push(Route.Background, make.metric(pkt.contextId, "turn.backchannel_dropped", pkt.text));
|
|
1172
|
+
this.emitAcousticSignal(pkt.contextId, pkt.timestampMs, "backchannel", { text: pkt.text });
|
|
953
1173
|
return;
|
|
954
1174
|
}
|
|
955
1175
|
|
|
1176
|
+
if (this.emittedTurnLocalizations.has(pkt.contextId)) {
|
|
1177
|
+
this.emittedTurnLocalizations.delete(pkt.contextId);
|
|
1178
|
+
this.turnLocalizationStates.delete(pkt.contextId);
|
|
1179
|
+
}
|
|
956
1180
|
this.lastFinalizedContextId = pkt.contextId;
|
|
957
1181
|
this.interaction.reset(pkt.contextId);
|
|
958
1182
|
|
|
@@ -963,6 +1187,7 @@ export class VoiceAgentSession {
|
|
|
963
1187
|
// - interruptedGenerationContextIds: else turn N+1's LLM/TTS packets are dropped after a prior barge-in
|
|
964
1188
|
// - fallbackInjectedContexts: else only one error fallback can ever be spoken per call
|
|
965
1189
|
this.firstTtsAudioFired.delete(pkt.contextId);
|
|
1190
|
+
this.pendingTurnLatency.delete(pkt.contextId);
|
|
966
1191
|
this.interruptedGenerationContextIds.delete(pkt.contextId);
|
|
967
1192
|
this.fallbackInjectedContexts.delete(pkt.contextId);
|
|
968
1193
|
|
|
@@ -1092,8 +1317,11 @@ export class VoiceAgentSession {
|
|
|
1092
1317
|
if (complete.text) {
|
|
1093
1318
|
const timing = this.turnTimings.get(contextId);
|
|
1094
1319
|
if (timing) timing.firstTtsTextMs ??= tsMs ?? Date.now();
|
|
1095
|
-
|
|
1096
|
-
|
|
1320
|
+
// Strip markdown before it reaches TTS, and track the SPOKEN form as the heard
|
|
1321
|
+
// prefix so barge-in truncation reconciles against what was actually said.
|
|
1322
|
+
const spoken = normalizeForSpeech(complete.text);
|
|
1323
|
+
this.bus.push(Route.Main, make.ttsText(contextId, Date.now(), spoken));
|
|
1324
|
+
buffer.emitted = appendVoiceText(buffer.emitted, spoken);
|
|
1097
1325
|
}
|
|
1098
1326
|
buffer.pending = complete.remaining;
|
|
1099
1327
|
this.ttsTextBuffers.set(contextId, buffer);
|
|
@@ -1102,7 +1330,7 @@ export class VoiceAgentSession {
|
|
|
1102
1330
|
private flushTtsText(contextId: string, tsMs?: number): string {
|
|
1103
1331
|
const buffer = this.ttsTextBuffers.get(contextId);
|
|
1104
1332
|
if (!buffer) return "";
|
|
1105
|
-
const tail = buffer.pending.trim();
|
|
1333
|
+
const tail = normalizeForSpeech(buffer.pending.trim());
|
|
1106
1334
|
if (tail) {
|
|
1107
1335
|
const timing = this.turnTimings.get(contextId);
|
|
1108
1336
|
if (timing) timing.firstTtsTextMs ??= tsMs ?? Date.now();
|
|
@@ -1224,6 +1452,9 @@ export class VoiceAgentSession {
|
|
|
1224
1452
|
}
|
|
1225
1453
|
|
|
1226
1454
|
private handleDelegateResult(pkt: DelegateResultPacket): void {
|
|
1455
|
+
if (pkt.control && isConversationControlName(pkt.control.name)) {
|
|
1456
|
+
this.markConversationFlag(pkt.contextId);
|
|
1457
|
+
}
|
|
1227
1458
|
this.emit("delegate_result", {
|
|
1228
1459
|
tsMs: pkt.timestampMs,
|
|
1229
1460
|
turnId: pkt.contextId,
|
|
@@ -1233,6 +1464,8 @@ export class VoiceAgentSession {
|
|
|
1233
1464
|
grounded: pkt.grounded,
|
|
1234
1465
|
toolId: pkt.toolId,
|
|
1235
1466
|
toolName: pkt.toolName,
|
|
1467
|
+
...(pkt.control ? { control: pkt.control } : {}),
|
|
1468
|
+
...(pkt.blocked ? { blocked: pkt.blocked } : {}),
|
|
1236
1469
|
});
|
|
1237
1470
|
this.debugPush({
|
|
1238
1471
|
component: "delegate",
|
|
@@ -1267,6 +1500,125 @@ export class VoiceAgentSession {
|
|
|
1267
1500
|
});
|
|
1268
1501
|
}
|
|
1269
1502
|
|
|
1503
|
+
private handleConversationMetric(pkt: ConversationMetricPacket): void {
|
|
1504
|
+
if (isInfrastructureMetric(pkt.name)) this.markInfrastructureBreach(pkt.contextId);
|
|
1505
|
+
if (isConversationEvaluationMetric(pkt.name, pkt.value)) this.markConversationFlag(pkt.contextId);
|
|
1506
|
+
if (pkt.name === "input.cadence_stall_ms") {
|
|
1507
|
+
this.emitAcousticSignal(pkt.contextId, pkt.timestampMs, "cadence", { value: pkt.value });
|
|
1508
|
+
}
|
|
1509
|
+
if (pkt.name === "llm.call_started") {
|
|
1510
|
+
const timing = this.timingFor(pkt.contextId);
|
|
1511
|
+
timing.llmCallCount = (timing.llmCallCount ?? 0) + 1;
|
|
1512
|
+
return;
|
|
1513
|
+
}
|
|
1514
|
+
if (pkt.name === "llm.pass_ttft_ms") {
|
|
1515
|
+
const ttftMs = Number(pkt.value);
|
|
1516
|
+
if (!Number.isFinite(ttftMs)) return;
|
|
1517
|
+
const timing = this.timingFor(pkt.contextId);
|
|
1518
|
+
(timing.llmPassTtftMs ??= []).push(ttftMs);
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
private emitAcousticSignal(
|
|
1523
|
+
contextId: string,
|
|
1524
|
+
timestampMs: number,
|
|
1525
|
+
signal: AcousticSignalPacket["signal"],
|
|
1526
|
+
payload?: Readonly<Record<string, unknown>>,
|
|
1527
|
+
): void {
|
|
1528
|
+
this.bus.push(Route.Background, make.acousticSignal(contextId, timestampMs, signal, payload));
|
|
1529
|
+
this.metricsExporter.observeCounter?.(`acoustic.${signal}`, 1, {
|
|
1530
|
+
provider: this.observabilityDims.provider,
|
|
1531
|
+
model: this.observabilityDims.model,
|
|
1532
|
+
region: this.observabilityDims.region,
|
|
1533
|
+
layer: "conversation",
|
|
1534
|
+
});
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
private localizationStateFor(contextId: string): {
|
|
1538
|
+
infrastructureBreached: boolean;
|
|
1539
|
+
conversationFlagged: boolean;
|
|
1540
|
+
} {
|
|
1541
|
+
const existing = this.turnLocalizationStates.get(contextId);
|
|
1542
|
+
if (existing) return existing;
|
|
1543
|
+
const state = { infrastructureBreached: false, conversationFlagged: false };
|
|
1544
|
+
this.turnLocalizationStates.set(contextId, state);
|
|
1545
|
+
return state;
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
private markInfrastructureBreach(contextId: string): void {
|
|
1549
|
+
this.localizationStateFor(contextId).infrastructureBreached = true;
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
private markConversationFlag(contextId: string): void {
|
|
1553
|
+
this.localizationStateFor(contextId).conversationFlagged = true;
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
private emitTurnLocalization(contextId: string): void {
|
|
1557
|
+
if (this.emittedTurnLocalizations.has(contextId)) return;
|
|
1558
|
+
const state = this.localizationStateFor(contextId);
|
|
1559
|
+
this.emittedTurnLocalizations.add(contextId);
|
|
1560
|
+
this.bus.push(
|
|
1561
|
+
Route.Background,
|
|
1562
|
+
make.turnLocalization(
|
|
1563
|
+
contextId,
|
|
1564
|
+
Date.now(),
|
|
1565
|
+
localizeTurn(state),
|
|
1566
|
+
state.infrastructureBreached,
|
|
1567
|
+
state.conversationFlagged,
|
|
1568
|
+
),
|
|
1569
|
+
);
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
private scheduleTurnLocalization(contextId: string): void {
|
|
1573
|
+
this.scheduler.schedule(turnLocalizationTimerKey(contextId), 0, () => {
|
|
1574
|
+
this.emitTurnLocalization(contextId);
|
|
1575
|
+
});
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
private static readonly USAGE_FIELDS = [
|
|
1579
|
+
"inputTokens",
|
|
1580
|
+
"outputTokens",
|
|
1581
|
+
"totalTokens",
|
|
1582
|
+
"cachedInputTokens",
|
|
1583
|
+
"reasoningTokens",
|
|
1584
|
+
"audioSeconds",
|
|
1585
|
+
"characters",
|
|
1586
|
+
] as const;
|
|
1587
|
+
|
|
1588
|
+
private handleUsageRecorded(pkt: UsageRecordedPacket): void {
|
|
1589
|
+
// Accumulate into the per-stage running total. A missing field stays missing
|
|
1590
|
+
// (never coerced to 0) so an un-reported dimension is distinguishable from a real 0.
|
|
1591
|
+
const prev = this.usageByStage.get(pkt.stage) ?? { stage: pkt.stage };
|
|
1592
|
+
const next: Record<string, unknown> = { ...prev };
|
|
1593
|
+
for (const field of VoiceAgentSession.USAGE_FIELDS) {
|
|
1594
|
+
const add = pkt[field];
|
|
1595
|
+
if (typeof add === "number") next[field] = ((prev[field] as number | undefined) ?? 0) + add;
|
|
1596
|
+
}
|
|
1597
|
+
this.usageByStage.set(pkt.stage, next as unknown as SessionStageUsage);
|
|
1598
|
+
|
|
1599
|
+
// Export each recorded unit as a counter, tagged low-cardinality only — same
|
|
1600
|
+
// discipline as turn_latency. provider/model are exemplar-safe; contextId is not,
|
|
1601
|
+
// and must never become a metric dimension (LiveKit caps at model/provider).
|
|
1602
|
+
const exporter = this.metricsExporter;
|
|
1603
|
+
if (exporter.observeCounter) {
|
|
1604
|
+
const tags = {
|
|
1605
|
+
stage: pkt.stage,
|
|
1606
|
+
provider: pkt.provider ?? "",
|
|
1607
|
+
model: pkt.model ?? "",
|
|
1608
|
+
layer: "infrastructure" as const,
|
|
1609
|
+
};
|
|
1610
|
+
for (const field of VoiceAgentSession.USAGE_FIELDS) {
|
|
1611
|
+
const value = pkt[field];
|
|
1612
|
+
if (typeof value === "number") exporter.observeCounter(`usage.${field}`, value, tags);
|
|
1613
|
+
}
|
|
1614
|
+
}
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
private emitSessionUsage(): void {
|
|
1618
|
+
if (this.usageByStage.size === 0) return;
|
|
1619
|
+
this.emit("usage", { tsMs: Date.now(), stages: [...this.usageByStage.values()] });
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1270
1622
|
private handleTtsAudio(pkt: TextToSpeechAudioPacket): void {
|
|
1271
1623
|
if (this.interruptedGenerationContextIds.has(pkt.contextId)) {
|
|
1272
1624
|
this.bus.push(
|
|
@@ -1287,17 +1639,24 @@ export class VoiceAgentSession {
|
|
|
1287
1639
|
);
|
|
1288
1640
|
this.turnUserStoppedAtMs.delete(pkt.contextId);
|
|
1289
1641
|
}
|
|
1290
|
-
this.
|
|
1642
|
+
if (this.generatingContextIds.has(pkt.contextId)) {
|
|
1643
|
+
this.pendingTurnLatency.set(pkt.contextId, pkt.timestampMs);
|
|
1644
|
+
} else {
|
|
1645
|
+
this.emitTurnLatency(pkt.contextId, pkt.timestampMs);
|
|
1646
|
+
}
|
|
1291
1647
|
}
|
|
1292
1648
|
|
|
1293
|
-
this.
|
|
1649
|
+
const audio = this.normalizeOutboundAudio(pkt.audio);
|
|
1650
|
+
// Transport subscribers receive this same packet after the core handler.
|
|
1651
|
+
if (audio !== pkt.audio) Object.assign(pkt, { audio });
|
|
1652
|
+
this.primarySpeakerGate.observeAssistantPlayout(audio);
|
|
1294
1653
|
// Audio just arrived — (re)arm the stall watchdog for this turn's TTS output.
|
|
1295
1654
|
this.watchdogs.armTtsStallTimer(pkt.contextId);
|
|
1296
1655
|
|
|
1297
1656
|
// Mark active and advance this context's playout cursor by the chunk's
|
|
1298
1657
|
// realtime duration.
|
|
1299
1658
|
const sampleRateHz = requireTtsAudioSampleRate(pkt.sampleRateHz);
|
|
1300
|
-
const audioDurationMs = estimatePcm16Duration(
|
|
1659
|
+
const audioDurationMs = estimatePcm16Duration(audio, sampleRateHz);
|
|
1301
1660
|
const now = Date.now();
|
|
1302
1661
|
this.ttsPlayout.noteAudio(pkt.contextId, audioDurationMs, now);
|
|
1303
1662
|
this.interaction.observe({
|
|
@@ -1305,7 +1664,7 @@ export class VoiceAgentSession {
|
|
|
1305
1664
|
contextId: pkt.contextId,
|
|
1306
1665
|
timestampMs: pkt.timestampMs,
|
|
1307
1666
|
ttsActive: true,
|
|
1308
|
-
audio: pcm16BytesToSamples(
|
|
1667
|
+
audio: pcm16BytesToSamples(audio),
|
|
1309
1668
|
sampleRateHz,
|
|
1310
1669
|
});
|
|
1311
1670
|
|
|
@@ -1322,18 +1681,37 @@ export class VoiceAgentSession {
|
|
|
1322
1681
|
type: "audio",
|
|
1323
1682
|
data: {
|
|
1324
1683
|
context_id: pkt.contextId,
|
|
1325
|
-
bytes: String(
|
|
1684
|
+
bytes: String(audio.length),
|
|
1326
1685
|
},
|
|
1327
1686
|
timestampMs: pkt.timestampMs,
|
|
1328
1687
|
});
|
|
1329
1688
|
|
|
1330
|
-
this.bus.push(Route.Main, make.recordAssistantAudio(pkt.contextId, Date.now(),
|
|
1689
|
+
this.bus.push(Route.Main, make.recordAssistantAudio(pkt.contextId, Date.now(), audio, sampleRateHz));
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1692
|
+
private normalizeOutboundAudio(audio: Uint8Array): Uint8Array {
|
|
1693
|
+
if (
|
|
1694
|
+
!this.outboundLoudnessConfig ||
|
|
1695
|
+
!this.outboundLoudnessState ||
|
|
1696
|
+
audio.byteLength % 2 !== 0
|
|
1697
|
+
) {
|
|
1698
|
+
return audio;
|
|
1699
|
+
}
|
|
1700
|
+
const samples = pcm16BytesToSamples(audio);
|
|
1701
|
+
return pcm16SamplesToBytes(
|
|
1702
|
+
normalizeLoudness(samples, this.outboundLoudnessState, this.outboundLoudnessConfig),
|
|
1703
|
+
);
|
|
1331
1704
|
}
|
|
1332
1705
|
|
|
1333
1706
|
private handleTtsEnd(pkt: TextToSpeechEndPacket): void {
|
|
1334
1707
|
// Generation finished, but the streamed audio is still playing out. Keep the
|
|
1335
1708
|
// context interruptible until its playout estimate elapses, then release it.
|
|
1336
1709
|
this.generatingContextIds.delete(pkt.contextId);
|
|
1710
|
+
const firstAudioMs = this.pendingTurnLatency.get(pkt.contextId);
|
|
1711
|
+
if (firstAudioMs !== undefined) {
|
|
1712
|
+
this.pendingTurnLatency.delete(pkt.contextId);
|
|
1713
|
+
this.emitTurnLatency(pkt.contextId, firstAudioMs);
|
|
1714
|
+
}
|
|
1337
1715
|
const now = Date.now();
|
|
1338
1716
|
this.ttsPlayout.scheduleRelease(pkt.contextId, now);
|
|
1339
1717
|
const playoutEndMs = this.ttsPlayout.playoutEnd(pkt.contextId);
|
|
@@ -1346,6 +1724,7 @@ export class VoiceAgentSession {
|
|
|
1346
1724
|
data: {},
|
|
1347
1725
|
timestampMs: Date.now(),
|
|
1348
1726
|
});
|
|
1727
|
+
this.scheduleTurnLocalization(pkt.contextId);
|
|
1349
1728
|
}
|
|
1350
1729
|
|
|
1351
1730
|
private scheduleInteractionPlayoutTick(contextId: string, delayMs: number): void {
|
|
@@ -1384,6 +1763,15 @@ export class VoiceAgentSession {
|
|
|
1384
1763
|
this.interruptedGenerationContextIds.add(pkt.contextId);
|
|
1385
1764
|
this.failPendingToolCues(pkt.contextId); // G3: the aborted delegate's cue fails (R5)
|
|
1386
1765
|
this.latencyFiller.cancel(pkt.contextId);
|
|
1766
|
+
// A barged turn still produced first audio, and its TTFA is a real measurement.
|
|
1767
|
+
// Deferring emission to tts.end (so later tool passes are counted) must not make
|
|
1768
|
+
// interrupted turns vanish from the metric: barge-in correlates with slow turns,
|
|
1769
|
+
// so silently dropping them biases the sample toward the fast ones — the worst
|
|
1770
|
+
// turns would disappear from exactly the number meant to detect them.
|
|
1771
|
+
this.flushPendingTurnLatency(pkt.contextId);
|
|
1772
|
+
this.emitAcousticSignal(pkt.contextId, pkt.timestampMs, "interruption", {
|
|
1773
|
+
source: pkt.source,
|
|
1774
|
+
});
|
|
1387
1775
|
this.turnTimings.delete(pkt.contextId);
|
|
1388
1776
|
this.firstLlmDeltaReceived.delete(pkt.contextId);
|
|
1389
1777
|
this.ttsTextBuffers.delete(pkt.contextId);
|
|
@@ -1434,6 +1822,7 @@ export class VoiceAgentSession {
|
|
|
1434
1822
|
this.interruptedGenerationContextIds.add(contextId);
|
|
1435
1823
|
this.failPendingToolCues(contextId); // G3: a superseded turn's pending cue fails
|
|
1436
1824
|
this.generatingContextIds.delete(contextId);
|
|
1825
|
+
this.pendingTurnLatency.delete(contextId);
|
|
1437
1826
|
this.latencyFiller.cancel(contextId);
|
|
1438
1827
|
this.turnTimings.delete(contextId);
|
|
1439
1828
|
this.firstLlmDeltaReceived.delete(contextId);
|
|
@@ -1447,6 +1836,12 @@ export class VoiceAgentSession {
|
|
|
1447
1836
|
}
|
|
1448
1837
|
|
|
1449
1838
|
private handleComponentError(pkt: VoiceErrorPacket): void {
|
|
1839
|
+
this.markInfrastructureBreach(pkt.contextId);
|
|
1840
|
+
this.metricsExporter.observeCounter?.("error.stage", 1, {
|
|
1841
|
+
stage: pkt.component,
|
|
1842
|
+
category: pkt.category,
|
|
1843
|
+
layer: "infrastructure",
|
|
1844
|
+
});
|
|
1450
1845
|
this.emit("error", {
|
|
1451
1846
|
tsMs: pkt.timestampMs,
|
|
1452
1847
|
stage: `${pkt.component}.error`,
|
|
@@ -1486,6 +1881,7 @@ export class VoiceAgentSession {
|
|
|
1486
1881
|
// leave the caller in unexplained silence: if the reasoning layer failed the turn,
|
|
1487
1882
|
// speak a graceful fallback (G4 — Deepgram guide "never fail silently").
|
|
1488
1883
|
this.maybeSpeakErrorFallback(pkt);
|
|
1884
|
+
this.emitTurnLocalization(pkt.contextId);
|
|
1489
1885
|
}
|
|
1490
1886
|
|
|
1491
1887
|
private maybeSpeakErrorFallback(pkt: VoiceErrorPacket): void {
|
|
@@ -1514,11 +1910,34 @@ export class VoiceAgentSession {
|
|
|
1514
1910
|
}
|
|
1515
1911
|
|
|
1516
1912
|
private handleInjectMessage(pkt: InjectMessagePacket): void {
|
|
1913
|
+
if (pkt.mode === "context") {
|
|
1914
|
+
const bridge = this.plugins.get("bridge") as (VoicePlugin & {
|
|
1915
|
+
injectContext?: (text: string) => void;
|
|
1916
|
+
}) | undefined;
|
|
1917
|
+
if (bridge?.injectContext) {
|
|
1918
|
+
bridge.injectContext(pkt.text);
|
|
1919
|
+
} else {
|
|
1920
|
+
console.warn("VoiceAgentSession: context injection requested but the bridge does not support injectContext");
|
|
1921
|
+
}
|
|
1922
|
+
return;
|
|
1923
|
+
}
|
|
1924
|
+
|
|
1517
1925
|
// Inject as synthetic LLM output — goes through normal TTS path
|
|
1518
1926
|
this.bus.push(Route.Main, make.llmDelta(pkt.contextId, Date.now(), pkt.text));
|
|
1519
1927
|
this.bus.push(Route.Main, make.llmDone(pkt.contextId, Date.now(), pkt.text));
|
|
1520
1928
|
}
|
|
1521
1929
|
|
|
1930
|
+
private handleSttReconfigure(pkt: SttReconfigurePacket): void {
|
|
1931
|
+
const stt = this.plugins.get("stt");
|
|
1932
|
+
if (stt?.sttReconfigure) {
|
|
1933
|
+
stt.sttReconfigure.reconfigure(pkt.partial);
|
|
1934
|
+
return;
|
|
1935
|
+
}
|
|
1936
|
+
console.warn(
|
|
1937
|
+
"VoiceAgentSession: stt.reconfigure requested but the STT plugin does not support sttReconfigure",
|
|
1938
|
+
);
|
|
1939
|
+
}
|
|
1940
|
+
|
|
1522
1941
|
private handleDisconnect(pkt: DisconnectRequestedPacket): void {
|
|
1523
1942
|
this.emit("closed", { tsMs: pkt.timestampMs, reason: pkt.reason });
|
|
1524
1943
|
this.debugPush({
|