@livekit/agents-plugin-assemblyai 0.0.0 → 1.2.8

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/src/stt.ts CHANGED
@@ -1,179 +1,588 @@
1
- // SPDX-FileCopyrightText: 2024 LiveKit, Inc.
1
+ // SPDX-FileCopyrightText: 2026 LiveKit, Inc.
2
2
  //
3
3
  // SPDX-License-Identifier: Apache-2.0
4
- import { type AudioBuffer, AudioByteStream, log, stt } from '@livekit/agents';
4
+ //
5
+ import {
6
+ type APIConnectOptions,
7
+ type AudioBuffer,
8
+ AudioByteStream,
9
+ Future,
10
+ Task,
11
+ createTimedString,
12
+ delay,
13
+ log,
14
+ normalizeLanguage,
15
+ stt,
16
+ waitForAbort,
17
+ } from '@livekit/agents';
5
18
  import type { AudioFrame } from '@livekit/rtc-node';
6
- import { AssemblyAI } from 'assemblyai';
7
- import type { RealtimeTranscriber, RealtimeTranscript } from 'assemblyai';
19
+ import type { RawData } from 'ws';
20
+ import { WebSocket } from 'ws';
21
+ import type { STTEncoding, STTModels } from './models.js';
22
+
23
+ // AssemblyAI Universal-Streaming (v3) message envelope. All fields are optional
24
+ // since we narrow on `type` before reading anything else.
25
+ interface StreamEventMessage {
26
+ type?: 'Begin' | 'SpeechStarted' | 'Turn' | 'Termination' | string;
27
+ // Begin
28
+ id?: string;
29
+ expires_at?: number;
30
+ // Turn
31
+ transcript?: string;
32
+ utterance?: string;
33
+ end_of_turn?: boolean;
34
+ end_of_turn_confidence?: number;
35
+ turn_is_formatted?: boolean;
36
+ language_code?: string;
37
+ speaker_label?: string;
38
+ words?: Array<{
39
+ text?: string;
40
+ start?: number;
41
+ end?: number;
42
+ confidence?: number;
43
+ speaker?: string;
44
+ }>;
45
+ // Termination
46
+ audio_duration_seconds?: number;
47
+ session_duration_seconds?: number;
48
+ }
8
49
 
9
50
  export interface STTOptions {
10
51
  apiKey?: string;
11
- interimResults: boolean;
12
52
  sampleRate: number;
13
- keywords: [string, number][];
14
- endUtteranceSilenceThreshold?: number;
53
+ /**
54
+ * How large each chunk of audio is before being sent to AssemblyAI, in
55
+ * milliseconds. Corresponds to Python's `buffer_size_seconds` (seconds there,
56
+ * ms here per this repo's time-unit convention).
57
+ */
58
+ bufferSizeMs: number;
59
+ encoding: STTEncoding;
60
+ speechModel: STTModels;
61
+ languageDetection?: boolean;
62
+ endOfTurnConfidenceThreshold?: number;
63
+ /** Minimum silence (ms) before a confident end-of-turn is finalized. */
64
+ minTurnSilence?: number;
65
+ /** Maximum silence (ms) before end-of-turn is forced regardless of confidence. */
66
+ maxTurnSilence?: number;
67
+ formatTurns?: boolean;
68
+ keytermsPrompt?: string[];
69
+ /** Only supported with the `u3-rt-pro` model. */
70
+ prompt?: string;
71
+ vadThreshold?: number;
72
+ /**
73
+ * Enable speaker diarization. Note: AssemblyAI will return per-word speaker
74
+ * labels, but the JS framework's `stt.SpeechData` type does not yet expose
75
+ * a `speakerId` field (unlike the Python framework), so the labels are not
76
+ * currently surfaced on emitted events. Setting this to `true` still has
77
+ * effect server-side. Once the base `SpeechData` interface gains speaker
78
+ * support, `#processStreamEvent` should forward `data.words[].speaker` too.
79
+ */
80
+ speakerLabels?: boolean;
81
+ maxSpeakers?: number;
82
+ domain?: string;
83
+ baseUrl: string;
15
84
  }
16
85
 
