@kuralle-syrinx/pipecat-smart-turn 4.1.0 → 4.3.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.
@@ -0,0 +1,278 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ import {
4
+ confidenceToWaitMs,
5
+ type InteractionDecision,
6
+ type InteractionObservation,
7
+ type LifecycleInteractionPolicy,
8
+ type PluginConfig,
9
+ } from "@kuralle-syrinx/core";
10
+
11
+ import {
12
+ fuseEndpointDecision,
13
+ latestTranscript,
14
+ scoreSemanticCompleteness,
15
+ type SemanticEndpointFusionConfig,
16
+ } from "./semantic-completeness.js";
17
+ import { LocalSmartTurnV3Predictor, type SmartTurnPredictor } from "./predictor.js";
18
+
19
+ const SAMPLE_RATE = 16000;
20
+ const DEFAULT_MAX_AUDIO_SAMPLES = SAMPLE_RATE * 8;
21
+
22
+ interface SmartTurnState {
23
+ readonly contextId: string;
24
+ audio: number[];
25
+ speechMs: number;
26
+ finalSegments: string[];
27
+ latestInterim: string;
28
+ speechActive: boolean;
29
+ boundarySequence: number;
30
+ boundaryAnalyzed: boolean;
31
+ probability: number;
32
+ decisionIssued: boolean;
33
+ pendingDecisions: InteractionDecision[];
34
+ fallbackTimer: ReturnType<typeof setTimeout> | null;
35
+ }
36
+
37
+ export interface SmartTurnInteractionPolicyConfig {
38
+ readonly probability_threshold?: number;
39
+ readonly semantic_endpointing_enabled?: boolean;
40
+ readonly finalize_delay_ms?: number;
41
+ readonly semantic_shortcut_delay_ms?: number;
42
+ readonly incomplete_fallback_ms?: number;
43
+ readonly semantic_defer_fallback_ms?: number;
44
+ readonly min_speech_ms?: number;
45
+ readonly max_audio_samples?: number;
46
+ }
47
+
48
+ export class SmartTurnInteractionPolicy implements LifecycleInteractionPolicy {
49
+ private readonly states = new Map<string, SmartTurnState>();
50
+ private probabilityThreshold = 0.5;
51
+ private semanticEndpointingEnabled = true;
52
+ private finalizeDelayMs = 250;
53
+ private semanticShortcutDelayMs = 50;
54
+ private incompleteFallbackMs = 2000;
55
+ private semanticDeferFallbackMs = 4000;
56
+ private minSpeechMs = 0;
57
+ private maxAudioSamples = DEFAULT_MAX_AUDIO_SAMPLES;
58
+ private initialized = false;
59
+
60
+ constructor(private readonly predictor: SmartTurnPredictor = new LocalSmartTurnV3Predictor()) {}
61
+
62
+ async initialize(config: PluginConfig = {}): Promise<void> {
63
+ this.probabilityThreshold = readProbability(config["probability_threshold"], 0.5);
64
+ this.semanticEndpointingEnabled = readBoolean(config["semantic_endpointing_enabled"], true);
65
+ this.finalizeDelayMs = readNonNegativeNumber(config["finalize_delay_ms"], 250);
66
+ this.semanticShortcutDelayMs = readNonNegativeNumber(config["semantic_shortcut_delay_ms"], 50);
67
+ this.incompleteFallbackMs = readNonNegativeNumber(config["incomplete_fallback_ms"], 2000);
68
+ this.semanticDeferFallbackMs = readNonNegativeNumber(config["semantic_defer_fallback_ms"], 4000);
69
+ this.minSpeechMs = readNonNegativeNumber(config["min_speech_ms"], 0);
70
+ this.maxAudioSamples = readPositiveNumber(config["max_audio_samples"], DEFAULT_MAX_AUDIO_SAMPLES);
71
+ await this.predictor.initialize(config);
72
+ this.initialized = true;
73
+ }
74
+
75
+ observe(observation: InteractionObservation): readonly InteractionDecision[] {
76
+ const state = this.stateFor(observation.contextId);
77
+ switch (observation.kind) {
78
+ case "audio_frame":
79
+ this.appendAudio(state, observation.audio, observation.sampleRateHz);
80
+ break;
81
+ case "stt_partial":
82
+ if (observation.text.trim()) state.latestInterim = observation.text.trim();
83
+ this.evaluateBoundary(state);
84
+ break;
85
+ case "stt_final": {
86
+ const text = observation.text.trim();
87
+ if (text && state.finalSegments.at(-1) !== text) state.finalSegments.push(text);
88
+ state.latestInterim = "";
89
+ this.evaluateBoundary(state);
90
+ break;
91
+ }
92
+ case "vad_speech_started":
93
+ this.beginSpeech(state);
94
+ break;
95
+ case "vad_speech_ended":
96
+ state.speechActive = false;
97
+ this.analyzeBoundary(state);
98
+ break;
99
+ default:
100
+ break;
101
+ }
102
+ return this.drainDecisions(state);
103
+ }
104
+
105
+ reset(contextId: string): void {
106
+ const state = this.states.get(contextId);
107
+ if (!state) return;
108
+ this.clearFallback(state);
109
+ state.boundarySequence += 1;
110
+ this.states.delete(contextId);
111
+ }
112
+
113
+ async close(): Promise<void> {
114
+ this.initialized = false;
115
+ for (const contextId of [...this.states.keys()]) this.reset(contextId);
116
+ await this.predictor.close();
117
+ }
118
+
119
+ private stateFor(contextId: string): SmartTurnState {
120
+ const existing = this.states.get(contextId);
121
+ if (existing) return existing;
122
+ const state: SmartTurnState = {
123
+ contextId,
124
+ audio: [],
125
+ speechMs: 0,
126
+ finalSegments: [],
127
+ latestInterim: "",
128
+ speechActive: false,
129
+ boundarySequence: 0,
130
+ boundaryAnalyzed: false,
131
+ probability: 0,
132
+ decisionIssued: false,
133
+ pendingDecisions: [],
134
+ fallbackTimer: null,
135
+ };
136
+ this.states.set(contextId, state);
137
+ return state;
138
+ }
139
+
140
+ private beginSpeech(state: SmartTurnState): void {
141
+ this.clearFallback(state);
142
+ state.audio = [];
143
+ state.speechMs = 0;
144
+ state.finalSegments = [];
145
+ state.latestInterim = "";
146
+ state.speechActive = true;
147
+ state.boundarySequence += 1;
148
+ state.boundaryAnalyzed = false;
149
+ state.probability = 0;
150
+ state.decisionIssued = false;
151
+ state.pendingDecisions = [];
152
+ }
153
+
154
+ private appendAudio(state: SmartTurnState, audio: Int16Array | undefined, sampleRateHz = SAMPLE_RATE): void {
155
+ if (!audio?.length) return;
156
+ for (const sample of audio) state.audio.push(sample / 32768);
157
+ if (state.speechActive && sampleRateHz > 0) {
158
+ state.speechMs += (audio.length / sampleRateHz) * 1000;
159
+ }
160
+ const overflow = state.audio.length - this.maxAudioSamples;
161
+ if (overflow > 0) state.audio.splice(0, overflow);
162
+ }
163
+
164
+ private analyzeBoundary(state: SmartTurnState): void {
165
+ if (!this.initialized || state.decisionIssued) return;
166
+ const sequence = ++state.boundarySequence;
167
+ const audio = Float32Array.from(state.audio);
168
+ void this.predictor.predict(audio).then(
169
+ (probability) => {
170
+ if (!this.initialized || state.boundarySequence !== sequence || !this.states.has(state.contextId)) return;
171
+ state.probability = clampProbability(probability);
172
+ state.boundaryAnalyzed = true;
173
+ this.evaluateBoundary(state);
174
+ },
175
+ () => {
176
+ if (!this.initialized || state.boundarySequence !== sequence || !this.states.has(state.contextId)) return;
177
+ state.boundaryAnalyzed = true;
178
+ state.probability = 0;
179
+ this.scheduleFallback(state, this.incompleteFallbackMs);
180
+ },
181
+ );
182
+ }
183
+
184
+ private evaluateBoundary(state: SmartTurnState): void {
185
+ if (!state.boundaryAnalyzed || state.speechActive || state.decisionIssued) return;
186
+ const smartTurnComplete = state.probability > this.probabilityThreshold;
187
+ const transcript = latestTranscript(state.finalSegments, state.latestInterim);
188
+ if (!transcript) {
189
+ if (smartTurnComplete && state.speechMs >= this.minSpeechMs) {
190
+ this.issueTakeTurn(state, state.probability);
191
+ } else {
192
+ this.scheduleFallback(state, this.incompleteFallbackMs);
193
+ }
194
+ return;
195
+ }
196
+
197
+ const semantic = scoreSemanticCompleteness(transcript);
198
+ const fusion = fuseEndpointDecision(smartTurnComplete, semantic, state.speechMs, this.fusionConfig());
199
+ if (fusion.release) {
200
+ const confidence = smartTurnComplete ? Math.max(state.probability, semantic.confidence) : semantic.confidence;
201
+ this.issueTakeTurn(state, confidence);
202
+ return;
203
+ }
204
+
205
+ if (!state.pendingDecisions.some((decision) => decision.kind === "hold")) {
206
+ state.pendingDecisions.push({ kind: "hold" });
207
+ }
208
+ this.scheduleFallback(
209
+ state,
210
+ fusion.deferReason ? this.semanticDeferFallbackMs : fusion.finalizeDelayMs,
211
+ );
212
+ }
213
+
214
+ private issueTakeTurn(state: SmartTurnState, confidence: number): void {
215
+ if (state.decisionIssued) return;
216
+ this.clearFallback(state);
217
+ state.decisionIssued = true;
218
+ state.pendingDecisions.push({
219
+ kind: "take_turn",
220
+ confidence,
221
+ waitMs: confidenceToWaitMs(confidence),
222
+ });
223
+ }
224
+
225
+ private scheduleFallback(state: SmartTurnState, delayMs: number): void {
226
+ if (state.fallbackTimer || state.decisionIssued) return;
227
+ state.fallbackTimer = setTimeout(() => {
228
+ state.fallbackTimer = null;
229
+ if (!this.initialized || state.speechActive || state.decisionIssued || !this.states.has(state.contextId)) return;
230
+ state.decisionIssued = true;
231
+ state.pendingDecisions.push({ kind: "take_turn", confidence: 0, waitMs: 0 });
232
+ }, delayMs);
233
+ }
234
+
235
+ private clearFallback(state: SmartTurnState): void {
236
+ if (!state.fallbackTimer) return;
237
+ clearTimeout(state.fallbackTimer);
238
+ state.fallbackTimer = null;
239
+ }
240
+
241
+ private drainDecisions(state: SmartTurnState): readonly InteractionDecision[] {
242
+ if (state.pendingDecisions.length === 0) return [];
243
+ return state.pendingDecisions.splice(0);
244
+ }
245
+
246
+ private fusionConfig(): SemanticEndpointFusionConfig {
247
+ return {
248
+ enabled: this.semanticEndpointingEnabled,
249
+ finalizeDelayMs: this.finalizeDelayMs,
250
+ semanticShortcutDelayMs: this.semanticShortcutDelayMs,
251
+ incompleteFallbackMs: this.incompleteFallbackMs,
252
+ minSpeechMs: this.minSpeechMs,
253
+ };
254
+ }
255
+ }
256
+
257
+ function clampProbability(value: number): number {
258
+ if (!Number.isFinite(value)) return 0;
259
+ return Math.min(1, Math.max(0, value));
260
+ }
261
+
262
+ function readBoolean(value: unknown, fallback: boolean): boolean {
263
+ return typeof value === "boolean" ? value : fallback;
264
+ }
265
+
266
+ function readNonNegativeNumber(value: unknown, fallback: number): number {
267
+ if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
268
+ return Math.max(0, Math.floor(value));
269
+ }
270
+
271
+ function readPositiveNumber(value: unknown, fallback: number): number {
272
+ const parsed = readNonNegativeNumber(value, fallback);
273
+ return parsed > 0 ? parsed : fallback;
274
+ }
275
+
276
+ function readProbability(value: unknown, fallback: number): number {
277
+ return typeof value === "number" ? clampProbability(value) : fallback;
278
+ }
@@ -0,0 +1,74 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ import { optionalStringConfig, type PluginConfig } from "@kuralle-syrinx/core";
6
+ import type { SmartTurnPredictor } from "./smart-turn-types.js";
7
+
8
+ export type { SmartTurnPredictor } from "./smart-turn-types.js";
9
+
10
+ type Ort = typeof import("onnxruntime-node");
11
+ type InferenceSession = import("onnxruntime-node").InferenceSession;
12
+
13
+ interface FeatureExtractor {
14
+ _extract_fbank_features(audio: Float32Array): Promise<{ data: unknown }>;
15
+ }
16
+
17
+ const SAMPLE_RATE = 16000;
18
+ const MAX_AUDIO_SAMPLES = SAMPLE_RATE * 8;
19
+ const DEFAULT_MODEL_PATH = fileURLToPath(new URL("../models/smart-turn-v3.2-cpu.onnx", import.meta.url));
20
+
21
+ export class LocalSmartTurnV3Predictor implements SmartTurnPredictor {
22
+ private ort: Ort | null = null;
23
+ private session: InferenceSession | null = null;
24
+ private featureExtractor: FeatureExtractor | null = null;
25
+
26
+ async initialize(config: PluginConfig): Promise<void> {
27
+ const sampleRate = readNonNegativeNumber(config["sample_rate"], SAMPLE_RATE);
28
+ if (sampleRate !== SAMPLE_RATE) {
29
+ throw new Error(`Smart Turn requires 16 kHz PCM input, got ${String(sampleRate)} Hz`);
30
+ }
31
+ const modelPath = optionalStringConfig(config, "model_path") ?? DEFAULT_MODEL_PATH;
32
+ const { WhisperFeatureExtractor } = await import("@huggingface/transformers");
33
+ this.featureExtractor = new WhisperFeatureExtractor({
34
+ feature_size: 80,
35
+ sampling_rate: SAMPLE_RATE,
36
+ hop_length: 160,
37
+ n_fft: 400,
38
+ n_samples: MAX_AUDIO_SAMPLES,
39
+ nb_max_frames: 800,
40
+ }) as FeatureExtractor;
41
+ this.ort = await import("onnxruntime-node");
42
+ this.session = await this.ort.InferenceSession.create(modelPath, {
43
+ executionProviders: ["cpu"],
44
+ interOpNumThreads: 1,
45
+ intraOpNumThreads: 1,
46
+ });
47
+ }
48
+
49
+ async predict(audio: Float32Array): Promise<number> {
50
+ if (!this.ort || !this.session || !this.featureExtractor) {
51
+ throw new Error("Smart Turn predictor is not initialized");
52
+ }
53
+
54
+ const modelAudio = new Float32Array(MAX_AUDIO_SAMPLES);
55
+ const tail = audio.length > MAX_AUDIO_SAMPLES ? audio.slice(-MAX_AUDIO_SAMPLES) : audio;
56
+ modelAudio.set(tail, MAX_AUDIO_SAMPLES - tail.length);
57
+ const features = await this.featureExtractor._extract_fbank_features(modelAudio);
58
+ const input = new this.ort.Tensor("float32", features.data as Float32Array, [1, 80, 800]);
59
+ const outputs = await this.session.run({ input_features: input });
60
+ const value = outputs["logits"]?.data[0];
61
+ return typeof value === "number" ? value : 0;
62
+ }
63
+
64
+ async close(): Promise<void> {
65
+ this.session = null;
66
+ this.ort = null;
67
+ this.featureExtractor = null;
68
+ }
69
+ }
70
+
71
+ function readNonNegativeNumber(value: unknown, fallback: number): number {
72
+ if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
73
+ return Math.max(0, Math.floor(value));
74
+ }
@@ -11,6 +11,7 @@ export interface SemanticEndpointFusionConfig {
11
11
  readonly finalizeDelayMs: number;
12
12
  readonly semanticShortcutDelayMs: number;
13
13
  readonly incompleteFallbackMs: number;
14
+ readonly minSpeechMs?: number;
14
15
  }
