@livekit/agents-plugin-assemblyai 1.4.9 → 1.5.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.
@@ -0,0 +1,132 @@
1
+ // SPDX-FileCopyrightText: 2026 LiveKit, Inc.
2
+ //
3
+ // SPDX-License-Identifier: Apache-2.0
4
+ import { stt as sttLib } from '@livekit/agents';
5
+ import { AudioFrame } from '@livekit/rtc-node';
6
+ import { once } from 'node:events';
7
+ import type { AddressInfo } from 'node:net';
8
+ import { describe, expect, it } from 'vitest';
9
+ import { WebSocketServer } from 'ws';
10
+ import { STT } from './stt.js';
11
+
12
+ function makeFrame(samplesPerChannel = 800, sampleRate = 16000): AudioFrame {
13
+ const data = new Int16Array(samplesPerChannel);
14
+ data.fill(1);
15
+ return new AudioFrame(data, sampleRate, 1, samplesPerChannel);
16
+ }
17
+
18
+ async function startWebSocketServer() {
19
+ const wss = new WebSocketServer({ host: '127.0.0.1', port: 0 });
20
+ await once(wss, 'listening');
21
+ const address = wss.address() as AddressInfo;
22
+ return { wss, baseUrl: `ws://127.0.0.1:${address.port}` };
23
+ }
24
+
25
+ async function closeWebSocketServer(wss: WebSocketServer): Promise<void> {
26
+ await new Promise<void>((resolve) => wss.close(() => resolve()));
27
+ }
28
+
29
+ async function waitUntil(predicate: () => boolean, timeoutMs = 1000): Promise<void> {
30
+ const startedAt = Date.now();
31
+ while (Date.now() - startedAt < timeoutMs) {
32
+ if (predicate()) return;
33
+ await new Promise((resolve) => setTimeout(resolve, 10));
34
+ }
35
+ throw new Error('timed out waiting for condition');
36
+ }
37
+
38
+ async function collectUntilEnd(stream: sttLib.SpeechStream): Promise<sttLib.SpeechEvent[]> {
39
+ const events: sttLib.SpeechEvent[] = [];
40
+ for await (const event of stream) {
41
+ events.push(event);
42
+ if (event.type === sttLib.SpeechEventType.END_OF_SPEECH) break;
43
+ }
44
+ return events;
45
+ }
46
+
47
+ describe('AssemblyAI STT metadata', () => {
48
+ it('maps turn confidence fields onto speech data metadata', async () => {
49
+ const { wss, baseUrl } = await startWebSocketServer();
50
+ let requestUrl = '';
51
+
52
+ wss.on('connection', (ws, req) => {
53
+ requestUrl = req.url ?? '';
54
+ ws.on('message', () => {
55
+ ws.send(
56
+ JSON.stringify({
57
+ type: 'Turn',
58
+ transcript: 'hola mundo',
59
+ utterance: 'hola mundo',
60
+ end_of_turn: true,
61
+ language_code: 'es',
62
+ language_confidence: 0.94,
63
+ words: [
64
+ { text: 'hola', start: 0, end: 200, confidence: 0.96 },
65
+ { text: 'mundo', start: 200, end: 500, confidence: 0.98 },
66
+ ],
67
+ }),
68
+ );
69
+ });
70
+ });
71
+
72
+ try {
73
+ const assemblyai = new STT({
74
+ apiKey: 'test-key',
75
+ baseUrl,
76
+ speechModel: 'u3-rt-pro',
77
+ });
78
+ const stream = assemblyai.stream({
79
+ connOptions: { maxRetry: 0, retryIntervalMs: 1, timeoutMs: 1000 },
80
+ });
81
+
82
+ await waitUntil(() => requestUrl !== '');
83
+
84
+ stream.pushFrame(makeFrame());
85
+ stream.endInput();
86
+
87
+ const events = await collectUntilEnd(stream);
88
+ stream.close();
89
+
90
+ expect(new URL(`ws://127.0.0.1${requestUrl}`).pathname).toBe('/v3/ws');
91
+ expect(
92
+ events
93
+ .filter((event) => event.alternatives?.[0])
94
+ .map((event) => ({
95
+ type: event.type,
96
+ language: event.alternatives?.[0]?.language,
97
+ metadata: event.alternatives?.[0]?.metadata,
98
+ })),
99
+ ).toEqual([
100
+ {
101
+ type: sttLib.SpeechEventType.INTERIM_TRANSCRIPT,
102
+ language: 'es',
103
+ metadata: {
104
+ assemblyai: {
105
+ languageConfidence: 0.94,
106
+ },
107
+ },
108
+ },
109
+ {
110
+ type: sttLib.SpeechEventType.PREFLIGHT_TRANSCRIPT,
111
+ language: 'es',
112
+ metadata: {
113
+ assemblyai: {
114
+ languageConfidence: 0.94,
115
+ },
116
+ },
117
+ },
118
+ {
119
+ type: sttLib.SpeechEventType.FINAL_TRANSCRIPT,
120
+ language: 'es',
121
+ metadata: {
122
+ assemblyai: {
123
+ languageConfidence: 0.94,
124
+ },
125
+ },
126
+ },
127
+ ]);
128
+ } finally {
129
+ await closeWebSocketServer(wss);
130
+ }
131
+ });
132
+ });
package/src/stt.test.ts CHANGED
@@ -3,9 +3,102 @@
3
3
  // SPDX-License-Identifier: Apache-2.0
