From 0893165e1ba0c3337cfe6b1ca42eab9ff678f895 Mon Sep 17 00:00:00 2001 From: Osman Samil Date: Fri, 1 May 2026 10:37:46 +0300 Subject: [PATCH] feat(whispering): send no_verbatim for ElevenLabs scribe_v2 The bundled `elevenlabs@1.59.0` SDK predates the scribe_v2 release and does not expose the `no_verbatim` flag in its `BodySpeechToTextV1SpeechToTextPost` type. Upgrading to `@elevenlabs/elevenlabs-js@2.x` is a package rename plus breaking-change migration, so this patch keeps the dependency footprint unchanged and posts the multipart form to `/v1/speech-to-text` directly. When the user picks scribe_v2, we now send `no_verbatim=true`, which strips filler words, false starts, and non-speech sounds at the model. This is a no-op on scribe_v1 / scribe_v1_experimental (parameter is silently ignored), so existing flows are unaffected. Ref: https://elevenlabs.io/docs/api-reference/speech-to-text/convert Co-Authored-By: Claude Opus 4.7 (1M context) --- .../transcription/cloud/elevenlabs.ts | 60 ++++++++++++++----- 1 file changed, 45 insertions(+), 15 deletions(-) diff --git a/apps/whispering/src/lib/services/transcription/cloud/elevenlabs.ts b/apps/whispering/src/lib/services/transcription/cloud/elevenlabs.ts index da29f7cbfd..0d14c7e505 100644 --- a/apps/whispering/src/lib/services/transcription/cloud/elevenlabs.ts +++ b/apps/whispering/src/lib/services/transcription/cloud/elevenlabs.ts @@ -1,12 +1,12 @@ -import { ElevenLabsClient } from 'elevenlabs'; import { defineErrors, extractErrorMessage, type InferErrors, } from 'wellcrafted/error'; -import { type Result, tryAsync } from 'wellcrafted/result'; +import { Err, type Result, tryAsync } from 'wellcrafted/result'; const MAX_FILE_SIZE_MB = 1000 as const; +const ELEVENLABS_STT_URL = 'https://api.elevenlabs.io/v1/speech-to-text'; export const ElevenLabsError = defineErrors({ MissingApiKey: () => ({ @@ -23,6 +23,17 @@ export const ElevenLabsError = defineErrors({ sizeMb, maxMb, }), + ApiError: ({ + status, + body, + }: { + status: number; + body: string; + }) => ({ + message: `ElevenLabs API error ${status}: ${body}`, + status, + body, + }), Unexpected: ({ cause }: { cause: unknown }) => ({ message: extractErrorMessage(cause), cause, @@ -51,23 +62,42 @@ export const ElevenLabsTranscriptionServiceLive = { }); } - const client = new ElevenLabsClient({ apiKey: options.apiKey }); + const formData = new FormData(); + formData.append('file', audioBlob); + formData.append('model_id', options.modelName); + if (options.outputLanguage !== 'auto') { + formData.append('language_code', options.outputLanguage); + } + formData.append('tag_audio_events', 'false'); + formData.append('diarize', 'true'); + // Remove filler words, false starts, and non-speech sounds. Only + // supported on scribe_v2 per ElevenLabs API; ignored on other models. + if (options.modelName === 'scribe_v2') { + formData.append('no_verbatim', 'true'); + } + + const { data: response, error: fetchError } = await tryAsync({ + try: () => + fetch(ELEVENLABS_STT_URL, { + method: 'POST', + headers: { 'xi-api-key': options.apiKey }, + body: formData, + }), + catch: (cause) => ElevenLabsError.Unexpected({ cause }), + }); + if (fetchError) return Err(fetchError); + + if (!response.ok) { + const body = await response.text().catch(() => ''); + return ElevenLabsError.ApiError({ status: response.status, body }); + } return tryAsync({ try: async () => { - const transcription = await client.speechToText.convert({ - file: audioBlob, - model_id: options.modelName, - language_code: - options.outputLanguage !== 'auto' - ? options.outputLanguage - : undefined, - tag_audio_events: false, - diarize: true, - }); - return transcription.text.trim(); + const data = (await response.json()) as { text: string }; + return data.text.trim(); }, - catch: (error) => ElevenLabsError.Unexpected({ cause: error }), + catch: (cause) => ElevenLabsError.Unexpected({ cause }), }); }, };