17
86
  const defaultSTTOptions: STTOptions = {
18
- apiKey: process.env.ASSEMBLY_AI_KEY,
19
- interimResults: true,
87
+ apiKey: process.env.ASSEMBLYAI_API_KEY,
20
88
  sampleRate: 16000,
21
- keywords: [],
22
- // NOTE:
23
- // The default is 700ms from AssemblyAI.
24
- // We use a low default of 300ms here because we also use
25
- // the new end-of-utterance model from LiveKit to handle
26
- // turn detection in my agent. Which means that even though
27
- // this will quickly return a final transcript EVEN THOUGH
28
- // USER IS NOT DONE SPEAKING, the EOU model from LiveKit
29
- // DOES properly differentiate and doesn't interrupt (magically!)
30
- // Ref: https://blog.livekit.io/using-a-transformer-to-improve-end-of-turn-detection/
31
- endUtteranceSilenceThreshold: 200,
89
+ bufferSizeMs: 50,
90
+ encoding: 'pcm_s16le',
91
+ speechModel: 'universal-streaming-english',
92
+ baseUrl: 'wss://streaming.assemblyai.com',
32
93
  };
33
94
 
34
95
  export class STT extends stt.STT {
35
96
  #opts: STTOptions;
36
- #logger = log();
97
+ #streams = new Set<WeakRef<SpeechStream>>();
37
98
  label = 'assemblyai.STT';
38
99
 
39
- constructor(opts: Partial<STTOptions> = defaultSTTOptions) {
100
+ get model(): string {
101
+ return this.#opts.speechModel;
102
+ }
103
+
104
+ get provider(): string {
105
+ return 'AssemblyAI';
106
+ }
107
+
108
+ constructor(opts: Partial<STTOptions> = {}) {
40
109
  super({
41
110
  streaming: true,
42
- interimResults: opts.interimResults ?? defaultSTTOptions.interimResults,
111
+ interimResults: true,
112
+ alignedTranscript: 'word',
43
113
  });
44
- if (opts.apiKey === undefined && defaultSTTOptions.apiKey === undefined) {
114
+
115
+ if (opts.speechModel === 'u3-pro') {
116
+ log().warn("'u3-pro' is deprecated, use 'u3-rt-pro' instead.");
117
+ opts.speechModel = 'u3-rt-pro';
118
+ }
119
+
120
+ if (opts.prompt !== undefined && opts.speechModel !== 'u3-rt-pro') {
121
+ throw new Error("The 'prompt' parameter is only supported with the 'u3-rt-pro' model.");
122
+ }
123
+
124
+ const apiKey = opts.apiKey ?? defaultSTTOptions.apiKey;
125
+ if (!apiKey) {
45
126
  throw new Error(
46
- 'AssemblyAI API key is required, whether as an argument or as $ASSEMBLY_AI_KEY',
127
+ 'AssemblyAI API key is required. Pass one in via the `apiKey` parameter, or set it as the `ASSEMBLYAI_API_KEY` environment variable',
47
128
  );
48
129
  }
49
130
 
50
- this.#opts = { ...defaultSTTOptions, ...opts };
131
+ // Minimize latency; matches LK's end-of-turn detector well.
132
+ const minTurnSilence = opts.minTurnSilence ?? 100;
133
+
134
+ this.#opts = {
135
+ ...defaultSTTOptions,
136
+ ...opts,
137
+ apiKey,
138
+ minTurnSilence,
139
+ };
51
140
  }
52
141
 
53
142
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
54
143
  async _recognize(_: AudioBuffer): Promise<stt.SpeechEvent> {
55
- throw new Error('Recognize is not supported on AssemblyAI STT');
144
+ throw new Error('Non-streaming recognize is not supported on AssemblyAI STT');
56
145
  }
57
146
 
58
- stream(): stt.SpeechStream {
59
- return new SpeechStream(this, this.#opts);
147
+ updateOptions(opts: Partial<STTOptions>) {
148
+ this.#opts = { ...this.#opts, ...opts };
149
+ for (const ref of this.#streams) {
150
+ const stream = ref.deref();
151
+ if (stream) {
152
+ stream.updateOptions(opts);
153
+ } else {
154
+ this.#streams.delete(ref);
155
+ }
156
+ }
157
+ }
158
+
159
+ stream(options?: { connOptions?: APIConnectOptions }): SpeechStream {
160
+ const stream = new SpeechStream(this, this.#opts, options?.connOptions);
161
+ this.#streams.add(new WeakRef(stream));
162
+ return stream;
60
163
  }
61
164
  }
62
165
 
63
166
  export class SpeechStream extends stt.SpeechStream {
167
+ static readonly CLOSE_MSG = JSON.stringify({ type: 'Terminate' });
168
+
64
169
  #opts: STTOptions;
65
170
  #logger = log();
66
- #speaking = false;
67
- #client: AssemblyAI;
68
- #transcriber?: RealtimeTranscriber;
171
+ #speechDurationInS = 0;
172
+ #lastPreflightStartTime = 0;
173
+ #pendingConfigMessages: Record<string, unknown>[] = [];
174
+ #configMessagePending = new Future();
175
+ #sessionId: string | null = null;
176
+ #expiresAt: number | null = null;
69
177
  label = 'assemblyai.SpeechStream';
70
178
 
71
- constructor(stt: STT, opts: STTOptions) {
72
- super(stt);
179
+ constructor(stt: STT, opts: STTOptions, connOptions?: APIConnectOptions) {
180
+ super(stt, opts.sampleRate, connOptions);
73
181
  this.#opts = opts;
74
182
  this.closed = false;
75
- this.#client = new AssemblyAI({
76
- apiKey: this.#opts.apiKey || '',
77
- });
183
+ }
78
184
 
79
- this.#run();
185
+ /**
186
+ * The AssemblyAI session ID. Set when the WebSocket connection is established
187
+ * (before any speech events). Null until the connection completes.
188
+ * Share this with the AssemblyAI team when reporting issues.
189
+ */
190
+ get sessionId(): string | null {
191
+ return this.#sessionId;
80
192
  }
81
193
 
82
- async #run() {
83
- try {
84
- this.#transcriber = this.#client.realtime.transcriber({
85
- sampleRate: this.#opts.sampleRate,
86
- wordBoost: this.#opts.keywords.map((k) => k[0]),
87
- endUtteranceSilenceThreshold: this.#opts.endUtteranceSilenceThreshold,
88
- });
194
+ /**
195
+ * Unix timestamp when the AssemblyAI session expires. Set alongside
196
+ * {@link sessionId} when the WebSocket connection is established.
197
+ */
198
+ get expiresAt(): number | null {
199
+ return this.#expiresAt;
200
+ }
89
201
 
90
- this.#transcriber.on('open', (data) => {
91
- this.#logger
92
- .child({ sessionId: data.sessionId, expiresAt: data.expiresAt })
93
- .debug(`AssemblyAI session opened`);
94
- });
202
+ updateOptions(opts: Partial<STTOptions>) {
203
+ this.#opts = { ...this.#opts, ...opts };
95
204
 
96
- this.#transcriber.on('close', (code, reason) => {
97
- this.#logger.child({ code, reason }).debug(`AssemblyAI session closed`);
98
- if (!this.closed) {
99
- this.#run();
100
- }
101
- });
205
+ const configMsg: Record<string, unknown> = { type: 'UpdateConfiguration' };
206
+ if (opts.prompt !== undefined) configMsg.prompt = opts.prompt;
207
+ if (opts.keytermsPrompt !== undefined) configMsg.keyterms_prompt = opts.keytermsPrompt;
208
+ if (opts.maxTurnSilence !== undefined) configMsg.max_turn_silence = opts.maxTurnSilence;
209
+ if (opts.minTurnSilence !== undefined) configMsg.min_turn_silence = opts.minTurnSilence;
210
+ if (opts.endOfTurnConfidenceThreshold !== undefined) {
211
+ configMsg.end_of_turn_confidence_threshold = opts.endOfTurnConfidenceThreshold;
212
+ }
213
+ if (opts.vadThreshold !== undefined) configMsg.vad_threshold = opts.vadThreshold;
102
214
 
