@livekit/agents-plugin-assemblyai 1.4.11 → 1.5.1

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,33 @@
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 { once } from 'node:events';
7
+ import type { AddressInfo } from 'node:net';
6
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
+
9
33
  describe('AssemblyAI options', () => {
10
34
  it('accepts u3-rt-pro-beta-1', () => {
11
35
  const stt = new STT({ apiKey: 'test-key', speechModel: 'u3-rt-pro-beta-1' });
@@ -47,6 +71,32 @@ describe('AssemblyAI options', () => {
47
71
  }),
48
72
  ).toThrow(/previousContextNTurns/);
49
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
+ });
50
100
  });
51
101
 
52
102
  const hasAssemblyAIApiKey = Boolean(process.env.ASSEMBLYAI_API_KEY);
package/src/stt.ts CHANGED
@@ -6,6 +6,8 @@ import {
6
6
  type APIConnectOptions,
7
7
  type AudioBuffer,
8
8
  AudioByteStream,
9
+ ChatMessage,
10
+ type ConversationItemAddedEvent,
9
11
  Future,
10
12
  Task,
11
13
  createTimedString,
@@ -41,6 +43,7 @@ interface StreamEventMessage {
41
43
  end_of_turn_confidence?: number;
42
44
  turn_is_formatted?: boolean;
43
45
  language_code?: string;
46
+ language_confidence?: number;
44
47
  speaker_label?: string;
45
48
  words?: Array<{
46
49
  text?: string;
@@ -54,6 +57,18 @@ interface StreamEventMessage {
54
57
  session_duration_seconds?: number;
55
58
  }
56
59
 
60
+ function speechDataMetadata(data: StreamEventMessage): stt.SpeechData['metadata'] | undefined {
61
+ const assemblyai: Record<string, number> = {};
62
+
63
+ if (typeof data.language_confidence === 'number') {
64
+ assemblyai.languageConfidence = data.language_confidence;
65
+ }
66
+
67
+ if (Object.keys(assemblyai).length === 0) return undefined;
68
+
69
+ return { assemblyai };
70
+ }
71
+
57
72
  export interface STTOptions {
58
73
  apiKey?: string;
59
74
  sampleRate: number;
@@ -66,6 +81,11 @@ export interface STTOptions {
66
81
  encoding: STTEncoding;
67
82
  speechModel: STTModels;
68
83
  languageDetection?: boolean;
84
+ /**
85
+ * Session inactivity timeout in seconds. AssemblyAI accepts integer values
86
+ * from 5 to 3600; when unset, no inactivity timeout is applied.
87
+ */
88
+ inactivityTimeout?: number;
69
89
  endOfTurnConfidenceThreshold?: number;
70
90
  /** Minimum silence (ms) before a confident end-of-turn is finalized. */
71
91
  minTurnSilence?: number;
@@ -100,6 +120,13 @@ export interface STTOptions {
100
120
  * or `max_accuracy`. Explicit turn-silence values still take precedence over mode defaults.
101
121
  */
102
122
  mode?: 'min_latency' | 'balanced' | 'max_accuracy';
123
+ /**
124
+ * When the model supports it, let an `AgentSession` push each assistant reply into
125
+ * `agentContext` so it is carried into the model's conversation context. Defaults to false;
126
+ * set true to enable. Prior user turns are carried automatically by the model regardless of
127
+ * this flag. Ignored on models without context support.
128
+ */
129
+ agentContextCarryover?: boolean;
103
130
  baseUrl: string;
104
131
  }
105
132
 
@@ -115,6 +142,9 @@ const defaultSTTOptions: STTOptions = {
115
142
  export class STT extends stt.STT {
116
143
  #opts: STTOptions;
117
144
  #streams = new Set<WeakRef<SpeechStream>>();
145
+ // set (user + session))
146
+ #userKeyterms: string[];
147
+ #sessionKeyterms: string[] = [];
118
148
  label = 'assemblyai.STT';
119
149
 
120
150
  get model(): string {
@@ -126,10 +156,20 @@ export class STT extends stt.STT {
126
156
  }
127
157
 
128
158
  constructor(opts: Partial<STTOptions> = {}) {
159
+ // u3-rt-pro family — "u3-pro" is normalized below — and is opt-in via the user)
160
+ const rawModel = opts.speechModel ?? defaultSTTOptions.speechModel;
161
+ const supportsCarryover = isU3ProModel(rawModel) || rawModel === 'u3-pro';
162
+ if (opts.agentContextCarryover && !supportsCarryover) {
163
+ log().warn(
164
+ `agentContextCarryover is enabled but model '${rawModel}' does not support it; ignoring`,
165
+ );
166
+ }
129
167
  super({
130
168
  streaming: true,
131
169
  interimResults: true,
132
170
  alignedTranscript: 'word',
171
+ keyterms: true,
172
+ chatContext: (opts.agentContextCarryover ?? false) && supportsCarryover,
133
173
  });
134
174
 
135
175
  if (opts.speechModel === 'u3-pro') {
@@ -171,6 +211,7 @@ export class STT extends stt.STT {
171
211
  apiKey,
172
212
  minTurnSilence,
173
213
  };
214
+ this.#userKeyterms = [...(this.#opts.keytermsPrompt ?? [])];
174
215
  }
175
216
 
176
217
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
@@ -179,17 +220,51 @@ export class STT extends stt.STT {
179
220
  }
180
221
 
181
222
  updateOptions(opts: Partial<STTOptions>) {
182
- this.#opts = { ...this.#opts, ...opts };
223
+ // session keyterms so a user update doesn't drop them)
224
+ const nextOpts = { ...opts };
225
+ if (nextOpts.keytermsPrompt !== undefined) {
226
+ this.#userKeyterms = [...nextOpts.keytermsPrompt];
227
+ nextOpts.keytermsPrompt = [...new Set([...this.#userKeyterms, ...this.#sessionKeyterms])];
228
+ }
229
+ this.#opts = { ...this.#opts, ...nextOpts };
183
230
  for (const ref of this.#streams) {
184
231
  const stream = ref.deref();
185
232
  if (stream) {
186
- stream.updateOptions(opts);
233
+ stream.updateOptions(nextOpts);
187
234
  } else {
188
235
  this.#streams.delete(ref);
189
236
  }
190
237
  }
191
238
  }
192
239
 
240
+ override _updateSessionKeyterms(keyterms: string[]): void {
241
+ if (
242
+ keyterms.length === this.#sessionKeyterms.length &&
243
+ keyterms.every((t, i) => t === this.#sessionKeyterms[i])
244
+ ) {
245
+ return;
246
+ }
247
+ this.#sessionKeyterms = [...keyterms];
248
+ const merged = [...new Set([...this.#userKeyterms, ...keyterms])];
249
+ this.#opts.keytermsPrompt = merged;
250
+ // applied live via the stream's UpdateConfiguration (no reconnect)
251
+ for (const ref of this.#streams) {
252
+ const stream = ref.deref();
253
+ if (stream) {
254
+ stream.updateOptions({ keytermsPrompt: merged });
255
+ } else {
256
+ this.#streams.delete(ref);
257
+ }
258
+ }
259
+ }
260
+
261
+ override _pushConversationItem(ev: ConversationItemAddedEvent): void {
262
+ const chatItem = ev.item;
263
+ if (chatItem instanceof ChatMessage && chatItem.role === 'assistant' && chatItem.textContent) {
264
+ this.updateOptions({ agentContext: chatItem.textContent });
265
+ }
266
+ }
267
+
193
268
  stream(options?: { connOptions?: APIConnectOptions }): SpeechStream {
194
269
  const stream = new SpeechStream(this, this.#opts, options?.connOptions);
195
270
  this.#streams.add(new WeakRef(stream));
@@ -321,10 +396,11 @@ export class SpeechStream extends stt.SpeechStream {
321
396
  min_turn_silence: minSilence,
322
397
  max_turn_silence: maxSilence,
323
398
  keyterms_prompt:
324
- this.#opts.keytermsPrompt !== undefined
399
+ this.#opts.keytermsPrompt !== undefined && this.#opts.keytermsPrompt.length > 0
325
400
  ? JSON.stringify(this.#opts.keytermsPrompt)
326
401
  : undefined,
327
402
  language_detection: languageDetection,
403
+ inactivity_timeout: this.#opts.inactivityTimeout,
328
404
  prompt: this.#opts.prompt,
329
405
  agent_context: this.#opts.agentContext,
330
406
  previous_context_n_turns: this.#opts.previousContextNTurns,
@@ -525,6 +601,7 @@ export class SpeechStream extends stt.SpeechStream {
525
601
  const utterance = data.utterance ?? '';
526
602
  const transcript = data.transcript ?? '';
527
603
  const language = normalizeLanguage(data.language_code ?? 'en');
604
+ const metadata = speechDataMetadata(data);
528
605
 
529
606
  // Word timestamps are in milliseconds:
530
607
  // https://www.assemblyai.com/docs/api-reference/streaming-api/streaming-api#receive.receiveTurn.words
@@ -559,6 +636,7 @@ export class SpeechStream extends stt.SpeechStream {
559
636
  endTime,
560
637
  confidence,
561
638
  words: timedWords,
639
+ ...(metadata ? { metadata } : {}),
562
640
  },
563
641
  ],
564
642
  });
@@ -586,6 +664,7 @@ export class SpeechStream extends stt.SpeechStream {
586
664
  endTime,
587
665
  confidence: utteranceConfidence,
588
666
  words: utteranceWords,
667
+ ...(metadata ? { metadata } : {}),
589
668
  },
590
669
  ],
591
670
  });
@@ -606,6 +685,7 @@ export class SpeechStream extends stt.SpeechStream {
606
685
  endTime,
607
686
  confidence,
608
687
  words: timedWords,
688
+ ...(metadata ? { metadata } : {}),
609
689
  },
610
690
  ],
611
691
  });