@kuralle-syrinx/deepgram 4.1.0 → 4.3.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/src/tts.ts CHANGED
@@ -65,6 +65,8 @@ export class DeepgramTTSPlugin implements VoicePlugin {
65
65
  private model = "aura-2-thalia-en";
66
66
  private endpointUrl = "wss://api.deepgram.com/v1/speak";
67
67
  private sampleRate = 24000;
68
+ private encoding = "linear16";
69
+ private container = "none";
68
70
  private retryConfig: RetryConfig = readProviderRetryConfig({});
69
71
  // Deepgram's speak socket has no per-message context id, but the engine
70
72
  // synthesizes one turn at a time, so the audio streaming back belongs to the
@@ -81,6 +83,8 @@ export class DeepgramTTSPlugin implements VoicePlugin {
81
83
  private finishTimers = new Map<string, ReturnType<typeof setTimeout>>();
82
84
  private disposers: Array<() => void> = [];
83
85
  private audioFormat: AudioFormat = { encoding: "pcm_s16le", sampleRateHz: 24000, channels: 1 };
86
+ /** Synthesized character counts per contextId — billed on successful tts.end. */
87
+ private charactersByContextId = new Map<string, number>();
84
88
 
85
89
  async initialize(bus: PipelineBus, config: PluginConfig): Promise<void> {
86
90
  this.bus = bus;
@@ -88,6 +92,8 @@ export class DeepgramTTSPlugin implements VoicePlugin {
88
92
  this.model = optionalStringConfig(config, "model") ?? this.model;
89
93
  this.endpointUrl = optionalStringConfig(config, "endpoint_url") ?? this.endpointUrl;
90
94
  this.sampleRate = readPositiveInteger(config["sample_rate"], this.sampleRate);
95
+ this.encoding = optionalStringConfig(config, "encoding") ?? this.encoding;
96
+ this.container = optionalStringConfig(config, "container") ?? this.container;
91
97
  this.retryConfig = readProviderRetryConfig(config);
92
98
  const finishTimeoutMs = readNonNegativeInteger(config["finish_timeout_ms"], 2000);
93
99
  this.audioFormat = { encoding: "pcm_s16le", sampleRateHz: this.sampleRate, channels: 1 };
@@ -97,9 +103,9 @@ export class DeepgramTTSPlugin implements VoicePlugin {
97
103
  url: () => {
98
104
  const params = new URLSearchParams({
99
105
  model: this.model,
100
- encoding: "linear16",
106
+ encoding: this.encoding,
101
107
  sample_rate: String(this.sampleRate),
102
- container: "none",
108
+ container: this.container,
103
109
  });
104
110
  const separator = this.endpointUrl.includes("?") ? "&" : "?";
105
111
  return `${this.endpointUrl}${separator}${params.toString()}`;
@@ -143,7 +149,11 @@ export class DeepgramTTSPlugin implements VoicePlugin {
143
149
  if (this.cancelledContexts.has(contextId)) return;
144
150
  this.activeContexts.add(contextId);
145
151
  this.currentContextId = contextId;
146
- if (!(await this.trySend(SPEAK(text), contextId))) this.activeContexts.delete(contextId);
152
+ if (!(await this.trySend(SPEAK(text), contextId))) {
153
+ this.activeContexts.delete(contextId);
154
+ return;
155
+ }
156
+ this.charactersByContextId.set(contextId, (this.charactersByContextId.get(contextId) ?? 0) + text.length);
147
157
  }
148
158
 
149
159
  private async finishContext(contextId: string): Promise<void> {
@@ -166,6 +176,7 @@ export class DeepgramTTSPlugin implements VoicePlugin {
166
176
  for (const dispose of this.disposers.splice(0)) dispose();
167
177
  this.activeContexts.clear();
168
178
  this.cancelledContexts.clear();
179
+ this.charactersByContextId.clear();
169
180
  for (const timer of this.finishTimers.values()) clearTimeout(timer);
170
181
  this.finishTimers.clear();
171
182
  this.currentContextId = "";
@@ -178,7 +189,10 @@ export class DeepgramTTSPlugin implements VoicePlugin {
178
189
 
179
190
  private async cancelActiveContexts(): Promise<void> {
180
191
  const contextIds = [...this.activeContexts];
181
- for (const contextId of contextIds) boundedAdd(this.cancelledContexts, contextId, MAX_CANCELLED_CONTEXTS);
192
+ for (const contextId of contextIds) {
193
+ boundedAdd(this.cancelledContexts, contextId, MAX_CANCELLED_CONTEXTS);
194
+ this.charactersByContextId.delete(contextId);
195
+ }
182
196
  this.activeContexts.clear();
183
197
  for (const contextId of contextIds) this.clearFinishTimeout(contextId);
184
198
  this.currentContextId = "";
@@ -298,7 +312,10 @@ export class DeepgramTTSPlugin implements VoicePlugin {
298
312
  private failActiveContexts(err: Error): void {
299
313
  const contextIds = [...this.activeContexts];
300
314
  this.activeContexts.clear();
301
- for (const contextId of contextIds) this.clearFinishTimeout(contextId);
315
+ for (const contextId of contextIds) {
316
+ this.clearFinishTimeout(contextId);
317
+ this.charactersByContextId.delete(contextId);
318
+ }
302
319
  this.currentContextId = "";
303
320
  this.carry = EMPTY;
304
321
  // A dropped socket voids any in-flight Clear/Cleared handshake: the server
@@ -330,6 +347,19 @@ export class DeepgramTTSPlugin implements VoicePlugin {
330
347
  }
331
348
 
332
349
  private emitEnd(contextId: string): void {
350
+ const characters = this.charactersByContextId.get(contextId) ?? 0;
351
+ this.charactersByContextId.delete(contextId);
352
+ if (characters > 0) {
353
+ this.bus?.push(Route.Background, {
354
+ kind: "usage.recorded",
355
+ contextId,
356
+ timestampMs: Date.now(),
357
+ stage: "tts",
358
+ provider: "deepgram",
359
+ model: this.model,
360
+ characters,
361
+ });
362
+ }
333
363
  this.bus?.push(Route.Main, {
334
364
  kind: "tts.end",
335
365
  contextId,
@@ -1,193 +0,0 @@
1
- import { describe, expect, it } from "vitest";
2
- import { WebSocketServer, type WebSocket } from "ws";
3
- import {
4
- PipelineBusImpl,
5
- Route,
6
- type ConversationMetricPacket,
7
- type SttErrorPacket,
8
- type SttResultPacket,
9
- } from "@kuralle-syrinx/core";
10
- import { DeepgramSTTPlugin } from "../src/index.js";
11
-
12
- async function createServer(onConnection: (socket: WebSocket) => void): Promise<{
13
- url: string;
14
- close: () => Promise<void>;
15
- }> {
16
- const server = await new Promise<WebSocketServer>((resolve) => {
17
- let nextServer: WebSocketServer;
18
- nextServer = new WebSocketServer({ port: 0 }, () => resolve(nextServer));
19
- });
20
- server.on("connection", onConnection);
21
- const address = server.address();
22
- if (!address || typeof address === "string") throw new Error("expected tcp address");
23
- return {
24
- url: `ws://127.0.0.1:${address.port}/listen`,
25
- close: () => new Promise((resolve) => {
26
- for (const client of server.clients) client.terminate();
27
- server.close(() => resolve());
28
- }),
29
- };
30
- }
31
-
32
- async function waitUntil(predicate: () => boolean): Promise<void> {
33
- for (let i = 0; i < 100; i += 1) {
34
- if (predicate()) return;
35
- await new Promise((resolve) => setTimeout(resolve, 10));
36
- }
37
- throw new Error("timed out waiting for probe condition");
38
- }
39
-
40
- describe("Deepgram finalize contract probe", () => {
41
- it("keeps a delayed Finalize response correlated to the requested context after currentContextId moves", async () => {
42
- const controlMessages: string[] = [];
43
- const server = await createServer((socket) => {
44
- socket.on("message", (data, isBinary) => {
45
- if (isBinary) return;
46
- const msg = JSON.parse(data.toString()) as { type?: string };
47
- if (msg.type) controlMessages.push(msg.type);
48
- if (msg.type !== "Finalize") return;
49
- setTimeout(() => {
50
- socket.send(JSON.stringify({
51
- is_final: true,
52
- speech_final: false,
53
- from_finalize: true,
54
- channel: { alternatives: [{ transcript: "old turn confirmed", confidence: 0.91 }] },
55
- }));
56
- }, 10);
57
- });
58
- });
59
- const bus = new PipelineBusImpl();
60
- const started = bus.start();
61
- const plugin = new DeepgramSTTPlugin();
62
- const finals: SttResultPacket[] = [];
63
- const errors: SttErrorPacket[] = [];
64
- const metrics: ConversationMetricPacket[] = [];
65
- bus.on("stt.result", (pkt) => finals.push(pkt as SttResultPacket));
66
- bus.on("stt.error", (pkt) => errors.push(pkt as SttErrorPacket));
67
- bus.on("metric.conversation", (pkt) => metrics.push(pkt as ConversationMetricPacket));
68
-
69
- await plugin.initialize(bus, {
70
- api_key: "test",
71
- endpoint_url: server.url,
72
- sample_rate: 16000,
73
- emit_eos_on_final: false,
74
- finalize_on_speech_final: false,
75
- provider_finalize_timeout_ms: 40,
76
- finalize_timeout_fallback: false,
77
- });
78
- bus.push(Route.Main, {
79
- kind: "stt.audio",
80
- contextId: "old-vad-fragment",
81
- timestampMs: Date.now(),
82
- audio: new Uint8Array(640),
83
- });
84
- await new Promise((resolve) => setTimeout(resolve, 5));
85
-
86
- bus.push(Route.Main, {
87
- kind: "turn.change",
88
- contextId: "new-vad-fragment",
89
- previousContextId: "old-vad-fragment",
90
- reason: "probe_vad_fragment_split",
91
- timestampMs: Date.now(),
92
- });
93
- await new Promise((resolve) => setTimeout(resolve, 5));
94
-
95
- plugin.forceFinalize("old-vad-fragment");
96
- await waitUntil(() => finals.length > 0);
97
- await new Promise((resolve) => setTimeout(resolve, 60));
98
-
99
- expect(controlMessages).toContain("Finalize");
100
- expect(finals).toEqual([
101
- expect.objectContaining({
102
- contextId: "old-vad-fragment",
103
- text: "old turn confirmed",
104
- }),
105
- ]);
106
- expect(errors).toHaveLength(0);
107
- expect(metrics).toEqual(expect.arrayContaining([
108
- expect.objectContaining({
109
- name: "stt_provider_final_segment",
110
- contextId: "old-vad-fragment",
111
- value: expect.stringContaining("\"fromFinalize\":true"),
112
- }),
113
- ]));
114
- expect(metrics).not.toEqual(expect.arrayContaining([
115
- expect.objectContaining({
116
- name: "stt_provider_finalize_timeout",
117
- contextId: "old-vad-fragment",
118
- }),
119
- ]));
120
-
121
- await plugin.close();
122
- bus.stop();
123
- await started;
124
- await server.close();
125
- });
126
-
127
- it("releases provider-final text without speech_final/from_finalize and does not need timeout fallback", async () => {
128
- const controlMessages: string[] = [];
129
- const server = await createServer((socket) => {
130
- socket.on("message", (data, isBinary) => {
131
- if (isBinary) {
132
- socket.send(JSON.stringify({
133
- is_final: true,
134
- speech_final: false,
135
- channel: { alternatives: [{ transcript: "just want to complain again so", confidence: 0.84 }] },
136
- }));
137
- return;
138
- }
139
- const msg = JSON.parse(data.toString()) as { type?: string };
140
- if (msg.type) controlMessages.push(msg.type);
141
- });
142
- });
143
- const bus = new PipelineBusImpl();
144
- const started = bus.start();
145
- const plugin = new DeepgramSTTPlugin();
146
- const finals: SttResultPacket[] = [];
147
- const errors: SttErrorPacket[] = [];
148
- const metrics: ConversationMetricPacket[] = [];
149
- bus.on("stt.result", (pkt) => finals.push(pkt as SttResultPacket));
150
- bus.on("stt.error", (pkt) => errors.push(pkt as SttErrorPacket));
151
- bus.on("metric.conversation", (pkt) => metrics.push(pkt as ConversationMetricPacket));
152
-
153
- await plugin.initialize(bus, {
154
- api_key: "test",
155
- endpoint_url: server.url,
156
- sample_rate: 16000,
157
- emit_eos_on_final: false,
158
- finalize_on_speech_final: false,
159
- provider_finalize_timeout_ms: 20,
160
- finalize_timeout_fallback: false,
161
- });
162
- bus.push(Route.Main, {
163
- kind: "stt.audio",
164
- contextId: "messy-live-turn",
165
- timestampMs: Date.now(),
166
- audio: new Uint8Array(640),
167
- });
168
- await waitUntil(() => metrics.some((m) => m.name === "stt_provider_final_segment"));
169
- await waitUntil(() => finals.length > 0);
170
- plugin.forceFinalize("messy-live-turn");
171
- await new Promise((resolve) => setTimeout(resolve, 40));
172
-
173
- expect(controlMessages).toContain("Finalize");
174
- expect(finals).toEqual([
175
- expect.objectContaining({
176
- contextId: "messy-live-turn",
177
- text: "just want to complain again so",
178
- }),
179
- ]);
180
- expect(errors).toHaveLength(0);
181
- expect(metrics).toEqual(expect.arrayContaining([
182
- expect.objectContaining({ name: "stt_provider_final_segment" }),
183
- ]));
184
- expect(metrics).not.toEqual(expect.arrayContaining([
185
- expect.objectContaining({ name: "stt_provider_finalize_timeout" }),
186
- ]));
187
-
188
- await plugin.close();
189
- bus.stop();
190
- await started;
191
- await server.close();
192
- });
193
- });
package/src/flux.test.ts DELETED
@@ -1,227 +0,0 @@
1
- // SPDX-License-Identifier: MIT
2
-
3
- import { afterEach, describe, expect, it } from "vitest";
4
- import { WebSocketServer, type WebSocket } from "ws";
5
- import {
6
- PipelineBusImpl,
7
- Route,
8
- type EndOfSpeechPacket,
9
- type EndOfSpeechRetractedPacket,
10
- type InterimEndOfSpeechPacket,
11
- type SttInterimPacket,
12
- type SttResultPacket,
13
- } from "@kuralle-syrinx/core";
14
-
15
- import { DeepgramFluxSTTPlugin } from "./flux.js";
16
-
17
- let servers: WebSocketServer[] = [];
18
-
19
- afterEach(async () => {
20
- await Promise.all(
21
- servers.splice(0).map(
22
- (server) =>
23
- new Promise<void>((resolve) => {
24
- for (const client of server.clients) client.terminate();
25
- server.close(() => resolve());
26
- }),
27
- ),
28
- );
29
- });
30
-
31
- interface LocalServer {
32
- endpointUrl: string;
33
- connectionUrls: string[];
34
- sockets: WebSocket[];
35
- }
36
-
37
- async function createLocalServer(): Promise<LocalServer> {
38
- const server = await new Promise<WebSocketServer>((resolve) => {
39
- let nextServer: WebSocketServer;
40
- nextServer = new WebSocketServer({ port: 0 }, () => resolve(nextServer));
41
- });
42
- servers.push(server);
43
- const state: LocalServer = { endpointUrl: "", connectionUrls: [], sockets: [] };
44
- server.on("connection", (socket, req) => {
45
- state.connectionUrls.push(req.url ?? "");
46
- state.sockets.push(socket);
47
- });
48
- const address = server.address();
49
- if (!address || typeof address === "string") throw new Error("Expected TCP server address");
50
- state.endpointUrl = `ws://127.0.0.1:${address.port}/v2/listen`;
51
- return state;
52
- }
53
-
54
- async function waitFor<T>(items: T[], count = 1): Promise<void> {
55
- for (let i = 0; i < 100; i += 1) {
56
- if (items.length >= count) return;
57
- await new Promise((resolve) => setTimeout(resolve, 10));
58
- }
59
- }
60
-
61
- function turnInfo(event: string, extra: Record<string, unknown> = {}): string {
62
- return JSON.stringify({
63
- type: "TurnInfo",
64
- event,
65
- turn_index: 0,
66
- audio_window_start: 0,
67
- audio_window_end: 0.6,
68
- transcript: "",
69
- words: [],
70
- end_of_turn_confidence: 0.5,
71
- ...extra,
72
- });
73
- }
74
-
75
- async function startPlugin(
76
- local: LocalServer,
77
- config: Record<string, unknown> = {},
78
- ): Promise<{ bus: PipelineBusImpl; plugin: DeepgramFluxSTTPlugin; started: Promise<void> }> {
79
- const bus = new PipelineBusImpl();
80
- const started = bus.start();
81
- const plugin = new DeepgramFluxSTTPlugin();
82
- await plugin.initialize(bus, {
83
- api_key: "test",
84
- endpoint_url: local.endpointUrl,
85
- sample_rate: 16000,
86
- ...config,
87
- });
88
- await waitFor(local.sockets);
89
- // Feed one audio packet so the plugin learns the current contextId.
90
- bus.push(Route.Main, {
91
- kind: "stt.audio",
92
- contextId: "turn-1",
93
- timestampMs: Date.now(),
94
- audio: new Uint8Array(320),
95
- });
96
- await new Promise((resolve) => setTimeout(resolve, 20));
97
- return { bus, plugin, started };
98
- }
99
-
100
- describe("DeepgramFluxSTTPlugin", () => {
101
- it("connects with Flux params and forwards keyterm + eager threshold", async () => {
102
- const local = await createLocalServer();
103
- const { bus, plugin, started } = await startPlugin(local, {
104
- eot_threshold: 0.85,
105
- eager_eot_threshold: 0.4,
106
- eot_timeout_ms: 7000,
107
- keyterm: ["Syrinx"],
108
- });
109
-
110
- await plugin.close();
111
- bus.stop();
112
- await started;
113
-
114
- const url = local.connectionUrls[0]!;
115
- expect(url).toContain("model=flux-general-en");
116
- expect(url).toContain("encoding=linear16");
117
- expect(url).toContain("sample_rate=16000");
118
- expect(url).toContain("eot_threshold=0.85");
119
- expect(url).toContain("eager_eot_threshold=0.4");
120
- expect(url).toContain("eot_timeout_ms=7000");
121
- expect(url).toContain("keyterm=Syrinx");
122
- });
123
-
124
- it("omits eager_eot_threshold by default (eager mode is opt-in)", async () => {
125
- const local = await createLocalServer();
126
- const { bus, plugin, started } = await startPlugin(local);
127
-
128
- await plugin.close();
129
- bus.stop();
130
- await started;
131
-
132
- const url = local.connectionUrls[0]!;
133
- expect(url).toContain("eot_threshold=0.7");
134
- expect(url).not.toContain("eager_eot_threshold=");
135
- });
136
-
137
- it("maps TurnInfo events onto the bus: Update→stt.interim, EndOfTurn→stt.result+eos.turn_complete", async () => {
138
- const local = await createLocalServer();
139
- const { bus, plugin, started } = await startPlugin(local);
140
-
141
- const interims: SttInterimPacket[] = [];
142
- const results: SttResultPacket[] = [];
143
- const turnCompletes: EndOfSpeechPacket[] = [];
144
- bus.on("stt.interim", (pkt) => {
145
- interims.push(pkt as SttInterimPacket);
146
- });
147
- bus.on("stt.result", (pkt) => {
148
- results.push(pkt as SttResultPacket);
149
- });
150
- bus.on("eos.turn_complete", (pkt) => {
151
- turnCompletes.push(pkt as EndOfSpeechPacket);
152
- });
153
-
154
- const socket = local.sockets[0]!;
155
- socket.send(turnInfo("Update", { transcript: "what are" }));
156
- socket.send(
157
- turnInfo("EndOfTurn", {
158
- transcript: "what are the lab fees",
159
- words: [
160
- { word: "what", confidence: 0.99 },
161
- { word: "are", confidence: 0.97 },
162
- ],
163
- end_of_turn_confidence: 0.91,
164
- }),
165
- );
166
- await waitFor(turnCompletes);
167
-
168
- await plugin.close();
169
- bus.stop();
170
- await started;
171
-
172
- expect(interims.map((p) => p.text)).toContain("what are");
173
- expect(results).toHaveLength(1);
174
- expect(results[0]!.text).toBe("what are the lab fees");
175
- expect(results[0]!.confidence).toBeCloseTo(0.98, 2);
176
- expect(turnCompletes).toEqual([
177
- expect.objectContaining({ contextId: "turn-1", text: "what are the lab fees" }),
178
- ]);
179
- });
180
-
181
- it("emits eos.interim on EagerEndOfTurn and eos.retracted on TurnResumed", async () => {
182
- const local = await createLocalServer();
183
- const { bus, plugin, started } = await startPlugin(local, { eager_eot_threshold: 0.4 });
184
-
185
- const eagers: InterimEndOfSpeechPacket[] = [];
186
- const retractions: EndOfSpeechRetractedPacket[] = [];
187
- bus.on("eos.interim", (pkt) => {
188
- eagers.push(pkt as InterimEndOfSpeechPacket);
189
- });
190
- bus.on("eos.retracted", (pkt) => {
191
- retractions.push(pkt as EndOfSpeechRetractedPacket);
192
- });
193
-
194
- const socket = local.sockets[0]!;
195
- socket.send(turnInfo("EagerEndOfTurn", { transcript: "book a room", end_of_turn_confidence: 0.55 }));
196
- await waitFor(eagers);
197
- socket.send(turnInfo("TurnResumed"));
198
- await waitFor(retractions);
199
-
200
- await plugin.close();
201
- bus.stop();
202
- await started;
203
-
204
- expect(eagers).toEqual([expect.objectContaining({ contextId: "turn-1", text: "book a room" })]);
205
- expect(retractions).toEqual([expect.objectContaining({ contextId: "turn-1" })]);
206
- });
207
-
208
- it("emits vad.speech_started on StartOfTurn (Flux owns barge-in signalling)", async () => {
209
- const local = await createLocalServer();
210
- const { bus, plugin, started } = await startPlugin(local);
211
-
212
- const speechStarts: unknown[] = [];
213
- bus.on("vad.speech_started", (pkt) => {
214
- speechStarts.push(pkt);
215
- });
216
-
217
- const socket = local.sockets[0]!;
218
- socket.send(turnInfo("StartOfTurn", { transcript: "hello" }));
219
- await waitFor(speechStarts);
220
-
221
- await plugin.close();
222
- bus.stop();
223
- await started;
224
-
225
- expect(speechStarts.length).toBe(1);
226
- });
227
- });