103
- this.#transcriber.on('error', (error) => {
104
- this.#logger.child({ error: error.message }).error(`AssemblyAI error`);
105
- });
215
+ // Only send if any actual fields (besides `type`) were specified.
216
+ if (Object.keys(configMsg).length > 1) {
217
+ this.#pendingConfigMessages.push(configMsg);
218
+ if (!this.#configMessagePending.done) this.#configMessagePending.resolve();
219
+ }
220
+ }
106
221
 
107
- this.#transcriber.on('transcript', (transcript) => {
108
- if (this.closed) return;
222
+ /**
223
+ * Force-finalize the current turn immediately.
224
+ */
225
+ forceEndpoint() {
226
+ this.#pendingConfigMessages.push({ type: 'ForceEndpoint' });
227
+ if (!this.#configMessagePending.done) this.#configMessagePending.resolve();
228
+ }
109
229
 
110
- if (!transcript.text || transcript.text.trim() === '') {
111
- return;
112
- }
230
+ // Deepgram-style reconnect loop around a single websocket lifetime.
231
+ protected async run() {
232
+ const maxRetry = 32;
233
+ let retries = 0;
113
234
 
114
- if (!this.#speaking) {
115
- this.#speaking = true;
116
- this.queue.put({ type: stt.SpeechEventType.START_OF_SPEECH });
117
- }
235
+ while (!this.input.closed && !this.closed) {
236
+ try {
237
+ const ws = await this.#connectWS();
238
+ await this.#runWS(ws);
239
+ retries = 0;
240
+ } catch (e) {
241
+ if (!this.closed && !this.input.closed) {
242
+ if (retries >= maxRetry) {
243
+ throw new Error(`failed to connect to AssemblyAI after ${retries} attempts: ${e}`);
244
+ }
245
+
246
+ const retryDelaySeconds = Math.min(retries * 5, 10);
247
+ retries++;
118
248
 
119
- if (transcript.message_type === 'PartialTranscript') {
120
- this.queue.put({
121
- type: stt.SpeechEventType.INTERIM_TRANSCRIPT,
122
- alternatives: [assemblyTranscriptToSpeechData(transcript)],
123
- });
124
- } else if (transcript.message_type === 'FinalTranscript') {
125
- this.queue.put({
126
- type: stt.SpeechEventType.FINAL_TRANSCRIPT,
127
- alternatives: [assemblyTranscriptToSpeechData(transcript)],
128
- });
249
+ this.#logger.warn(
250
+ `failed to connect to AssemblyAI, retrying in ${retryDelaySeconds} seconds: ${e} (${retries}/${maxRetry})`,
251
+ );
252
+ await delay(retryDelaySeconds * 1000);
253
+ } else {
254
+ this.#logger.warn(
255
+ `AssemblyAI disconnected, connection is closed: ${e} (inputClosed: ${this.input.closed}, isClosed: ${this.closed})`,
256
+ );
129
257
  }
258
+ }
259
+ }
260
+
261
+ this.closed = true;
262
+ }
263
+
264
+ async #connectWS(): Promise<WebSocket> {
265
+ // u3-rt-pro has different silence defaults — if unset, both min and max default to 100ms.
266
+ let minSilence = this.#opts.minTurnSilence;
267
+ let maxSilence = this.#opts.maxTurnSilence;
268
+ if (this.#opts.speechModel === 'u3-rt-pro') {
269
+ if (minSilence === undefined) minSilence = 100;
270
+ if (maxSilence === undefined) maxSilence = minSilence;
271
+ }
272
+
273
+ // Default language_detection to true for multilingual / u3-rt-pro models, false otherwise.
274
+ const defaultLanguageDetection =
275
+ this.#opts.speechModel.includes('multilingual') || this.#opts.speechModel === 'u3-rt-pro';
276
+ const languageDetection = this.#opts.languageDetection ?? defaultLanguageDetection;
277
+
278
+ const liveConfig: Record<string, unknown> = {
279
+ sample_rate: this.#opts.sampleRate,
280
+ encoding: this.#opts.encoding,
281
+ speech_model: this.#opts.speechModel,
282
+ format_turns: this.#opts.formatTurns,
283
+ end_of_turn_confidence_threshold: this.#opts.endOfTurnConfidenceThreshold,
284
+ min_turn_silence: minSilence,
285
+ max_turn_silence: maxSilence,
286
+ keyterms_prompt:
287
+ this.#opts.keytermsPrompt !== undefined
288
+ ? JSON.stringify(this.#opts.keytermsPrompt)
289
+ : undefined,
290
+ language_detection: languageDetection,
291
+ prompt: this.#opts.prompt,
292
+ vad_threshold: this.#opts.vadThreshold,
293
+ speaker_labels: this.#opts.speakerLabels,
294
+ max_speakers: this.#opts.maxSpeakers,
295
+ domain: this.#opts.domain,
296
+ };
297
+
298
+ const url = new URL(`${this.#opts.baseUrl}/v3/ws`);
299
+ // Python serializes booleans as the strings "true"/"false", so we mirror that.
300
+ for (const [key, value] of Object.entries(liveConfig)) {
301
+ if (value === undefined || value === null) continue;
302
+ if (typeof value === 'boolean') {
303
+ url.searchParams.append(key, value ? 'true' : 'false');
304
+ } else {
305
+ url.searchParams.append(key, String(value));
306
+ }
307
+ }
308
+
309
+ const ws = new WebSocket(url, {
310
+ headers: {
311
+ Authorization: this.#opts.apiKey!,
312
+ 'Content-Type': 'application/json',
313
+ 'User-Agent': 'AssemblyAI/1.0 (integration=Livekit)',
314
+ },
315
+ });
316
+
317
+ await new Promise<void>((resolve, reject) => {
318
+ ws.on('open', () => resolve());
319
+ ws.on('error', (error) => reject(error));
320
+ ws.on('close', (code) => reject(new Error(`WebSocket returned ${code}`)));
321
+ });
322
+
323
+ return ws;
324
+ }
325
+
326
+ async #runWS(ws: WebSocket) {
327
+ let closing = false;
328
+ const sessionController = new AbortController();
329
+
330
+ // gets cancelled also when sendTask is complete
331
+ const wsMonitor = Task.from(async (controller) => {
332
+ const closed = new Promise<void>((_, reject) => {
333
+ ws.once('close', (code, reason) => {
334
+ if (!closing) {
335
+ this.#logger.error(`WebSocket closed with code ${code}: ${reason}`);
336
+ reject(new Error('WebSocket closed'));
337
+ }
338
+ });
130
339
  });
131
340
 
132
- await this.#transcriber.connect();
341
+ await Promise.race([closed, waitForAbort(controller.signal)]);
342
+ });
343
+
344
+ const sendTask = async () => {
345
+ const samplesPerBuffer = Math.floor((this.#opts.sampleRate * this.#opts.bufferSizeMs) / 1000);
346
+ const audioStream = new AudioByteStream(this.#opts.sampleRate, 1, samplesPerBuffer);
133
347
 
134
- const sendTask = async () => {
135
- const samples100Ms = Math.floor(this.#opts.sampleRate / 10);
136
- const stream = new AudioByteStream(this.#opts.sampleRate, 1, samples100Ms);
348
+ const abortPromise = waitForAbort(this.abortSignal);
349
+ const sessionAbort = waitForAbort(sessionController.signal);
137
350
 
138
- for await (const data of this.input) {
139
- if (this.closed) break;
351
+ try {
352
+ while (!this.closed) {
353
+ const result = await Promise.race([this.input.next(), abortPromise, sessionAbort]);
354
+
355
+ if (result === undefined) return; // aborted
356
+ if (result.done) break;
357
+
358
+ const data = result.value;
140
359
 
141
360
  let frames: AudioFrame[];
142
361
  if (data === SpeechStream.FLUSH_SENTINEL) {
143
- frames = stream.flush();
144
- } else if (data.sampleRate === this.#opts.sampleRate) {
145
- frames = stream.write(data.data.buffer);
362
+ frames = audioStream.flush();
363
+ } else if (data.sampleRate === this.#opts.sampleRate && data.channels === 1) {
364
+ // AssemblyAI expects mono PCM. The base SpeechStream only resamples
365
+ // sample rate, so reject any frame that is not already downmixed.
366
+ frames = audioStream.write(data.data.buffer as ArrayBuffer);
146
367
  } else {
147
- throw new Error(`Sample rate or channel count of frame does not match`);
368
+ throw new Error('sample rate or channel count of frame does not match');
148
369
  }
149
370
 
150
- for await (const frame of frames) {
151
- this.#transcriber?.sendAudio(new Uint8Array(frame.data.buffer));
371
+ for (const frame of frames) {
372
+ this.#speechDurationInS += frame.samplesPerChannel / frame.sampleRate;
373
+ ws.send(frame.data.buffer);
152
374
  }
153
375
  }
376
+ } finally {
377
+ closing = true;
378
+ try {
379
+ ws.send(SpeechStream.CLOSE_MSG);
380
+ } catch {
381
+ // ignore — socket may already be closing
382
+ }
383
+ wsMonitor.cancel();
384
+ }
385
+ };
386
+
387
+ let messageHandler: ((msg: RawData, isBinary: boolean) => void) | null = null;
388
+ const listenTask = Task.from(async (controller) => {
389
+ const listenMessage = new Promise<void>((resolve, reject) => {
390
+ messageHandler = (msg, isBinary) => {
391
+ if (isBinary) {
392
+ this.#logger.error('unexpected binary message from AssemblyAI');
393
+ return;
394
+ }
395
+ try {
396
+ const json = JSON.parse(msg.toString()) as StreamEventMessage;
397
+ this.#processStreamEvent(json);
398
+ if (this.closed || closing) {
399
+ resolve();
400
+ }
401
+ } catch (err) {
402
+ this.#logger.error(`AssemblyAI: error processing message: ${msg}`);
403
+ reject(err);
404
+ }
405
+ };
406
+ ws.on('message', messageHandler);
407
+ });
408
+
409
+ await Promise.race([listenMessage, waitForAbort(controller.signal)]);
410
+ });
154
411
 
155
- if (this.#transcriber) {
156
- await this.#transcriber.close();
412
+ const configTask = Task.from(async (controller) => {
413
+ // Drain any messages queued while the socket was reconnecting.
414
+ while (this.#pendingConfigMessages.length > 0) {
415
+ const msg = this.#pendingConfigMessages.shift()!;
416
+ ws.send(JSON.stringify(msg));
417
+ }
418
+
419
+ while (!controller.signal.aborted) {
420
+ await Promise.race([this.#configMessagePending.await, waitForAbort(controller.signal)]);
421
+ if (controller.signal.aborted) return;
422
+
423
+ this.#configMessagePending = new Future();
424
+ while (this.#pendingConfigMessages.length > 0) {
425
+ const msg = this.#pendingConfigMessages.shift()!;
426
+ ws.send(JSON.stringify(msg));
157
427
  }
158
- };
428
+ }
429
+ });
430
+
431
+ try {
432
+ await Promise.all([sendTask(), listenTask.result, wsMonitor.result]);
433
+ } finally {
434
+ closing = true;
435
+ sessionController.abort();
436
+ listenTask.cancel();
437
+ configTask.cancel();
438
+ if (messageHandler) ws.off('message', messageHandler);
439
+ try {
440
+ ws.close();
441
+ } catch {
442
+ // ignore
443
+ }
444
+ }
445
+ }
446
+
447
+ #averageConfidence(words: Array<{ confidence?: number }>): number {
448
+ if (words.length === 0) return 0;
449
+ return words.reduce((sum, w) => sum + (w.confidence ?? 0), 0) / words.length;
450
+ }
451
+
452
+ #processStreamEvent(data: StreamEventMessage) {
453
+ const messageType = data.type;
159
454
 
160
- await sendTask();
161
- } catch (error: unknown) {
162
- this.#logger.child({ error }).error(`Error in AssemblyAI STT`);
455
+ if (messageType === 'Begin') {
456
+ this.#sessionId = data.id ?? null;
457
+ this.#expiresAt = data.expires_at ?? null;
458
+ this.#logger.info(
459
+ `AssemblyAI session started id=${this.#sessionId} expires_at=${this.#expiresAt}`,
460
+ );
461
+ return;
462
+ }
463
+
464
+ if (messageType === 'SpeechStarted') {
465
+ this.queue.put({ type: stt.SpeechEventType.START_OF_SPEECH });
466
+ return;
467
+ }
468
+
469
+ if (messageType === 'Termination') {
470
+ this.#logger.debug(
471
+ `AssemblyAI session terminated audio_duration=${data.audio_duration_seconds}s session_duration=${data.session_duration_seconds}s`,
472
+ );
473
+ return;
474
+ }
163
475
 
164
- if (!this.closed) {
165
- setTimeout(() => this.#run(), 5000);
476
+ if (messageType !== 'Turn') {
477
+ return;
478
+ }
479
+
480
+ const words = data.words ?? [];
481
+ const endOfTurn = Boolean(data.end_of_turn);
482
+ const turnIsFormatted = Boolean(data.turn_is_formatted);
483
+ const utterance = data.utterance ?? '';
484
+ const transcript = data.transcript ?? '';
485
+ const language = normalizeLanguage(data.language_code ?? 'en');
486
+
487
+ // Word timestamps are in milliseconds:
488
+ // https://www.assemblyai.com/docs/api-reference/streaming-api/streaming-api#receive.receiveTurn.words
489
+ const timedWords = words.map((word) =>
490
+ createTimedString({
491
+ text: word.text ?? '',
492
+ startTime: (word.start ?? 0) / 1000 + this.startTimeOffset,
493
+ endTime: (word.end ?? 0) / 1000 + this.startTimeOffset,
494
+ confidence: word.confidence ?? 0,
495
+ startTimeOffset: this.startTimeOffset,
496
+ }),
497
+ );
498
+
499
+ let startTime = 0;
500
+ let endTime = 0;
501
+ let confidence = 0;
502
+
503
+ // `words` are cumulative for the turn — emit as an interim transcript.
504
+ if (timedWords.length > 0) {
505
+ const interimText = timedWords.map((w) => w.text).join(' ');
506
+ startTime = timedWords[0]!.startTime ?? 0;
507
+ endTime = timedWords[timedWords.length - 1]!.endTime ?? 0;
508
+ confidence = this.#averageConfidence(timedWords);
509
+
510
+ this.queue.put({
511
+ type: stt.SpeechEventType.INTERIM_TRANSCRIPT,
512
+ alternatives: [
513
+ {
514
+ language,
515
+ text: interimText,
516
+ startTime,
517
+ endTime,
518
+ confidence,
519
+ words: timedWords,
520
+ },
521
+ ],
522
+ });
523
+ }
524
+
525
+ // `utterance` is chunk-based (not cumulative) — emit as a preflight transcript
526
+ // covering only the words since the last preflight.
527
+ if (utterance) {
528
+ if (this.#lastPreflightStartTime === 0) {
529
+ this.#lastPreflightStartTime = startTime;
530
+ }
531
+
532
+ const utteranceWords = timedWords.filter(
533
+ (w) => w.startTime !== undefined && w.startTime >= this.#lastPreflightStartTime,
534
+ );
535
+ const utteranceConfidence = this.#averageConfidence(utteranceWords);
536
+
537
+ this.queue.put({
538
+ type: stt.SpeechEventType.PREFLIGHT_TRANSCRIPT,
539
+ alternatives: [
540
+ {
541
+ language,
542
+ text: utterance,
543
+ startTime: this.#lastPreflightStartTime,
544
+ endTime,
545
+ confidence: utteranceConfidence,
546
+ words: utteranceWords,
547
+ },
548
+ ],
549
+ });
550
+ this.#lastPreflightStartTime = endTime;
551
+ }
552
+
553
+ // End-of-turn: emit FINAL_TRANSCRIPT + END_OF_SPEECH.
554
+ // If the user asked for formatted turns, wait for a formatted final.
555
+ const waitingForFormatted = this.#opts.formatTurns === true && !turnIsFormatted;
556
+ if (endOfTurn && !waitingForFormatted) {
557
+ this.queue.put({
558
+ type: stt.SpeechEventType.FINAL_TRANSCRIPT,
559
+ alternatives: [
560
+ {
561
+ language,
562
+ text: transcript,
563
+ startTime,
564
+ endTime,
565
+ confidence,
566
+ words: timedWords,
567
+ },
568
+ ],
569
+ });
570
+
571
+ this.queue.put({ type: stt.SpeechEventType.END_OF_SPEECH });
572
+ if (this.#speechDurationInS > 0) {
573
+ this.queue.put({
574
+ type: stt.SpeechEventType.RECOGNITION_USAGE,
575
+ // Propagate the AssemblyAI session id as the request id so metrics
576
+ // can be correlated back to a specific connection, mirroring how
577
+ // Deepgram surfaces its `request_id`.
578
+ requestId: this.#sessionId ?? undefined,
579
+ recognitionUsage: {
580
+ audioDuration: this.#speechDurationInS,
581
+ },
582
+ });
583
+ this.#speechDurationInS = 0;
584
+ this.#lastPreflightStartTime = 0;
166
585
  }
167
586
  }
168
587
  }
169
588
  }
170
-
171
- const assemblyTranscriptToSpeechData = (transcript: RealtimeTranscript): stt.SpeechData => {
172
- return {
173
- language: 'en-US',
174
- startTime: transcript.audio_start || 0,
175
- endTime: transcript.audio_end || 0,
176
- confidence: transcript.confidence || 1.0,
177
- text: transcript.text || '',
178
- };
179
- };