@kuralle-syrinx/deepgram 3.0.0 → 4.0.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 +3 -3
- package/src/stt.test.ts +41 -0
- package/src/stt.ts +54 -2
- package/src/tts.ts +14 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kuralle-syrinx/deepgram",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "4.0.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -12,8 +12,8 @@
|
|
|
12
12
|
"./tts": "./src/tts.ts"
|
|
13
13
|
},
|
|
14
14
|
"dependencies": {
|
|
15
|
-
"@kuralle-syrinx/core": "
|
|
16
|
-
"@kuralle-syrinx/ws": "
|
|
15
|
+
"@kuralle-syrinx/core": "4.0.0",
|
|
16
|
+
"@kuralle-syrinx/ws": "4.0.0"
|
|
17
17
|
},
|
|
18
18
|
"devDependencies": {
|
|
19
19
|
"@types/ws": "^8.5.0",
|
package/src/stt.test.ts
CHANGED
|
@@ -175,6 +175,47 @@ describe("DeepgramSTTPlugin", () => {
|
|
|
175
175
|
await started;
|
|
176
176
|
});
|
|
177
177
|
|
|
178
|
+
it("completes a wedged turn via UtteranceEnd when speech_final never fires (noisy-line backstop)", async () => {
|
|
179
|
+
// A noisy/continuous line: Deepgram sends an is_final segment but never
|
|
180
|
+
// speech_final. UtteranceEnd (gap-based) must complete the turn so it can't wedge.
|
|
181
|
+
const endpointUrl = await createLocalServer((socket) => {
|
|
182
|
+
socket.on("message", (data, isBinary) => {
|
|
183
|
+
if (isBinary) {
|
|
184
|
+
socket.send(JSON.stringify({
|
|
185
|
+
is_final: true,
|
|
186
|
+
speech_final: false, // never speech_final on this noisy line
|
|
187
|
+
channel: { alternatives: [{ transcript: "book a room", confidence: 0.9 }] },
|
|
188
|
+
}));
|
|
189
|
+
setTimeout(() => socket.send(JSON.stringify({ type: "UtteranceEnd", last_word_end: 1.2 })), 5);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
const bus = new PipelineBusImpl();
|
|
195
|
+
const started = startBus(bus);
|
|
196
|
+
const plugin = new DeepgramSTTPlugin();
|
|
197
|
+
const turnCompletes: Array<{ text: string; contextId: string }> = [];
|
|
198
|
+
bus.on("eos.turn_complete", (pkt) => {
|
|
199
|
+
const p = pkt as unknown as { text: string; contextId: string };
|
|
200
|
+
turnCompletes.push({ text: p.text, contextId: p.contextId });
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
await plugin.initialize(bus, {
|
|
204
|
+
api_key: "test",
|
|
205
|
+
endpoint_url: endpointUrl,
|
|
206
|
+
sample_rate: 16000,
|
|
207
|
+
utterance_end_ms: 1000,
|
|
208
|
+
});
|
|
209
|
+
bus.push(Route.Main, { kind: "stt.audio", contextId: "turn-1", timestampMs: Date.now(), audio: new Uint8Array(640) });
|
|
210
|
+
await new Promise((resolve) => setTimeout(resolve, 40));
|
|
211
|
+
|
|
212
|
+
expect(turnCompletes).toEqual([{ text: "book a room", contextId: "turn-1" }]);
|
|
213
|
+
|
|
214
|
+
await plugin.close();
|
|
215
|
+
bus.stop();
|
|
216
|
+
await started;
|
|
217
|
+
});
|
|
218
|
+
|
|
178
219
|
it("emits trailing provider-final segments after Pipecat requests finalize", async () => {
|
|
179
220
|
const endpointUrl = await createLocalServer((socket) => {
|
|
180
221
|
socket.on("message", (data, isBinary) => {
|
package/src/stt.ts
CHANGED
|
@@ -42,6 +42,24 @@ interface ProviderTranscriptState {
|
|
|
42
42
|
finalConfidence: number;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
/**
|
|
46
|
+
* A retired-contextId set (finalized turns) must stay bounded: contextIds are
|
|
47
|
+
* per-turn on every transport (telephony rotates `<callSid>-t<n>`), so an
|
|
48
|
+
* unbounded set would grow one entry per turn for the life of a call. Keeping a
|
|
49
|
+
* recent-turns window is enough to reject the late/duplicate provider finals the
|
|
50
|
+
* set exists to guard against, without leaking memory over a long conversation.
|
|
51
|
+
*/
|
|
52
|
+
const MAX_RETIRED_CONTEXTS = 256;
|
|
53
|
+
|
|
54
|
+
function boundedAdd(set: Set<string>, value: string, cap: number): void {
|
|
55
|
+
set.add(value);
|
|
56
|
+
while (set.size > cap) {
|
|
57
|
+
const oldest = set.values().next().value;
|
|
58
|
+
if (oldest === undefined) break;
|
|
59
|
+
set.delete(oldest);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
45
63
|
export class DeepgramSTTPlugin implements VoicePlugin {
|
|
46
64
|
readonly endpointingCapability = {
|
|
47
65
|
owner: "provider_stt" as const,
|
|
@@ -61,6 +79,10 @@ export class DeepgramSTTPlugin implements VoicePlugin {
|
|
|
61
79
|
private smartFormat: boolean = true;
|
|
62
80
|
private interimResults: boolean = true;
|
|
63
81
|
private vadEvents: boolean = true;
|
|
82
|
+
// Deepgram gap-based utterance boundary (ms). When > 0 (requires interim_results),
|
|
83
|
+
// Deepgram emits UtteranceEnd even when `speech_final` never fires — the documented
|
|
84
|
+
// backstop for noisy/continuous lines (telephony) where a turn would otherwise wedge.
|
|
85
|
+
private utteranceEndMs: number = 0;
|
|
64
86
|
private confidenceThreshold: number = 0;
|
|
65
87
|
private finalizeOnSpeechFinal: boolean = true;
|
|
66
88
|
private emitEosOnFinal: boolean = true;
|
|
@@ -117,6 +139,11 @@ export class DeepgramSTTPlugin implements VoicePlugin {
|
|
|
117
139
|
// laggier speech-start signal corrupts VAD/EOS-owned turn-taking (the turn
|
|
118
140
|
// never completes) — proven by the Fly telephony spike.
|
|
119
141
|
this.vadEvents = (config["vad_events"] as boolean) ?? false;
|
|
142
|
+
// Deepgram requires utterance_end_ms >= 1000 and interim_results; clamp to that.
|
|
143
|
+
{
|
|
144
|
+
const raw = (config["utterance_end_ms"] as number) ?? 0;
|
|
145
|
+
this.utteranceEndMs = raw > 0 ? Math.max(1000, raw) : 0;
|
|
146
|
+
}
|
|
120
147
|
this.confidenceThreshold =
|
|
121
148
|
(config["confidence_threshold"] as number) ?? 0;
|
|
122
149
|
this.finalizeOnSpeechFinal = (config["finalize_on_speech_final"] as boolean) ?? true;
|
|
@@ -142,6 +169,7 @@ export class DeepgramSTTPlugin implements VoicePlugin {
|
|
|
142
169
|
channels: "1",
|
|
143
170
|
no_delay: "true",
|
|
144
171
|
vad_events: String(this.vadEvents),
|
|
172
|
+
...(this.utteranceEndMs > 0 ? { utterance_end_ms: String(this.utteranceEndMs) } : {}),
|
|
145
173
|
});
|
|
146
174
|
const separator = this.endpointUrl.includes("?") ? "&" : "?";
|
|
147
175
|
return `${this.endpointUrl}${separator}${params.toString()}`;
|
|
@@ -231,6 +259,30 @@ export class DeepgramSTTPlugin implements VoicePlugin {
|
|
|
231
259
|
return;
|
|
232
260
|
}
|
|
233
261
|
|
|
262
|
+
// Deepgram's gap-based utterance boundary (utterance_end_ms). This fires even
|
|
263
|
+
// when `speech_final` is missed on a noisy/continuous line, so it's the backstop
|
|
264
|
+
// that stops a turn from wedging forever. If we hold un-finalized transcript for
|
|
265
|
+
// the current turn, complete it.
|
|
266
|
+
if (msg["type"] === "UtteranceEnd") {
|
|
267
|
+
const ctxId = this.currentContextId;
|
|
268
|
+
if (
|
|
269
|
+
this.emitEosOnFinal &&
|
|
270
|
+
ctxId &&
|
|
271
|
+
!this.finalizedContextIds.has(ctxId) &&
|
|
272
|
+
this.combinedFinalTranscript(ctxId)
|
|
273
|
+
) {
|
|
274
|
+
this.bus?.push(Route.Background, {
|
|
275
|
+
kind: "metric.conversation",
|
|
276
|
+
contextId: ctxId,
|
|
277
|
+
timestampMs: Date.now(),
|
|
278
|
+
name: "deepgram.utterance_end_backstop",
|
|
279
|
+
value: "1",
|
|
280
|
+
});
|
|
281
|
+
this.pushTurnComplete(ctxId);
|
|
282
|
+
}
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
|
|
234
286
|
const alt = providerAlternative(msg);
|
|
235
287
|
if (!alt || typeof alt["transcript"] !== "string") return;
|
|
236
288
|
|
|
@@ -395,7 +447,7 @@ export class DeepgramSTTPlugin implements VoicePlugin {
|
|
|
395
447
|
private pushFinal(transcript: string, confidence: number, contextId = this.currentContextId): void {
|
|
396
448
|
const ctxId = contextId;
|
|
397
449
|
this.resolveProviderFinalize(ctxId);
|
|
398
|
-
this.finalizedContextIds
|
|
450
|
+
boundedAdd(this.finalizedContextIds, ctxId, MAX_RETIRED_CONTEXTS);
|
|
399
451
|
this.consecutiveFinalizeTimeouts = 0;
|
|
400
452
|
this.pushMetric(ctxId, "stt_audio_sent", this.audioStats(ctxId));
|
|
401
453
|
this.pushResult(transcript, confidence, ctxId);
|
|
@@ -416,7 +468,7 @@ export class DeepgramSTTPlugin implements VoicePlugin {
|
|
|
416
468
|
const transcript = this.combinedFinalTranscript(contextId);
|
|
417
469
|
if (!transcript || !this.bus) return;
|
|
418
470
|
this.resolveProviderFinalize(contextId);
|
|
419
|
-
this.finalizedContextIds
|
|
471
|
+
boundedAdd(this.finalizedContextIds, contextId, MAX_RETIRED_CONTEXTS);
|
|
420
472
|
this.consecutiveFinalizeTimeouts = 0;
|
|
421
473
|
this.pushMetric(contextId, "stt_audio_sent", this.audioStats(contextId));
|
|
422
474
|
this.bus.push(Route.Main, {
|
package/src/tts.ts
CHANGED
|
@@ -40,6 +40,19 @@ const FLUSH_MSG = JSON.stringify({ type: "Flush" });
|
|
|
40
40
|
const CLEAR_MSG = JSON.stringify({ type: "Clear" });
|
|
41
41
|
const EMPTY = new Uint8Array(0);
|
|
42
42
|
const KEEP_ALIVE_INTERVAL_MS = 10_000;
|
|
43
|
+
// Retired-turn guard: contextIds are per-turn (telephony rotates), so cancelled
|
|
44
|
+
// ids accumulate one per barge-in. Keep a recent-turns window to reject late
|
|
45
|
+
// post-cancel audio without leaking over a long call.
|
|
46
|
+
const MAX_CANCELLED_CONTEXTS = 256;
|
|
47
|
+
|
|
48
|
+
function boundedAdd(set: Set<string>, value: string, cap: number): void {
|
|
49
|
+
set.add(value);
|
|
50
|
+
while (set.size > cap) {
|
|
51
|
+
const oldest = set.values().next().value;
|
|
52
|
+
if (oldest === undefined) break;
|
|
53
|
+
set.delete(oldest);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
43
56
|
|
|
44
57
|
export class DeepgramTTSPlugin implements VoicePlugin {
|
|
45
58
|
// socketFactory is injectable so the same plugin runs on Node (default) or
|
|
@@ -165,7 +178,7 @@ export class DeepgramTTSPlugin implements VoicePlugin {
|
|
|
165
178
|
|
|
166
179
|
private async cancelActiveContexts(): Promise<void> {
|
|
167
180
|
const contextIds = [...this.activeContexts];
|
|
168
|
-
for (const contextId of contextIds) this.cancelledContexts
|
|
181
|
+
for (const contextId of contextIds) boundedAdd(this.cancelledContexts, contextId, MAX_CANCELLED_CONTEXTS);
|
|
169
182
|
this.activeContexts.clear();
|
|
170
183
|
for (const contextId of contextIds) this.clearFinishTimeout(contextId);
|
|
171
184
|
this.currentContextId = "";
|