4
4
  import { VAD } from '@livekit/agents-plugin-silero';
5
5
  import { stt } from '@livekit/agents-plugins-test';
6
- import { describe, it } from 'vitest';
6
+ import { once } from 'node:events';
7
+ import type { AddressInfo } from 'node:net';
8
+ import { describe, expect, it } from 'vitest';
9
+ import { WebSocketServer } from 'ws';
7
10
  import { STT } from './stt.js';
8
11
 
12
+ async function startWebSocketServer() {
13
+ const wss = new WebSocketServer({ host: '127.0.0.1', port: 0 });
14
+ await once(wss, 'listening');
15
+ const address = wss.address() as AddressInfo;
16
+ return { wss, baseUrl: `ws://127.0.0.1:${address.port}` };
17
+ }
18
+
19
+ async function closeWebSocketServer(wss: WebSocketServer): Promise<void> {
20
+ for (const client of wss.clients) client.close();
21
+ await new Promise<void>((resolve) => wss.close(() => resolve()));
22
+ }
23
+
24
+ async function waitUntil(predicate: () => boolean, timeoutMs = 1000): Promise<void> {
25
+ const startedAt = Date.now();
26
+ while (Date.now() - startedAt < timeoutMs) {
27
+ if (predicate()) return;
28
+ await new Promise((resolve) => setTimeout(resolve, 10));
29
+ }
30
+ throw new Error('timed out waiting for condition');
31
+ }
32
+
33
+ describe('AssemblyAI options', () => {
34
+ it('accepts u3-rt-pro-beta-1', () => {
35
+ const stt = new STT({ apiKey: 'test-key', speechModel: 'u3-rt-pro-beta-1' });
36
+
37
+ expect(stt.model).toBe('u3-rt-pro-beta-1');
38
+ });
39
+
40
+ it('accepts u3-pro parameters for u3-rt-pro-beta-1', () => {
41
+ expect(
42
+ () =>
43
+ new STT({
44
+ apiKey: 'test-key',
45
+ speechModel: 'u3-rt-pro-beta-1',
46
+ prompt: 'medical dictation',
47
+ agentContext: "The agent asked for the patient's name.",
48
+ previousContextNTurns: 10,
49
+ }),
50
+ ).not.toThrow();
51
+ });
52
+
53
+ it('requires a u3-rt-pro model for agentContext', () => {
54
+ expect(
55
+ () =>
56
+ new STT({
57
+ apiKey: 'test-key',
58
+ speechModel: 'universal-streaming-english',
59
+ agentContext: 'hello',
60
+ }),
61
+ ).toThrow(/agentContext/);
62
+ });
63
+
64
+ it('requires a u3-rt-pro model for previousContextNTurns', () => {
65
+ expect(
66
+ () =>
67
+ new STT({
68
+ apiKey: 'test-key',
69
+ speechModel: 'universal-streaming-english',
70
+ previousContextNTurns: 5,
71
+ }),
72
+ ).toThrow(/previousContextNTurns/);
73
+ });
74
+
75
+ it('forwards inactivity timeout to the streaming query', async () => {
76
+ const { wss, baseUrl } = await startWebSocketServer();
77
+ let requestUrl = '';
78
+
79
+ wss.on('connection', (_ws, req) => {
80
+ requestUrl = req.url ?? '';
81
+ });
82
+
83
+ try {
84
+ const stream = new STT({
85
+ apiKey: 'test-key',
86
+ baseUrl,
87
+ inactivityTimeout: 45,
88
+ }).stream();
89
+
90
+ await waitUntil(() => requestUrl !== '');
91
+ stream.close();
92
+
93
+ const url = new URL(`ws://127.0.0.1${requestUrl}`);
94
+ expect(url.pathname).toBe('/v3/ws');
95
+ expect(url.searchParams.get('inactivity_timeout')).toBe('45');
96
+ } finally {
97
+ await closeWebSocketServer(wss);
98
+ }
99
+ });
100
+ });
101
+
9
102
  const hasAssemblyAIApiKey = Boolean(process.env.ASSEMBLYAI_API_KEY);
