@ai-sdk/elevenlabs 3.0.12 → 3.0.14

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.
@@ -1,22 +1,80 @@
1
- import type { TranscriptionModelV4, SharedV4Warning } from '@ai-sdk/provider';
1
+ import {
2
+ InvalidArgumentError,
3
+ UnsupportedFunctionalityError,
4
+ type Experimental_TranscriptionModelV4StreamOptions as TranscriptionModelV4StreamOptions,
5
+ type SharedV4Warning,
6
+ type TranscriptionModelV4,
7
+ } from '@ai-sdk/provider';
2
8
  import {
3
9
  combineHeaders,
4
10
  convertBase64ToUint8Array,
11
+ convertToBase64,
12
+ connectToWebSocket,
5
13
  createJsonResponseHandler,
6
14
  mediaTypeToExtension,
7
15
  parseProviderOptions,
8
16
  postFormDataToApi,
17
+ safeParseJSON,
9
18
  serializeModelOptions,
19
+ toWebSocketUrl,
20
+ waitForWebSocketBufferDrain,
10
21
  WORKFLOW_SERIALIZE,
11
22
  WORKFLOW_DESERIALIZE,
23
+ type WebSocketConnection,
24
+ type WebSocketLike,
12
25
  } from '@ai-sdk/provider-utils';
13
26
  import { z } from 'zod/v4';
14
27
  import type { ElevenLabsConfig } from './elevenlabs-config';
15
28
  import { elevenlabsFailedResponseHandler } from './elevenlabs-error';
16
- import { elevenLabsTranscriptionModelOptionsSchema } from './elevenlabs-transcription-model-options';
29
+ import {
30
+ elevenLabsTranscriptionModelOptionsSchema,
31
+ type ElevenLabsTranscriptionModelOptions,
32
+ } from './elevenlabs-transcription-model-options';
17
33
  import type { ElevenLabsTranscriptionModelId } from './elevenlabs-transcription-options';
18
34
  import type { ElevenLabsTranscriptionAPITypes } from './elevenlabs-api-types';
19
35
 
