@kuralle-syrinx/pipecat-smart-turn 4.2.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 +7 -3
- package/src/eos-plugin.ts +566 -0
- package/src/index.ts +9 -450
- package/src/interaction-policy.ts +18 -5
- package/src/predictor.ts +3 -6
- package/src/semantic-completeness.ts +3 -1
- package/src/smart-turn-types.ts +10 -0
- package/src/index.test.ts +0 -803
- package/src/interaction-policy.test.ts +0 -136
- package/src/semantic-completeness.test.ts +0 -164
- package/tsconfig.json +0 -21
|
@@ -1,136 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,164 +0,0 @@
|
|
|
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
|
-
});
|
package/tsconfig.json
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"compilerOptions": {
|
|
3
|
-
"target": "ES2022",
|
|
4
|
-
"module": "NodeNext",
|
|
5
|
-
"moduleResolution": "NodeNext",
|
|
6
|
-
"lib": ["ES2022"],
|
|
7
|
-
"strict": true,
|
|
8
|
-
"noUncheckedIndexedAccess": true,
|
|
9
|
-
"noImplicitReturns": true,
|
|
10
|
-
"declaration": true,
|
|
11
|
-
"declarationMap": true,
|
|
12
|
-
"sourceMap": true,
|
|
13
|
-
"outDir": "./dist",
|
|
14
|
-
"rootDir": "./src",
|
|
15
|
-
"esModuleInterop": true,
|
|
16
|
-
"skipLibCheck": true,
|
|
17
|
-
"forceConsistentCasingInFileNames": true
|
|
18
|
-
},
|
|
19
|
-
"include": ["src/**/*.ts"],
|
|
20
|
-
"exclude": ["node_modules", "dist"]
|
|
21
|
-
}
|