10
103
 
11
104
  if (hasAssemblyAIApiKey) {
package/src/stt.ts CHANGED
@@ -20,6 +20,7 @@ import type { RawData } from 'ws';
20
20
  import { WebSocket } from 'ws';
21
21
  import type { STTEncoding, STTModels, VoiceFocus } from './models.js';
22
22
 
23
+ // Speech models in the Universal-3 Pro family, which share the same parameter support.
23
24
  const U3_PRO_MODELS = ['u3-rt-pro', 'u3-rt-pro-beta-1', 'universal-3-5-pro'] as const;
24
25
 
25
26
  function isU3ProModel(model: STTModels): boolean {
@@ -40,6 +41,7 @@ interface StreamEventMessage {
40
41
  end_of_turn_confidence?: number;
41
42
  turn_is_formatted?: boolean;
42
43
  language_code?: string;
44
+ language_confidence?: number;
43
45
  speaker_label?: string;
44
46
  words?: Array<{
45
47
  text?: string;
@@ -53,6 +55,18 @@ interface StreamEventMessage {
53
55
  session_duration_seconds?: number;
54
56
  }
55
57
 
58
+ function speechDataMetadata(data: StreamEventMessage): stt.SpeechData['metadata'] | undefined {
59
+ const assemblyai: Record<string, number> = {};
60
+
61
+ if (typeof data.language_confidence === 'number') {
62
+ assemblyai.languageConfidence = data.language_confidence;
63
+ }
64
+
65
+ if (Object.keys(assemblyai).length === 0) return undefined;
66
+
67
+ return { assemblyai };
68
+ }
69
+
56
70
  export interface STTOptions {
57
71
  apiKey?: string;
58
72
  sampleRate: number;
@@ -65,6 +79,11 @@ export interface STTOptions {
65
79
  encoding: STTEncoding;
66
80
  speechModel: STTModels;
67
81
  languageDetection?: boolean;
82
+ /**
83
+ * Session inactivity timeout in seconds. AssemblyAI accepts integer values
84
+ * from 5 to 3600; when unset, no inactivity timeout is applied.
85
+ */
86
+ inactivityTimeout?: number;
68
87
  endOfTurnConfidenceThreshold?: number;
69
88
  /** Minimum silence (ms) before a confident end-of-turn is finalized. */
70
89
  minTurnSilence?: number;
@@ -72,8 +91,12 @@ export interface STTOptions {
72
91
  maxTurnSilence?: number;
73
92
  formatTurns?: boolean;
74
93
  keytermsPrompt?: string[];
75
- /** Only supported with the `u3-rt-pro` model. */
94
+ /** Only supported with the Universal-3 Pro model family. */
76
95
  prompt?: string;
96
+ /** Only supported with the Universal-3 Pro model family. */
97
+ agentContext?: string;
98
+ /** Only supported with the Universal-3 Pro model family. Set at connection time only. */
99
+ previousContextNTurns?: number;
77
100
  vadThreshold?: number;
78
101
  /**
79
102
  * Enable speaker diarization. Note: AssemblyAI will return per-word speaker
@@ -91,8 +114,8 @@ export interface STTOptions {
91
114
  /** Background audio suppression aggressiveness, from 0.0 to 1.0. Connect-time only. */
92
115
  voiceFocusThreshold?: number;
93
116
  /**
94
- * Accuracy/latency preset for u3-rt-pro: `min_latency`, `balanced`, or `max_accuracy`.
95
- * Explicit silence, partials, or VAD options still take precedence over mode defaults.
117
+ * Accuracy/latency preset for the Universal-3 Pro model family: `min_latency`, `balanced`,
118
+ * or `max_accuracy`. Explicit turn-silence values still take precedence over mode defaults.
96
119
  */
97
120
  mode?: 'min_latency' | 'balanced' | 'max_accuracy';
98
121
  baseUrl: string;
@@ -128,13 +151,20 @@ export class STT extends stt.STT {
128
151
  });
129
152
 
130
153
  if (opts.speechModel === 'u3-pro') {
131
- log().warn("'u3-pro' is deprecated, use 'u3-rt-pro' instead.");
132
- opts.speechModel = 'u3-rt-pro';
154
+ log().warn("'u3-pro' is deprecated, use 'universal-3-5-pro' instead.");
155
+ opts.speechModel = 'universal-3-5-pro';
133
156
  }
134
157
 
135
158
  const speechModel = opts.speechModel ?? defaultSTTOptions.speechModel;
136
159
  if (!isU3ProModel(speechModel)) {
137
- for (const param of ['prompt', 'voiceFocus', 'voiceFocusThreshold', 'mode'] as const) {
160
+ for (const param of [
161
+ 'prompt',
162
+ 'agentContext',
163
+ 'previousContextNTurns',
164
+ 'voiceFocus',
165
+ 'voiceFocusThreshold',
166
+ 'mode',
167
+ ] as const) {
138
168
  if (opts[param] !== undefined) {
139
169
  throw new Error(
140
170
  `The '${param}' parameter is only supported with the ${U3_PRO_MODELS.join(', ')} models.`,
@@ -150,8 +180,8 @@ export class STT extends stt.STT {
150
180
  );
151
181
  }
152
182
 
153
- // Minimize latency; matches LK's end-of-turn detector well.
154
- const minTurnSilence = opts.minTurnSilence ?? 100;
183
+ // Minimize latency by default, but let AssemblyAI's mode preset control silence tuning.
184
+ const minTurnSilence = opts.minTurnSilence ?? (opts.mode === undefined ? 100 : undefined);
155
185
 
156
186
  this.#opts = {
157
187
  ...defaultSTTOptions,
@@ -226,6 +256,7 @@ export class SpeechStream extends stt.SpeechStream {
226
256
 
227
257
  const configMsg: Record<string, unknown> = { type: 'UpdateConfiguration' };
228
258
  if (opts.prompt !== undefined) configMsg.prompt = opts.prompt;
259
+ if (opts.agentContext !== undefined) configMsg.agent_context = opts.agentContext;
229
260
  if (opts.keytermsPrompt !== undefined) configMsg.keyterms_prompt = opts.keytermsPrompt;
230
261
  if (opts.maxTurnSilence !== undefined) configMsg.max_turn_silence = opts.maxTurnSilence;
231
262
  if (opts.minTurnSilence !== undefined) configMsg.min_turn_silence = opts.minTurnSilence;
@@ -284,11 +315,13 @@ export class SpeechStream extends stt.SpeechStream {
284
315
  }
285
316
 
286
317
  async #connectWS(): Promise<WebSocket> {
287
- // u3-rt-pro family models default both min and max silence to 100ms when unset.
318
+ // Universal-3 Pro family models default both min and max silence to 100ms when unset.
319
+ // When a mode preset is selected, leave them unset unless explicitly provided so the
320
+ // server's per-mode silence tuning is not overridden by the latency-optimized default.
288
321
  let minSilence = this.#opts.minTurnSilence;
289
322
  let maxSilence = this.#opts.maxTurnSilence;
290
323
  if (isU3ProModel(this.#opts.speechModel)) {
291
- if (minSilence === undefined) minSilence = 100;
324
+ if (minSilence === undefined && this.#opts.mode === undefined) minSilence = 100;
292
325
  if (maxSilence === undefined) maxSilence = minSilence;
293
326
  }
294
327
 
@@ -310,7 +343,10 @@ export class SpeechStream extends stt.SpeechStream {
310
343
  ? JSON.stringify(this.#opts.keytermsPrompt)
311
344
  : undefined,
312
345
  language_detection: languageDetection,
346
+ inactivity_timeout: this.#opts.inactivityTimeout,
313
347
  prompt: this.#opts.prompt,
348
+ agent_context: this.#opts.agentContext,
349
+ previous_context_n_turns: this.#opts.previousContextNTurns,
314
350
  vad_threshold: this.#opts.vadThreshold,
315
351
  speaker_labels: this.#opts.speakerLabels,
316
352
  max_speakers: this.#opts.maxSpeakers,
@@ -508,6 +544,7 @@ export class SpeechStream extends stt.SpeechStream {
508
544
  const utterance = data.utterance ?? '';
509
545
  const transcript = data.transcript ?? '';
510
546
  const language = normalizeLanguage(data.language_code ?? 'en');
547
+ const metadata = speechDataMetadata(data);
511
548
 
512
549
  // Word timestamps are in milliseconds:
513
550
  // https://www.assemblyai.com/docs/api-reference/streaming-api/streaming-api#receive.receiveTurn.words
@@ -542,6 +579,7 @@ export class SpeechStream extends stt.SpeechStream {
542
579
  endTime,
543
580
  confidence,
544
581
  words: timedWords,
582
+ ...(metadata ? { metadata } : {}),
545
583
  },
546
584
  ],
547
585
  });
@@ -569,6 +607,7 @@ export class SpeechStream extends stt.SpeechStream {
569
607
  endTime,
570
608
  confidence: utteranceConfidence,
571
609
  words: utteranceWords,
610
+ ...(metadata ? { metadata } : {}),
572
611
  },
573
612
  ],
574
613
  });
@@ -589,6 +628,7 @@ export class SpeechStream extends stt.SpeechStream {
589
628
  endTime,
590
629
  confidence,
591
630
  words: timedWords,
631
+ ...(metadata ? { metadata } : {}),
592
632
  },
593
633
  ],
594
634
  });