@kuralle-syrinx/core 4.0.0 → 4.2.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/README.md +4 -0
- package/package.json +21 -2
- package/src/confidence-to-wait.test.ts +21 -0
- package/src/confidence-to-wait.ts +30 -0
- package/src/hedge-throwing-backend.test.ts +46 -0
- package/src/incremental-unit.ts +19 -0
- package/src/index.ts +41 -0
- package/src/interaction-coordinator.test.ts +425 -0
- package/src/interaction-coordinator.ts +240 -0
- package/src/interaction-policy.ts +104 -0
- package/src/iu-ledger.test.ts +276 -0
- package/src/iu-ledger.ts +110 -0
- package/src/packet-factories.test.ts +20 -1
- package/src/packet-factories.ts +27 -0
- package/src/packets.ts +35 -3
- package/src/policies/defer.test.ts +86 -0
- package/src/policies/defer.ts +15 -0
- package/src/policies/rule-based.test.ts +269 -0
- package/src/policies/rule-based.ts +140 -0
- package/src/reasoner-hedge.test.ts +361 -0
- package/src/reasoner-hedge.ts +216 -0
- package/src/reasoner-route.test.ts +248 -0
- package/src/reasoner-route.ts +113 -0
- package/src/route-throwing-spec.test.ts +44 -0
- package/src/turn-arbiter.ts +11 -2
- package/src/voice-agent-session.test.ts +557 -2
- package/src/voice-agent-session.ts +308 -24
|
@@ -41,6 +41,8 @@ import type {
|
|
|
41
41
|
SpeechToTextAudioPacket,
|
|
42
42
|
SttResultPacket,
|
|
43
43
|
SttInterimPacket,
|
|
44
|
+
SttPartialPacket,
|
|
45
|
+
TurnChangePacket,
|
|
44
46
|
VadAudioPacket,
|
|
45
47
|
VadSpeechStartedPacket,
|
|
46
48
|
VadSpeechActivityPacket,
|
|
@@ -64,6 +66,11 @@ import { PrimarySpeakerGate } from "./primary-speaker-gate.js";
|
|
|
64
66
|
import { takeCompleteVoiceText, isCompleteVoiceText, appendVoiceText } from "./voice-text.js";
|
|
65
67
|
import { TtsPlayoutClock } from "./tts-playout-clock.js";
|
|
66
68
|
import { TurnArbiter, isBackchannel } from "./turn-arbiter.js";
|
|
69
|
+
import { InteractionCoordinator } from "./interaction-coordinator.js";
|
|
70
|
+
import { isLifecycleInteractionPolicy, type InteractionPolicy, type WordTiming } from "./interaction-policy.js";
|
|
71
|
+
import { pcm16BytesToSamples } from "./audio/pcm.js";
|
|
72
|
+
import { DeferInteractionPolicy } from "./policies/defer.js";
|
|
73
|
+
import { RuleBasedInteractionPolicy } from "./policies/rule-based.js";
|
|
67
74
|
import * as make from "./packet-factories.js";
|
|
68
75
|
import { pluginStage, stageOrder, isAudioStage } from "./init-stage-order.js";
|
|
69
76
|
import {
|
|
@@ -162,6 +169,21 @@ export interface VoiceAgentSessionConfig {
|
|
|
162
169
|
* provider STT ownership; Smart Turn sessions must opt in explicitly.
|
|
163
170
|
*/
|
|
164
171
|
endpointingOwner?: "provider_stt" | "smart_turn" | "timer";
|
|
172
|
+
/** The front model owns full-duplex interaction (turn-taking + barge-in). When true, the session's
|
|
173
|
+
* InteractionPolicy runs observe-only (DeferInteractionPolicy) — Syrinx does not drive its own
|
|
174
|
+
* turn/interrupt decisions; the front's native decisions stand. Default: false (Syrinx drives).
|
|
175
|
+
* A realtime factory sets this from RealtimeAdapter.caps.supportsFullDuplex. */
|
|
176
|
+
fullDuplex?: boolean;
|
|
177
|
+
/** When true, Syrinx suppresses policy-timed backchannel cue packets (front/provider owns them). */
|
|
178
|
+
emitsBackchannel?: boolean;
|
|
179
|
+
/**
|
|
180
|
+
* Optional interaction policy injected by the caller (learned controllers, Smart Turn policy, etc.).
|
|
181
|
+
* When omitted, the session uses RuleBasedInteractionPolicy. When `fullDuplex` is true, the
|
|
182
|
+
* coordinator runs observe-only via DeferInteractionPolicy regardless of this setting.
|
|
183
|
+
*/
|
|
184
|
+
interactionPolicy?: InteractionPolicy;
|
|
185
|
+
/** Config passed to `interactionPolicy.initialize` when the injected policy is lifecycle-capable. */
|
|
186
|
+
interactionPolicyConfig?: Record<string, unknown>;
|
|
165
187
|
readonly metricsExporter?: MetricsExporter;
|
|
166
188
|
readonly scheduler?: Scheduler;
|
|
167
189
|
readonly observability?: {
|
|
@@ -190,6 +212,24 @@ export interface VoiceAgentSessionEvents {
|
|
|
190
212
|
* these as `tool_call_*` wire messages — the standard "thinking" cue.
|
|
191
213
|
*/
|
|
192
214
|
tool_call_cue: (event: { tsMs: number; turnId: string; phase: "started" | "delayed" | "complete" | "failed"; toolId: string; toolName: string; afterMs?: number }) => void;
|
|
215
|
+
/**
|
|
216
|
+
* Per-turn latency decomposition, emitted once at the turn's first TTS audio.
|
|
217
|
+
* `ttfaMs` is anchored to the real end of user speech (VAD speech-end, falling
|
|
218
|
+
* back to the endpoint decision) — `fillerUsed` flags turns where a latency filler spoke
|
|
219
|
+
* first, and `backchannelUsed` flags turns where a wait-gap cue played before the answer.
|
|
220
|
+
* Decomposition: eouDelayMs (speech end → endpoint) + llmTtftMs (endpoint →
|
|
221
|
+
* first LLM delta) + ttsTtfbMs (first TTS text dispatched → first audio).
|
|
222
|
+
*/
|
|
223
|
+
turn_latency: (event: {
|
|
224
|
+
tsMs: number;
|
|
225
|
+
turnId: string;
|
|
226
|
+
ttfaMs: number;
|
|
227
|
+
eouDelayMs?: number;
|
|
228
|
+
llmTtftMs?: number;
|
|
229
|
+
ttsTtfbMs?: number;
|
|
230
|
+
fillerUsed: boolean;
|
|
231
|
+
backchannelUsed: boolean;
|
|
232
|
+
}) => void;
|
|
193
233
|
agent_first_audio: (event: { tsMs: number; turnId: string }) => void;
|
|
194
234
|
agent_finished: (event: { tsMs: number; turnId: string } & Record<string, unknown>) => void;
|
|
195
235
|
error: (event: { tsMs: number; stage: string; category: string; message: string }) => void;
|
|
@@ -204,6 +244,15 @@ interface TtsTextBuffer {
|
|
|
204
244
|
emitted: string;
|
|
205
245
|
}
|
|
206
246
|
|
|
247
|
+
interface TurnTiming {
|
|
248
|
+
speechEndedMs?: number;
|
|
249
|
+
eosMs?: number;
|
|
250
|
+
firstLlmDeltaMs?: number;
|
|
251
|
+
firstTtsTextMs?: number;
|
|
252
|
+
fillerUsed?: boolean;
|
|
253
|
+
backchannelUsed?: boolean;
|
|
254
|
+
}
|
|
255
|
+
|
|
207
256
|
/** Suffix marking a context created to speak an error fallback, so it never recurses. */
|
|
208
257
|
const FALLBACK_CONTEXT_SUFFIX = ":error-fallback";
|
|
209
258
|
|
|
@@ -212,6 +261,10 @@ function toolCueTimerKey(contextId: string, toolId: string): string {
|
|
|
212
261
|
return `tool_cue:${contextId}:${toolId}`;
|
|
213
262
|
}
|
|
214
263
|
|
|
264
|
+
function interactionPlayoutTimerKey(contextId: string): string {
|
|
265
|
+
return `interaction.playout:${contextId}`;
|
|
266
|
+
}
|
|
267
|
+
|
|
215
268
|
// =============================================================================
|
|
216
269
|
// Session Implementation
|
|
217
270
|
// =============================================================================
|
|
@@ -243,7 +296,10 @@ export class VoiceAgentSession {
|
|
|
243
296
|
private ttsTextBuffers = new Map<string, TtsTextBuffer>();
|
|
244
297
|
private readonly minInterruptionMs: number;
|
|
245
298
|
private readonly primarySpeakerGate: PrimarySpeakerGate;
|
|
246
|
-
private readonly
|
|
299
|
+
private readonly ruleBasedPolicy!: RuleBasedInteractionPolicy;
|
|
300
|
+
private readonly injectedInteractionPolicy: InteractionPolicy | null;
|
|
301
|
+
private readonly activeInteractionPolicy: InteractionPolicy;
|
|
302
|
+
private readonly interaction!: InteractionCoordinator;
|
|
247
303
|
private readonly latencyFiller: LatencyFillerController;
|
|
248
304
|
private firstLlmDeltaReceived = new Set<string>();
|
|
249
305
|
private readonly vaqiMissedResponseMs: number;
|
|
@@ -252,15 +308,21 @@ export class VoiceAgentSession {
|
|
|
252
308
|
private readonly watchdogs!: VoiceSessionWatchdogs;
|
|
253
309
|
private readonly observabilityObserver: ObservabilityObserver;
|
|
254
310
|
private turnUserStoppedAtMs = new Map<string, number>();
|
|
311
|
+
private turnTimings = new Map<string, TurnTiming>();
|
|
255
312
|
private speakerEnrollmentContextId: string | null = null;
|
|
256
313
|
private firstTtsAudioFired = new Set<string>();
|
|
314
|
+
private readonly pendingInteractionPlayoutTimers = new Set<string>();
|
|
257
315
|
private readonly errorFallbackText: string;
|
|
258
316
|
private fallbackInjectedContexts = new Set<string>();
|
|
259
317
|
// G3: pending tool calls per context (toolId → toolName) driving the tool_call_cue lifecycle.
|
|
260
318
|
private readonly delayCueAfterMs: number;
|
|
261
319
|
private pendingToolCues = new Map<string, Map<string, string>>();
|
|
262
320
|
private readonly endpointingOwner: "provider_stt" | "smart_turn" | "timer";
|
|
321
|
+
private readonly fullDuplex: boolean;
|
|
322
|
+
private readonly emitsBackchannel: boolean;
|
|
323
|
+
private userSpeaking = false;
|
|
263
324
|
private lastFinalizedContextId = "";
|
|
325
|
+
private readonly sttPartialWordTimings = new Map<string, readonly WordTiming[]>();
|
|
264
326
|
|
|
265
327
|
constructor(config: VoiceAgentSessionConfig) {
|
|
266
328
|
const owner = config.endpointingOwner;
|
|
@@ -268,6 +330,8 @@ export class VoiceAgentSession {
|
|
|
268
330
|
throw new Error(`Unsupported endpointingOwner: ${owner}`);
|
|
269
331
|
}
|
|
270
332
|
this.endpointingOwner = owner ?? "provider_stt";
|
|
333
|
+
this.fullDuplex = config.fullDuplex === true;
|
|
334
|
+
this.emitsBackchannel = config.emitsBackchannel === true;
|
|
271
335
|
this.config = config;
|
|
272
336
|
this.scheduler = config.scheduler ?? new TimerScheduler();
|
|
273
337
|
this.ttsPlayout = new TtsPlayoutClock(this.scheduler);
|
|
@@ -318,12 +382,29 @@ export class VoiceAgentSession {
|
|
|
318
382
|
},
|
|
319
383
|
});
|
|
320
384
|
|
|
321
|
-
this.
|
|
385
|
+
this.injectedInteractionPolicy = config.interactionPolicy ?? null;
|
|
386
|
+
this.ruleBasedPolicy = new RuleBasedInteractionPolicy({
|
|
322
387
|
bus: this.bus,
|
|
323
388
|
primarySpeakerGate: this.primarySpeakerGate,
|
|
324
389
|
ttsPlayout: this.ttsPlayout,
|
|
325
390
|
minInterruptionMs: this.minInterruptionMs,
|
|
326
391
|
});
|
|
392
|
+
const coordinatorPolicy: InteractionPolicy = this.fullDuplex
|
|
393
|
+
? new DeferInteractionPolicy()
|
|
394
|
+
: (this.injectedInteractionPolicy ?? this.ruleBasedPolicy);
|
|
395
|
+
this.activeInteractionPolicy = coordinatorPolicy;
|
|
396
|
+
this.interaction = new InteractionCoordinator({
|
|
397
|
+
bus: this.bus,
|
|
398
|
+
policy: coordinatorPolicy,
|
|
399
|
+
executor: this.ruleBasedPolicy.arbiter,
|
|
400
|
+
scheduler: this.scheduler,
|
|
401
|
+
caps: { emitsBackchannel: this.emitsBackchannel },
|
|
402
|
+
isUserSpeaking: () => this.userSpeaking,
|
|
403
|
+
isTtsActive: () => this.ttsPlayout.activeContexts().length > 0,
|
|
404
|
+
onBackchannelEmitted: (contextId) => {
|
|
405
|
+
this.timingFor(contextId).backchannelUsed = true;
|
|
406
|
+
},
|
|
407
|
+
});
|
|
327
408
|
this.watchdogs = new VoiceSessionWatchdogs({
|
|
328
409
|
bus: this.bus,
|
|
329
410
|
plugins: this.plugins,
|
|
@@ -360,6 +441,10 @@ export class VoiceAgentSession {
|
|
|
360
441
|
this.modeSwitcher = new ModeSwitcher(this.bus);
|
|
361
442
|
}
|
|
362
443
|
|
|
444
|
+
private get turnArbiter(): TurnArbiter {
|
|
445
|
+
return this.ruleBasedPolicy.arbiter;
|
|
446
|
+
}
|
|
447
|
+
|
|
363
448
|
// =========================================================================
|
|
364
449
|
// Public API
|
|
365
450
|
// =========================================================================
|
|
@@ -386,6 +471,7 @@ export class VoiceAgentSession {
|
|
|
386
471
|
|
|
387
472
|
// 1. Wire all bus handlers
|
|
388
473
|
this.wireBusHandlers();
|
|
474
|
+
this.interaction.initialize();
|
|
389
475
|
|
|
390
476
|
// 2. Start bus drain loop
|
|
391
477
|
this.busStartPromise = this.bus.start();
|
|
@@ -417,16 +503,23 @@ export class VoiceAgentSession {
|
|
|
417
503
|
|
|
418
504
|
// 1. Stop idle timeout
|
|
419
505
|
this.idleTimeout.dispose();
|
|
506
|
+
this.interaction.dispose();
|
|
420
507
|
this.watchdogs.dispose();
|
|
421
508
|
this.observabilityObserver.dispose();
|
|
422
509
|
this.ttsPlayout.clear();
|
|
510
|
+
for (const contextId of this.pendingInteractionPlayoutTimers) {
|
|
511
|
+
this.scheduler.cancel(interactionPlayoutTimerKey(contextId));
|
|
512
|
+
}
|
|
513
|
+
this.pendingInteractionPlayoutTimers.clear();
|
|
423
514
|
this.turnArbiter.clear();
|
|
424
515
|
this.turnUserStoppedAtMs.clear();
|
|
516
|
+
this.turnTimings.clear();
|
|
425
517
|
this.firstTtsAudioFired.clear();
|
|
426
518
|
this.fallbackInjectedContexts.clear();
|
|
427
519
|
this.ttsTextBuffers.clear();
|
|
428
520
|
this.interruptedGenerationContextIds.clear();
|
|
429
521
|
this.firstLlmDeltaReceived.clear();
|
|
522
|
+
this.sttPartialWordTimings.clear();
|
|
430
523
|
for (const [contextId, pending] of this.pendingToolCues) {
|
|
431
524
|
for (const toolId of pending.keys()) this.scheduler.cancel(toolCueTimerKey(contextId, toolId));
|
|
432
525
|
}
|
|
@@ -511,6 +604,7 @@ export class VoiceAgentSession {
|
|
|
511
604
|
// STT results
|
|
512
605
|
this.bus.on("stt.audio", this.handleSttAudio.bind(this));
|
|
513
606
|
this.bus.on("stt.interim", this.handleSttInterim.bind(this));
|
|
607
|
+
this.bus.on("stt.partial", this.handleSttPartial.bind(this));
|
|
514
608
|
this.bus.on("stt.result", this.handleSttResult.bind(this));
|
|
515
609
|
|
|
516
610
|
// VAD
|
|
@@ -520,8 +614,11 @@ export class VoiceAgentSession {
|
|
|
520
614
|
this.bus.on("vad.audio", this.handleVadAudioForSpeakerGate.bind(this));
|
|
521
615
|
|
|
522
616
|
// EOS
|
|
523
|
-
this.bus.on("turn.change", () => {
|
|
617
|
+
this.bus.on("turn.change", (pkt: TurnChangePacket) => {
|
|
524
618
|
this.lastFinalizedContextId = "";
|
|
619
|
+
if (pkt.previousContextId) {
|
|
620
|
+
this.sttPartialWordTimings.delete(pkt.previousContextId);
|
|
621
|
+
}
|
|
525
622
|
});
|
|
526
623
|
this.bus.on("eos.turn_complete", this.handleTurnComplete.bind(this));
|
|
527
624
|
this.bus.on("eos.interim", this.handleEosInterim.bind(this));
|
|
@@ -608,9 +705,22 @@ export class VoiceAgentSession {
|
|
|
608
705
|
timestampMs: pkt.timestampMs,
|
|
609
706
|
});
|
|
610
707
|
|
|
708
|
+
this.observeAudioFrame(pkt);
|
|
709
|
+
|
|
611
710
|
this.watchdogs.scheduleInputCadenceWatchdog(pkt.contextId);
|
|
612
711
|
}
|
|
613
712
|
|
|
713
|
+
private observeAudioFrame(pkt: UserAudioReceivedPacket): void {
|
|
714
|
+
if (pkt.audio.byteLength < 2 || pkt.audio.byteLength % 2 !== 0) return;
|
|
715
|
+
this.interaction.observe({
|
|
716
|
+
kind: "audio_frame",
|
|
717
|
+
contextId: pkt.contextId,
|
|
718
|
+
timestampMs: pkt.timestampMs,
|
|
719
|
+
audio: pcm16BytesToSamples(pkt.audio),
|
|
720
|
+
sampleRateHz: pkt.sampleRateHz ?? 16_000,
|
|
721
|
+
});
|
|
722
|
+
}
|
|
723
|
+
|
|
614
724
|
private handleSttAudio(pkt: SpeechToTextAudioPacket): void {
|
|
615
725
|
this.watchdogs.scheduleSttForceFinalize(pkt.contextId);
|
|
616
726
|
}
|
|
@@ -620,9 +730,14 @@ export class VoiceAgentSession {
|
|
|
620
730
|
this.bus.push(Route.Main, make.eosTurnComplete(pkt.contextId, pkt.timestampMs, pkt.text, []));
|
|
621
731
|
}
|
|
622
732
|
|
|
733
|
+
private handleSttPartial(pkt: SttPartialPacket): void {
|
|
734
|
+
if (pkt.wordTimings) {
|
|
735
|
+
this.sttPartialWordTimings.set(pkt.contextId, pkt.wordTimings);
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
|
|
623
739
|
private handleSttInterim(pkt: SttInterimPacket): void {
|
|
624
|
-
this.
|
|
625
|
-
this.maybeBargeInFromProviderStt(pkt.contextId, pkt.text, pkt.timestampMs);
|
|
740
|
+
this.observeSttForBargeIn(pkt.contextId, pkt.timestampMs, pkt.text, "stt_partial");
|
|
626
741
|
this.currentTurnId = pkt.contextId;
|
|
627
742
|
this.emit("user_input_partial", {
|
|
628
743
|
tsMs: pkt.timestampMs,
|
|
@@ -639,8 +754,7 @@ export class VoiceAgentSession {
|
|
|
639
754
|
|
|
640
755
|
private handleSttResult(pkt: SttResultPacket): void {
|
|
641
756
|
this.watchdogs.clearSttForceFinalizeIfContext(pkt.contextId);
|
|
642
|
-
this.
|
|
643
|
-
this.maybeBargeInFromProviderStt(pkt.contextId, pkt.text, pkt.timestampMs);
|
|
757
|
+
this.observeSttForBargeIn(pkt.contextId, pkt.timestampMs, pkt.text, "stt_final", pkt.confidence);
|
|
644
758
|
this.currentTurnId = pkt.contextId;
|
|
645
759
|
this.emit("user_input_final", {
|
|
646
760
|
tsMs: pkt.timestampMs,
|
|
@@ -661,7 +775,7 @@ export class VoiceAgentSession {
|
|
|
661
775
|
}
|
|
662
776
|
|
|
663
777
|
private handleVadAudioForSpeakerGate(pkt: VadAudioPacket): void {
|
|
664
|
-
if (this.
|
|
778
|
+
if (this.interaction.observeBargeInAudio(pkt)) return;
|
|
665
779
|
|
|
666
780
|
if (this.shouldEnrollPrimarySpeaker(pkt.contextId)) {
|
|
667
781
|
this.primarySpeakerGate.enrollUserTurnChunk(pkt.audio);
|
|
@@ -680,16 +794,43 @@ export class VoiceAgentSession {
|
|
|
680
794
|
// Provider transcripts arriving while TTS playout is active are the speech
|
|
681
795
|
// evidence instead (echo of our own playout is mitigated by client AEC plus
|
|
682
796
|
// the arbiter's backchannel / low-confidence suppression).
|
|
683
|
-
private
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
797
|
+
private observeSttForBargeIn(
|
|
798
|
+
contextId: string,
|
|
799
|
+
timestampMs: number,
|
|
800
|
+
text: string,
|
|
801
|
+
kind: "stt_partial" | "stt_final",
|
|
802
|
+
confidence?: number,
|
|
803
|
+
): void {
|
|
804
|
+
const interruptedContextId =
|
|
805
|
+
this.endpointingOwner === "provider_stt" && text.trim()
|
|
806
|
+
? this.latestActiveTtsContextId() ?? undefined
|
|
807
|
+
: undefined;
|
|
808
|
+
const wordTimings = this.sttPartialWordTimings.get(contextId);
|
|
809
|
+
if (kind === "stt_partial") {
|
|
810
|
+
this.interaction.observe({
|
|
811
|
+
kind: "stt_partial",
|
|
812
|
+
contextId,
|
|
813
|
+
timestampMs,
|
|
814
|
+
text,
|
|
815
|
+
interruptedContextId,
|
|
816
|
+
...(wordTimings ? { wordTimings } : {}),
|
|
817
|
+
});
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
820
|
+
this.interaction.observe({
|
|
821
|
+
kind: "stt_final",
|
|
822
|
+
contextId,
|
|
823
|
+
timestampMs,
|
|
824
|
+
text,
|
|
825
|
+
confidence,
|
|
826
|
+
interruptedContextId,
|
|
827
|
+
...(wordTimings ? { wordTimings } : {}),
|
|
828
|
+
});
|
|
689
829
|
}
|
|
690
830
|
|
|
691
831
|
private handleVadSpeechStarted(pkt: VadSpeechStartedPacket): void {
|
|
692
832
|
this.lastFinalizedContextId = "";
|
|
833
|
+
this.userSpeaking = true;
|
|
693
834
|
|
|
694
835
|
if (this.latencyFiller.isFillerOnly(this.currentTurnId)) {
|
|
695
836
|
this.cancelLatencyFillerTurn(this.currentTurnId, pkt.timestampMs);
|
|
@@ -712,17 +853,27 @@ export class VoiceAgentSession {
|
|
|
712
853
|
const interruptedContextId = this.latestActiveTtsContextId();
|
|
713
854
|
if (!interruptedContextId) {
|
|
714
855
|
this.speakerEnrollmentContextId = pkt.contextId;
|
|
715
|
-
return;
|
|
716
856
|
}
|
|
717
857
|
|
|
718
|
-
this.
|
|
858
|
+
this.interaction.observe({
|
|
859
|
+
kind: "vad_speech_started",
|
|
860
|
+
contextId: pkt.contextId,
|
|
861
|
+
timestampMs: pkt.timestampMs,
|
|
862
|
+
confidence: pkt.confidence,
|
|
863
|
+
...(interruptedContextId ? { interruptedContextId } : {}),
|
|
864
|
+
});
|
|
719
865
|
}
|
|
720
866
|
|
|
721
867
|
private handleVadSpeechActivity(pkt: VadSpeechActivityPacket): void {
|
|
722
|
-
this.
|
|
868
|
+
this.interaction.observe({
|
|
869
|
+
kind: "vad_speech_activity",
|
|
870
|
+
contextId: pkt.contextId,
|
|
871
|
+
timestampMs: pkt.timestampMs,
|
|
872
|
+
});
|
|
723
873
|
}
|
|
724
874
|
|
|
725
875
|
private handleVadSpeechEnded(pkt: VadSpeechEndedPacket): void {
|
|
876
|
+
this.userSpeaking = false;
|
|
726
877
|
this.emit("user_stopped_speaking", {
|
|
727
878
|
tsMs: pkt.timestampMs,
|
|
728
879
|
turnId: pkt.contextId,
|
|
@@ -738,12 +889,51 @@ export class VoiceAgentSession {
|
|
|
738
889
|
this.speakerEnrollmentContextId = null;
|
|
739
890
|
}
|
|
740
891
|
|
|
741
|
-
this.
|
|
892
|
+
this.interaction.observe({
|
|
893
|
+
kind: "vad_speech_ended",
|
|
894
|
+
contextId: pkt.contextId,
|
|
895
|
+
timestampMs: pkt.timestampMs,
|
|
896
|
+
hasActiveTts: Boolean(this.latestActiveTtsContextId()),
|
|
897
|
+
});
|
|
742
898
|
|
|
743
899
|
this.turnUserStoppedAtMs.set(pkt.contextId, pkt.timestampMs);
|
|
900
|
+
this.timingFor(pkt.contextId).speechEndedMs = pkt.timestampMs;
|
|
744
901
|
this.watchdogs.startVaqiMissedResponseTimer(pkt.contextId, pkt.timestampMs);
|
|
745
902
|
}
|
|
746
903
|
|
|
904
|
+
private timingFor(contextId: string): TurnTiming {
|
|
905
|
+
let timing = this.turnTimings.get(contextId);
|
|
906
|
+
if (!timing) {
|
|
907
|
+
timing = {};
|
|
908
|
+
this.turnTimings.set(contextId, timing);
|
|
909
|
+
}
|
|
910
|
+
return timing;
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
private emitTurnLatency(contextId: string, firstAudioMs: number): void {
|
|
914
|
+
const timing = this.turnTimings.get(contextId);
|
|
915
|
+
this.turnTimings.delete(contextId);
|
|
916
|
+
if (!timing) return;
|
|
917
|
+
const anchorMs = timing.speechEndedMs ?? timing.eosMs;
|
|
918
|
+
if (anchorMs === undefined) return; // text-injected or fallback turn — not a voice TTFA
|
|
919
|
+
this.emit("turn_latency", {
|
|
920
|
+
tsMs: firstAudioMs,
|
|
921
|
+
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,
|
|
934
|
+
});
|
|
935
|
+
}
|
|
936
|
+
|
|
747
937
|
private handleTurnComplete(pkt: EndOfSpeechPacket): void {
|
|
748
938
|
if (this.lastFinalizedContextId === pkt.contextId) {
|
|
749
939
|
this.bus.push(Route.Background, make.metric(pkt.contextId, "eos.duplicate_dropped", "1"));
|
|
@@ -764,6 +954,7 @@ export class VoiceAgentSession {
|
|
|
764
954
|
}
|
|
765
955
|
|
|
766
956
|
this.lastFinalizedContextId = pkt.contextId;
|
|
957
|
+
this.interaction.reset(pkt.contextId);
|
|
767
958
|
|
|
768
959
|
// Re-arm per-turn guard state for the next turn. Transports with a stable
|
|
769
960
|
// per-call contextId (telephony callSid) reuse one id across turns, so these
|
|
@@ -807,8 +998,13 @@ export class VoiceAgentSession {
|
|
|
807
998
|
// disconnect prompt later in the call.
|
|
808
999
|
this.bus.push(Route.Main, make.stopIdleTimeout(pkt.contextId, Date.now(), true));
|
|
809
1000
|
|
|
1001
|
+
const timing = this.timingFor(pkt.contextId);
|
|
1002
|
+
timing.eosMs = pkt.timestampMs;
|
|
1003
|
+
|
|
810
1004
|
const fillerText = this.latencyFiller.start(pkt.contextId, pkt.text, pkt.timestampMs);
|
|
811
1005
|
if (fillerText) {
|
|
1006
|
+
timing.fillerUsed = true;
|
|
1007
|
+
timing.firstTtsTextMs ??= pkt.timestampMs;
|
|
812
1008
|
this.bus.push(Route.Main, make.ttsText(pkt.contextId, Date.now(), fillerText));
|
|
813
1009
|
this.bus.push(Route.Background, make.metric(pkt.contextId, "filler.started", fillerText));
|
|
814
1010
|
}
|
|
@@ -840,6 +1036,8 @@ export class VoiceAgentSession {
|
|
|
840
1036
|
let deltaText = pkt.text;
|
|
841
1037
|
if (!this.firstLlmDeltaReceived.has(pkt.contextId)) {
|
|
842
1038
|
this.firstLlmDeltaReceived.add(pkt.contextId);
|
|
1039
|
+
const timing = this.turnTimings.get(pkt.contextId);
|
|
1040
|
+
if (timing) timing.firstLlmDeltaMs ??= pkt.timestampMs;
|
|
843
1041
|
if (this.latencyFiller.isActive(pkt.contextId)) {
|
|
844
1042
|
deltaText = this.latencyFiller.spliceLlmDelta(pkt.contextId, deltaText);
|
|
845
1043
|
this.bus.push(Route.Background, make.metric(pkt.contextId, "filler.spliced", "1"));
|
|
@@ -858,7 +1056,7 @@ export class VoiceAgentSession {
|
|
|
858
1056
|
timestampMs: pkt.timestampMs,
|
|
859
1057
|
});
|
|
860
1058
|
|
|
861
|
-
this.bufferTtsText(pkt.contextId, deltaText);
|
|
1059
|
+
this.bufferTtsText(pkt.contextId, deltaText, pkt.timestampMs);
|
|
862
1060
|
}
|
|
863
1061
|
|
|
864
1062
|
private handleLlmDone(pkt: LlmResponseDonePacket): void {
|
|
@@ -869,7 +1067,7 @@ export class VoiceAgentSession {
|
|
|
869
1067
|
return;
|
|
870
1068
|
}
|
|
871
1069
|
|
|
872
|
-
const spokenText = this.flushTtsText(pkt.contextId);
|
|
1070
|
+
const spokenText = this.flushTtsText(pkt.contextId, pkt.timestampMs);
|
|
873
1071
|
this.emit("agent_finished", {
|
|
874
1072
|
tsMs: pkt.timestampMs,
|
|
875
1073
|
turnId: pkt.contextId,
|
|
@@ -887,11 +1085,13 @@ export class VoiceAgentSession {
|
|
|
887
1085
|
this.bus.push(Route.Main, make.ttsDone(pkt.contextId, Date.now(), spokenText));
|
|
888
1086
|
}
|
|
889
1087
|
|
|
890
|
-
private bufferTtsText(contextId: string, text: string): void {
|
|
1088
|
+
private bufferTtsText(contextId: string, text: string, tsMs?: number): void {
|
|
891
1089
|
const buffer = this.ttsTextBuffers.get(contextId) ?? { pending: "", emitted: "" };
|
|
892
1090
|
buffer.pending += text;
|
|
893
1091
|
const complete = takeCompleteVoiceText(buffer.pending);
|
|
894
1092
|
if (complete.text) {
|
|
1093
|
+
const timing = this.turnTimings.get(contextId);
|
|
1094
|
+
if (timing) timing.firstTtsTextMs ??= tsMs ?? Date.now();
|
|
895
1095
|
this.bus.push(Route.Main, make.ttsText(contextId, Date.now(), complete.text));
|
|
896
1096
|
buffer.emitted = appendVoiceText(buffer.emitted, complete.text);
|
|
897
1097
|
}
|
|
@@ -899,11 +1099,13 @@ export class VoiceAgentSession {
|
|
|
899
1099
|
this.ttsTextBuffers.set(contextId, buffer);
|
|
900
1100
|
}
|
|
901
1101
|
|
|
902
|
-
private flushTtsText(contextId: string): string {
|
|
1102
|
+
private flushTtsText(contextId: string, tsMs?: number): string {
|
|
903
1103
|
const buffer = this.ttsTextBuffers.get(contextId);
|
|
904
1104
|
if (!buffer) return "";
|
|
905
1105
|
const tail = buffer.pending.trim();
|
|
906
1106
|
if (tail) {
|
|
1107
|
+
const timing = this.turnTimings.get(contextId);
|
|
1108
|
+
if (timing) timing.firstTtsTextMs ??= tsMs ?? Date.now();
|
|
907
1109
|
this.bus.push(Route.Main, make.ttsText(contextId, Date.now(), tail));
|
|
908
1110
|
buffer.emitted = appendVoiceText(buffer.emitted, tail);
|
|
909
1111
|
buffer.pending = "";
|
|
@@ -965,14 +1167,22 @@ export class VoiceAgentSession {
|
|
|
965
1167
|
toolName: string,
|
|
966
1168
|
afterMs?: number,
|
|
967
1169
|
): void {
|
|
1170
|
+
const tsMs = Date.now();
|
|
968
1171
|
this.emit("tool_call_cue", {
|
|
969
|
-
tsMs
|
|
1172
|
+
tsMs,
|
|
970
1173
|
turnId: contextId,
|
|
971
1174
|
phase,
|
|
972
1175
|
toolId,
|
|
973
1176
|
toolName,
|
|
974
1177
|
...(afterMs !== undefined ? { afterMs } : {}),
|
|
975
1178
|
});
|
|
1179
|
+
this.interaction.observe({
|
|
1180
|
+
kind: "delegate_state",
|
|
1181
|
+
contextId,
|
|
1182
|
+
timestampMs: tsMs,
|
|
1183
|
+
delegateInFlight: phase === "started" || phase === "delayed",
|
|
1184
|
+
toolCallPhase: phase,
|
|
1185
|
+
});
|
|
976
1186
|
}
|
|
977
1187
|
|
|
978
1188
|
/** G3: resolve one pending tool call with a terminal cue phase. */
|
|
@@ -1077,6 +1287,7 @@ export class VoiceAgentSession {
|
|
|
1077
1287
|
);
|
|
1078
1288
|
this.turnUserStoppedAtMs.delete(pkt.contextId);
|
|
1079
1289
|
}
|
|
1290
|
+
this.emitTurnLatency(pkt.contextId, pkt.timestampMs);
|
|
1080
1291
|
}
|
|
1081
1292
|
|
|
1082
1293
|
this.primarySpeakerGate.observeAssistantPlayout(pkt.audio);
|
|
@@ -1089,6 +1300,14 @@ export class VoiceAgentSession {
|
|
|
1089
1300
|
const audioDurationMs = estimatePcm16Duration(pkt.audio, sampleRateHz);
|
|
1090
1301
|
const now = Date.now();
|
|
1091
1302
|
this.ttsPlayout.noteAudio(pkt.contextId, audioDurationMs, now);
|
|
1303
|
+
this.interaction.observe({
|
|
1304
|
+
kind: "playout_tick",
|
|
1305
|
+
contextId: pkt.contextId,
|
|
1306
|
+
timestampMs: pkt.timestampMs,
|
|
1307
|
+
ttsActive: true,
|
|
1308
|
+
audio: pcm16BytesToSamples(pkt.audio),
|
|
1309
|
+
sampleRateHz,
|
|
1310
|
+
});
|
|
1092
1311
|
|
|
1093
1312
|
// Anchor the idle timer to when playout actually *ends* (P2), not to chunk
|
|
1094
1313
|
// arrival. TTS streams faster than realtime, so extending by each chunk's
|
|
@@ -1115,7 +1334,11 @@ export class VoiceAgentSession {
|
|
|
1115
1334
|
// Generation finished, but the streamed audio is still playing out. Keep the
|
|
1116
1335
|
// context interruptible until its playout estimate elapses, then release it.
|
|
1117
1336
|
this.generatingContextIds.delete(pkt.contextId);
|
|
1118
|
-
|
|
1337
|
+
const now = Date.now();
|
|
1338
|
+
this.ttsPlayout.scheduleRelease(pkt.contextId, now);
|
|
1339
|
+
const playoutEndMs = this.ttsPlayout.playoutEnd(pkt.contextId);
|
|
1340
|
+
const remainingMs = playoutEndMs === undefined ? 0 : Math.max(0, playoutEndMs - now);
|
|
1341
|
+
this.scheduleInteractionPlayoutTick(pkt.contextId, remainingMs);
|
|
1119
1342
|
this.watchdogs.clearTtsStallTimerFor(pkt.contextId);
|
|
1120
1343
|
this.debugPush({
|
|
1121
1344
|
component: "tts",
|
|
@@ -1125,14 +1348,43 @@ export class VoiceAgentSession {
|
|
|
1125
1348
|
});
|
|
1126
1349
|
}
|
|
1127
1350
|
|
|
1351
|
+
private scheduleInteractionPlayoutTick(contextId: string, delayMs: number): void {
|
|
1352
|
+
this.pendingInteractionPlayoutTimers.add(contextId);
|
|
1353
|
+
this.scheduler.schedule(interactionPlayoutTimerKey(contextId), delayMs, () => {
|
|
1354
|
+
this.pendingInteractionPlayoutTimers.delete(contextId);
|
|
1355
|
+
if (this.ttsPlayout.isActive(contextId)) return;
|
|
1356
|
+
this.interaction.observe({
|
|
1357
|
+
kind: "playout_tick",
|
|
1358
|
+
contextId,
|
|
1359
|
+
timestampMs: Date.now(),
|
|
1360
|
+
ttsActive: false,
|
|
1361
|
+
});
|
|
1362
|
+
});
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
private cancelInteractionPlayoutTick(contextId: string): void {
|
|
1366
|
+
if (!this.pendingInteractionPlayoutTimers.delete(contextId)) return;
|
|
1367
|
+
this.scheduler.cancel(interactionPlayoutTimerKey(contextId));
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1128
1370
|
private handleTtsPlayoutProgress(pkt: TextToSpeechPlayoutProgressPacket): void {
|
|
1371
|
+
this.cancelInteractionPlayoutTick(pkt.contextId);
|
|
1129
1372
|
this.ttsPlayout.noteProgress(pkt.contextId, pkt.complete, pkt.playedOutMs);
|
|
1373
|
+
this.interaction.observe({
|
|
1374
|
+
kind: "playout_tick",
|
|
1375
|
+
contextId: pkt.contextId,
|
|
1376
|
+
timestampMs: pkt.timestampMs,
|
|
1377
|
+
playedOutMs: pkt.playedOutMs,
|
|
1378
|
+
ttsActive: !pkt.complete,
|
|
1379
|
+
});
|
|
1130
1380
|
}
|
|
1131
1381
|
|
|
1132
1382
|
private handleInterruptDetected(pkt: InterruptionDetectedPacket): void {
|
|
1383
|
+
this.cancelInteractionPlayoutTick(pkt.contextId);
|
|
1133
1384
|
this.interruptedGenerationContextIds.add(pkt.contextId);
|
|
1134
1385
|
this.failPendingToolCues(pkt.contextId); // G3: the aborted delegate's cue fails (R5)
|
|
1135
1386
|
this.latencyFiller.cancel(pkt.contextId);
|
|
1387
|
+
this.turnTimings.delete(pkt.contextId);
|
|
1136
1388
|
this.firstLlmDeltaReceived.delete(pkt.contextId);
|
|
1137
1389
|
this.ttsTextBuffers.delete(pkt.contextId);
|
|
1138
1390
|
this.ttsPlayout.release(pkt.contextId);
|
|
@@ -1178,10 +1430,12 @@ export class VoiceAgentSession {
|
|
|
1178
1430
|
* the LLM, and drops late deltas/audio for the stale context.
|
|
1179
1431
|
*/
|
|
1180
1432
|
private cancelStaleGeneration(contextId: string, timestampMs: number): void {
|
|
1433
|
+
this.cancelInteractionPlayoutTick(contextId);
|
|
1181
1434
|
this.interruptedGenerationContextIds.add(contextId);
|
|
1182
1435
|
this.failPendingToolCues(contextId); // G3: a superseded turn's pending cue fails
|
|
1183
1436
|
this.generatingContextIds.delete(contextId);
|
|
1184
1437
|
this.latencyFiller.cancel(contextId);
|
|
1438
|
+
this.turnTimings.delete(contextId);
|
|
1185
1439
|
this.firstLlmDeltaReceived.delete(contextId);
|
|
1186
1440
|
this.ttsTextBuffers.delete(contextId);
|
|
1187
1441
|
this.ttsPlayout.release(contextId);
|
|
@@ -1298,6 +1552,19 @@ export class VoiceAgentSession {
|
|
|
1298
1552
|
});
|
|
1299
1553
|
}
|
|
1300
1554
|
|
|
1555
|
+
if (
|
|
1556
|
+
this.activeInteractionPolicy === this.injectedInteractionPolicy &&
|
|
1557
|
+
isLifecycleInteractionPolicy(this.activeInteractionPolicy)
|
|
1558
|
+
) {
|
|
1559
|
+
const policy = this.activeInteractionPolicy;
|
|
1560
|
+
steps.push({
|
|
1561
|
+
name: "interaction_policy",
|
|
1562
|
+
stage: InitStage.EOS,
|
|
1563
|
+
run: () => policy.initialize(this.config.interactionPolicyConfig ?? {}),
|
|
1564
|
+
cleanup: () => policy.close(),
|
|
1565
|
+
});
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1301
1568
|
const orderedPluginSteps = steps.sort(
|
|
1302
1569
|
(a, b) => stageOrder(a.stage) - stageOrder(b.stage),
|
|
1303
1570
|
);
|
|
@@ -1327,6 +1594,20 @@ export class VoiceAgentSession {
|
|
|
1327
1594
|
}
|
|
1328
1595
|
|
|
1329
1596
|
private applyEndpointingOwnerInvariant(): void {
|
|
1597
|
+
if (this.activeInteractionPolicy === this.injectedInteractionPolicy) {
|
|
1598
|
+
for (const [name, plugin] of this.plugins) {
|
|
1599
|
+
const capability = plugin.endpointingCapability;
|
|
1600
|
+
if (!capability || capability.owner !== "provider_stt") continue;
|
|
1601
|
+
if (!capability.disableConfig) {
|
|
1602
|
+
throw new Error(`interactionPolicy requires ${name} to expose endpointingCapability.disableConfig`);
|
|
1603
|
+
}
|
|
1604
|
+
this.config.plugins[name] = {
|
|
1605
|
+
...(this.config.plugins[name] ?? {}),
|
|
1606
|
+
...capability.disableConfig,
|
|
1607
|
+
};
|
|
1608
|
+
}
|
|
1609
|
+
return;
|
|
1610
|
+
}
|
|
1330
1611
|
if (this.endpointingOwner === "timer") return;
|
|
1331
1612
|
const owner: EndpointingOwner = this.endpointingOwner;
|
|
1332
1613
|
const finalizers = [...this.plugins.entries()]
|
|
@@ -1352,6 +1633,9 @@ export class VoiceAgentSession {
|
|
|
1352
1633
|
private shouldInitializePlugin(plugin: VoicePlugin): boolean {
|
|
1353
1634
|
const capability = plugin.endpointingCapability;
|
|
1354
1635
|
if (!capability) return true;
|
|
1636
|
+
if (this.activeInteractionPolicy === this.injectedInteractionPolicy) {
|
|
1637
|
+
return capability.owner === "provider_stt";
|
|
1638
|
+
}
|
|
1355
1639
|
if (this.endpointingOwner === "timer") return false;
|
|
1356
1640
|
if (capability.owner === "smart_turn") return capability.owner === this.endpointingOwner;
|
|
1357
1641
|
return true;
|