@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.
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/dist/models.cjs.map +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 +31 -8
- package/dist/stt.cjs.map +1 -1
- package/dist/stt.d.cts +12 -3
- package/dist/stt.d.ts +12 -3
- package/dist/stt.d.ts.map +1 -1
- package/dist/stt.js +31 -8
- package/dist/stt.js.map +1 -1
- package/dist/stt.test.cjs +76 -0
- package/dist/stt.test.cjs.map +1 -1
- package/dist/stt.test.js +77 -1
- package/dist/stt.test.js.map +1 -1
- package/package.json +7 -7
- package/src/models.ts +1 -1
- package/src/stt-metadata.test.ts +132 -0
- package/src/stt.test.ts +94 -1
- package/src/stt.ts +50 -10
|
@@ -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 {
|
|
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
|
|
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
|
|
95
|
-
* Explicit silence
|
|
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 '
|
|
132
|
-
opts.speechModel = '
|
|
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 [
|
|
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
|
|
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
|
-
//
|
|
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
|
});
|