@kuralle-syrinx/core 4.0.0 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kuralle-syrinx/core",
3
- "version": "4.0.0",
3
+ "version": "4.1.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/index.ts CHANGED
@@ -36,6 +36,7 @@ export {
36
36
  type EndOfSpeechAudioPacket,
37
37
  type EndOfSpeechPacket,
38
38
  type InterimEndOfSpeechPacket,
39
+ type EndOfSpeechRetractedPacket,
39
40
  type UserInputPacket,
40
41
  } from "./packets.js";
41
42
 
package/src/packets.ts CHANGED
@@ -211,6 +211,16 @@ export interface InterimEndOfSpeechPacket extends VoicePacket {
211
211
  readonly text: string;
212
212
  }
213
213
 
214
+ /**
215
+ * Retraction of a prior `eos.interim` for the same context: the endpoint model
216
+ * signalled a likely turn end, then the user kept speaking (Deepgram Flux
217
+ * `TurnResumed` semantics). Consumers doing speculative work off `eos.interim`
218
+ * must cancel it.
219
+ */
220
+ export interface EndOfSpeechRetractedPacket extends VoicePacket {
221
+ readonly kind: "eos.retracted";
222
+ }
223
+
214
224
  // =============================================================================
215
225
  // User Input (processed — feeds LLM)
216
226
  // =============================================================================
@@ -556,6 +566,7 @@ export type InputPacket =
556
566
  | EndOfSpeechAudioPacket
557
567
  | EndOfSpeechPacket
558
568
  | InterimEndOfSpeechPacket
569
+ | EndOfSpeechRetractedPacket
559
570
  | UserInputPacket;
560
571
 
561
572
  /** All interruption packets (Critical route). */
@@ -370,6 +370,124 @@ describe("VoiceAgentSession", () => {
370
370
  await closeSession(session);
371
371
  });
372
372
 
