@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 +1 -1
- package/src/index.ts +1 -0
- package/src/packets.ts +11 -0
- package/src/voice-agent-session.test.ts +118 -0
- package/src/voice-agent-session.ts +78 -4
package/package.json
CHANGED
package/src/index.ts
CHANGED
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);
|