@livekit/agents-plugin-deepgram 0.0.0-next-20260624041820

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.
Files changed (75) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +17 -0
  3. package/dist/_utils.cjs +56 -0
  4. package/dist/_utils.cjs.map +1 -0
  5. package/dist/_utils.d.cts +12 -0
  6. package/dist/_utils.d.ts +12 -0
  7. package/dist/_utils.d.ts.map +1 -0
  8. package/dist/_utils.js +32 -0
  9. package/dist/_utils.js.map +1 -0
  10. package/dist/index.cjs +38 -0
  11. package/dist/index.cjs.map +1 -0
  12. package/dist/index.d.cts +4 -0
  13. package/dist/index.d.ts +4 -0
  14. package/dist/index.d.ts.map +1 -0
  15. package/dist/index.js +15 -0
  16. package/dist/index.js.map +1 -0
  17. package/dist/models.cjs +17 -0
  18. package/dist/models.cjs.map +1 -0
  19. package/dist/models.d.cts +6 -0
  20. package/dist/models.d.ts +6 -0
  21. package/dist/models.d.ts.map +1 -0
  22. package/dist/models.js +1 -0
  23. package/dist/models.js.map +1 -0
  24. package/dist/stt.cjs +496 -0
  25. package/dist/stt.cjs.map +1 -0
  26. package/dist/stt.d.cts +53 -0
  27. package/dist/stt.d.ts +53 -0
  28. package/dist/stt.d.ts.map +1 -0
  29. package/dist/stt.js +483 -0
  30. package/dist/stt.js.map +1 -0
  31. package/dist/stt.test.cjs +28 -0
  32. package/dist/stt.test.cjs.map +1 -0
  33. package/dist/stt.test.d.cts +2 -0
  34. package/dist/stt.test.d.ts +2 -0
  35. package/dist/stt.test.d.ts.map +1 -0
  36. package/dist/stt.test.js +27 -0
  37. package/dist/stt.test.js.map +1 -0
  38. package/dist/stt_v2.cjs +413 -0
  39. package/dist/stt_v2.cjs.map +1 -0
  40. package/dist/stt_v2.d.cts +97 -0
  41. package/dist/stt_v2.d.ts +97 -0
  42. package/dist/stt_v2.d.ts.map +1 -0
  43. package/dist/stt_v2.js +387 -0
  44. package/dist/stt_v2.js.map +1 -0
  45. package/dist/stt_v2.test.cjs +17 -0
  46. package/dist/stt_v2.test.cjs.map +1 -0
  47. package/dist/stt_v2.test.d.cts +2 -0
  48. package/dist/stt_v2.test.d.ts +2 -0
  49. package/dist/stt_v2.test.d.ts.map +1 -0
  50. package/dist/stt_v2.test.js +16 -0
  51. package/dist/stt_v2.test.js.map +1 -0
  52. package/dist/tts.cjs +385 -0
  53. package/dist/tts.cjs.map +1 -0
  54. package/dist/tts.d.cts +43 -0
  55. package/dist/tts.d.ts +43 -0
  56. package/dist/tts.d.ts.map +1 -0
  57. package/dist/tts.js +369 -0
  58. package/dist/tts.js.map +1 -0
  59. package/dist/tts.test.cjs +17 -0
  60. package/dist/tts.test.cjs.map +1 -0
  61. package/dist/tts.test.d.cts +2 -0
  62. package/dist/tts.test.d.ts +2 -0
  63. package/dist/tts.test.d.ts.map +1 -0
  64. package/dist/tts.test.js +16 -0
  65. package/dist/tts.test.js.map +1 -0
  66. package/package.json +54 -0
  67. package/src/_utils.ts +50 -0
  68. package/src/index.ts +20 -0
  69. package/src/models.ts +93 -0
  70. package/src/stt.test.ts +36 -0
  71. package/src/stt.ts +612 -0
  72. package/src/stt_v2.test.ts +19 -0
  73. package/src/stt_v2.ts +560 -0
  74. package/src/tts.test.ts +19 -0
  75. package/src/tts.ts +450 -0