373
+ it("emits a decomposed turn_latency session event on first TTS audio", async () => {
374
+ const session = new VoiceAgentSession({ plugins: {} });
375
+ const events: Array<Record<string, unknown>> = [];
376
+ session.on("turn_latency", (event) => {
377
+ events.push(event);
378
+ });
379
+ await session.start();
380
+
381
+ const t0 = 100_000;
382
+ session.bus.push(Route.Main, { kind: "vad.speech_ended", contextId: "turn-1", timestampMs: t0 });
383
+ await new Promise((resolve) => setTimeout(resolve, 10));
384
+ session.bus.push(Route.Main, {
385
+ kind: "eos.turn_complete",
386
+ contextId: "turn-1",
387
+ timestampMs: t0 + 200,
388
+ text: "what are the lab fees",
389
+ transcripts: [],
390
+ });
391
+ await new Promise((resolve) => setTimeout(resolve, 10));
392
+ session.bus.push(Route.Main, { kind: "llm.delta", contextId: "turn-1", timestampMs: t0 + 900, text: "The fee is ten dollars." });
393
+ await new Promise((resolve) => setTimeout(resolve, 10));
394
+ session.bus.push(Route.Main, {
395
+ kind: "tts.audio",
396
+ contextId: "turn-1",
397
+ timestampMs: t0 + 1150,
398
+ audio: new Uint8Array(320),
399
+ sampleRateHz: 16000,
400
+ });
401
+ await new Promise((resolve) => setTimeout(resolve, 20));
402
+
403
+ expect(events).toHaveLength(1);
404
+ expect(events[0]).toEqual({
405
+ tsMs: expect.any(Number),
406
+ turnId: "turn-1",
407
+ ttfaMs: 1150,
408
+ eouDelayMs: 200,
409
+ llmTtftMs: 700,
410
+ ttsTtfbMs: 250,
411
+ fillerUsed: false,
412
+ });
413
+
414
+ await closeSession(session);
415
+ });
416
+
417
+ it("turn_latency anchors TTFA to eos when no VAD speech-end was seen, and marks filler turns", async () => {
418
+ const session = new VoiceAgentSession({ plugins: {}, latencyFillerEnabled: true });
419
+ const events: Array<Record<string, unknown>> = [];
420
+ session.on("turn_latency", (event) => {
421
+ events.push(event);
422
+ });
423
+ await session.start();
424
+
425
+ const t0 = 200_000;
426
+ session.bus.push(Route.Main, {
427
+ kind: "eos.turn_complete",
428
+ contextId: "turn-2",
429
+ timestampMs: t0,
430
+ text: "tell me about registration holds",
431
+ transcripts: [],
432
+ });
433
+ await new Promise((resolve) => setTimeout(resolve, 10));
434
+ session.bus.push(Route.Main, {
435
+ kind: "tts.audio",
436
+ contextId: "turn-2",
437
+ timestampMs: t0 + 400,
438
+ audio: new Uint8Array(320),
439
+ sampleRateHz: 16000,
440
+ });
441
+ await new Promise((resolve) => setTimeout(resolve, 20));
442
+
443
+ expect(events).toHaveLength(1);
444
+ expect(events[0]).toMatchObject({
445
+ turnId: "turn-2",
446
+ ttfaMs: 400,
447
+ fillerUsed: true,
448
+ });
449
+ expect(events[0]!["eouDelayMs"]).toBeUndefined();
450
+
451
+ await closeSession(session);
452
+ });
453
+
454
+ it("turn_latency is not emitted for an interrupted turn", async () => {
455
+ const session = new VoiceAgentSession({ plugins: {} });
456
+ const events: unknown[] = [];
457
+ session.on("turn_latency", (event) => {
458
+ events.push(event);
459
+ });
460
+ await session.start();
461
+
462
+ const t0 = 300_000;
463
+ session.bus.push(Route.Main, {
464
+ kind: "eos.turn_complete",
465
+ contextId: "turn-3",
466
+ timestampMs: t0,
467
+ text: "hello there",
468
+ transcripts: [],
469
+ });
470
+ await new Promise((resolve) => setTimeout(resolve, 10));
471
+ session.bus.push(Route.Critical, {
472
+ kind: "interrupt.detected",
473
+ contextId: "turn-3",
474
+ timestampMs: t0 + 100,
475
+ });
476
+ await new Promise((resolve) => setTimeout(resolve, 10));
477
+ session.bus.push(Route.Main, {
478
+ kind: "tts.audio",
479
+ contextId: "turn-3",
480
+ timestampMs: t0 + 300,
481
+ audio: new Uint8Array(320),
482
+ sampleRateHz: 16000,
483
+ });
484
+ await new Promise((resolve) => setTimeout(resolve, 20));
485
+
486
+ expect(events).toHaveLength(0);
487
+
488
+ await closeSession(session);
489
+ });
490
+
373
491
  it("G3/WBS-3: tool-call lifecycle cues fire started → delayed → complete", async () => {
374
492
  const session = new VoiceAgentSession({ plugins: {}, delayCueAfterMs: 30 });
375
493
  const cues: Array<{ phase: string; turnId: string; toolId: string; toolName: string; afterMs?: number }> = [];
@@ -190,6 +190,23 @@ export interface VoiceAgentSessionEvents {
190
190
  * these as `tool_call_*` wire messages — the standard "thinking" cue.
191
191
  */
192
192
  tool_call_cue: (event: { tsMs: number; turnId: string; phase: "started" | "delayed" | "complete" | "failed"; toolId: string; toolName: string; afterMs?: number }) => void;
193
+ /**
194
+ * Per-turn latency decomposition, emitted once at the turn's first TTS audio.
195
+ * `ttfaMs` is anchored to the real end of user speech (VAD speech-end, falling
196
+ * back to the endpoint decision) — and `fillerUsed` flags turns where a latency
197
+ * filler spoke first, so TTFA on those turns is not mistaken for reasoning speed.
198
+ * Decomposition: eouDelayMs (speech end → endpoint) + llmTtftMs (endpoint →
199
+ * first LLM delta) + ttsTtfbMs (first TTS text dispatched → first audio).
200
+ */
201
+ turn_latency: (event: {
202
+ tsMs: number;
203
+ turnId: string;
204
+ ttfaMs: number;
205
+ eouDelayMs?: number;
206
+ llmTtftMs?: number;
207
+ ttsTtfbMs?: number;
208
+ fillerUsed: boolean;
209
+ }) => void;
193
210
  agent_first_audio: (event: { tsMs: number; turnId: string }) => void;
194
211
  agent_finished: (event: { tsMs: number; turnId: string } & Record<string, unknown>) => void;
195
212
  error: (event: { tsMs: number; stage: string; category: string; message: string }) => void;
@@ -204,6 +221,14 @@ interface TtsTextBuffer {
204
221
  emitted: string;
205
222
  }
206
223
 
224
+ interface TurnTiming {
225
+ speechEndedMs?: number;
226
+ eosMs?: number;
227
+ firstLlmDeltaMs?: number;
228
+ firstTtsTextMs?: number;
229
+ fillerUsed?: boolean;
230
+ }
231
+
207
232
  /** Suffix marking a context created to speak an error fallback, so it never recurses. */
208
233
  const FALLBACK_CONTEXT_SUFFIX = ":error-fallback";
209
234
 
@@ -252,6 +277,7 @@ export class VoiceAgentSession {
252
277
  private readonly watchdogs!: VoiceSessionWatchdogs;
253
278
  private readonly observabilityObserver: ObservabilityObserver;
254
279
  private turnUserStoppedAtMs = new Map<string, number>();
280
+ private turnTimings = new Map<string, TurnTiming>();
255
281
  private speakerEnrollmentContextId: string | null = null;
256
282
  private firstTtsAudioFired = new Set<string>();
257
283
  private readonly errorFallbackText: string;
@@ -422,6 +448,7 @@ export class VoiceAgentSession {
422
448
  this.ttsPlayout.clear();
423
449
  this.turnArbiter.clear();
424
450
  this.turnUserStoppedAtMs.clear();
451
+ this.turnTimings.clear();
425
452
  this.firstTtsAudioFired.clear();
426
453
  this.fallbackInjectedContexts.clear();
427
454
  this.ttsTextBuffers.clear();
@@ -741,9 +768,42 @@ export class VoiceAgentSession {
741
768
  this.turnArbiter.onSpeechEnded(pkt, Boolean(this.latestActiveTtsContextId()));
742
769
 
743
770
  this.turnUserStoppedAtMs.set(pkt.contextId, pkt.timestampMs);
771
+ this.timingFor(pkt.contextId).speechEndedMs = pkt.timestampMs;
744
772
  this.watchdogs.startVaqiMissedResponseTimer(pkt.contextId, pkt.timestampMs);
745
773
  }
746
774
 
775
+ private timingFor(contextId: string): TurnTiming {
776
+ let timing = this.turnTimings.get(contextId);
777
+ if (!timing) {
778
+ timing = {};
779
+ this.turnTimings.set(contextId, timing);
780
+ }
781
+ return timing;
782
+ }
783
+
784
+ private emitTurnLatency(contextId: string, firstAudioMs: number): void {
785
+ const timing = this.turnTimings.get(contextId);
786
+ this.turnTimings.delete(contextId);
787
+ if (!timing) return;
788
+ const anchorMs = timing.speechEndedMs ?? timing.eosMs;
789
+ if (anchorMs === undefined) return; // text-injected or fallback turn — not a voice TTFA
790
+ this.emit("turn_latency", {
791
+ tsMs: firstAudioMs,
792
+ turnId: contextId,
793
+ ttfaMs: firstAudioMs - anchorMs,
794
+ ...(timing.speechEndedMs !== undefined && timing.eosMs !== undefined
795
+ ? { eouDelayMs: timing.eosMs - timing.speechEndedMs }
796
+ : {}),
797
+ ...(timing.eosMs !== undefined && timing.firstLlmDeltaMs !== undefined
798
+ ? { llmTtftMs: timing.firstLlmDeltaMs - timing.eosMs }
799
+ : {}),
800
+ ...(timing.firstTtsTextMs !== undefined
801
+ ? { ttsTtfbMs: firstAudioMs - timing.firstTtsTextMs }
802
+ : {}),
803
+ fillerUsed: timing.fillerUsed === true,
804
+ });
805
+ }
806
+
747
807
  private handleTurnComplete(pkt: EndOfSpeechPacket): void {
748
808
  if (this.lastFinalizedContextId === pkt.contextId) {
749
809
  this.bus.push(Route.Background, make.metric(pkt.contextId, "eos.duplicate_dropped", "1"));
@@ -807,8 +867,13 @@ export class VoiceAgentSession {
807
867
  // disconnect prompt later in the call.
808
868
  this.bus.push(Route.Main, make.stopIdleTimeout(pkt.contextId, Date.now(), true));
809
869
 
870
+ const timing = this.timingFor(pkt.contextId);
871
+ timing.eosMs = pkt.timestampMs;
872
+
810
873
  const fillerText = this.latencyFiller.start(pkt.contextId, pkt.text, pkt.timestampMs);
811
874
  if (fillerText) {
875
+ timing.fillerUsed = true;
876
+ timing.firstTtsTextMs ??= pkt.timestampMs;
812
877
  this.bus.push(Route.Main, make.ttsText(pkt.contextId, Date.now(), fillerText));
813
878
  this.bus.push(Route.Background, make.metric(pkt.contextId, "filler.started", fillerText));
814
879
  }
@@ -840,6 +905,8 @@ export class VoiceAgentSession {
840
905
  let deltaText = pkt.text;
841
906
  if (!this.firstLlmDeltaReceived.has(pkt.contextId)) {
842
907
  this.firstLlmDeltaReceived.add(pkt.contextId);
908
+ const timing = this.turnTimings.get(pkt.contextId);
909
+ if (timing) timing.firstLlmDeltaMs ??= pkt.timestampMs;
843
910
  if (this.latencyFiller.isActive(pkt.contextId)) {
844
911
  deltaText = this.latencyFiller.spliceLlmDelta(pkt.contextId, deltaText);
845
912
  this.bus.push(Route.Background, make.metric(pkt.contextId, "filler.spliced", "1"));
@@ -858,7 +925,7 @@ export class VoiceAgentSession {
858
925
  timestampMs: pkt.timestampMs,
859
926
  });
860
927
 
861
- this.bufferTtsText(pkt.contextId, deltaText);
928
+ this.bufferTtsText(pkt.contextId, deltaText, pkt.timestampMs);
862
929
  }
863
930
 
864
931
  private handleLlmDone(pkt: LlmResponseDonePacket): void {
@@ -869,7 +936,7 @@ export class VoiceAgentSession {
869
936
  return;
870
937
  }
871
938
 
872
- const spokenText = this.flushTtsText(pkt.contextId);
939
+ const spokenText = this.flushTtsText(pkt.contextId, pkt.timestampMs);
873
940
  this.emit("agent_finished", {
874
941
  tsMs: pkt.timestampMs,
875
942
  turnId: pkt.contextId,
@@ -887,11 +954,13 @@ export class VoiceAgentSession {
887
954
  this.bus.push(Route.Main, make.ttsDone(pkt.contextId, Date.now(), spokenText));
888
955
  }
889
956
 
890
- private bufferTtsText(contextId: string, text: string): void {
957
+ private bufferTtsText(contextId: string, text: string, tsMs?: number): void {
891
958
  const buffer = this.ttsTextBuffers.get(contextId) ?? { pending: "", emitted: "" };
892
959
  buffer.pending += text;
893
960
  const complete = takeCompleteVoiceText(buffer.pending);
894
961
  if (complete.text) {
962
+ const timing = this.turnTimings.get(contextId);
963
+ if (timing) timing.firstTtsTextMs ??= tsMs ?? Date.now();
895
964
  this.bus.push(Route.Main, make.ttsText(contextId, Date.now(), complete.text));
896
965
  buffer.emitted = appendVoiceText(buffer.emitted, complete.text);
897
966
  }
@@ -899,11 +968,13 @@ export class VoiceAgentSession {
899
968
  this.ttsTextBuffers.set(contextId, buffer);
900
969
  }
901
970
 
902
- private flushTtsText(contextId: string): string {
971
+ private flushTtsText(contextId: string, tsMs?: number): string {
903
972
  const buffer = this.ttsTextBuffers.get(contextId);
904
973
  if (!buffer) return "";
905
974
  const tail = buffer.pending.trim();
906
975
  if (tail) {
976
+ const timing = this.turnTimings.get(contextId);
977
+ if (timing) timing.firstTtsTextMs ??= tsMs ?? Date.now();
907
978
  this.bus.push(Route.Main, make.ttsText(contextId, Date.now(), tail));
908
979
  buffer.emitted = appendVoiceText(buffer.emitted, tail);
909
980
  buffer.pending = "";
@@ -1077,6 +1148,7 @@ export class VoiceAgentSession {
1077
1148
  );
1078
1149
  this.turnUserStoppedAtMs.delete(pkt.contextId);
1079
1150
  }
1151
+ this.emitTurnLatency(pkt.contextId, pkt.timestampMs);
1080
1152
  }
1081
1153
 
1082
1154
  this.primarySpeakerGate.observeAssistantPlayout(pkt.audio);
@@ -1133,6 +1205,7 @@ export class VoiceAgentSession {
1133
1205
  this.interruptedGenerationContextIds.add(pkt.contextId);
1134
1206
  this.failPendingToolCues(pkt.contextId); // G3: the aborted delegate's cue fails (R5)
1135
1207
  this.latencyFiller.cancel(pkt.contextId);
1208
+ this.turnTimings.delete(pkt.contextId);
1136
1209
  this.firstLlmDeltaReceived.delete(pkt.contextId);
1137
1210
  this.ttsTextBuffers.delete(pkt.contextId);
1138
1211
  this.ttsPlayout.release(pkt.contextId);
@@ -1182,6 +1255,7 @@ export class VoiceAgentSession {
1182
1255
  this.failPendingToolCues(contextId); // G3: a superseded turn's pending cue fails
1183
1256
  this.generatingContextIds.delete(contextId);
1184
1257
  this.latencyFiller.cancel(contextId);
1258
+ this.turnTimings.delete(contextId);
1185
1259
  this.firstLlmDeltaReceived.delete(contextId);
1186
1260
  this.ttsTextBuffers.delete(contextId);
1187
1261
  this.ttsPlayout.release(contextId);