@kuralle-syrinx/pipecat-smart-turn 2.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/src/index.ts ADDED
@@ -0,0 +1,537 @@
1
+ // SPDX-License-Identifier: MIT
2
+ //
3
+ // Syrinx Kernel v2 - Pipecat Smart Turn Plugin
4
+ //
5
+ // Mirrors Pipecat's LocalSmartTurnAnalyzerV3 turn-stop strategy:
6
+ // Silero determines candidate speech boundaries, then Smart Turn v3 decides
7
+ // whether a pause is an actual completed user turn.
8
+
9
+ import { fileURLToPath } from "node:url";
10
+
11
+ import {
12
+ fuseEndpointDecision,
13
+ latestTranscript,
14
+ scoreSemanticCompleteness,
15
+ type SemanticEndpointFusionConfig,
16
+ } from "./semantic-completeness.js";
17
+ import {
18
+ Route,
19
+ type EndOfSpeechPacket,
20
+ type FinalizeSttPacket,
21
+ type InterruptionDetectedPacket,
22
+ type InterimEndOfSpeechPacket,
23
+ type PipelineBus,
24
+ type PluginConfig,
25
+ type SttInterimPacket,
26
+ type SttResultPacket,
27
+ type TextToSpeechAudioPacket,
28
+ type TextToSpeechEndPacket,
29
+ type TextToSpeechPlayoutProgressPacket,
30
+ type VadAudioPacket,
31
+ type VadSpeechEndedPacket,
32
+ type VadSpeechStartedPacket,
33
+ type VoicePlugin,
34
+ optionalStringConfig,
35
+ } from "@kuralle-syrinx/core";
36
+ import { pcm16BytesToSamples } from "@kuralle-syrinx/core/audio";
37
+
38
+ type Ort = typeof import("onnxruntime-node");
39
+ type InferenceSession = import("onnxruntime-node").InferenceSession;
40
+ interface FeatureExtractor {
41
+ _extract_fbank_features(audio: Float32Array): Promise<{ data: unknown }>;
42
+ }
43
+
44
+ const SAMPLE_RATE = 16000;
45
+ const MAX_AUDIO_SAMPLES = SAMPLE_RATE * 8;
46
+ const DEFAULT_MODEL_PATH = fileURLToPath(new URL("../models/smart-turn-v3.2-cpu.onnx", import.meta.url));
47
+
48
+ interface TurnState {
49
+ readonly contextId: string;
50
+ audio: number[];
51
+ finalPackets: SttResultPacket[];
52
+ finalSegments: string[];
53
+ latestInterim: string;
54
+ boundaryAnalyzed: boolean;
55
+ smartTurnComplete: boolean;
56
+ semanticComplete: boolean;
57
+ speechActive: boolean;
58
+ finalized: boolean;
59
+ analysisSequence: number;
60
+ finalizeTimer: ReturnType<typeof setTimeout> | null;
61
+ sttQuietTimer: ReturnType<typeof setTimeout> | null;
62
+ maxTimer: ReturnType<typeof setTimeout> | null;
63
+ deferTimer: ReturnType<typeof setTimeout> | null;
64
+ }
65
+
66
+ export interface SmartTurnPredictor {
67
+ initialize(config: PluginConfig): Promise<void>;
68
+ predict(audio: Float32Array): Promise<number>;
69
+ close(): Promise<void>;
70
+ }
71
+
72
+ export class LocalSmartTurnV3Predictor implements SmartTurnPredictor {
73
+ private ort: Ort | null = null;
74
+ private session: InferenceSession | null = null;
75
+ private featureExtractor: FeatureExtractor | null = null;
76
+
77
+ async initialize(config: PluginConfig): Promise<void> {
78
+ const sampleRate = readNonNegativeNumber(config["sample_rate"], SAMPLE_RATE);
79
+ if (sampleRate !== SAMPLE_RATE) {
80
+ throw new Error(`PipecatEOSPlugin requires 16 kHz PCM input, got ${String(sampleRate)} Hz`);
81
+ }
82
+ const modelPath = optionalStringConfig(config, "model_path") ?? DEFAULT_MODEL_PATH;
83
+ const { WhisperFeatureExtractor } = await import("@huggingface/transformers");
84
+ this.featureExtractor = new WhisperFeatureExtractor({
85
+ feature_size: 80,
86
+ sampling_rate: SAMPLE_RATE,
87
+ hop_length: 160,
88
+ n_fft: 400,
89
+ n_samples: MAX_AUDIO_SAMPLES,
90
+ nb_max_frames: 800,
91
+ }) as FeatureExtractor;
92
+ this.ort = await import("onnxruntime-node");
93
+ this.session = await this.ort.InferenceSession.create(modelPath, {
94
+ executionProviders: ["cpu"],
95
+ interOpNumThreads: 1,
96
+ intraOpNumThreads: 1,
97
+ });
98
+ }
99
+
100
+ async predict(audio: Float32Array): Promise<number> {
101
+ if (!this.ort || !this.session || !this.featureExtractor) throw new Error("Smart Turn predictor is not initialized");
102
+
103
+ const modelAudio = new Float32Array(MAX_AUDIO_SAMPLES);
104
+ const tail = audio.length > MAX_AUDIO_SAMPLES ? audio.slice(-MAX_AUDIO_SAMPLES) : audio;
105
+ modelAudio.set(tail, MAX_AUDIO_SAMPLES - tail.length);
106
+
107
+ const features = await this.featureExtractor._extract_fbank_features(modelAudio);
108
+ const input = new this.ort.Tensor("float32", features.data as Float32Array, [1, 80, 800]);
109
+ const outputs = await this.session.run({ input_features: input });
110
+ const value = outputs["logits"]?.data[0];
111
+ return typeof value === "number" ? value : 0;
112
+ }
113
+
114
+ async close(): Promise<void> {
115
+ this.session = null;
116
+ this.ort = null;
117
+ this.featureExtractor = null;
118
+ }
119
+ }
120
+
121
+ export class PipecatEOSPlugin implements VoicePlugin {
122
+ readonly endpointingCapability = { owner: "smart_turn" as const };
123
+
124
+ private bus: PipelineBus | null = null;
125
+ private disposers: Array<() => void> = [];
126
+ private turns = new Map<string, TurnState>();
127
+ private finalizeDelayMs = 250;
128
+ private sttQuietFallbackMs = 2500;
129
+ private maxDelayMs = 2000;
130
+ private incompleteFallbackMs = 2000;
131
+ private semanticShortcutDelayMs = 50;
132
+ private semanticDeferFallbackMs = 4000;
133
+ private semanticEndpointingEnabled = true;
134
+ private probabilityThreshold = 0.5;
135
+ private readonly lockedContextIds = new Set<string>();
136
+ private readonly lockedContextsWithAssistantAudio = new Set<string>();
137
+
138
+ constructor(private readonly predictor: SmartTurnPredictor = new LocalSmartTurnV3Predictor()) {}
139
+
140
+ async initialize(bus: PipelineBus, config: PluginConfig): Promise<void> {
141
+ this.bus = bus;
142
+ this.finalizeDelayMs = readNonNegativeNumber(config["finalize_delay_ms"], 250);
143
+ this.sttQuietFallbackMs = readNonNegativeNumber(config["stt_quiet_fallback_ms"], 2500);
144
+ this.maxDelayMs = readNonNegativeNumber(config["max_delay_ms"], 2000);
145
+ this.incompleteFallbackMs = readNonNegativeNumber(config["incomplete_fallback_ms"], 2000);
146
+ this.semanticShortcutDelayMs = readNonNegativeNumber(config["semantic_shortcut_delay_ms"], 50);
147
+ this.semanticDeferFallbackMs = readNonNegativeNumber(config["semantic_defer_fallback_ms"], 4000);
148
+ this.semanticEndpointingEnabled = readBooleanConfig(config["semantic_endpointing_enabled"], true);
149
+ this.probabilityThreshold = readProbability(config["probability_threshold"], 0.5);
150
+ await this.predictor.initialize(config);
151
+
152
+ this.disposers.push(
153
+ bus.on("vad.audio", (pkt) => {
154
+ this.handleAudio(pkt as VadAudioPacket);
155
+ }),
156
+ bus.on("stt.interim", (pkt) => {
157
+ this.handleInterim(pkt as SttInterimPacket);
158
+ }),
159
+ bus.on("stt.result", (pkt) => {
160
+ this.handleFinal(pkt as SttResultPacket);
161
+ }),
162
+ bus.on("vad.speech_started", (pkt) => {
163
+ this.handleSpeechStarted(pkt as VadSpeechStartedPacket);
164
+ }),
165
+ bus.on("vad.speech_ended", async (pkt) => {
166
+ await this.handleSpeechEnded(pkt as VadSpeechEndedPacket);
167
+ }),
168
+ bus.on("tts.audio", (pkt) => {
169
+ this.handleTtsAudio(pkt as TextToSpeechAudioPacket);
170
+ }),
171
+ bus.on("tts.end", (pkt) => {
172
+ this.handleTtsEnd(pkt as TextToSpeechEndPacket);
173
+ }),
174
+ bus.on("tts.playout_progress", (pkt) => {
175
+ this.handleTtsPlayoutProgress(pkt as TextToSpeechPlayoutProgressPacket);
176
+ }),
177
+ bus.on("interrupt.detected", (pkt) => {
178
+ this.releaseContextLock((pkt as InterruptionDetectedPacket).contextId);
179
+ }),
180
+ );
181
+ }
182
+
183
+ async close(): Promise<void> {
184
+ for (const dispose of this.disposers.splice(0)) {
185
+ dispose();
186
+ }
187
+ for (const state of this.turns.values()) {
188
+ clearTurnTimers(state);
189
+ }
190
+ this.turns.clear();
191
+ this.lockedContextIds.clear();
192
+ this.lockedContextsWithAssistantAudio.clear();
193
+ await this.predictor.close();
194
+ this.bus = null;
195
+ }
196
+
197
+ private handleAudio(pkt: VadAudioPacket): void {
198
+ if (this.lockedContextIds.has(pkt.contextId)) return;
199
+ if (pkt.audio.byteLength % 2 !== 0) return;
200
+ const state = this.stateFor(pkt.contextId);
201
+ const samples = pcm16BytesToSamples(pkt.audio);
202
+ for (const sample of samples) {
203
+ state.audio.push(sample / 32768);
204
+ }
205
+ if (state.audio.length > MAX_AUDIO_SAMPLES) {
206
+ state.audio.splice(0, state.audio.length - MAX_AUDIO_SAMPLES);
207
+ }
208
+ }
209
+
210
+ private handleInterim(pkt: SttInterimPacket): void {
211
+ if (this.lockedContextIds.has(pkt.contextId)) return;
212
+ if (!pkt.text.trim()) return;
213
+ const state = this.stateFor(pkt.contextId);
214
+ state.latestInterim = pkt.text.trim();
215
+ if (state.sttQuietTimer) this.armSttQuietFallback(state);
216
+ this.bus?.push(Route.Main, {
217
+ kind: "eos.interim",
218
+ contextId: pkt.contextId,
219
+ timestampMs: Date.now(),
220
+ text: pkt.text,
221
+ } satisfies InterimEndOfSpeechPacket);
222
+ }
223
+
224
+ private handleFinal(pkt: SttResultPacket): void {
225
+ if (this.lockedContextIds.has(pkt.contextId)) return;
226
+ if (!pkt.text.trim()) return;
227
+ const state = this.stateFor(pkt.contextId);
228
+ appendFinalPacket(state, pkt);
229
+ state.latestInterim = "";
230
+
231
+ const transcript = latestTranscript(state.finalSegments, state.latestInterim);
232
+ const semantic = scoreSemanticCompleteness(transcript);
233
+ state.semanticComplete = semantic.complete;
234
+
235
+ if (state.boundaryAnalyzed && state.smartTurnComplete && state.semanticComplete) {
236
+ this.scheduleFinalize(state, this.finalizeDelayMs);
237
+ return;
238
+ }
239
+ if (state.smartTurnComplete && state.semanticComplete) {
240
+ this.scheduleFinalize(state, this.finalizeDelayMs);
241
+ return;
242
+ }
243
+ if (state.boundaryAnalyzed && state.smartTurnComplete && !state.semanticComplete) {
244
+ return;
245
+ }
246
+ if (state.boundaryAnalyzed && !state.smartTurnComplete && state.semanticComplete) {
247
+ this.scheduleSemanticShortcut(state);
248
+ return;
249
+ }
250
+ if (state.boundaryAnalyzed) {
251
+ this.scheduleIncompleteFallback(state);
252
+ return;
253
+ }
254
+ if (state.speechActive) {
255
+ this.armSttQuietFallback(state);
256
+ return;
257
+ }
258
+ this.scheduleMaxFinalize(state);
259
+ }
260
+
261
+ private handleSpeechStarted(pkt: VadSpeechStartedPacket): void {
262
+ if (this.lockedContextIds.has(pkt.contextId)) return;
263
+ const state = this.stateFor(pkt.contextId);
264
+ if (state.sttQuietTimer) {
265
+ clearTimeout(state.sttQuietTimer);
266
+ state.sttQuietTimer = null;
267
+ }
268
+ state.boundaryAnalyzed = false;
269
+ state.smartTurnComplete = false;
270
+ state.semanticComplete = false;
271
+ state.speechActive = true;
272
+ state.latestInterim = "";
273
+ state.analysisSequence += 1;
274
+ clearTurnTimers(state);
275
+ }
276
+
277
+ private async handleSpeechEnded(pkt: VadSpeechEndedPacket): Promise<void> {
278
+ if (this.lockedContextIds.has(pkt.contextId)) return;
279
+ const state = this.stateFor(pkt.contextId);
280
+ if (state.sttQuietTimer) {
281
+ clearTimeout(state.sttQuietTimer);
282
+ state.sttQuietTimer = null;
283
+ }
284
+ await this.analyzeBoundary(state);
285
+ }
286
+
287
+ // Shared end-of-speech boundary analysis: used by the VAD speech_ended path
288
+ // and by the STT-quiet fallback (when the provider transcript has gone quiet
289
+ // but the VAD never closed the segment — e.g. model state saturation on long
290
+ // telephony audio). The turn must never be held hostage by a wedged VAD.
291
+ private async analyzeBoundary(state: TurnState): Promise<void> {
292
+ state.speechActive = false;
293
+ const sequence = ++state.analysisSequence;
294
+ const probability = await this.predictor.predict(Float32Array.from(state.audio));
295
+ if (state.finalized || state.analysisSequence !== sequence) return;
296
+
297
+ state.boundaryAnalyzed = true;
298
+ state.smartTurnComplete = probability > this.probabilityThreshold;
299
+
300
+ const transcript = latestTranscript(state.finalSegments, state.latestInterim);
301
+ const semantic = transcript.trim()
302
+ ? scoreSemanticCompleteness(transcript)
303
+ : { complete: state.smartTurnComplete, label: "complete" as const, confidence: 0 };
304
+ state.semanticComplete = semantic.complete;
305
+
306
+ const fusion = transcript.trim()
307
+ ? fuseEndpointDecision(state.smartTurnComplete, semantic, this.fusionConfig())
308
+ : {
309
+ release: state.smartTurnComplete,
310
+ requestFinalize: state.smartTurnComplete,
311
+ finalizeDelayMs: this.finalizeDelayMs,
312
+ };
313
+
314
+ if (state.maxTimer) {
315
+ clearTimeout(state.maxTimer);
316
+ state.maxTimer = null;
317
+ }
318
+
319
+ if (fusion.deferReason) {
320
+ this.scheduleSemanticDefer(state);
321
+ return;
322
+ }
323
+
324
+ if (!fusion.release) {
325
+ this.scheduleIncompleteFallback(state);
326
+ return;
327
+ }
328
+
329
+ if (fusion.requestFinalize) {
330
+ this.requestSttFinalize(state.contextId);
331
+ }
332
+ if (state.finalPackets.length > 0) {
333
+ this.scheduleFinalize(state, fusion.finalizeDelayMs);
334
+ }
335
+ }
336
+
337
+ private stateFor(contextId: string): TurnState {
338
+ const existing = this.turns.get(contextId);
339
+ if (existing) return existing;
340
+
341
+ const state: TurnState = {
342
+ contextId,
343
+ audio: [],
344
+ finalPackets: [],
345
+ finalSegments: [],
346
+ latestInterim: "",
347
+ boundaryAnalyzed: false,
348
+ smartTurnComplete: false,
349
+ semanticComplete: false,
350
+ speechActive: false,
351
+ finalized: false,
352
+ analysisSequence: 0,
353
+ finalizeTimer: null,
354
+ sttQuietTimer: null,
355
+ maxTimer: null,
356
+ deferTimer: null,
357
+ };
358
+ this.turns.set(contextId, state);
359
+ return state;
360
+ }
361
+
362
+ private scheduleFinalize(state: TurnState, delayMs: number): void {
363
+ if (state.finalized || state.finalizeTimer) return;
364
+ state.finalizeTimer = setTimeout(() => {
365
+ state.finalizeTimer = null;
366
+ this.finalize(state);
367
+ }, delayMs);
368
+ }
369
+
370
+ private scheduleIncompleteFallback(state: TurnState): void {
371
+ if (state.finalized || state.finalizeTimer) return;
372
+ state.finalizeTimer = setTimeout(() => {
373
+ state.finalizeTimer = null;
374
+ state.smartTurnComplete = true;
375
+ if (state.finalPackets.length > 0) {
376
+ this.finalize(state);
377
+ return;
378
+ }
379
+ this.requestSttFinalize(state.contextId);
380
+ }, this.incompleteFallbackMs);
381
+ }
382
+
383
+ private scheduleSemanticShortcut(state: TurnState): void {
384
+ if (state.finalized || state.finalizeTimer) return;
385
+ state.finalizeTimer = setTimeout(() => {
386
+ state.finalizeTimer = null;
387
+ state.smartTurnComplete = true;
388
+ this.requestSttFinalize(state.contextId);
389
+ if (state.finalPackets.length > 0) {
390
+ this.finalize(state);
391
+ }
392
+ }, this.semanticShortcutDelayMs);
393
+ }
394
+
395
+ private scheduleSemanticDefer(state: TurnState): void {
396
+ if (state.finalized || state.deferTimer) return;
397
+ state.deferTimer = setTimeout(() => {
398
+ state.deferTimer = null;
399
+ if (state.finalized || state.speechActive) return;
400
+ const transcript = latestTranscript(state.finalSegments, state.latestInterim);
401
+ const semantic = scoreSemanticCompleteness(transcript);
402
+ state.smartTurnComplete = true;
403
+ this.requestSttFinalize(state.contextId);
404
+ if (state.finalPackets.length > 0) {
405
+ if (semantic.complete) {
406
+ state.semanticComplete = true;
407
+ this.scheduleFinalize(state, this.finalizeDelayMs);
408
+ } else {
409
+ this.finalize(state);
410
+ }
411
+ }
412
+ }, this.semanticDeferFallbackMs);
413
+ }
414
+
415
+ private fusionConfig(): SemanticEndpointFusionConfig {
416
+ return {
417
+ enabled: this.semanticEndpointingEnabled,
418
+ finalizeDelayMs: this.finalizeDelayMs,
419
+ semanticShortcutDelayMs: this.semanticShortcutDelayMs,
420
+ incompleteFallbackMs: this.incompleteFallbackMs,
421
+ };
422
+ }
423
+
424
+ private armSttQuietFallback(state: TurnState): void {
425
+ if (this.sttQuietFallbackMs <= 0 || state.finalized) return;
426
+ if (state.sttQuietTimer) clearTimeout(state.sttQuietTimer);
427
+ state.sttQuietTimer = setTimeout(() => {
428
+ state.sttQuietTimer = null;
429
+ if (state.finalized || !state.speechActive || state.finalPackets.length === 0) return;
430
+ this.bus?.push(Route.Main, {
431
+ kind: "metric.conversation",
432
+ contextId: state.contextId,
433
+ timestampMs: Date.now(),
434
+ name: "eos.stt_quiet_fallback",
435
+ value: String(this.sttQuietFallbackMs),
436
+ });
437
+ void this.analyzeBoundary(state);
438
+ }, this.sttQuietFallbackMs);
439
+ }
440
+
441
+ private scheduleMaxFinalize(state: TurnState): void {
442
+ if (state.finalized || state.maxTimer || this.maxDelayMs <= 0) return;
443
+ state.maxTimer = setTimeout(() => {
444
+ state.maxTimer = null;
445
+ this.finalize(state);
446
+ }, this.maxDelayMs);
447
+ }
448
+
449
+ private handleTtsAudio(pkt: TextToSpeechAudioPacket): void {
450
+ if (this.lockedContextIds.has(pkt.contextId)) {
451
+ this.lockedContextsWithAssistantAudio.add(pkt.contextId);
452
+ }
453
+ }
454
+
455
+ private handleTtsEnd(pkt: TextToSpeechEndPacket): void {
456
+ if (!this.lockedContextsWithAssistantAudio.has(pkt.contextId)) {
457
+ this.releaseContextLock(pkt.contextId);
458
+ }
459
+ }
460
+
461
+ private handleTtsPlayoutProgress(pkt: TextToSpeechPlayoutProgressPacket): void {
462
+ if (pkt.complete) this.releaseContextLock(pkt.contextId);
463
+ }
464
+
465
+ private releaseContextLock(contextId: string): void {
466
+ this.lockedContextIds.delete(contextId);
467
+ this.lockedContextsWithAssistantAudio.delete(contextId);
468
+ }
469
+
470
+ private finalize(state: TurnState): void {
471
+ const text = state.finalSegments.join(" ").replace(/\s+/g, " ").trim();
472
+ if (state.finalized || state.finalPackets.length === 0 || !text) return;
473
+ state.finalized = true;
474
+ this.lockedContextIds.add(state.contextId);
475
+ clearTurnTimers(state);
476
+ this.bus?.push(Route.Main, {
477
+ kind: "eos.turn_complete",
478
+ contextId: state.contextId,
479
+ timestampMs: Date.now(),
480
+ text,
481
+ transcripts: state.finalPackets,
482
+ } satisfies EndOfSpeechPacket);
483
+ this.turns.delete(state.contextId);
484
+ }
485
+
486
+ private requestSttFinalize(contextId: string): void {
487
+ this.bus?.push(Route.Critical, {
488
+ kind: "stt.finalize",
489
+ contextId,
490
+ timestampMs: Date.now(),
491
+ } satisfies FinalizeSttPacket);
492
+ }
493
+ }
494
+
495
+ function appendFinalPacket(state: TurnState, packet: SttResultPacket): void {
496
+ const text = packet.text.trim();
497
+ if (state.finalSegments.at(-1) === text) return;
498
+ state.finalSegments.push(text);
499
+ state.finalPackets.push(packet);
500
+ }
501
+
502
+ function clearTurnTimers(state: TurnState): void {
503
+ if (state.finalizeTimer) clearTimeout(state.finalizeTimer);
504
+ if (state.sttQuietTimer) clearTimeout(state.sttQuietTimer);
505
+ if (state.maxTimer) clearTimeout(state.maxTimer);
506
+ if (state.deferTimer) clearTimeout(state.deferTimer);
507
+ state.finalizeTimer = null;
508
+ state.sttQuietTimer = null;
509
+ state.maxTimer = null;
510
+ state.deferTimer = null;
511
+ }
512
+
513
+ function readBooleanConfig(value: unknown, fallback: boolean): boolean {
514
+ if (typeof value === "boolean") return value;
515
+ return fallback;
516
+ }
517
+
518
+ export {
519
+ fuseEndpointDecision,
520
+ latestTranscript,
521
+ scoreSemanticCompleteness,
522
+ type EndpointFusionDecision,
523
+ type SemanticCompletenessLabel,
524
+ type SemanticCompletenessScore,
525
+ type SemanticEndpointFusionConfig,
526
+ } from "./semantic-completeness.js";
527
+ export { SEMANTIC_LABELED_UTTERANCES, type SemanticLabeledUtterance } from "./semantic-fixtures.js";
528
+
529
+ function readNonNegativeNumber(value: unknown, fallback: number): number {
530
+ if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
531
+ return Math.max(0, Math.floor(value));
532
+ }
533
+
534
+ function readProbability(value: unknown, fallback: number): number {
535
+ if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
536
+ return Math.min(1, Math.max(0, value));
537
+ }
@@ -0,0 +1,164 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ import { describe, expect, it } from "vitest";
4
+ import {
5
+ fuseEndpointDecision,
6
+ scoreSemanticCompleteness,
7
+ type SemanticEndpointFusionConfig,
8
+ } from "./semantic-completeness.js";
9
+ import { SEMANTIC_LABELED_UTTERANCES } from "./semantic-fixtures.js";
10
+
11
+ const fusionConfig: SemanticEndpointFusionConfig = {
12
+ enabled: true,
13
+ finalizeDelayMs: 250,
14
+ semanticShortcutDelayMs: 50,
15
+ incompleteFallbackMs: 2000,
16
+ };
17
+
18
+ describe("scoreSemanticCompleteness", () => {
19
+ it("labels complete utterances as complete", () => {
20
+ for (const fixture of SEMANTIC_LABELED_UTTERANCES.filter((item) => item.category === "complete")) {
21
+ const score = scoreSemanticCompleteness(fixture.text);
22
+ expect(score.complete, fixture.id).toBe(true);
23
+ expect(score.label).toBe("complete");
24
+ }
25
+ });
26
+
27
+ it("labels mid-thought pauses as incomplete", () => {
28
+ for (const fixture of SEMANTIC_LABELED_UTTERANCES.filter(
29
+ (item) => item.category === "mid_thought_pause",
30
+ )) {
31
+ const score = scoreSemanticCompleteness(fixture.text);
32
+ expect(score.complete, fixture.id).toBe(false);
33
+ expect(score.label).toBe("incomplete");
34
+ }
35
+ });
36
+
37
+ it("does not treat word count alone as a complete turn", () => {
38
+ const score = scoreSemanticCompleteness("I was wondering if I can still add biology");
39
+ expect(score.complete).toBe(false);
40
+ expect(score.label).toBe("incomplete");
41
+ });
42
+
43
+ it("labels backchannels as complete turns", () => {
44
+ for (const fixture of SEMANTIC_LABELED_UTTERANCES.filter((item) => item.category === "backchannel")) {
45
+ const score = scoreSemanticCompleteness(fixture.text);
46
+ expect(score.complete, fixture.id).toBe(true);
47
+ expect(score.label).toBe("backchannel");
48
+ }
49
+ });
50
+ });
51
+
52
+ describe("fuseEndpointDecision", () => {
53
+ it("releases when Smart Turn and semantics agree on completion", () => {
54
+ const decision = fuseEndpointDecision(
55
+ true,
56
+ scoreSemanticCompleteness("What are your office hours?"),
57
+ fusionConfig,
58
+ );
59
+ expect(decision).toEqual({
60
+ release: true,
61
+ requestFinalize: true,
62
+ finalizeDelayMs: 250,
63
+ });
64
+ });
65
+
66
+ it("defers when Smart Turn approves but semantics are incomplete", () => {
67
+ const decision = fuseEndpointDecision(
68
+ true,
69
+ scoreSemanticCompleteness("I need to know"),
70
+ fusionConfig,
71
+ );
72
+ expect(decision).toEqual({
73
+ release: false,
74
+ requestFinalize: false,
75
+ finalizeDelayMs: 250,
76
+ deferReason: "semantic_incomplete",
77
+ });
78
+ });
79
+
80
+ it("shortcuts only high-confidence semantics when Smart Turn is uncertain", () => {
81
+ const decision = fuseEndpointDecision(
82
+ false,
83
+ scoreSemanticCompleteness("What are your office hours?"),
84
+ fusionConfig,
85
+ );
86
+ expect(decision).toEqual({
87
+ release: true,
88
+ requestFinalize: true,
89
+ finalizeDelayMs: 50,
90
+ shortcutReason: "semantic_complete",
91
+ });
92
+ });
93
+
94
+ it("waits for fallback when semantics are weak and Smart Turn is uncertain", () => {
95
+ const decision = fuseEndpointDecision(
96
+ false,
97
+ scoreSemanticCompleteness("I was wondering if I can still add biology"),
98
+ fusionConfig,
99
+ );
100
+ expect(decision).toEqual({
101
+ release: false,
102
+ requestFinalize: false,
103
+ finalizeDelayMs: 2000,
104
+ });
105
+ });
106
+
107
+ it("waits when both Smart Turn and semantics are incomplete", () => {
108
+ const decision = fuseEndpointDecision(
109
+ false,
110
+ scoreSemanticCompleteness("I need to know"),
111
+ fusionConfig,
112
+ );
113
+ expect(decision).toEqual({
114
+ release: false,
115
+ requestFinalize: false,
116
+ finalizeDelayMs: 2000,
117
+ });
118
+ });
119
+
120
+ it("falls back to Smart Turn only when semantic endpointing is disabled", () => {
121
+ const decision = fuseEndpointDecision(
122
+ true,
123
+ scoreSemanticCompleteness("I need to know"),
124
+ { ...fusionConfig, enabled: false },
125
+ );
126
+ expect(decision).toEqual({
127
+ release: true,
128
+ requestFinalize: true,
129
+ finalizeDelayMs: 250,
130
+ });
131
+ });
132
+ });
133
+
134
+ describe("labeled fusion outcomes vs Smart-Turn-only", () => {
135
+ it("releases complete utterances earlier than Smart-Turn-only when acoustics are uncertain", () => {
136
+ const complete = SEMANTIC_LABELED_UTTERANCES.filter((item) => item.category === "complete");
137
+ for (const fixture of complete) {
138
+ const fused = fuseEndpointDecision(false, scoreSemanticCompleteness(fixture.text), fusionConfig);
139
+ const smartTurnOnly = fuseEndpointDecision(
140
+ false,
141
+ scoreSemanticCompleteness(fixture.text),
142
+ { ...fusionConfig, enabled: false },
143
+ );
144
+ expect(fused.release, fixture.id).toBe(true);
145
+ expect(smartTurnOnly.release, fixture.id).toBe(false);
146
+ expect(fused.finalizeDelayMs).toBeLessThan(fusionConfig.incompleteFallbackMs);
147
+ }
148
+ });
149
+
150
+ it("defers mid-thought pauses when Smart Turn would have released", () => {
151
+ const midThought = SEMANTIC_LABELED_UTTERANCES.filter((item) => item.category === "mid_thought_pause");
152
+ for (const fixture of midThought) {
153
+ const fused = fuseEndpointDecision(true, scoreSemanticCompleteness(fixture.text), fusionConfig);
154
+ const smartTurnOnly = fuseEndpointDecision(
155
+ true,
156
+ scoreSemanticCompleteness(fixture.text),
157
+ { ...fusionConfig, enabled: false },
158
+ );
159
+ expect(fused.release, fixture.id).toBe(false);
160
+ expect(smartTurnOnly.release, fixture.id).toBe(true);
161
+ expect(fused.deferReason).toBe("semantic_incomplete");
162
+ }
163
+ });
164
+ });