@kuralle-syrinx/deepgram 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kuralle-syrinx/deepgram",
3
- "version": "4.2.0",
3
+ "version": "4.4.0",
4
4
  "private": false,
5
5
  "description": "Deepgram adapters for Syrinx — nova-3 STT, Flux turn-aware STT (semantic end-of-turn), and Aura TTS",
6
6
  "keywords": [
@@ -31,14 +31,15 @@
31
31
  "./tts": "./src/tts.ts"
32
32
  },
33
33
  "dependencies": {
34
- "@kuralle-syrinx/core": "4.2.0",
35
- "@kuralle-syrinx/ws": "4.2.0"
34
+ "@kuralle-syrinx/core": "4.4.0",
35
+ "@kuralle-syrinx/stt-core": "4.4.0",
36
+ "@kuralle-syrinx/ws": "4.4.0"
36
37
  },
37
38
  "devDependencies": {
38
39
  "@types/ws": "^8.5.0",
39
40
  "typescript": "^5.7.0",
40
- "vitest": "^2.1.0",
41
- "ws": "^8.18.0"
41
+ "vitest": "^3.2.6",
42
+ "ws": "^8.21.0"
42
43
  },
43
44
  "scripts": {
44
45
  "typecheck": "tsc --noEmit",
package/src/flux.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  // is the semantic end-of-turn path for the edge cascade, where local ONNX
8
8
  // endpointers (smart-turn) cannot run.
9
9
  //
10
- // TurnInfo state machine → bus mapping:
10
+ // TurnInfo state machine → bus mapping (via stt-core SttEvent):
11
11
  // StartOfTurn → vad.speech_started (barge-in signal; Flux recommends it)
12
12
  // Update → stt.interim
13
13
  // EagerEndOfTurn → eos.interim (speculative-generation trigger)
@@ -20,19 +20,29 @@
20
20
  // when no TurnResumed intervened, so a speculative result keyed on the eager
21
21
  // transcript can be committed as-is.
22
22
 
23
- import type { PipelineBus } from "@kuralle-syrinx/core";
24
23
  import {
25
24
  Route,
25
+ categorizeSttError,
26
+ isRecoverable,
27
+ type AudioFormat,
28
+ type PipelineBus,
26
29
  type PluginConfig,
27
30
  type SttErrorPacket,
31
+ type SttReconfigure,
32
+ type SttReconfigurePartial,
28
33
  type VoicePlugin,
29
- categorizeSttError,
30
- isRecoverable,
31
34
  optionalStringConfig,
32
35
  readProviderRetryConfig,
33
36
  requireStringConfig,
34
37
  } from "@kuralle-syrinx/core";
35
- import { WebSocketConnection, type SocketFactory } from "@kuralle-syrinx/ws";
38
+ import {
39
+ defaultNodeSocketFactory,
40
+ startStreamingSttSession,
41
+ type SttEvent,
42
+ type SttWireProtocol,
43
+ type StreamingSttSession,
44
+ } from "@kuralle-syrinx/stt-core";
45
+ import type { SocketData, SocketFactory } from "@kuralle-syrinx/ws";
36
46
 
37
47
  interface TurnInfoMessage {
38
48
  readonly type: "TurnInfo";
@@ -49,6 +59,111 @@ function meanWordConfidence(words: TurnInfoMessage["words"]): number {
49
59
  return sum / words.length;
50
60
  }
51
61
 
62
+ type MetricSink = (name: string, value: string) => void;
63
+
64
+ class DeepgramFluxSttWireProtocol implements SttWireProtocol {
65
+ constructor(
66
+ private readonly speechStartedEvents: boolean,
67
+ private readonly onMetric: MetricSink,
68
+ ) {}
69
+
70
+ encodeFinalize(_contextId: string): readonly SocketData[] {
71
+ return [];
72
+ }
73
+
74
+ encodeClose(): readonly SocketData[] {
75
+ return [JSON.stringify({ type: "CloseStream" })];
76
+ }
77
+
78
+ encodeReconfigure(partial: SttReconfigurePartial): readonly SocketData[] {
79
+ const thresholds: Record<string, number> = {};
80
+ if (partial.eotThreshold !== undefined) thresholds["eot_threshold"] = partial.eotThreshold;
81
+ if (partial.eagerEotThreshold !== undefined) {
82
+ thresholds["eager_eot_threshold"] = partial.eagerEotThreshold;
83
+ }
84
+ if (partial.eotTimeoutMs !== undefined) thresholds["eot_timeout_ms"] = partial.eotTimeoutMs;
85
+
86
+ const configure: Record<string, unknown> = { type: "Configure" };
87
+ if (Object.keys(thresholds).length > 0) configure["thresholds"] = thresholds;
88
+ if (partial.keyterms !== undefined) configure["keyterms"] = partial.keyterms;
89
+ if (partial.languageHints !== undefined) configure["language_hints"] = partial.languageHints;
90
+
91
+ return [JSON.stringify(configure)];
92
+ }
93
+
94
+ decode(data: SocketData, _isBinary: boolean): readonly SttEvent[] {
95
+ if (typeof data !== "string") return [];
96
+ let msg: Record<string, unknown>;
97
+ try {
98
+ msg = JSON.parse(data) as Record<string, unknown>;
99
+ } catch (err) {
100
+ return [
101
+ {
102
+ type: "error",
103
+ error: new Error(
104
+ `Deepgram Flux sent malformed JSON: ${err instanceof Error ? err.message : String(err)}`,
105
+ ),
106
+ },
107
+ ];
108
+ }
109
+
110
+ if (msg["type"] === "Error") {
111
+ const description =
112
+ typeof msg["description"] === "string" ? msg["description"] : JSON.stringify(msg);
113
+ return [
114
+ {
115
+ type: "error",
116
+ error: new Error(`Deepgram Flux provider error: ${description}`),
117
+ },
118
+ ];
119
+ }
120
+ // Ack/nack for a mid-stream Configure (dynamic reconfigure). Surface as metrics so the
121
+ // conversation-state biasing that actuates reconfigure can observe that it took effect.
122
+ if (msg["type"] === "ConfigureSuccess") {
123
+ this.onMetric("stt.flux.configure_success", "1");
124
+ return [];
125
+ }
126
+ if (msg["type"] === "ConfigureFailure") {
127
+ const description =
128
+ typeof msg["description"] === "string" ? msg["description"] : JSON.stringify(msg);
129
+ this.onMetric("stt.flux.configure_failure", description);
130
+ return [];
131
+ }
132
+ if (msg["type"] !== "TurnInfo") return [];
133
+
134
+ const info = msg as unknown as TurnInfoMessage;
135
+ const transcript = (info.transcript ?? "").trim();
136
+
137
+ switch (info.event) {
138
+ case "StartOfTurn":
139
+ if (!this.speechStartedEvents) return [];
140
+ return [{ type: "speech_started" }];
141
+ case "Update":
142
+ if (!transcript) return [];
143
+ return [{ type: "interim", contextId: "", text: transcript }];
144
+ case "EagerEndOfTurn":
145
+ if (!transcript) return [];
146
+ return [{ type: "eos_interim", text: transcript }];
147
+ case "TurnResumed":
148
+ return [{ type: "eos_retracted" }];
149
+ case "EndOfTurn":
150
+ if (!transcript) return [];
151
+ return [
152
+ {
153
+ type: "final",
154
+ contextId: "",
155
+ text: transcript,
156
+ confidence: meanWordConfidence(info.words),
157
+ language: "en",
158
+ speechFinal: true,
159
+ },
160
+ ];
161
+ default:
162
+ return [];
163
+ }
164
+ }
165
+ }
166
+
52
167
  export class DeepgramFluxSTTPlugin implements VoicePlugin {
53
168
  readonly endpointingCapability = {
54
169
  owner: "provider_stt" as const,
@@ -58,7 +173,7 @@ export class DeepgramFluxSTTPlugin implements VoicePlugin {
58
173
  };
59
174
 
60
175
  private bus: PipelineBus | null = null;
61
- private apiKey = "";
176
+ private session: StreamingSttSession | null = null;
62
177
  private model = "flux-general-en";
63
178
  private endpointUrl = "wss://api.deepgram.com/v2/listen";
64
179
  private sampleRate = 16000;
@@ -68,11 +183,11 @@ export class DeepgramFluxSTTPlugin implements VoicePlugin {
68
183
  private keyterms: readonly string[] = [];
69
184
  private languageHints: readonly string[] = [];
70
185
  private speechStartedEvents = true;
71
- private emitEosOnFinal = true;
72
-
73
- private conn: WebSocketConnection | null = null;
186
+ private encoding = "linear16";
187
+ /** Open-ended Flux listen query knobs (profanity_filter, tag, mip_opt_out, …). */
188
+ private queryParams: Record<string, unknown> | undefined;
189
+ private apiKey = "";
74
190
  private currentContextId = "";
75
- private disposers: Array<() => void> = [];
76
191
 
77
192
  constructor(private readonly socketFactory?: SocketFactory) {}
78
193
 
@@ -86,7 +201,7 @@ export class DeepgramFluxSTTPlugin implements VoicePlugin {
86
201
  this.eagerEotThreshold = config["eager_eot_threshold"] as number | undefined;
87
202
  this.eotTimeoutMs = (config["eot_timeout_ms"] as number) ?? 5000;
88
203
  this.speechStartedEvents = (config["speech_started_events"] as boolean) ?? true;
89
- this.emitEosOnFinal = (config["emit_eos_on_final"] as boolean) ?? true;
204
+ const emitEosOnFinal = (config["emit_eos_on_final"] as boolean) ?? true;
90
205
  {
91
206
  const raw = config["keyterm"];
92
207
  this.keyterms = Array.isArray(raw)
@@ -103,13 +218,33 @@ export class DeepgramFluxSTTPlugin implements VoicePlugin {
103
218
  ? [raw]
104
219
  : [];
105
220
  }
221
+ this.encoding = optionalStringConfig(config, "encoding") ?? "linear16";
222
+ this.queryParams = readPlainObject(config["query_params"]);
106
223
 
107
- const socketFactory = this.socketFactory ?? (await defaultSocketFactory());
108
- this.conn = new WebSocketConnection({
224
+ const audioFormat: AudioFormat = {
225
+ encoding: "pcm_s16le",
226
+ sampleRateHz: this.sampleRate,
227
+ channels: 1,
228
+ };
229
+
230
+ this.session = await startStreamingSttSession(bus, {
231
+ protocol: new DeepgramFluxSttWireProtocol(this.speechStartedEvents, (name, value) => {
232
+ bus.push(Route.Background, {
233
+ kind: "metric.conversation",
234
+ contextId: "",
235
+ timestampMs: Date.now(),
236
+ name,
237
+ value,
238
+ });
239
+ }),
240
+ provider: { name: "deepgram", model: this.model, region: "global" },
241
+ format: audioFormat,
242
+ language: "en",
243
+ emitEosOnFinal,
109
244
  url: () => {
110
245
  const params = new URLSearchParams({
111
246
  model: this.model,
112
- encoding: "linear16",
247
+ encoding: this.encoding,
113
248
  sample_rate: String(this.sampleRate),
114
249
  eot_threshold: String(this.eotThreshold),
115
250
  eot_timeout_ms: String(this.eotTimeoutMs),
@@ -119,151 +254,52 @@ export class DeepgramFluxSTTPlugin implements VoicePlugin {
119
254
  });
120
255
  for (const term of this.keyterms) params.append("keyterm", term);
121
256
  for (const hint of this.languageHints) params.append("language_hint", hint);
257
+ applyQueryParams(params, this.queryParams);
122
258
  const separator = this.endpointUrl.includes("?") ? "&" : "?";
123
259
  return `${this.endpointUrl}${separator}${params.toString()}`;
124
260
  },
125
261
  headers: { Authorization: `Token ${this.apiKey}` },
126
- socketFactory,
262
+ socketFactory: this.socketFactory ?? (await defaultNodeSocketFactory()),
127
263
  retry: readProviderRetryConfig(config),
128
264
  replayBufferSize: (config["replay_buffer_size"] as number) ?? 64,
129
- onReplay: (event, count) => {
130
- this.pushMetric(this.currentContextId, `stt.flux.reconnect_replay_${event}`, String(count));
131
- },
132
- // No provider keepalive text message: the Flux v2 protocol keeps the turn
133
- // model fed by continuous audio; transports stream silence frames between
134
- // utterances, so an idle socket means the call itself has gone quiet.
135
- onMessage: (data) => {
136
- if (typeof data === "string") this.handleProviderMessage(data);
137
- },
138
- onConnectionLost: (err) => {
139
- this.emitError(this.currentContextId, err);
140
- },
265
+ metricPrefix: "stt.flux",
141
266
  });
142
- await this.conn.connect();
267
+ }
143
268
 
144
- this.disposers.push(
145
- bus.on("stt.audio", async (pkt: unknown) => {
146
- const audioPkt = pkt as { audio: Uint8Array; contextId?: string };
147
- if (audioPkt.contextId) this.currentContextId = audioPkt.contextId;
148
- if (!this.conn) return;
149
- try {
150
- await this.conn.ensureReady();
151
- this.conn.send(audioPkt.audio);
152
- } catch (err) {
153
- this.emitError(this.currentContextId, err instanceof Error ? err : new Error(String(err)));
154
- }
155
- }),
156
- bus.on("turn.change", (pkt: unknown) => {
157
- const tc = pkt as { contextId: string };
158
- this.currentContextId = tc.contextId;
159
- }),
160
- );
269
+ /**
270
+ * Mid-stream reconfigure of keyterms / end-of-turn thresholds via the Flux v2 `Configure`
271
+ * control message no socket restart (Deepgram "on-the-fly configuration"). Instance state is
272
+ * updated too, so a reconnect replays the current config (single source of truth with `url()`).
273
+ * `keyterms` REPLACES the list (Flux semantics), not merges. The provider acks with
274
+ * ConfigureSuccess / ConfigureFailure (surfaced as metrics in the wire protocol).
275
+ * `language` (hard switch) is ignored: Flux's model is language-specific (flux-general-en) —
276
+ * use `languageHints` to bias.
277
+ */
278
+ get sttReconfigure(): SttReconfigure {
279
+ return this;
161
280
  }
162
281
 
163
- private handleProviderMessage(data: string): void {
164
- let msg: Record<string, unknown>;
282
+ reconfigure(partial: SttReconfigurePartial): void {
283
+ if (partial.keyterms !== undefined) this.keyterms = partial.keyterms;
284
+ if (partial.eotThreshold !== undefined) this.eotThreshold = partial.eotThreshold;
285
+ if (partial.eagerEotThreshold !== undefined) this.eagerEotThreshold = partial.eagerEotThreshold;
286
+ if (partial.eotTimeoutMs !== undefined) this.eotTimeoutMs = partial.eotTimeoutMs;
287
+ if (partial.languageHints !== undefined) this.languageHints = partial.languageHints;
288
+
165
289
  try {
166
- msg = JSON.parse(data) as Record<string, unknown>;
290
+ this.session?.reconfigure(partial);
167
291
  } catch (err) {
168
- this.emitError(
169
- this.currentContextId,
170
- new Error(`Deepgram Flux sent malformed JSON: ${err instanceof Error ? err.message : String(err)}`),
171
- );
172
- return;
173
- }
174
-
175
- if (msg["type"] === "Error") {
176
- const description = typeof msg["description"] === "string" ? msg["description"] : JSON.stringify(msg);
177
- this.emitError(this.currentContextId, new Error(`Deepgram Flux provider error: ${description}`));
178
- return;
292
+ this.emitError(err instanceof Error ? err : new Error(String(err)));
179
293
  }
180
- if (msg["type"] !== "TurnInfo") return;
181
-
182
- const info = msg as unknown as TurnInfoMessage;
183
- const contextId = this.currentContextId;
184
- const transcript = (info.transcript ?? "").trim();
185
-
186
- switch (info.event) {
187
- case "StartOfTurn": {
188
- if (!this.speechStartedEvents) return;
189
- this.bus?.push(Route.Main, {
190
- kind: "vad.speech_started",
191
- contextId,
192
- timestampMs: Date.now(),
193
- confidence: 1,
194
- });
195
- return;
196
- }
197
- case "Update": {
198
- if (!transcript) return;
199
- this.bus?.push(Route.Main, {
200
- kind: "stt.interim",
201
- contextId,
202
- timestampMs: Date.now(),
203
- text: transcript,
204
- });
205
- return;
206
- }
207
- case "EagerEndOfTurn": {
208
- if (!transcript) return;
209
- this.bus?.push(Route.Main, {
210
- kind: "eos.interim",
211
- contextId,
212
- timestampMs: Date.now(),
213
- text: transcript,
214
- });
215
- return;
216
- }
217
- case "TurnResumed": {
218
- this.bus?.push(Route.Main, {
219
- kind: "eos.retracted",
220
- contextId,
221
- timestampMs: Date.now(),
222
- });
223
- return;
224
- }
225
- case "EndOfTurn": {
226
- if (!transcript) return;
227
- this.bus?.push(Route.Main, {
228
- kind: "stt.result",
229
- contextId,
230
- timestampMs: Date.now(),
231
- text: transcript,
232
- confidence: meanWordConfidence(info.words),
233
- language: "en",
234
- provider: { name: "deepgram", model: this.model, region: "global" },
235
- });
236
- if (this.emitEosOnFinal) {
237
- this.bus?.push(Route.Main, {
238
- kind: "eos.turn_complete",
239
- contextId,
240
- timestampMs: Date.now(),
241
- text: transcript,
242
- transcripts: [],
243
- });
244
- }
245
- return;
246
- }
247
- }
248
- }
249
-
250
- private pushMetric(contextId: string, name: string, value: string): void {
251
- this.bus?.push(Route.Background, {
252
- kind: "metric.conversation",
253
- contextId,
254
- timestampMs: Date.now(),
255
- name,
256
- value,
257
- });
258
294
  }
259
295
 
260
- private emitError(contextId: string, err: Error): void {
296
+ private emitError(err: Error): void {
261
297
  const category = categorizeSttError(err);
262
298
  const packet: SttErrorPacket = {
263
299
  kind: "stt.error",
264
- contextId,
300
+ contextId: this.currentContextId,
265
301
  timestampMs: Date.now(),
266
- component: "stt" as const,
302
+ component: "stt",
267
303
  category,
268
304
  cause: err,
269
305
  isRecoverable: isRecoverable(category),
@@ -272,23 +308,30 @@ export class DeepgramFluxSTTPlugin implements VoicePlugin {
272
308
  }
273
309
 
274
310
  async close(): Promise<void> {
275
- for (const dispose of this.disposers.splice(0)) dispose();
276
- if (this.conn) {
277
- if (this.conn.isReady) {
278
- try {
279
- this.conn.send(JSON.stringify({ type: "CloseStream" }));
280
- } catch {
281
- // Socket already going away — CloseStream is best-effort.
282
- }
283
- }
284
- await this.conn.close();
285
- this.conn = null;
286
- }
311
+ await this.session?.dispose();
312
+ this.session = null;
287
313
  this.bus = null;
288
314
  }
289
315
  }
290
316
 
291
- async function defaultSocketFactory(): Promise<SocketFactory> {
292
- const mod = await import("@kuralle-syrinx/ws/node");
293
- return mod.createNodeWsSocket;
317
+ function readPlainObject(value: unknown): Record<string, unknown> | undefined {
318
+ if (value !== null && typeof value === "object" && !Array.isArray(value)) {
319
+ return value as Record<string, unknown>;
320
+ }
321
+ return undefined;
322
+ }
323
+
324
+ function applyQueryParams(params: URLSearchParams, extra: Record<string, unknown> | undefined): void {
325
+ if (!extra) return;
326
+ for (const [key, value] of Object.entries(extra)) {
327
+ if (value === undefined || value === null) continue;
328
+ if (Array.isArray(value)) {
329
+ for (const item of value) {
330
+ if (item === undefined || item === null) continue;
331
+ params.append(key, String(item));
332
+ }
333
+ continue;
334
+ }
335
+ params.set(key, String(value));
336
+ }
294
337
  }