@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.
Files changed (49) hide show
  1. package/package.json +2 -2
  2. package/src/audio/alaw.ts +56 -0
  3. package/src/audio/g722.ts +329 -0
  4. package/src/audio/index.ts +12 -0
  5. package/src/audio/loudness.ts +87 -0
  6. package/src/idle-timeout.ts +1 -0
  7. package/src/index.ts +63 -3
  8. package/src/interaction-policy.ts +12 -0
  9. package/src/observability-observer.ts +8 -4
  10. package/src/observability.ts +30 -1
  11. package/src/packet-factories.ts +110 -2
  12. package/src/packets.ts +116 -1
  13. package/src/plugin-contract.ts +41 -0
  14. package/src/policies/rule-based.ts +17 -2
  15. package/src/pricing.ts +137 -0
  16. package/src/primary-speaker-gate.ts +23 -3
  17. package/src/reasoner.ts +28 -1
  18. package/src/spend-cap.ts +52 -0
  19. package/src/turn-arbiter.ts +34 -2
  20. package/src/voice-agent-session.ts +453 -34
  21. package/src/voice-text.ts +69 -0
  22. package/src/audio/audio.test.ts +0 -285
  23. package/src/audio-envelope.test.ts +0 -167
  24. package/src/confidence-to-wait.test.ts +0 -21
  25. package/src/error-handler.test.ts +0 -56
  26. package/src/hedge-throwing-backend.test.ts +0 -46
  27. package/src/interaction-coordinator.test.ts +0 -425
  28. package/src/iu-ledger.test.ts +0 -276
  29. package/src/latency-filler.test.ts +0 -62
  30. package/src/observability-observer.test.ts +0 -245
  31. package/src/observability.test.ts +0 -85
  32. package/src/packet-factories.test.ts +0 -53
  33. package/src/pipeline-bus.g10.test.ts +0 -145
  34. package/src/pipeline-bus.test.ts +0 -211
  35. package/src/policies/defer.test.ts +0 -86
  36. package/src/policies/rule-based.test.ts +0 -269
  37. package/src/primary-speaker-gate.test.ts +0 -150
  38. package/src/provider-fallback.test.ts +0 -87
  39. package/src/reasoner-hedge.test.ts +0 -361
  40. package/src/reasoner-route.test.ts +0 -248
  41. package/src/reasoner.test.ts +0 -69
  42. package/src/retry.test.ts +0 -83
  43. package/src/route-throwing-spec.test.ts +0 -44
  44. package/src/tts-playout-clock.test.ts +0 -125
  45. package/src/turn-arbiter.characterization.test.ts +0 -477
  46. package/src/turn-arbiter.test.ts +0 -567
  47. package/src/voice-agent-session.test.ts +0 -3316
  48. package/src/voice-text.test.ts +0 -94
  49. 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: { tsMs: number; turnId: string; query: string; answer: string; durationMs: number; grounded: boolean; toolId?: string; toolName?: string }) => void;
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, emitted once at the turn's first TTS audio.
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) + ttsTtfbMs (first TTS text dispatched → first audio).
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: config.metricsExporter ?? noopMetricsExporter,
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.turnTimings.get(contextId);
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: firstAudioMs - anchorMs,
923
- ...(timing.speechEndedMs !== undefined && timing.eosMs !== undefined
924
- ? { eouDelayMs: timing.eosMs - timing.speechEndedMs }
925
- : {}),
926
- ...(timing.eosMs !== undefined && timing.firstLlmDeltaMs !== undefined
927
- ? { llmTtftMs: timing.firstLlmDeltaMs - timing.eosMs }
928
- : {}),
929
- ...(timing.firstTtsTextMs !== undefined
930
- ? { ttsTtfbMs: firstAudioMs - timing.firstTtsTextMs }
931
- : {}),
932
- fillerUsed: timing.fillerUsed === true,
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
- this.bus.push(Route.Main, make.ttsText(contextId, Date.now(), complete.text));
1096
- buffer.emitted = appendVoiceText(buffer.emitted, complete.text);
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.emitTurnLatency(pkt.contextId, pkt.timestampMs);
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.primarySpeakerGate.observeAssistantPlayout(pkt.audio);
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(pkt.audio, sampleRateHz);
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(pkt.audio),
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(pkt.audio.length),
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(), pkt.audio, sampleRateHz));
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({