@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kuralle-syrinx/deepgram",
3
- "version": "3.0.0",
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": "3.0.0",
16
- "@kuralle-syrinx/ws": "3.0.0"
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.add(ctxId);
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.add(contextId);
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.add(contextId);
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 = "";