@kuralle-syrinx/pipecat-smart-turn 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 +26 -0
- package/package.json +23 -3
- package/src/index.ts +6 -65
- package/src/interaction-policy.test.ts +136 -0
- package/src/interaction-policy.ts +265 -0
- package/src/predictor.ts +77 -0
package/README.md
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# @kuralle-syrinx/pipecat-smart-turn
|
|
2
|
+
|
|
3
|
+
Smart Turn v3 endpointing for Syrinx, fused with transcript-level semantic completeness.
|
|
4
|
+
|
|
5
|
+
## InteractionPolicy selection
|
|
6
|
+
|
|
7
|
+
Use `SmartTurnInteractionPolicy` when the session policy seam should own endpointing. Register Silero VAD
|
|
8
|
+
and provider STT as usual, but do not register `PipecatEOSPlugin`; the session disables provider-owned EOS
|
|
9
|
+
and routes the policy's `take_turn` through STT finalization.
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
const session = new VoiceAgentSession({
|
|
13
|
+
plugins: pluginConfig,
|
|
14
|
+
interactionPolicy: new SmartTurnInteractionPolicy(),
|
|
15
|
+
interactionPolicyConfig: pluginConfig.eos,
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
session.registerPlugin("stt", new DeepgramSTTPlugin());
|
|
19
|
+
session.registerPlugin("vad", new SileroVADPlugin());
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
The policy consumes session `audio_frame`, Silero speech-boundary, STT, and playout observations. Acoustic
|
|
23
|
+
and semantic completion confidence maps monotonically to a 150-2000 ms wait before turn commit. The model
|
|
24
|
+
inference is asynchronous; `observe()` remains synchronous and is gated at p99 <= 5 ms/frame.
|
|
25
|
+
|
|
26
|
+
`PipecatEOSPlugin` remains available for existing plugin-based sessions.
|
package/package.json
CHANGED
|
@@ -1,15 +1,35 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kuralle-syrinx/pipecat-smart-turn",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.2.0",
|
|
4
4
|
"private": false,
|
|
5
|
-
"
|
|
5
|
+
"description": "Pipecat Smart Turn v3 semantic endpointing for Syrinx — local ONNX end-of-turn analysis",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"voice",
|
|
8
|
+
"voice-agent",
|
|
9
|
+
"speech",
|
|
10
|
+
"syrinx",
|
|
11
|
+
"pipecat",
|
|
12
|
+
"turn-taking",
|
|
13
|
+
"endpointing",
|
|
14
|
+
"onnx"
|
|
15
|
+
],
|
|
6
16
|
"license": "MIT",
|
|
17
|
+
"homepage": "https://github.com/kuralle/syrinx#readme",
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/kuralle/syrinx.git",
|
|
21
|
+
"directory": "packages/pipecat-smart-turn"
|
|
22
|
+
},
|
|
23
|
+
"bugs": {
|
|
24
|
+
"url": "https://github.com/kuralle/syrinx/issues"
|
|
25
|
+
},
|
|
26
|
+
"type": "module",
|
|
7
27
|
"main": "./src/index.ts",
|
|
8
28
|
"types": "./src/index.ts",
|
|
9
29
|
"dependencies": {
|
|
10
30
|
"@huggingface/transformers": "^4.2.0",
|
|
11
31
|
"onnxruntime-node": "1.24.3",
|
|
12
|
-
"@kuralle-syrinx/core": "4.
|
|
32
|
+
"@kuralle-syrinx/core": "4.2.0"
|
|
13
33
|
},
|
|
14
34
|
"devDependencies": {
|
|
15
35
|
"typescript": "^5.7.0",
|
package/src/index.ts
CHANGED
|
@@ -6,8 +6,6 @@
|
|
|
6
6
|
// Silero determines candidate speech boundaries, then Smart Turn v3 decides
|
|
7
7
|
// whether a pause is an actual completed user turn.
|
|
8
8
|
|
|
9
|
-
import { fileURLToPath } from "node:url";
|
|
10
|
-
|
|
11
9
|
import {
|
|
12
10
|
fuseEndpointDecision,
|
|
13
11
|
latestTranscript,
|
|
@@ -31,19 +29,12 @@ import {
|
|
|
31
29
|
type VadSpeechEndedPacket,
|
|
32
30
|
type VadSpeechStartedPacket,
|
|
33
31
|
type VoicePlugin,
|
|
34
|
-
optionalStringConfig,
|
|
35
32
|
} from "@kuralle-syrinx/core";
|
|
36
33
|
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
|
-
}
|
|
34
|
+
import { LocalSmartTurnV3Predictor, type SmartTurnPredictor } from "./predictor.js";
|
|
43
35
|
|
|
44
36
|
const SAMPLE_RATE = 16000;
|
|
45
37
|
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
38
|
|
|
48
39
|
interface TurnState {
|
|
49
40
|
readonly contextId: string;
|
|
@@ -63,61 +54,6 @@ interface TurnState {
|
|
|
63
54
|
deferTimer: ReturnType<typeof setTimeout> | null;
|
|
64
55
|
}
|
|
65
56
|
|
|
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
57
|
export class PipecatEOSPlugin implements VoicePlugin {
|
|
122
58
|
readonly endpointingCapability = { owner: "smart_turn" as const };
|
|
123
59
|
|
|
@@ -525,6 +461,11 @@ export {
|
|
|
525
461
|
type SemanticEndpointFusionConfig,
|
|
526
462
|
} from "./semantic-completeness.js";
|
|
527
463
|
export { SEMANTIC_LABELED_UTTERANCES, type SemanticLabeledUtterance } from "./semantic-fixtures.js";
|
|
464
|
+
export { LocalSmartTurnV3Predictor, type SmartTurnPredictor } from "./predictor.js";
|
|
465
|
+
export {
|
|
466
|
+
SmartTurnInteractionPolicy,
|
|
467
|
+
type SmartTurnInteractionPolicyConfig,
|
|
468
|
+
} from "./interaction-policy.js";
|
|
528
469
|
|
|
529
470
|
function readNonNegativeNumber(value: unknown, fallback: number): number {
|
|
530
471
|
if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
|
|
5
|
+
import { SmartTurnInteractionPolicy } from "./interaction-policy.js";
|
|
6
|
+
import type { SmartTurnPredictor } from "./predictor.js";
|
|
7
|
+
|
|
8
|
+
class PredictableSmartTurn implements SmartTurnPredictor {
|
|
9
|
+
initialized = false;
|
|
10
|
+
closed = false;
|
|
11
|
+
|
|
12
|
+
constructor(private readonly probabilities: number[]) {}
|
|
13
|
+
|
|
14
|
+
async initialize(): Promise<void> {
|
|
15
|
+
this.initialized = true;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async predict(): Promise<number> {
|
|
19
|
+
return this.probabilities.shift() ?? 0;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async close(): Promise<void> {
|
|
23
|
+
this.closed = true;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function speechStarted(contextId = "turn-1") {
|
|
28
|
+
return {
|
|
29
|
+
kind: "vad_speech_started" as const,
|
|
30
|
+
contextId,
|
|
31
|
+
timestampMs: 1000,
|
|
32
|
+
confidence: 0.95,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function speechEnded(contextId = "turn-1") {
|
|
37
|
+
return {
|
|
38
|
+
kind: "vad_speech_ended" as const,
|
|
39
|
+
contextId,
|
|
40
|
+
timestampMs: 1500,
|
|
41
|
+
hasActiveTts: false,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function audioFrame(contextId = "turn-1") {
|
|
46
|
+
return {
|
|
47
|
+
kind: "audio_frame" as const,
|
|
48
|
+
contextId,
|
|
49
|
+
timestampMs: 1520,
|
|
50
|
+
audio: new Int16Array(320),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
describe("SmartTurnInteractionPolicy", () => {
|
|
55
|
+
it("selects the Silero-boundary + Smart Turn + semantic fusion path", async () => {
|
|
56
|
+
const predictor = new PredictableSmartTurn([1]);
|
|
57
|
+
const policy = new SmartTurnInteractionPolicy(predictor);
|
|
58
|
+
await policy.initialize();
|
|
59
|
+
|
|
60
|
+
policy.observe(speechStarted());
|
|
61
|
+
policy.observe({
|
|
62
|
+
kind: "stt_partial",
|
|
63
|
+
contextId: "turn-1",
|
|
64
|
+
timestampMs: 1400,
|
|
65
|
+
text: "What are your office hours?",
|
|
66
|
+
});
|
|
67
|
+
policy.observe(speechEnded());
|
|
68
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
69
|
+
|
|
70
|
+
expect(policy.observe(audioFrame())).toEqual([
|
|
71
|
+
{ kind: "take_turn", confidence: 1, waitMs: 150 },
|
|
72
|
+
]);
|
|
73
|
+
await policy.close();
|
|
74
|
+
expect(predictor.initialized).toBe(true);
|
|
75
|
+
expect(predictor.closed).toBe(true);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("holds an acoustically-complete mid-thought pause, then releases at the safety fallback", async () => {
|
|
79
|
+
const policy = new SmartTurnInteractionPolicy(new PredictableSmartTurn([1]));
|
|
80
|
+
await policy.initialize({ semantic_defer_fallback_ms: 1 });
|
|
81
|
+
|
|
82
|
+
policy.observe(speechStarted("turn-hold"));
|
|
83
|
+
policy.observe({
|
|
84
|
+
kind: "stt_partial",
|
|
85
|
+
contextId: "turn-hold",
|
|
86
|
+
timestampMs: 1400,
|
|
87
|
+
text: "I need to know",
|
|
88
|
+
});
|
|
89
|
+
policy.observe(speechEnded("turn-hold"));
|
|
90
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
91
|
+
expect(policy.observe(audioFrame("turn-hold"))).toEqual([{ kind: "hold" }]);
|
|
92
|
+
|
|
93
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
94
|
+
expect(policy.observe(audioFrame("turn-hold"))).toEqual([
|
|
95
|
+
{ kind: "take_turn", confidence: 0, waitMs: 0 },
|
|
96
|
+
]);
|
|
97
|
+
await policy.close();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("uses high-confidence semantics to shortcut an uncertain acoustic result", async () => {
|
|
101
|
+
const policy = new SmartTurnInteractionPolicy(new PredictableSmartTurn([0.1]));
|
|
102
|
+
await policy.initialize();
|
|
103
|
+
|
|
104
|
+
policy.observe(speechStarted("turn-shortcut"));
|
|
105
|
+
policy.observe({
|
|
106
|
+
kind: "stt_partial",
|
|
107
|
+
contextId: "turn-shortcut",
|
|
108
|
+
timestampMs: 1400,
|
|
109
|
+
text: "What are your office hours?",
|
|
110
|
+
});
|
|
111
|
+
policy.observe(speechEnded("turn-shortcut"));
|
|
112
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
113
|
+
|
|
114
|
+
expect(policy.observe(audioFrame("turn-shortcut"))).toEqual([
|
|
115
|
+
{ kind: "take_turn", confidence: 0.9, waitMs: 335 },
|
|
116
|
+
]);
|
|
117
|
+
await policy.close();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("keeps synchronous audio-frame observation p99 within 5ms", async () => {
|
|
121
|
+
const policy = new SmartTurnInteractionPolicy(new PredictableSmartTurn([]));
|
|
122
|
+
await policy.initialize({ max_audio_samples: 16000 });
|
|
123
|
+
policy.observe(speechStarted("turn-bench"));
|
|
124
|
+
|
|
125
|
+
const durations: number[] = [];
|
|
126
|
+
for (let index = 0; index < 1000; index += 1) {
|
|
127
|
+
const startedAt = performance.now();
|
|
128
|
+
policy.observe(audioFrame("turn-bench"));
|
|
129
|
+
durations.push(performance.now() - startedAt);
|
|
130
|
+
}
|
|
131
|
+
durations.sort((left, right) => left - right);
|
|
132
|
+
const p99 = durations[Math.floor(durations.length * 0.99) - 1] ?? Infinity;
|
|
133
|
+
expect(p99).toBeLessThanOrEqual(5);
|
|
134
|
+
await policy.close();
|
|
135
|
+
});
|
|
136
|
+
});
|
|
@@ -0,0 +1,265 @@
|
|
|
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
|
+
finalSegments: string[];
|
|
26
|
+
latestInterim: string;
|
|
27
|
+
speechActive: boolean;
|
|
28
|
+
boundarySequence: number;
|
|
29
|
+
boundaryAnalyzed: boolean;
|
|
30
|
+
probability: number;
|
|
31
|
+
decisionIssued: boolean;
|
|
32
|
+
pendingDecisions: InteractionDecision[];
|
|
33
|
+
fallbackTimer: ReturnType<typeof setTimeout> | null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface SmartTurnInteractionPolicyConfig {
|
|
37
|
+
readonly probability_threshold?: number;
|
|
38
|
+
readonly semantic_endpointing_enabled?: boolean;
|
|
39
|
+
readonly finalize_delay_ms?: number;
|
|
40
|
+
readonly semantic_shortcut_delay_ms?: number;
|
|
41
|
+
readonly incomplete_fallback_ms?: number;
|
|
42
|
+
readonly semantic_defer_fallback_ms?: number;
|
|
43
|
+
readonly max_audio_samples?: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export class SmartTurnInteractionPolicy implements LifecycleInteractionPolicy {
|
|
47
|
+
private readonly states = new Map<string, SmartTurnState>();
|
|
48
|
+
private probabilityThreshold = 0.5;
|
|
49
|
+
private semanticEndpointingEnabled = true;
|
|
50
|
+
private finalizeDelayMs = 250;
|
|
51
|
+
private semanticShortcutDelayMs = 50;
|
|
52
|
+
private incompleteFallbackMs = 2000;
|
|
53
|
+
private semanticDeferFallbackMs = 4000;
|
|
54
|
+
private maxAudioSamples = DEFAULT_MAX_AUDIO_SAMPLES;
|
|
55
|
+
private initialized = false;
|
|
56
|
+
|
|
57
|
+
constructor(private readonly predictor: SmartTurnPredictor = new LocalSmartTurnV3Predictor()) {}
|
|
58
|
+
|
|
59
|
+
async initialize(config: PluginConfig = {}): Promise<void> {
|
|
60
|
+
this.probabilityThreshold = readProbability(config["probability_threshold"], 0.5);
|
|
61
|
+
this.semanticEndpointingEnabled = readBoolean(config["semantic_endpointing_enabled"], true);
|
|
62
|
+
this.finalizeDelayMs = readNonNegativeNumber(config["finalize_delay_ms"], 250);
|
|
63
|
+
this.semanticShortcutDelayMs = readNonNegativeNumber(config["semantic_shortcut_delay_ms"], 50);
|
|
64
|
+
this.incompleteFallbackMs = readNonNegativeNumber(config["incomplete_fallback_ms"], 2000);
|
|
65
|
+
this.semanticDeferFallbackMs = readNonNegativeNumber(config["semantic_defer_fallback_ms"], 4000);
|
|
66
|
+
this.maxAudioSamples = readPositiveNumber(config["max_audio_samples"], DEFAULT_MAX_AUDIO_SAMPLES);
|
|
67
|
+
await this.predictor.initialize(config);
|
|
68
|
+
this.initialized = true;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
observe(observation: InteractionObservation): readonly InteractionDecision[] {
|
|
72
|
+
const state = this.stateFor(observation.contextId);
|
|
73
|
+
switch (observation.kind) {
|
|
74
|
+
case "audio_frame":
|
|
75
|
+
this.appendAudio(state, observation.audio);
|
|
76
|
+
break;
|
|
77
|
+
case "stt_partial":
|
|
78
|
+
if (observation.text.trim()) state.latestInterim = observation.text.trim();
|
|
79
|
+
this.evaluateBoundary(state);
|
|
80
|
+
break;
|
|
81
|
+
case "stt_final": {
|
|
82
|
+
const text = observation.text.trim();
|
|
83
|
+
if (text && state.finalSegments.at(-1) !== text) state.finalSegments.push(text);
|
|
84
|
+
state.latestInterim = "";
|
|
85
|
+
this.evaluateBoundary(state);
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
case "vad_speech_started":
|
|
89
|
+
this.beginSpeech(state);
|
|
90
|
+
break;
|
|
91
|
+
case "vad_speech_ended":
|
|
92
|
+
state.speechActive = false;
|
|
93
|
+
this.analyzeBoundary(state);
|
|
94
|
+
break;
|
|
95
|
+
default:
|
|
96
|
+
break;
|
|
97
|
+
}
|
|
98
|
+
return this.drainDecisions(state);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
reset(contextId: string): void {
|
|
102
|
+
const state = this.states.get(contextId);
|
|
103
|
+
if (!state) return;
|
|
104
|
+
this.clearFallback(state);
|
|
105
|
+
state.boundarySequence += 1;
|
|
106
|
+
this.states.delete(contextId);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async close(): Promise<void> {
|
|
110
|
+
this.initialized = false;
|
|
111
|
+
for (const contextId of [...this.states.keys()]) this.reset(contextId);
|
|
112
|
+
await this.predictor.close();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
private stateFor(contextId: string): SmartTurnState {
|
|
116
|
+
const existing = this.states.get(contextId);
|
|
117
|
+
if (existing) return existing;
|
|
118
|
+
const state: SmartTurnState = {
|
|
119
|
+
contextId,
|
|
120
|
+
audio: [],
|
|
121
|
+
finalSegments: [],
|
|
122
|
+
latestInterim: "",
|
|
123
|
+
speechActive: false,
|
|
124
|
+
boundarySequence: 0,
|
|
125
|
+
boundaryAnalyzed: false,
|
|
126
|
+
probability: 0,
|
|
127
|
+
decisionIssued: false,
|
|
128
|
+
pendingDecisions: [],
|
|
129
|
+
fallbackTimer: null,
|
|
130
|
+
};
|
|
131
|
+
this.states.set(contextId, state);
|
|
132
|
+
return state;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
private beginSpeech(state: SmartTurnState): void {
|
|
136
|
+
this.clearFallback(state);
|
|
137
|
+
state.audio = [];
|
|
138
|
+
state.finalSegments = [];
|
|
139
|
+
state.latestInterim = "";
|
|
140
|
+
state.speechActive = true;
|
|
141
|
+
state.boundarySequence += 1;
|
|
142
|
+
state.boundaryAnalyzed = false;
|
|
143
|
+
state.probability = 0;
|
|
144
|
+
state.decisionIssued = false;
|
|
145
|
+
state.pendingDecisions = [];
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
private appendAudio(state: SmartTurnState, audio?: Int16Array): void {
|
|
149
|
+
if (!audio?.length) return;
|
|
150
|
+
for (const sample of audio) state.audio.push(sample / 32768);
|
|
151
|
+
const overflow = state.audio.length - this.maxAudioSamples;
|
|
152
|
+
if (overflow > 0) state.audio.splice(0, overflow);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
private analyzeBoundary(state: SmartTurnState): void {
|
|
156
|
+
if (!this.initialized || state.decisionIssued) return;
|
|
157
|
+
const sequence = ++state.boundarySequence;
|
|
158
|
+
const audio = Float32Array.from(state.audio);
|
|
159
|
+
void this.predictor.predict(audio).then(
|
|
160
|
+
(probability) => {
|
|
161
|
+
if (!this.initialized || state.boundarySequence !== sequence || !this.states.has(state.contextId)) return;
|
|
162
|
+
state.probability = clampProbability(probability);
|
|
163
|
+
state.boundaryAnalyzed = true;
|
|
164
|
+
this.evaluateBoundary(state);
|
|
165
|
+
},
|
|
166
|
+
() => {
|
|
167
|
+
if (!this.initialized || state.boundarySequence !== sequence || !this.states.has(state.contextId)) return;
|
|
168
|
+
state.boundaryAnalyzed = true;
|
|
169
|
+
state.probability = 0;
|
|
170
|
+
this.scheduleFallback(state, this.incompleteFallbackMs);
|
|
171
|
+
},
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
private evaluateBoundary(state: SmartTurnState): void {
|
|
176
|
+
if (!state.boundaryAnalyzed || state.speechActive || state.decisionIssued) return;
|
|
177
|
+
const smartTurnComplete = state.probability > this.probabilityThreshold;
|
|
178
|
+
const transcript = latestTranscript(state.finalSegments, state.latestInterim);
|
|
179
|
+
if (!transcript) {
|
|
180
|
+
if (smartTurnComplete) this.issueTakeTurn(state, state.probability);
|
|
181
|
+
else this.scheduleFallback(state, this.incompleteFallbackMs);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const semantic = scoreSemanticCompleteness(transcript);
|
|
186
|
+
const fusion = fuseEndpointDecision(smartTurnComplete, semantic, this.fusionConfig());
|
|
187
|
+
if (fusion.release) {
|
|
188
|
+
const confidence = smartTurnComplete ? Math.max(state.probability, semantic.confidence) : semantic.confidence;
|
|
189
|
+
this.issueTakeTurn(state, confidence);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (!state.pendingDecisions.some((decision) => decision.kind === "hold")) {
|
|
194
|
+
state.pendingDecisions.push({ kind: "hold" });
|
|
195
|
+
}
|
|
196
|
+
this.scheduleFallback(
|
|
197
|
+
state,
|
|
198
|
+
fusion.deferReason ? this.semanticDeferFallbackMs : fusion.finalizeDelayMs,
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
private issueTakeTurn(state: SmartTurnState, confidence: number): void {
|
|
203
|
+
if (state.decisionIssued) return;
|
|
204
|
+
this.clearFallback(state);
|
|
205
|
+
state.decisionIssued = true;
|
|
206
|
+
state.pendingDecisions.push({
|
|
207
|
+
kind: "take_turn",
|
|
208
|
+
confidence,
|
|
209
|
+
waitMs: confidenceToWaitMs(confidence),
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
private scheduleFallback(state: SmartTurnState, delayMs: number): void {
|
|
214
|
+
if (state.fallbackTimer || state.decisionIssued) return;
|
|
215
|
+
state.fallbackTimer = setTimeout(() => {
|
|
216
|
+
state.fallbackTimer = null;
|
|
217
|
+
if (!this.initialized || state.speechActive || state.decisionIssued || !this.states.has(state.contextId)) return;
|
|
218
|
+
state.decisionIssued = true;
|
|
219
|
+
state.pendingDecisions.push({ kind: "take_turn", confidence: 0, waitMs: 0 });
|
|
220
|
+
}, delayMs);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
private clearFallback(state: SmartTurnState): void {
|
|
224
|
+
if (!state.fallbackTimer) return;
|
|
225
|
+
clearTimeout(state.fallbackTimer);
|
|
226
|
+
state.fallbackTimer = null;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
private drainDecisions(state: SmartTurnState): readonly InteractionDecision[] {
|
|
230
|
+
if (state.pendingDecisions.length === 0) return [];
|
|
231
|
+
return state.pendingDecisions.splice(0);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
private fusionConfig(): SemanticEndpointFusionConfig {
|
|
235
|
+
return {
|
|
236
|
+
enabled: this.semanticEndpointingEnabled,
|
|
237
|
+
finalizeDelayMs: this.finalizeDelayMs,
|
|
238
|
+
semanticShortcutDelayMs: this.semanticShortcutDelayMs,
|
|
239
|
+
incompleteFallbackMs: this.incompleteFallbackMs,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function clampProbability(value: number): number {
|
|
245
|
+
if (!Number.isFinite(value)) return 0;
|
|
246
|
+
return Math.min(1, Math.max(0, value));
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function readBoolean(value: unknown, fallback: boolean): boolean {
|
|
250
|
+
return typeof value === "boolean" ? value : fallback;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function readNonNegativeNumber(value: unknown, fallback: number): number {
|
|
254
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
|
|
255
|
+
return Math.max(0, Math.floor(value));
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function readPositiveNumber(value: unknown, fallback: number): number {
|
|
259
|
+
const parsed = readNonNegativeNumber(value, fallback);
|
|
260
|
+
return parsed > 0 ? parsed : fallback;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function readProbability(value: unknown, fallback: number): number {
|
|
264
|
+
return typeof value === "number" ? clampProbability(value) : fallback;
|
|
265
|
+
}
|
package/src/predictor.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
import { optionalStringConfig, type PluginConfig } from "@kuralle-syrinx/core";
|
|
6
|
+
|
|
7
|
+
type Ort = typeof import("onnxruntime-node");
|
|
8
|
+
type InferenceSession = import("onnxruntime-node").InferenceSession;
|
|
9
|
+
|
|
10
|
+
interface FeatureExtractor {
|
|
11
|
+
_extract_fbank_features(audio: Float32Array): Promise<{ data: unknown }>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const SAMPLE_RATE = 16000;
|
|
15
|
+
const MAX_AUDIO_SAMPLES = SAMPLE_RATE * 8;
|
|
16
|
+
const DEFAULT_MODEL_PATH = fileURLToPath(new URL("../models/smart-turn-v3.2-cpu.onnx", import.meta.url));
|
|
17
|
+
|
|
18
|
+
export interface SmartTurnPredictor {
|
|
19
|
+
initialize(config: PluginConfig): Promise<void>;
|
|
20
|
+
predict(audio: Float32Array): Promise<number>;
|
|
21
|
+
close(): Promise<void>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export class LocalSmartTurnV3Predictor implements SmartTurnPredictor {
|
|
25
|
+
private ort: Ort | null = null;
|
|
26
|
+
private session: InferenceSession | null = null;
|
|
27
|
+
private featureExtractor: FeatureExtractor | null = null;
|
|
28
|
+
|
|
29
|
+
async initialize(config: PluginConfig): Promise<void> {
|
|
30
|
+
const sampleRate = readNonNegativeNumber(config["sample_rate"], SAMPLE_RATE);
|
|
31
|
+
if (sampleRate !== SAMPLE_RATE) {
|
|
32
|
+
throw new Error(`Smart Turn requires 16 kHz PCM input, got ${String(sampleRate)} Hz`);
|
|
33
|
+
}
|
|
34
|
+
const modelPath = optionalStringConfig(config, "model_path") ?? DEFAULT_MODEL_PATH;
|
|
35
|
+
const { WhisperFeatureExtractor } = await import("@huggingface/transformers");
|
|
36
|
+
this.featureExtractor = new WhisperFeatureExtractor({
|
|
37
|
+
feature_size: 80,
|
|
38
|
+
sampling_rate: SAMPLE_RATE,
|
|
39
|
+
hop_length: 160,
|
|
40
|
+
n_fft: 400,
|
|
41
|
+
n_samples: MAX_AUDIO_SAMPLES,
|
|
42
|
+
nb_max_frames: 800,
|
|
43
|
+
}) as FeatureExtractor;
|
|
44
|
+
this.ort = await import("onnxruntime-node");
|
|
45
|
+
this.session = await this.ort.InferenceSession.create(modelPath, {
|
|
46
|
+
executionProviders: ["cpu"],
|
|
47
|
+
interOpNumThreads: 1,
|
|
48
|
+
intraOpNumThreads: 1,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async predict(audio: Float32Array): Promise<number> {
|
|
53
|
+
if (!this.ort || !this.session || !this.featureExtractor) {
|
|
54
|
+
throw new Error("Smart Turn predictor is not initialized");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const modelAudio = new Float32Array(MAX_AUDIO_SAMPLES);
|
|
58
|
+
const tail = audio.length > MAX_AUDIO_SAMPLES ? audio.slice(-MAX_AUDIO_SAMPLES) : audio;
|
|
59
|
+
modelAudio.set(tail, MAX_AUDIO_SAMPLES - tail.length);
|
|
60
|
+
const features = await this.featureExtractor._extract_fbank_features(modelAudio);
|
|
61
|
+
const input = new this.ort.Tensor("float32", features.data as Float32Array, [1, 80, 800]);
|
|
62
|
+
const outputs = await this.session.run({ input_features: input });
|
|
63
|
+
const value = outputs["logits"]?.data[0];
|
|
64
|
+
return typeof value === "number" ? value : 0;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async close(): Promise<void> {
|
|
68
|
+
this.session = null;
|
|
69
|
+
this.ort = null;
|
|
70
|
+
this.featureExtractor = null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function readNonNegativeNumber(value: unknown, fallback: number): number {
|
|
75
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
|
|
76
|
+
return Math.max(0, Math.floor(value));
|
|
77
|
+
}
|