15
16
 
16
17
  export interface EndpointFusionDecision {
@@ -111,6 +112,7 @@ export function scoreSemanticCompleteness(text: string): SemanticCompletenessSco
111
112
  export function fuseEndpointDecision(
112
113
  smartTurnComplete: boolean,
113
114
  semantic: SemanticCompletenessScore,
115
+ speechMs: number,
114
116
  config: SemanticEndpointFusionConfig,
115
117
  ): EndpointFusionDecision {
116
118
  if (!config.enabled) {
@@ -121,7 +123,7 @@ export function fuseEndpointDecision(
121
123
  };
122
124
  }
123
125
 
124
- if (smartTurnComplete && semantic.complete) {
126
+ if (smartTurnComplete && semantic.complete && speechMs >= (config.minSpeechMs ?? 0)) {
125
127
  return {
126
128
  release: true,
127
129
  requestFinalize: true,
@@ -0,0 +1,10 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ import type { PluginConfig } from "@kuralle-syrinx/core";
4
+
5
+ /** Host- or model-agnostic Smart Turn probability scorer. */
6
+ export interface SmartTurnPredictor {
7
+ initialize(config: PluginConfig): Promise<void>;
8
+ predict(audio: Float32Array): Promise<number>;
9
+ close(): Promise<void>;
10
+ }