36
+ type ElevenLabsRealtimeTranscriptionEvent = {
37
+ message_type?: string;
38
+ session_id?: string;
39
+ text?: string;
40
+ language_code?: string | null;
41
+ words?: Array<{
42
+ text?: string;
43
+ start?: number;
44
+ end?: number;
45
+ type?: string;
46
+ }> | null;
47
+ error?: string;
48
+ };
49
+
50
+ const elevenLabsRealtimeErrorTypes = new Set([
51
+ 'auth_error',
52
+ 'chunk_size_exceeded',
53
+ 'commit_throttled',
54
+ 'error',
55
+ 'input_error',
56
+ 'insufficient_audio_activity',
57
+ 'queue_overflow',
58
+ 'quota_exceeded',
59
+ 'rate_limited',
60
+ 'resource_exhausted',
61
+ 'session_time_limit_exceeded',
62
+ 'transcriber_error',
63
+ 'unaccepted_terms',
64
+ ]);
65
+
66
+ const elevenLabsLateFinalizationErrorTypes = new Set([
67
+ 'commit_throttled',
68
+ 'input_error',
69
+ 'insufficient_audio_activity',
70
+ ]);
71
+
72
+ const finalCommitGracePeriodMs = 250;
73
+
74
+ function isRealtimeTranscriptionModelId(modelId: string): boolean {
75
+ return modelId === 'scribe_v2_realtime';
76
+ }
77
+
20
78
  interface ElevenLabsTranscriptionModelConfig extends ElevenLabsConfig {
21
79
  _internal?: {
22
80
  currentDate?: () => Date;
@@ -63,6 +121,15 @@ export class ElevenLabsTranscriptionModel implements TranscriptionModelV4 {
63
121
  schema: elevenLabsTranscriptionModelOptionsSchema,
64
122
  });
65
123
 
124
+ if (elevenlabsOptions?.streaming != null) {
125
+ warnings.push({
126
+ type: 'unsupported',
127
+ feature: 'providerOptions.elevenlabs.streaming',
128
+ details:
129
+ 'ElevenLabs batch transcription does not support streaming options.',
130
+ });
131
+ }
132
+
66
133
  // Create form data with base fields
67
134
  const formData = new FormData();
68
135
  const blob =
@@ -114,6 +181,12 @@ export class ElevenLabsTranscriptionModel implements TranscriptionModelV4 {
114
181
  async doGenerate(
115
182
  options: Parameters<TranscriptionModelV4['doGenerate']>[0],
116
183
  ): Promise<Awaited<ReturnType<TranscriptionModelV4['doGenerate']>>> {
184
+ if (isRealtimeTranscriptionModelId(this.modelId)) {
185
+ throw new UnsupportedFunctionalityError({
186
+ functionality: `non-streaming transcription with ${this.modelId}`,
187
+ });
188
+ }
189
+
117
190
  const currentDate = this.config._internal?.currentDate?.() ?? new Date();
118
191
  const { formData, warnings } = await this.getArgs(options);
119
192
 
@@ -155,6 +228,493 @@ export class ElevenLabsTranscriptionModel implements TranscriptionModelV4 {
155
228
  },
156
229
  };
157
230
  }
231
+
232
+ async doStream(
233
+ options: TranscriptionModelV4StreamOptions,
234
+ ): Promise<
235
+ Awaited<ReturnType<NonNullable<TranscriptionModelV4['doStream']>>>
236
+ > {
237
+ if (!isRealtimeTranscriptionModelId(this.modelId)) {
238
+ throw new UnsupportedFunctionalityError({
239
+ functionality: `streaming transcription with ${this.modelId}`,
240
+ });
241
+ }
242
+
243
+ const currentDate = this.config._internal?.currentDate?.() ?? new Date();
244
+ const elevenLabsOptions = await parseProviderOptions({
245
+ provider: 'elevenlabs',
246
+ providerOptions: options.providerOptions,
247
+ schema: elevenLabsTranscriptionModelOptionsSchema,
248
+ });
249
+ const streamingOptions = elevenLabsOptions?.streaming ?? undefined;
250
+ const warnings: SharedV4Warning[] = [];
251
+
252
+ const rawElevenLabsOptions = options.providerOptions?.elevenlabs ?? {};
253
+ for (const option of [
254
+ 'diarize',
255
+ 'fileFormat',
256
+ 'numSpeakers',
257
+ 'tagAudioEvents',
258
+ 'timestampsGranularity',
259
+ ]) {
260
+ if (
261
+ rawElevenLabsOptions[option as keyof typeof rawElevenLabsOptions] !=
262
+ null
263
+ ) {
264
+ warnings.push({
265
+ type: 'unsupported',
266
+ feature: `providerOptions.elevenlabs.${option}`,
267
+ details: `ElevenLabs realtime transcription does not support ${option}.`,
268
+ });
269
+ }
270
+ }
271
+
272
+ // ElevenLabs documents filter_background_audio as incompatible with
273
+ // include_timestamps. Language detection is delivered on the same
274
+ // timestamp-bearing event, so it also requires include_timestamps here.
275
+ // https://elevenlabs.io/docs/api-reference/speech-to-text/v-1-speech-to-text-realtime
276
+ if (
277
+ streamingOptions?.filterBackgroundAudio === true &&
278
+ (streamingOptions.includeTimestamps === true ||
279
+ streamingOptions.includeLanguageDetection === true)
280
+ ) {
281
+ throw new InvalidArgumentError({
282
+ argument: 'providerOptions',
283
+ message:
284
+ 'providerOptions.elevenlabs.streaming.filterBackgroundAudio cannot be combined with includeTimestamps or includeLanguageDetection',
285
+ });
286
+ }
287
+
288
+ const inputFormat = getElevenLabsRealtimeAudioFormat(
289
+ options.inputAudioFormat,
290
+ );
291
+ const url = buildElevenLabsRealtimeTranscriptionUrl({
292
+ baseUrl: toWebSocketUrl(
293
+ this.config.url({
294
+ path: '/v1/speech-to-text/realtime',
295
+ modelId: this.modelId,
296
+ }),
297
+ ),
298
+ inputFormat: inputFormat.audioFormat,
299
+ languageCode: elevenLabsOptions?.languageCode ?? undefined,
300
+ modelId: this.modelId,
301
+ streamingOptions,
302
+ });
303
+
304
+ return {
305
+ request: { body: url.toString() },
306
+ response: {
307
+ timestamp: currentDate,
308
+ modelId: this.modelId,
309
+ },
310
+ stream: createElevenLabsRealtimeTranscriptionStream({
311
+ abortSignal: options.abortSignal,
312
+ audio: options.audio,
313
+ headers: combineHeaders(this.config.headers?.(), options.headers),
314
+ includeLanguageDetection:
315
+ streamingOptions?.includeLanguageDetection === true,
316
+ includeRawChunks: options.includeRawChunks,
317
+ includeTimestamps: streamingOptions?.includeTimestamps === true,
318
+ language: elevenLabsOptions?.languageCode ?? undefined,
319
+ previousText: streamingOptions?.previousText ?? undefined,
320
+ sampleRate: inputFormat.sampleRate,
321
+ url,
322
+ warnings,
323
+ webSocket: this.config.webSocket,
324
+ }),
325
+ };
326
+ }
327
+ }
328
+
329
+ function getElevenLabsRealtimeAudioFormat(
330
+ inputAudioFormat: TranscriptionModelV4StreamOptions['inputAudioFormat'],
331
+ ): { audioFormat: string; sampleRate: number } {
332
+ const type = inputAudioFormat.type.toLowerCase();
333
+ const rate = inputAudioFormat.rate;
334
+
335
+ if (type === 'audio/pcmu') {
336
+ if (rate != null && rate !== 8000) {
337
+ throw new InvalidArgumentError({
338
+ argument: 'inputAudioFormat',
339
+ message: 'ElevenLabs only supports audio/pcmu at 8000 Hz',
340
+ });
341
+ }
342
+ return { audioFormat: 'ulaw_8000', sampleRate: 8000 };
343
+ }
344
+
345
+ const supportedPcmRates = [8000, 16000, 22050, 24000, 44100, 48000];
346
+ const pcmRate = rate ?? 16000;
347
+ if (type !== 'audio/pcm' || !supportedPcmRates.includes(pcmRate)) {
348
+ throw new InvalidArgumentError({
349
+ argument: 'inputAudioFormat',
350
+ message:
351
+ 'ElevenLabs realtime transcription supports audio/pcm at 8000, 16000, 22050, 24000, 44100, or 48000 Hz, and audio/pcmu at 8000 Hz',
352
+ });
353
+ }
354
+
355
+ return { audioFormat: `pcm_${pcmRate}`, sampleRate: pcmRate };
356
+ }
357
+
358
+ function buildElevenLabsRealtimeTranscriptionUrl({
359
+ baseUrl,
360
+ inputFormat,
361
+ languageCode,
362
+ modelId,
363
+ streamingOptions,
364
+ }: {
365
+ baseUrl: URL;
366
+ inputFormat: string;
367
+ languageCode: string | undefined;
368
+ modelId: string;
369
+ streamingOptions:
370
+ | NonNullable<ElevenLabsTranscriptionModelOptions['streaming']>
371
+ | undefined;
372
+ }): URL {
373
+ const url = new URL(baseUrl);
374
+ url.searchParams.set('model_id', modelId);
375
+ url.searchParams.set('audio_format', inputFormat);
376
+ const includeDetailedCommit =
377
+ streamingOptions?.includeTimestamps === true ||
378
+ streamingOptions?.includeLanguageDetection === true;
379
+
380
+ const parameters = {
381
+ commit_strategy: streamingOptions?.commitStrategy,
382
+ enable_logging: streamingOptions?.enableLogging,
383
+ filter_background_audio: streamingOptions?.filterBackgroundAudio,
384
+ include_language_detection: streamingOptions?.includeLanguageDetection,
385
+ include_timestamps: includeDetailedCommit
386
+ ? true
387
+ : streamingOptions?.includeTimestamps,
388
+ language_code: languageCode,
389
+ min_silence_duration_ms: streamingOptions?.minSilenceDurationMs,
390
+ min_speech_duration_ms: streamingOptions?.minSpeechDurationMs,
391
+ no_verbatim: streamingOptions?.noVerbatim,
392
+ vad_silence_threshold_secs: streamingOptions?.vadSilenceThresholdSecs,
393
+ vad_threshold: streamingOptions?.vadThreshold,
394
+ };
395
+ for (const [key, value] of Object.entries(parameters)) {
396
+ if (value != null) {
397
+ url.searchParams.set(key, String(value));
398
+ }
399
+ }
400
+ for (const keyterm of streamingOptions?.keyterms ?? []) {
401
+ url.searchParams.append('keyterms', keyterm);
402
+ }
403
+ for (const secondaryLanguage of streamingOptions?.secondaryLanguages ?? []) {
404
+ url.searchParams.append('secondary_languages', secondaryLanguage);
405
+ }
406
+
407
+ return url;
408
+ }
409
+
410
+ function createElevenLabsRealtimeTranscriptionStream({
411
+ abortSignal,
412
+ audio,
413
+ headers,
414
+ includeLanguageDetection,
415
+ includeRawChunks,
416
+ includeTimestamps,
417
+ language,
418
+ previousText,
419
+ sampleRate,
420
+ url,
421
+ warnings,
422
+ webSocket,
423
+ }: {
424
+ abortSignal: AbortSignal | undefined;
425
+ audio: ReadableStream<Uint8Array | string>;
426
+ headers: Record<string, string | undefined>;
427
+ includeLanguageDetection: boolean;
428
+ includeRawChunks: boolean | undefined;
429
+ includeTimestamps: boolean;
430
+ language: string | undefined;
431
+ previousText: string | undefined;
432
+ sampleRate: number;
433
+ url: URL;
434
+ warnings: SharedV4Warning[];
435
+ webSocket: ElevenLabsConfig['webSocket'];
436
+ }) {
437
+ let finished = false;
438
+ let cleanup: (closeCode?: number) => void = () => {};
439
+
440
+ return new ReadableStream({
441
+ start: controller => {
442
+ let audioReader:
443
+ | ReadableStreamDefaultReader<Uint8Array | string>
444
+ | undefined;
445
+ let connection: WebSocketConnection | undefined;
446
+ let detectedLanguage = language;
447
+ let endOfInput = false;
448
+ let finalCommitGracePeriod: ReturnType<typeof setTimeout> | undefined;
449
+ let receivedPostInputCommit = false;
450
+ let segmentIndex = 0;
451
+ let sessionId: string | undefined;
452
+ let committedEventCount = 0;
453
+ let committedEventsAtEndOfInput = 0;
454
+ let finalCommitEventCount: number | undefined;
455
+ let timestampedCommitCount = 0;
456
+ const expectDetailedCommit =
457
+ includeTimestamps || includeLanguageDetection;
458
+ const finalSegments: Array<{
459
+ text: string;
460
+ startSecond: number;
461
+ endSecond: number;
462
+ }> = [];
463
+ const finalTexts: string[] = [];
464
+
465
+ cleanup = (closeCode?: number) => {
466
+ clearTimeout(finalCommitGracePeriod);
467
+ if (audioReader != null) {
468
+ void audioReader.cancel().catch(() => {});
469
+ } else {
470
+ void audio.cancel().catch(() => {});
471
+ }
472
+ connection?.close(closeCode);
473
+ };
474
+
475
+ const finishWithError = (error: unknown) => {
476
+ if (finished) return;
477
+ finished = true;
478
+ cleanup();
479
+ controller.error(error);
480
+ };
481
+
482
+ const finish = () => {
483
+ if (finished) return;
484
+ finished = true;
485
+ controller.enqueue({
486
+ type: 'finish',
487
+ text: finalTexts.join(' ').trim(),
488
+ segments: finalSegments,
489
+ language: detectedLanguage,
490
+ durationInSeconds: finalSegments.at(-1)?.endSecond,
491
+ });
492
+ controller.close();
493
+ cleanup(1000);
494
+ };
495
+
496
+ const scheduleFinish = () => {
497
+ clearTimeout(finalCommitGracePeriod);
498
+ finalCommitGracePeriod = setTimeout(finish, finalCommitGracePeriodMs);
499
+ };
500
+
501
+ const sendAudio = async (socket: WebSocketLike) => {
502
+ audioReader = audio.getReader();
503
+ let firstChunk = true;
504
+ try {
505
+ while (true) {
506
+ const { done, value } = await audioReader.read();
507
+ if (done || finished) break;
508
+ socket.send(
509
+ JSON.stringify({
510
+ message_type: 'input_audio_chunk',
511
+ audio_base_64: convertToBase64(value),
512
+ commit: false,
513
+ sample_rate: sampleRate,
514
+ ...(firstChunk && previousText != null
515
+ ? { previous_text: previousText }
516
+ : {}),
517
+ }),
518
+ );
519
+ firstChunk = false;
520
+ await waitForWebSocketBufferDrain(socket);
521
+ }
522
+ } finally {
523
+ audioReader.releaseLock();
524
+ audioReader = undefined;
525
+ }
526
+ if (!finished) {
527
+ committedEventsAtEndOfInput = committedEventCount;
528
+ endOfInput = true;
529
+ socket.send(
530
+ JSON.stringify({
531
+ message_type: 'input_audio_chunk',
532
+ audio_base_64: '',
533
+ commit: true,
534
+ sample_rate: sampleRate,
535
+ }),
536
+ );
537
+ }
538
+ };
539
+
540
+ connection = connectToWebSocket({
541
+ abortSignal,
542
+ headers,
543
+ onAbort: finishWithError,
544
+ onClose: () => {
545
+ if (finished) return;
546
+ if (endOfInput && receivedPostInputCommit) {
547
+ finish();
548
+ return;
549
+ }
550
+ finishWithError(
551
+ new Error(
552
+ 'ElevenLabs realtime transcription stream closed before completion.',
553
+ ),
554
+ );
555
+ },
556
+ onMessageText: async text => {
557
+ const parsed = await safeParseJSON({ text });
558
+ if (!parsed.success) return;
559
+ const raw = parsed.value as ElevenLabsRealtimeTranscriptionEvent;
560
+
561
+ if (includeRawChunks) {
562
+ controller.enqueue({ type: 'raw', rawValue: raw });
563
+ }
564
+
565
+ if (
566
+ raw.message_type != null &&
567
+ elevenLabsRealtimeErrorTypes.has(raw.message_type)
568
+ ) {
569
+ if (
570
+ endOfInput &&
571
+ (committedEventCount > 0 || timestampedCommitCount > 0) &&
572
+ elevenLabsLateFinalizationErrorTypes.has(raw.message_type)
573
+ ) {
574
+ receivedPostInputCommit = true;
575
+ finish();
576
+ return;
577
+ }
578
+ finishWithError(
579
+ new Error(raw.error ?? 'ElevenLabs realtime transcription error'),
580
+ );
581
+ return;
582
+ }
583
+
584
+ switch (raw.message_type) {
585
+ case 'session_started': {
586
+ sessionId = raw.session_id;
587
+ controller.enqueue({ type: 'stream-start', warnings });
588
+ const socket = connection?.socket;
589
+ if (socket == null) {
590
+ finishWithError(new Error('WebSocket is not connected.'));
591
+ break;
592
+ }
593
+ void sendAudio(socket).catch(finishWithError);
594
+ break;
595
+ }
596
+ case 'partial_transcript': {
597
+ controller.enqueue({
598
+ type: 'transcript-partial',
599
+ id: `${sessionId ?? 'session'}:${segmentIndex}`,
600
+ text: raw.text ?? '',
601
+ });
602
+ break;
603
+ }
604
+ case 'final_transcript':
605
+ case 'committed_transcript': {
606
+ committedEventCount++;
607
+ const text = (raw.text ?? '').trim();
608
+ const id = `${sessionId ?? 'session'}:${segmentIndex}`;
609
+
610
+ if (text.length > 0) {
611
+ finalTexts.push(text);
612
+ segmentIndex++;
613
+ controller.enqueue({
614
+ type: 'transcript-final',
615
+ id,
616
+ text,
617
+ });
618
+ }
619
+
620
+ // When timestamps are requested, ElevenLabs follows this event
621
+ // with committed_transcript_with_timestamps for the same commit.
622
+ if (
623
+ endOfInput &&
624
+ committedEventCount > committedEventsAtEndOfInput
625
+ ) {
626
+ receivedPostInputCommit = true;
627
+ finalCommitEventCount = committedEventCount;
628
+ if (
629
+ !expectDetailedCommit ||
630
+ raw.message_type === 'final_transcript'
631
+ ) {
632
+ scheduleFinish();
633
+ }
634
+ }
635
+ break;
636
+ }
637
+ case 'final_transcript_with_timestamps':
638
+ case 'committed_transcript_with_timestamps': {
639
+ const text = (raw.text ?? '').trim();
640
+ const words = raw.words ?? [];
641
+ const timestampedWords = words.filter(
642
+ (word): word is typeof word & { start: number; end: number } =>
643
+ typeof word.start === 'number' &&
644
+ typeof word.end === 'number',
645
+ );
646
+ detectedLanguage = raw.language_code ?? detectedLanguage;
647
+
648
+ // Normally this is paired with the preceding committed_transcript.
649
+ // Still handle a timestamp-only server response defensively.
650
+ if (
651
+ finalTexts[timestampedCommitCount] == null &&
652
+ text.length > 0
653
+ ) {
654
+ const id = `${sessionId ?? 'session'}:${segmentIndex++}`;
655
+ finalTexts.push(text);
656
+ controller.enqueue({
657
+ type: 'transcript-final',
658
+ id,
659
+ text,
660
+ ...(includeTimestamps
661
+ ? {
662
+ startSecond: timestampedWords[0]?.start,
663
+ endSecond: timestampedWords.at(-1)?.end,
664
+ }
665
+ : {}),
666
+ });
667
+ }
668
+ timestampedCommitCount++;
669
+ if (includeTimestamps) {
670
+ finalSegments.push(
671
+ ...timestampedWords.map(word => ({
672
+ text: word.text ?? '',
673
+ startSecond: word.start,
674
+ endSecond: word.end,
675
+ })),
676
+ );
677
+ }
678
+
679
+ if (
680
+ endOfInput &&
681
+ timestampedCommitCount > committedEventsAtEndOfInput &&
682
+ (finalCommitEventCount == null ||
683
+ timestampedCommitCount >= finalCommitEventCount)
684
+ ) {
685
+ receivedPostInputCommit = true;
686
+ scheduleFinish();
687
+ }
688
+ break;
689
+ }
690
+ }
691
+ },
692
+ onProcessingError: finishWithError,
693
+ onSocketError: () => {
694
+ finishWithError(
695
+ new Error(
696
+ 'ElevenLabs realtime transcription error.' +
697
+ (webSocket == null
698
+ ? ' Note: the native WebSocket implementation in browsers,' +
699
+ ' Node.js, Deno, and Bun cannot send the xi-api-key header' +
700
+ ' required by ElevenLabs. Pass a header-capable WebSocket' +
701
+ " implementation (e.g. the 'ws' package) via" +
702
+ ' createElevenLabs({ webSocket }).'
703
+ : ''),
704
+ ),
705
+ );
706
+ },
707
+ url,
708
+ webSocket,
709
+ });
710
+ },
711
+
712
+ cancel: () => {
713
+ if (finished) return;
714
+ finished = true;
715
+ cleanup();
716
+ },
717
+ });
158
718
  }
159
719
 
160
720
  const elevenlabsTranscriptionResponseSchema = z.object({
@@ -2,4 +2,5 @@ export type ElevenLabsTranscriptionModelId =
2
2
  | 'scribe_v1'
3
3
  | 'scribe_v1_experimental'
4
4
  | 'scribe_v2'
5
+ | 'scribe_v2_realtime'
5
6
  | (string & {});