@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.
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/dist/stt-metadata.test.cjs +122 -0
- package/dist/stt-metadata.test.cjs.map +1 -0
- package/dist/stt-metadata.test.d.cts +2 -0
- package/dist/stt-metadata.test.d.ts +2 -0
- package/dist/stt-metadata.test.d.ts.map +1 -0
- package/dist/stt-metadata.test.js +121 -0
- package/dist/stt-metadata.test.js.map +1 -0
- package/dist/stt.cjs +60 -7
- package/dist/stt.cjs.map +1 -1
- package/dist/stt.d.cts +15 -1
- package/dist/stt.d.ts +15 -1
- package/dist/stt.d.ts.map +1 -1
- package/dist/stt.js +61 -7
- package/dist/stt.js.map +1 -1
- package/dist/stt.test.cjs +41 -0
- package/dist/stt.test.cjs.map +1 -1
- package/dist/stt.test.js +41 -0
- package/dist/stt.test.js.map +1 -1
- package/package.json +7 -7
- package/src/stt-metadata.test.ts +132 -0
- package/src/stt.test.ts +50 -0
- package/src/stt.ts +83 -3
|
@@ -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
|
-
|
|
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(
|
|
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
|
});
|