package/src/stt_v2.ts ADDED
@@ -0,0 +1,560 @@
1
+ // SPDX-FileCopyrightText: 2025 LiveKit, Inc.
2
+ //
3
+ // SPDX-License-Identifier: Apache-2.0
4
+ import {
5
+ type APIConnectOptions,
6
+ AudioByteStream,
7
+ Event,
8
+ calculateAudioDurationSeconds,
9
+ createTimedString,
10
+ log,
11
+ normalizeLanguage,
12
+ stt,
13
+ } from '@livekit/agents';
14
+ import type { AudioFrame } from '@livekit/rtc-node';
15
+ import * as queryString from 'node:querystring';
16
+ import { WebSocket } from 'ws';
17
+ import { PeriodicCollector } from './_utils.js';
18
+ import type { V2Models } from './models.js';
19
+
20
+ const _CLOSE_MSG = JSON.stringify({ type: 'CloseStream' });
21
+
22
+ // --- Configuration ---
23
+
24
+ /**
25
+ * Configuration options for STTv2 (Deepgram Flux model).
26
+ */
27
+ export interface STTv2Options {
28
+ apiKey?: string;
29
+ model: V2Models | string;
30
+ sampleRate: number;
31
+ keyterms: string[];
32
+ endpointUrl: string;
33
+ language?: string;
34
+ eagerEotThreshold?: number;
35
+ eotThreshold?: number;
36
+ eotTimeoutMs?: number;
37
+ mipOptOut?: boolean;
38
+ tags?: string[];
39
+ /**
40
+ * List of language hints to bias the model for improved accuracy.
41
+ * Only usable with `flux-general-multi`.
42
+ */
43
+ // Ref: python livekit-plugins/livekit-plugins-deepgram/livekit/plugins/deepgram/stt_v2.py - 61 line
44
+ languageHint?: string[];
45
+ }
46
+
47
+ const defaultSTTv2Options: Omit<STTv2Options, 'apiKey'> = {
48
+ model: 'flux-general-en',
49
+ sampleRate: 16000,
50
+ keyterms: [],
51
+ endpointUrl: 'wss://api.deepgram.com/v2/listen',
52
+ language: 'en',
53
+ mipOptOut: false,
54
+ };
55
+
56
+ function validateTags(tags: string[]): string[] {
57
+ for (const tag of tags) {
58
+ if (tag.length > 128) {
59
+ throw new Error('tag must be no more than 128 characters');
60
+ }
61
+ }
62
+ return tags;
63
+ }
64
+
65
+ /**
66
+ * Deepgram STTv2 using the Flux model for streaming speech-to-text.
67
+ *
68
+ * This uses Deepgram's V2 API (`/v2/listen`) which provides turn-based
69
+ * transcription with support for preemptive generation.
70
+ *
71
+ * @remarks
72
+ * Key differences from STT (V1):
73
+ * - Uses `TurnInfo` events instead of `SpeechStarted`/`Results`
74
+ * - Supports `eagerEotThreshold` for preemptive LLM generation
75
+ * - Sends `PREFLIGHT_TRANSCRIPT` events when eager end-of-turn is detected
76
+ *
77
+ * @example
78
+ * ```typescript
79
+ * import { STTv2 } from '@livekit/agents-plugin-deepgram';
80
+ *
81
+ * const stt = new STTv2({
82
+ * model: 'flux-general-en',
83
+ * eagerEotThreshold: 0.5, // Enable preemptive generation
84
+ * });
85
+ *
86
+ * const stream = stt.stream();
87
+ * stream.pushFrame(audioFrame);
88
+ *
89
+ * for await (const event of stream) {
90
+ * if (event.type === SpeechEventType.FINAL_TRANSCRIPT) {
91
+ * console.log(event.alternatives?.[0]?.text);
92
+ * }
93
+ * }
94
+ * ```
95
+ */
96
+ export class STTv2 extends stt.STT {
97
+ readonly label = 'deepgram.STTv2';
98
+ #opts: STTv2Options;
99
+ #apiKey: string;
100
+ #logger = log();
101
+
102
+ /**
103
+ * Create a new Deepgram STTv2 instance.
104
+ *
105
+ * @param opts - Configuration options
106
+ * @param opts.apiKey - Deepgram API key (defaults to `DEEPGRAM_API_KEY` env var)
107
+ * @param opts.model - Model to use (default: `flux-general-en`)
108
+ * @param opts.eagerEotThreshold - Threshold (0.3-0.9) for preemptive generation
109
+ * @param opts.eotThreshold - End-of-turn detection threshold (default: 0.7)
110
+ * @param opts.eotTimeoutMs - End-of-turn timeout in ms (default: 3000)
111
+ * @param opts.keyterms - List of key terms to improve recognition
112
+ * @param opts.tags - Tags for usage reporting (max 128 chars each)
113
+ * @param opts.languageHint - List of language hints to bias the model for improved accuracy.
114
+ * Only usable with `flux-general-multi`.
115
+ *
116
+ * @throws Error if no API key is provided
117
+ */
118
+ constructor(opts: Partial<STTv2Options> = {}) {
119
+ super({
120
+ streaming: true,
121
+ interimResults: true,
122
+ alignedTranscript: 'word',
123
+ });
124
+
125
+ this.#opts = {
126
+ ...defaultSTTv2Options,
127
+ ...opts,
128
+ language: opts.language ? normalizeLanguage(opts.language) : defaultSTTv2Options.language,
129
+ };
130
+
131
+ const apiKey = opts.apiKey || process.env.DEEPGRAM_API_KEY;
132
+ if (!apiKey) {
133
+ throw new Error('Deepgram API key is required');
134
+ }
135
+ this.#apiKey = apiKey;
136
+
137
+ if (this.#opts.tags) {
138
+ this.#opts.tags = validateTags(this.#opts.tags);
139
+ }
140
+
141
+ // Ref: python livekit-plugins/livekit-plugins-deepgram/livekit/plugins/deepgram/stt_v2.py - 134-138 lines
142
+ if (
143
+ this.#opts.languageHint &&
144
+ this.#opts.languageHint.length > 0 &&
145
+ this.#opts.model !== 'flux-general-multi'
146
+ ) {
147
+ this.#logger.warn(
148
+ { model: this.#opts.model },
149
+ '`languageHint` is only supported by `flux-general-multi` and will be ignored for this model',
150
+ );
151
+ }
152
+ }
153
+
154
+ /** The model being used for transcription */
155
+ get model(): string {
156
+ return this.#opts.model;
157
+ }
158
+
159
+ /** The STT provider name */
160
+ get provider(): string {
161
+ return 'Deepgram';
162
+ }
163
+
164
+ protected async _recognize(
165
+ _frame: AudioFrame | AudioFrame[],
166
+ _abortSignal?: AbortSignal,
167
+ ): Promise<stt.SpeechEvent> {
168
+ throw new Error('V2 API does not support non-streaming recognize. Use .stream()');
169
+ }
170
+
171
+ /**
172
+ * Create a new streaming transcription session.
173
+ *
174
+ * @param options - Stream options
175
+ * @returns A SpeechStream that emits transcription events
176
+ */
177
+ stream(options?: { connOptions?: APIConnectOptions }): stt.SpeechStream {
178
+ const streamOpts = { ...this.#opts, apiKey: this.#apiKey };
179
+ return new SpeechStreamv2(this, streamOpts, options?.connOptions);
180
+ }
181
+
182
+ /**
183
+ * Update STT options. Changes will take effect on the next stream.
184
+ *
185
+ * @param opts - Partial options to update
186
+ */
187
+ updateOptions(opts: Partial<STTv2Options>) {
188
+ this.#opts = {
189
+ ...this.#opts,
190
+ ...opts,
191
+ language:
192
+ opts.language !== undefined ? normalizeLanguage(opts.language) : this.#opts.language,
193
+ };
194
+ if (opts.tags) this.#opts.tags = validateTags(opts.tags);
195
+ // Ref: python livekit-plugins/livekit-plugins-deepgram/livekit/plugins/deepgram/stt_v2.py - 244-249 lines
196
+ if (
197
+ this.#opts.languageHint &&
198
+ this.#opts.languageHint.length > 0 &&
199
+ this.#opts.model !== 'flux-general-multi'
200
+ ) {
201
+ this.#logger.warn(
202
+ { model: this.#opts.model },
203
+ '`languageHint` is only supported by `flux-general-multi` and will be ignored for this model',
204
+ );
205
+ }
206
+ this.#logger.debug('Updated STTv2 options');
207
+ }
208
+ }
209
+
210
+ // --- Stream Implementation ---
211
+
212
+ class SpeechStreamv2 extends stt.SpeechStream {
213
+ readonly label = 'deepgram.SpeechStreamv2';
214
+ #opts: STTv2Options & { apiKey: string };
215
+ #logger = log();
216
+ #ws: WebSocket | null = null;
217
+
218
+ #audioDurationCollector: PeriodicCollector<number>;
219
+ #requestId = '';
220
+ #speaking = false;
221
+
222
+ // Parity: _reconnect_event - using existing Event class from @livekit/agents
223
+ #reconnectEvent = new Event();
224
+
225
+ constructor(
226
+ sttInstance: STTv2,
227
+ opts: STTv2Options & { apiKey: string },
228
+ connOptions?: APIConnectOptions,
229
+ ) {
230
+ super(sttInstance, opts.sampleRate, connOptions);
231
+ this.#opts = opts;
232
+
233
+ this.#audioDurationCollector = new PeriodicCollector(
234
+ (duration) => this.#onAudioDurationReport(duration),
235
+ { duration: 5.0 },
236
+ );
237
+ }
238
+
239
+ updateOptions(opts: Partial<STTv2Options>) {
240
+ this.#logger.debug('Stream received option update', opts);
241
+ this.#opts = {
242
+ ...this.#opts,
243
+ ...opts,
244
+ language:
245
+ opts.language !== undefined ? normalizeLanguage(opts.language) : this.#opts.language,
246
+ };
247
+ if (opts.tags) this.#opts.tags = validateTags(opts.tags);
248
+
249
+ // Trigger reconnection loop
250
+ this.#reconnectEvent.set();
251
+ }
252
+
253
+ protected async run() {
254
+ // Outer Loop: Handles reconnections (Configuration updates)
255
+ while (!this.closed) {
256
+ try {
257
+ this.#reconnectEvent.clear();
258
+
259
+ const url = this.#getDeepgramUrl();
260
+ this.#logger.debug(`Connecting to Deepgram: ${url}`);
261
+
262
+ this.#ws = new WebSocket(url, {
263
+ headers: { Authorization: `Token ${this.#opts.apiKey}` },
264
+ });
265
+
266
+ // 1. Wait for Connection Open
267
+ await new Promise<void>((resolve, reject) => {
268
+ if (!this.#ws) return reject(new Error('WebSocket not initialized'));
269
+
270
+ const onOpen = () => {
271
+ this.#ws?.off('error', onError);
272
+ resolve();
273
+ };
274
+ const onError = (err: Error) => {
275
+ this.#ws?.off('open', onOpen);
276
+ reject(err);
277
+ };
278
+
279
+ this.#ws.once('open', onOpen);
280
+ this.#ws.once('error', onError);
281
+ });
282
+
283
+ // 2. Run Concurrent Tasks (Send & Receive)
284
+ const sendPromise = this.#sendTask();
285
+ const recvPromise = this.#recvTask();
286
+ const reconnectWait = this.#reconnectEvent.wait();
287
+
288
+ // 3. Race: Normal Completion vs Reconnect Signal
289
+ const result = await Promise.race([
290
+ Promise.all([sendPromise, recvPromise]),
291
+ reconnectWait.then(() => 'RECONNECT'),
292
+ ]);
293
+
294
+ if (result === 'RECONNECT') {
295
+ this.#logger.debug('Reconnecting stream due to option update...');
296
+ // Close current socket; loop will restart and open a new one
297
+ this.#ws.close();
298
+ } else {
299
+ // Normal finish (Stream ended or Error thrown)
300
+ break;
301
+ }
302
+ } catch (error) {
303
+ this.#logger.error('Deepgram stream error', { error });
304
+ throw error; // Let Base Class handle retry logic
305
+ } finally {
306
+ if (this.#ws?.readyState === WebSocket.OPEN) {
307
+ this.#ws.close();
308
+ }
309
+ }
310
+ }
311
+ this.close();
312
+ }
313
+
314
+ async #sendTask() {
315
+ if (!this.#ws) return;
316
+
317
+ // Buffer audio into 50ms chunks (Parity)
318
+ const samples50ms = Math.floor(this.#opts.sampleRate / 20);
319
+ const audioBstream = new AudioByteStream(this.#opts.sampleRate, 1, samples50ms);
320
+
321
+ let hasEnded = false;
322
+
323
+ // Manual Iterator to allow racing against Reconnect Signal
324
+ const iterator = this.input[Symbol.asyncIterator]();
325
+
326
+ while (true) {
327
+ const nextPromise = iterator.next();
328
+ // If reconnect signal fires, abort the wait
329
+ const abortPromise = this.#reconnectEvent.wait().then(() => ({ abort: true }) as const);
330
+
331
+ const result = await Promise.race([nextPromise, abortPromise]);
332
+
333
+ // Check if we need to abort (Reconnect) or if stream is done
334
+ if ('abort' in result || result.done) {
335
+ if (!('abort' in result) && result.done) {
336
+ // Normal stream end
337
+ hasEnded = true;
338
+ } else {
339
+ // Reconnect triggered - break loop immediately
340
+ break;
341
+ }
342
+ }
343
+
344
+ // If we broke above, we don't process data. If not, 'result' is IteratorResult
345
+ if (hasEnded && result.value === undefined) {
346
+ // Process flush below
347
+ } else if ('value' in result) {
348
+ const data = result.value;
349
+ const frames: AudioFrame[] = [];
350
+
351
+ if (data === SpeechStreamv2.FLUSH_SENTINEL) {
352
+ frames.push(...audioBstream.flush());
353
+ hasEnded = true;
354
+ } else {
355
+ frames.push(...audioBstream.write((data as AudioFrame).data.buffer as ArrayBuffer));
356
+ }
357
+
358
+ for (const frame of frames) {
359
+ this.#audioDurationCollector.push(calculateAudioDurationSeconds(frame));
360
+
361
+ if (this.#ws!.readyState === WebSocket.OPEN) {
362
+ this.#ws!.send(frame.data);
363
+ }
364
+
365
+ if (hasEnded) {
366
+ this.#audioDurationCollector.flush();
367
+ hasEnded = false;
368
+ }
369
+ }
370
+ }
371
+
372
+ if (hasEnded) break;
373
+ }
374
+
375
+ // Only send CloseStream if we are exiting normally (not reconnecting)
376
+ if (!this.#reconnectEvent.isSet && this.#ws!.readyState === WebSocket.OPEN) {
377
+ this.#logger.debug('Sending CloseStream message to Deepgram');
378
+ this.#ws!.send(_CLOSE_MSG);
379
+ }
380
+ }
381
+
382
+ async #recvTask() {
383
+ if (!this.#ws) return;
384
+
385
+ return new Promise<void>((resolve) => {
386
+ if (!this.#ws) return resolve();
387
+
388
+ this.#ws.on('message', (data: Buffer, isBinary: boolean) => {
389
+ if (isBinary) {
390
+ this.#logger.warn('Received unexpected binary message from Deepgram');
391
+ return;
392
+ }
393
+ try {
394
+ const msg = JSON.parse(data.toString());
395
+ this.#processStreamEvent(msg);
396
+ } catch (error) {
397
+ this.#logger.error('Failed to parse Deepgram message', { error });
398
+ }
399
+ });
400
+
401
+ this.#ws.on('close', (code, reason) => {
402
+ this.#logger.debug(`Deepgram WebSocket closed: ${code} ${reason}`);
403
+ resolve();
404
+ });
405
+
406
+ // Errors are caught by run() listener, resolve here to clean up task
407
+ this.#ws.on('error', () => resolve());
408
+ });
409
+ }
410
+
411
+ #processStreamEvent(data: Record<string, unknown>) {
412
+ if (data.request_id) {
413
+ this.#requestId = data.request_id as string;
414
+ }
415
+
416
+ if (data.type === 'TurnInfo') {
417
+ const eventType = data.event;
418
+
419
+ if (eventType === 'StartOfTurn') {
420
+ if (this.#speaking) return;
421
+
422
+ this.#speaking = true;
423
+ this.queue.put({
424
+ type: stt.SpeechEventType.START_OF_SPEECH,
425
+ requestId: this.#requestId,
426
+ });
427
+
428
+ this.#sendTranscriptEvent(stt.SpeechEventType.INTERIM_TRANSCRIPT, data);
429
+ } else if (eventType === 'Update') {
430
+ if (!this.#speaking) return;
431
+ this.#sendTranscriptEvent(stt.SpeechEventType.INTERIM_TRANSCRIPT, data);
432
+ } else if (eventType === 'EagerEndOfTurn') {
433
+ if (!this.#speaking) return;
434
+ this.#sendTranscriptEvent(stt.SpeechEventType.PREFLIGHT_TRANSCRIPT, data);
435
+ } else if (eventType === 'TurnResumed') {
436
+ this.#sendTranscriptEvent(stt.SpeechEventType.INTERIM_TRANSCRIPT, data);
437
+ } else if (eventType === 'EndOfTurn') {
438
+ if (!this.#speaking) return;
439
+
440
+ this.#speaking = false;
441
+ this.#sendTranscriptEvent(stt.SpeechEventType.FINAL_TRANSCRIPT, data);
442
+
443
+ this.queue.put({
444
+ type: stt.SpeechEventType.END_OF_SPEECH,
445
+ requestId: this.#requestId,
446
+ });
447
+ }
448
+ } else if (data.type === 'Error') {
449
+ this.#logger.warn('deepgram sent an error', { data });
450
+ const desc = (data.description as string) || 'unknown error from deepgram';
451
+ throw new Error(`Deepgram API Error: ${desc}`);
452
+ }
453
+ }
454
+
455
+ #sendTranscriptEvent(eventType: stt.SpeechEventType, data: Record<string, unknown>) {
456
+ const alts = parseTranscription(this.#opts.language || 'en', data, this.startTimeOffset);
457
+
458
+ if (alts.length > 0) {
459
+ this.queue.put({
460
+ type: eventType,
461
+ requestId: this.#requestId,
462
+ alternatives: [alts[0]!, ...alts.slice(1)],
463
+ });
464
+ }
465
+ }
466
+
467
+ #onAudioDurationReport(duration: number) {
468
+ const usageEvent: stt.SpeechEvent = {
469
+ type: stt.SpeechEventType.RECOGNITION_USAGE,
470
+ requestId: this.#requestId,
471
+ recognitionUsage: {
472
+ audioDuration: duration,
473
+ },
474
+ };
475
+ this.queue.put(usageEvent);
476
+ }
477
+
478
+ #getDeepgramUrl(): string {
479
+ const params: Record<string, string | string[]> = {
480
+ model: this.#opts.model,
481
+ sample_rate: this.#opts.sampleRate.toString(),
482
+ encoding: 'linear16',
483
+ mip_opt_out: String(this.#opts.mipOptOut),
484
+ };
485
+
486
+ // Note: v2 API does NOT include 'language' parameter
487
+ if (this.#opts.eagerEotThreshold)
488
+ params.eager_eot_threshold = this.#opts.eagerEotThreshold.toString();
489
+ if (this.#opts.eotThreshold) params.eot_threshold = this.#opts.eotThreshold.toString();
490
+ if (this.#opts.eotTimeoutMs) params.eot_timeout_ms = this.#opts.eotTimeoutMs.toString();
491
+
492
+ if (this.#opts.keyterms.length > 0) params.keyterm = this.#opts.keyterms;
493
+ if (this.#opts.tags && this.#opts.tags.length > 0) params.tag = this.#opts.tags;
494
+
495
+ // Ref: python livekit-plugins/livekit-plugins-deepgram/livekit/plugins/deepgram/stt_v2.py - 480-481 lines
496
+ if (this.#opts.languageHint && this.#opts.languageHint.length > 0) {
497
+ params.language_hint = this.#opts.languageHint;
498
+ }
499
+
500
+ const baseUrl = this.#opts.endpointUrl.replace(/^http/, 'ws');
501
+ const qs = queryString.stringify(params);
502
+ return `${baseUrl}?${qs}`;
503
+ }
504
+
505
+ override close() {
506
+ super.close();
507
+ this.#ws?.close();
508
+ }
509
+ }
510
+
511
+ // --- Helpers ---
512
+
513
+ function parseTranscription(
514
+ language: string,
515
+ data: Record<string, unknown>,
516
+ startTimeOffset: number,
517
+ ): stt.SpeechData[] {
518
+ const transcript = data.transcript as string | undefined;
519
+ const wordsData = (data.words as Array<Record<string, unknown>>) || [];
520
+
521
+ if (!wordsData || wordsData.length === 0) {
522
+ return [];
523
+ }
524
+
525
+ let confidence = 0;
526
+ if (wordsData.length > 0) {
527
+ const sum = wordsData.reduce((acc: number, w) => acc + ((w.confidence as number) || 0), 0);
528
+ confidence = sum / wordsData.length;
529
+ }
530
+
531
+ // Ref: python livekit-plugins/livekit-plugins-deepgram/livekit/plugins/deepgram/stt_v2.py - 587-591 lines
532
+ const detectedLanguagesRaw = Array.isArray(data.languages) ? (data.languages as string[]) : [];
533
+ const detectedLanguages = detectedLanguagesRaw.map((lang) => normalizeLanguage(lang));
534
+ const primaryLanguage =
535
+ detectedLanguages.length > 0 ? detectedLanguages[0]! : normalizeLanguage(language);
536
+
537
+ const sd: stt.SpeechData = {
538
+ language: primaryLanguage,
539
+ startTime: ((data.audio_window_start as number) || 0) + startTimeOffset,
540
+ endTime: ((data.audio_window_end as number) || 0) + startTimeOffset,
541
+ confidence: confidence,
542
+ text: transcript || '',
543
+ // Ref: python livekit-plugins/livekit-plugins-deepgram/livekit/plugins/deepgram/stt_v2.py - 598 line
544
+ sourceLanguages: detectedLanguages.length > 0 ? detectedLanguages : undefined,
545
+ // Note: Deepgram V2 (Flux) API does not provide word-level timing (start/end).
546
+ // Words only contain 'word' and 'confidence' fields, so startTime/endTime will be 0.
547
+ // See: https://developers.deepgram.com/docs/flux/nova-3-migration
548
+ words: wordsData.map((word) =>
549
+ createTimedString({
550
+ text: (word.word as string) ?? '',
551
+ startTime: ((word.start as number) ?? 0) + startTimeOffset,
552
+ endTime: ((word.end as number) ?? 0) + startTimeOffset,
553
+ confidence: (word.confidence as number) ?? 0.0,
554
+ startTimeOffset,
555
+ }),
556
+ ),
557
+ };
558
+
559
+ return [sd];
560
+ }
@@ -0,0 +1,19 @@
1
+ // SPDX-FileCopyrightText: 2024 LiveKit, Inc.
2
+ //
3
+ // SPDX-License-Identifier: Apache-2.0
4
+ import { STT } from '@livekit/agents-plugin-openai';
5
+ import { tts } from '@livekit/agents-plugins-test';
6
+ import { describe, it } from 'vitest';
7
+ import { TTS } from './tts.js';
8
+
9
+ const hasDeepgramTtsConfig = Boolean(process.env.DEEPGRAM_API_KEY && process.env.OPENAI_API_KEY);
10
+
11
+ if (hasDeepgramTtsConfig) {
12
+ describe('Deepgram', async () => {
13
+ await tts(new TTS(), new STT());
14
+ });
15
+ } else {
16
+ describe('Deepgram', () => {
17
+ it.skip('requires DEEPGRAM_API_KEY and OPENAI_API_KEY', () => {});
18
+ });
19
+ }