@kuralle-syrinx/core 4.3.0 → 4.4.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 +2 -0
- package/src/interaction-coordinator.ts +17 -2
- package/src/packet-factories.ts +14 -1
- package/src/packets.ts +24 -1
- package/src/voice-agent-session-util.ts +18 -0
- package/src/voice-agent-session.ts +16 -2
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// SPDX-License-Identifier: MIT
|
|
2
2
|
|
|
3
3
|
import type { VadAudioPacket } from "./packets.js";
|
|
4
|
-
import type { SttInterimPacket, SttResultPacket } from "./packets.js";
|
|
4
|
+
import type { SttInterimPacket, SttResultPacket, TurnEndOwner, TurnEndReason } from "./packets.js";
|
|
5
5
|
import { Route } from "./pipeline-bus.js";
|
|
6
6
|
import type { PipelineBus } from "./pipeline-bus.js";
|
|
7
7
|
import { confidenceToWaitMs } from "./confidence-to-wait.js";
|
|
@@ -41,6 +41,10 @@ export class InteractionCoordinator {
|
|
|
41
41
|
isTtsActive?: () => boolean;
|
|
42
42
|
hasCueAsset?: (cueId: string) => boolean;
|
|
43
43
|
onBackchannelEmitted?: (contextId: string) => void;
|
|
44
|
+
/** Session's configured endpointing owner — labels every eos this coordinator emits. */
|
|
45
|
+
endpointingOwner?: TurnEndOwner;
|
|
46
|
+
/** True when the STT force-finalize watchdog already fired for this context. */
|
|
47
|
+
wasForceFinalized?: (contextId: string) => boolean;
|
|
44
48
|
},
|
|
45
49
|
) {
|
|
46
50
|
this.scheduler = deps.scheduler ?? new TimerScheduler();
|
|
@@ -180,7 +184,18 @@ export class InteractionCoordinator {
|
|
|
180
184
|
live.transcripts.length > 0
|
|
181
185
|
? live.transcripts
|
|
182
186
|
: [{ kind: "stt.result", contextId, timestampMs: Date.now(), text: finalText, confidence: 0 }];
|
|
183
|
-
this
|
|
187
|
+
// The policy committed to this turn, so the endpointing decision is genuinely
|
|
188
|
+
// known here — label who decided and why. A force-finalize watchdog firing
|
|
189
|
+
// underneath us is a different diagnosis from a natural endpoint.
|
|
190
|
+
const configuredOwner = this.deps.endpointingOwner;
|
|
191
|
+
const forced = this.deps.wasForceFinalized?.(contextId) === true;
|
|
192
|
+
const reason: TurnEndReason = forced ? "force_finalized" : "end_of_speech";
|
|
193
|
+
const endpointing =
|
|
194
|
+
configuredOwner === undefined ? undefined : { owner: configuredOwner, reason };
|
|
195
|
+
this.deps.bus.push(
|
|
196
|
+
Route.Main,
|
|
197
|
+
make.eosTurnComplete(contextId, Date.now(), finalText, transcripts, endpointing),
|
|
198
|
+
);
|
|
184
199
|
});
|
|
185
200
|
}
|
|
186
201
|
|
package/src/packet-factories.ts
CHANGED
|
@@ -29,6 +29,8 @@ import type {
|
|
|
29
29
|
SpeechToTextAudioPacket,
|
|
30
30
|
EndOfSpeechAudioPacket,
|
|
31
31
|
EndOfSpeechPacket,
|
|
32
|
+
TurnEndOwner,
|
|
33
|
+
TurnEndReason,
|
|
32
34
|
SttPartialPacket,
|
|
33
35
|
SttResultPacket,
|
|
34
36
|
FinalizeSttPacket,
|
|
@@ -220,8 +222,19 @@ export function eosTurnComplete(
|
|
|
220
222
|
timestampMs: number,
|
|
221
223
|
text: string,
|
|
222
224
|
transcripts: readonly SttResultPacket[],
|
|
225
|
+
endpointing?: { readonly owner: TurnEndOwner; readonly reason: TurnEndReason },
|
|
223
226
|
): EndOfSpeechPacket {
|
|
224
|
-
return
|
|
227
|
+
return endpointing === undefined
|
|
228
|
+
? { kind: "eos.turn_complete", contextId, timestampMs, text, transcripts }
|
|
229
|
+
: {
|
|
230
|
+
kind: "eos.turn_complete",
|
|
231
|
+
contextId,
|
|
232
|
+
timestampMs,
|
|
233
|
+
text,
|
|
234
|
+
transcripts,
|
|
235
|
+
endpointingOwner: endpointing.owner,
|
|
236
|
+
endpointingReason: endpointing.reason,
|
|
237
|
+
};
|
|
225
238
|
}
|
|
226
239
|
|
|
227
240
|
export function finalizeStt(contextId: string, timestampMs: number): FinalizeSttPacket {
|
package/src/packets.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// Lifecycle: InitStepCompleted, InitFailed, InitCompleted
|
|
11
11
|
|
|
12
12
|
import type { WordTiming } from "./interaction-policy.js";
|
|
13
|
-
import type { SttReconfigurePartial } from "./plugin-contract.js";
|
|
13
|
+
import type { EndpointingOwner, SttReconfigurePartial } from "./plugin-contract.js";
|
|
14
14
|
|
|
15
15
|
// =============================================================================
|
|
16
16
|
// Base Types
|
|
@@ -220,11 +220,34 @@ export interface EndOfSpeechAudioPacket extends VoicePacket {
|
|
|
220
220
|
readonly audio: Uint8Array;
|
|
221
221
|
}
|
|
222
222
|
|
|
223
|
+
/**
|
|
224
|
+
* Who decided a turn ended. Extends {@link EndpointingOwner} with the two
|
|
225
|
+
* non-plugin regimes: `"timer"` (a realtime front owns its own turn
|
|
226
|
+
* detection) and `"text"` (the user typed — no endpointer fired at all).
|
|
227
|
+
*
|
|
228
|
+
* Absent means genuinely unknown: never fabricate an owner the backend did
|
|
229
|
+
* not measure. A debugging surface that guesses is worse than one that says so.
|
|
230
|
+
*/
|
|
231
|
+
export type TurnEndOwner = EndpointingOwner | "timer" | "text";
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Why a turn ended, paired with {@link TurnEndOwner}.
|
|
235
|
+
* - `end_of_speech` — an endpointer marked natural end of speech.
|
|
236
|
+
* - `force_finalized` — the STT was force-finalized by the
|
|
237
|
+
* `sttForceFinalizeTimeoutMs` watchdog (a timeout), not a natural endpoint.
|
|
238
|
+
* - `typed` — the turn came from typed input; no speech was endpointed.
|
|
239
|
+
*/
|
|
240
|
+
export type TurnEndReason = "end_of_speech" | "force_finalized" | "typed";
|
|
241
|
+
|
|
223
242
|
export interface EndOfSpeechPacket extends VoicePacket {
|
|
224
243
|
readonly kind: "eos.turn_complete";
|
|
225
244
|
readonly text: string;
|
|
226
245
|
/** All accumulated STT transcripts for this turn. */
|
|
227
246
|
readonly transcripts: readonly SttResultPacket[];
|
|
247
|
+
/** Which owner decided the turn ended. Omitted when genuinely unknown. */
|
|
248
|
+
readonly endpointingOwner?: TurnEndOwner;
|
|
249
|
+
/** Why the turn ended. Omitted when genuinely unknown. */
|
|
250
|
+
readonly endpointingReason?: TurnEndReason;
|
|
228
251
|
}
|
|
229
252
|
|
|
230
253
|
export interface InterimEndOfSpeechPacket extends VoicePacket {
|
|
@@ -81,6 +81,8 @@ export class VoiceSessionWatchdogs {
|
|
|
81
81
|
private readonly scheduler: Scheduler;
|
|
82
82
|
private sttForceFinalizeScheduled = false;
|
|
83
83
|
private pendingSttContextId = "";
|
|
84
|
+
/** Contexts whose STT the force-finalize watchdog has already fired on this turn. */
|
|
85
|
+
private readonly forceFinalizedContexts = new Set<string>();
|
|
84
86
|
private vaqiMissedResponseScheduled = false;
|
|
85
87
|
private vaqiMissedResponseContextId = "";
|
|
86
88
|
private vaqiMissedResponseStartMs = 0;
|
|
@@ -98,6 +100,7 @@ export class VoiceSessionWatchdogs {
|
|
|
98
100
|
this.clearVaqiMissedResponseTimer();
|
|
99
101
|
this.clearTtsStallTimer();
|
|
100
102
|
this.clearInputCadenceWatchdog();
|
|
103
|
+
this.forceFinalizedContexts.clear();
|
|
101
104
|
}
|
|
102
105
|
|
|
103
106
|
scheduleSttForceFinalize(contextId: string): void {
|
|
@@ -109,11 +112,26 @@ export class VoiceSessionWatchdogs {
|
|
|
109
112
|
this.sttForceFinalizeScheduled = true;
|
|
110
113
|
this.scheduler.schedule("voice.watchdog.stt_force_finalize", this.deps.sttForceFinalizeTimeoutMs, () => {
|
|
111
114
|
this.sttForceFinalizeScheduled = false;
|
|
115
|
+
this.forceFinalizedContexts.add(contextId);
|
|
116
|
+
// Broadcast the decision on the bus: the completing eos may come from the STT
|
|
117
|
+
// plugin (which cannot know the watchdog fired), so the metrics tracker reads
|
|
118
|
+
// this mark to label the turn rather than each emitter re-deriving it.
|
|
119
|
+
this.deps.bus.push(Route.Background, make.metric(contextId, "stt.force_finalized", "1"));
|
|
112
120
|
const plugin = findForceFinalizableSttPlugin(this.deps.plugins);
|
|
113
121
|
plugin?.forceFinalize(contextId);
|
|
114
122
|
});
|
|
115
123
|
}
|
|
116
124
|
|
|
125
|
+
/** True when the force-finalize watchdog has already fired for this context this turn. */
|
|
126
|
+
wasForceFinalized(contextId: string): boolean {
|
|
127
|
+
return this.forceFinalizedContexts.has(contextId);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Retire the flag once the turn has completed (read by the emitter + metrics tracker). */
|
|
131
|
+
clearForceFinalized(contextId: string): void {
|
|
132
|
+
this.forceFinalizedContexts.delete(contextId);
|
|
133
|
+
}
|
|
134
|
+
|
|
117
135
|
clearSttForceFinalizeIfContext(contextId: string): void {
|
|
118
136
|
if (this.pendingSttContextId === contextId) {
|
|
119
137
|
this.clearSttForceFinalizeTimer();
|
|
@@ -511,6 +511,8 @@ export class VoiceAgentSession {
|
|
|
511
511
|
onBackchannelEmitted: (contextId) => {
|
|
512
512
|
this.timingFor(contextId).backchannelUsed = true;
|
|
513
513
|
},
|
|
514
|
+
endpointingOwner: this.endpointingOwner,
|
|
515
|
+
wasForceFinalized: (contextId) => this.watchdogs.wasForceFinalized(contextId),
|
|
514
516
|
});
|
|
515
517
|
this.watchdogs = new VoiceSessionWatchdogs({
|
|
516
518
|
bus: this.bus,
|
|
@@ -874,8 +876,16 @@ export class VoiceAgentSession {
|
|
|
874
876
|
}
|
|
875
877
|
|
|
876
878
|
private handleUserText(pkt: UserTextReceivedPacket): void {
|
|
877
|
-
//
|
|
878
|
-
|
|
879
|
+
// Typed input is an immediate turn end — but nothing endpointed it and no one
|
|
880
|
+
// decided the caller stopped talking, because they typed. Label it as `text` so
|
|
881
|
+
// the timeline never claims a speech endpointer fired on a typed turn.
|
|
882
|
+
this.bus.push(
|
|
883
|
+
Route.Main,
|
|
884
|
+
make.eosTurnComplete(pkt.contextId, pkt.timestampMs, pkt.text, [], {
|
|
885
|
+
owner: "text",
|
|
886
|
+
reason: "typed",
|
|
887
|
+
}),
|
|
888
|
+
);
|
|
879
889
|
}
|
|
880
890
|
|
|
881
891
|
private handleSttPartial(pkt: SttPartialPacket): void {
|
|
@@ -1179,6 +1189,10 @@ export class VoiceAgentSession {
|
|
|
1179
1189
|
}
|
|
1180
1190
|
this.lastFinalizedContextId = pkt.contextId;
|
|
1181
1191
|
this.interaction.reset(pkt.contextId);
|
|
1192
|
+
// The force-finalize flag has now been read by whoever emitted this eos (and
|
|
1193
|
+
// captured by the metrics tracker via its metric) — retire it so the per-call
|
|
1194
|
+
// Set does not grow unbounded on a stable transport contextId.
|
|
1195
|
+
this.watchdogs.clearForceFinalized(pkt.contextId);
|
|
1182
1196
|
|
|
1183
1197
|
// Re-arm per-turn guard state for the next turn. Transports with a stable
|
|
1184
1198
|
// per-call contextId (telephony callSid) reuse one id across turns, so these
|