Transcription (STT)
transcribe — audio in, text (and timings) out, over the OpenAI and Deepgram speech-to-text wires.
Doküman sayfalarının içeriği İngilizce’dir. Gezinme, arama ve arayüz metinleri seçtiğiniz dile göre çevrilir.
transcribe is the other half of generateSpeech: audio goes in, text comes out. Like every media module it is one request and one answer — no streaming, no loop.
Two wires ship in 2.0, and they are far less alike than the two TTS wires:
| Provider | Factory | Wire |
|---|---|---|
| OpenAI (and OpenAI-compatible relays) | createOpenAITranscription | POST {baseURL}/audio/transcriptions, multipart/form-data, Authorization: Bearer |
| Deepgram | createDeepgram | POST {baseURL}/listen?model=…, raw audio as the body, Authorization: Token |
Transcription models are a separate kind from chat LanguageModel (surface: 'openai-transcription' | 'deepgram-transcription') — they cannot be passed to streamChat or generateText. The module is pure and edge-safe: HTTP goes through deps.fetch and the API key is resolved from the factory, createClient, or a deps.keyProvider, never from the environment.
Audio input to a chat model is a file part
This page is about a dedicated STT endpoint. Audio you want a chat model to hear travels as filePart({ mediaType: 'audio/…' }), exactly like a PDF or an image — the Part union stayed at five members in 2.0.
Verified against mocks, not yet against a live API
Everything on this page is pinned by unit tests (test/transcription.test.ts) that drive both adapters through an injected deps.fetch and assert the exact URL, query string, auth scheme, multipart fields, and response parsing. That proves the SDK sends and reads what this page says it does. It does not prove OpenAI and Deepgram accept it — this module has no live-API test; test/live/ covers DeepSeek, Google, and xAI only.
The wire details below come from the providers' documentation. Run one real file through it before you ship, and correct anything that turns out wrong with providerOptions rather than waiting for an SDK release.
Choosing a wire
The two providers are not interchangeable, and the differences show up in the first hour rather than the second month:
| If you… | Pick | Because |
|---|---|---|
| Already have an OpenAI key and want the shortest path | createOpenAITranscription | One key, one factory, and gpt-4o-transcribe needs no tuning. |
| Need word timings | createDeepgram, or OpenAI whisper-1 | The gpt-4o-* models cannot return them at all — see the verbose_json gate. |
| Have the audio in object storage already | createDeepgram with audio: { url } | Deepgram fetches it itself; nothing large moves through your process. |
| Need per-word confidence, or diarization | createDeepgram | confidence comes back per word; diarize rides providerOptions. |
| Want a vocabulary hint (names, jargon) | createOpenAITranscription | prompt is OpenAI-only. Deepgram's equivalent is keyterm via providerOptions. |
| Are transcribing to feed a chat model, not a human | Either | The extra fields cost nothing if you ignore them. |
And when not to use this module at all: if you want a chat model to reason about how something was said (tone, background, overlapping speakers), a transcript throws that away — send the audio to a model whose registry row sets audio: true as a filePart instead. If you want live captions, see what this does not do: there is no streaming path here.
transcribe
Import transcribe from @deuz-sdk/core/transcription. Build a model descriptor with a factory, hand it the audio bytes, and pass the mediaType along with them.
import { transcribe, createOpenAITranscription } from '@deuz-sdk/core/transcription';
import { readFile } from 'node:fs/promises';
const openaiStt = createOpenAITranscription({
apiKey: process.env.OPENAI_API_KEY!,
});
const { text, usage } = await transcribe({
model: openaiStt('gpt-4o-transcribe'),
audio: await readFile('meeting.mp3'),
mediaType: 'audio/mpeg',
language: 'tr',
});
console.log(text); // 'Merhaba dünya.'
console.log(usage.inputTokens); // token-billed on this modelOptions
transcribe(options) takes a single TranscribeOptions object.
| Option | Type | Default | Notes |
|---|---|---|---|
model | TranscriptionModel | — | From createOpenAITranscription / createDeepgram. Required. |
audio | Uint8Array | ArrayBuffer | Blob | { url } | — | Required. { url } is Deepgram-only — see below. |
mediaType | string | from a Blob's own type | e.g. 'audio/mpeg'. Strongly recommended — see Media types and filenames. |
filename | string | derived from mediaType | Overrides the multipart filename (OpenAI only). |
language | string | provider auto-detect | ISO-639-1 hint, e.g. 'tr'. |
prompt | string | unset | Vocabulary/style hint (OpenAI only). |
timestamps | boolean | false | Ask for segments + words — see Timestamps. |
providerOptions | { openai?, deepgram? } | unset | Extra form fields / query params. Canonical fields always win. |
signal | AbortSignal | unset | Aborts the underlying fetch. |
headers | Record<string, string> | unset | Per-call headers, merged over factory headers. |
deps | Dependencies | resolved defaults | Inject fetch, keyProvider, observer, … |
Result shape
interface TranscribeResult {
text: string;
language?: string; // detected or echoed, when the provider reports one
durationSeconds?: number; // audio length, when the provider reports one
segments?: TranscriptionSegment[]; // { start, end, text }
words?: TranscriptionWord[]; // { word, start, end, confidence? }
usage: TranscriptionUsage;
raw: unknown; // the untouched provider JSON
}Every optional field is absent, not undefined, when the wire did not produce it — a plain json OpenAI response yields exactly { text, usage, raw }. raw always carries the provider's own JSON, so a field this module does not model is never lost.
Timestamps
timestamps: true asks for per-sentence segments and per-word words. What that costs — and whether it is even legal — depends entirely on the wire.
| Model | response_format sent | Timings returned |
|---|---|---|
whisper-1 (OpenAI), timestamps: true | verbose_json + timestamp_granularities[] = segment, word | segments and words |
whisper-1 (OpenAI), timestamps unset | json | none |
gpt-4o-transcribe, gpt-4o-mini-transcribe | json — always | none |
| Deepgram (any model) | n/a | words always; segments whenever smart_format is on |
verbose_json is whisper-only
verbose_json is the only OpenAI response format that carries timings, and the gpt-4o-transcribe family rejects it with a 400. So timestamps: true is gated on the model slug: it upgrades the format only for whisper-*, and silently stays on json for a 4o model rather than failing the call. If you need word timings from OpenAI, use whisper-1.
const { segments, words } = await transcribe({
model: openaiStt('whisper-1'),
audio: bytes,
mediaType: 'audio/mpeg',
timestamps: true,
});
segments?.[0]; // { start: 0, end: 4.2, text: ' Merhaba dünya.' }
words?.[0]; // { word: 'Merhaba', start: 0, end: 0.8 }Media types and filenames
OpenAI does not read the multipart part's Content-Type — it dispatches on the file extension. So the upload filename is load-bearing, and this module derives it from mediaType:
mediaType | Filename sent |
|---|---|
audio/mpeg, audio/mp3, audio/mpga | audio.mp3 |
audio/wav, audio/x-wav, audio/wave | audio.wav |
audio/mp4 / audio/m4a, audio/x-m4a | audio.mp4 / audio.m4a |
audio/webm, audio/ogg, audio/flac, audio/aac | audio.webm, audio.ogg, audio.flac, audio.aac |
| anything else, or omitted | audio.bin — OpenAI will reject it |
Parameters are ignored (audio/wav; codecs=1 is still audio.wav), a Blob's own type fills in when mediaType is omitted, and filename overrides the derivation outright. On the Deepgram wire the same value is sent as the request Content-Type instead, falling back to application/octet-stream.
Never set content-type yourself on the OpenAI wire
The multipart request deliberately carries no content-type header from this SDK: fetch writes multipart/form-data; boundary=… when it sees a FormData body, and a hand-written header would omit the boundary and 400 every request. Per-call headers are still merged, so do not add one there either.
Deepgram
Deepgram is not OpenAI-shaped. Three differences are worth internalizing.
1. Authorization: Token, not Bearer. Deepgram uses its own scheme; a bearer token is a 401. The adapter writes it for you:
import { transcribe, createDeepgram } from '@deuz-sdk/core/transcription';
const deepgram = createDeepgram({ apiKey: process.env.DEEPGRAM_API_KEY! });
const { text, words, durationSeconds } = await transcribe({
model: deepgram('nova-3'),
audio: bytes,
mediaType: 'audio/mpeg',
language: 'en',
});
// POST https://api.deepgram.com/v1/listen?model=nova-3&language=en&smart_format=true
// Authorization: Token <key> ← not Bearer
// Content-Type: audio/mpeg ← the audio IS the body2. The audio is the request body, and a URL works too. Bytes are POSTed raw — no multipart wrapper. Deepgram can also fetch the audio itself, which is the cheapest path for anything already sitting in object storage:
const { text } = await transcribe({
model: deepgram('nova-3'),
audio: { url: 'https://cdn.example/talk.mp3' },
});
// body → {"url":"https://cdn.example/talk.mp3"}, content-type: application/jsonPassing { url } to an OpenAI model throws an InvalidRequestError before any request is made — that wire uploads bytes, full stop.
3. smart_format is on by default. It adds punctuation and casing, and it is what produces the paragraphs block that this module maps into segments. Turn it off through providerOptions if you want the raw token stream:
providerOptions: { deepgram: { smart_format: false } } // → the param is simply omittedWord entries prefer Deepgram's punctuated_word over the raw word when smart formatting produced one, and carry Deepgram's per-word confidence. language comes from results.channels[0].detected_language, durationSeconds from metadata.duration.
A real pipeline: recording → notes
Transcription is rarely the deliverable; it is the step before one. The whole point of segments is that they survive into the summary as citations you can jump to.
import { generateText } from '@deuz-sdk/core';
import { createAnthropic } from '@deuz-sdk/core/anthropic';
import { transcribe, createDeepgram } from '@deuz-sdk/core/transcription';
const deepgram = createDeepgram({ apiKey: process.env.DEEPGRAM_API_KEY! });
const anthropic = createAnthropic({ apiKey: process.env.ANTHROPIC_API_KEY! });
export async function meetingNotes(recordingUrl: string) {
// Deepgram pulls the file itself: a 90-minute recording never touches this
// process, and `diarize` labels who spoke — a Deepgram query param, not a
// canonical option, so it rides providerOptions.
const { text, segments, durationSeconds, usage } = await transcribe({
model: deepgram('nova-3'),
audio: { url: recordingUrl },
language: 'en',
providerOptions: { deepgram: { diarize: true, utterances: true } },
});
// Timestamped lines, so the summary can cite a position in the audio.
const timeline = (segments ?? [])
.map((s) => `[${Math.floor(s.start)}s] ${s.text}`)
.join('\n');
const { text: notes } = await generateText({
model: anthropic('claude-sonnet-4-6'),
instructions:
'Summarize this meeting transcript into Decisions, Action items (with owners), ' +
'and Open questions. Cite the [Ns] marker for every claim.',
prompt: timeline || text,
maxOutputTokens: 2_000,
});
return { notes, durationSeconds, billedSeconds: usage.seconds };
}Notes on the shape, all of them things that bite once:
segmentsis optional and this code treats it as such. On Deepgram it exists only whensmart_formatproduced paragraphs (it is on by default); on OpenAI it exists only forwhisper-1withtimestamps: true.segments ?? []with a fall back to plaintextis not defensive noise — it is the actual contract.- Diarization does not reach the canonical shape.
TranscriptionSegmentis{ start, end, text }— no speaker field. Deepgram's speaker labels are inraw, which is exactly whatrawis for: readresult.rawwhen you need a field this module does not model. - A long recording is a long wait. There is no streaming and no progress signal; the promise resolves when the provider is done. Put this behind a queue, not an HTTP request handler.
- The transcript is untrusted input. It came from a microphone someone else controlled. If it flows into a tool-calling agent rather than a summary, treat it the way you would a user message — see request validation and guardrails.
usage — two meters, one shape
Providers do not agree on what they charge for, so TranscriptionUsage carries every field optionally and fills in only what the response actually reported:
interface TranscriptionUsage {
seconds?: number; // audio duration billing
inputTokens?: number;
outputTokens?: number;
audioTokens?: number; // the audio-derived slice of inputTokens
totalTokens?: number;
}| Wire / model | usage you get |
|---|---|
gpt-4o-transcribe, gpt-4o-mini-transcribe | { inputTokens, outputTokens, audioTokens, totalTokens } |
whisper-1 with verbose_json | { seconds } — taken from the response duration |
whisper-1 with a { type: 'duration' } usage block | { seconds } |
| Deepgram | { seconds } — from metadata.duration |
Read usage.seconds ?? usage.totalTokens if you need one number for a meter, and branch on which one is present if you are pricing the call.
Two honest caveats about that number. Nothing here converts it to money: deps.priceProvider is never consulted by transcribe, PRICES_2026 has no STT rows, and no cost event reaches the observation stream. And usage can be empty: every field is optional, so a relay that omits both meters yields {} rather than a zero — usage.seconds ?? usage.totalTokens ?? 0 is the safe read, and durationSeconds is a second source for the same fact when the provider reports it.
Practically: seconds scales with the recording, inputTokens scales with the recording and the model's tokenizer, and neither scales with the length of the transcript. A silent hour costs the same as a talkative one.
providerOptions
providerOptions is keyed by provider name and is a shallow escape hatch for wire fields the SDK does not model. Canonical fields always win — the hatch may add, never redefine:
await transcribe({
model: openaiStt('whisper-1'),
audio: bytes,
mediaType: 'audio/mpeg',
language: 'tr',
providerOptions: {
openai: { temperature: 0, chunking_strategy: 'auto', language: 'de' },
},
});
// form fields → file, model, language=tr (NOT 'de'), response_format=json,
// temperature=0, chunking_strategy=autoValues are coerced with String(value); undefined / null entries are dropped. On the OpenAI wire the entries become extra multipart fields; on Deepgram they become extra query parameters (diarize, utterances, detect_language, keyterm, …), with smart_format special-cased as described above.
Creating transcription model descriptors
Both factories return a TranscriptionProvider — a (modelId: string) => TranscriptionModel function. Factory settings are carried on a private symbol, so the public TranscriptionModel shape stays clean and the key never leaks via enumeration.
| Setting | Type | Default | Notes |
|---|---|---|---|
apiKey | string | — | Resolved against keyProvider / createClient if omitted. |
baseURL | string | per-wire (below) | Point at any compatible relay. |
fetch | typeof fetch | deps.fetch | Factory fetch wins over deps.fetch. |
headers | Record<string, string> | — | Default headers for every call. |
provider | string | 'openai' / 'deepgram' | Logical id used for key/baseURL resolution. |
| Factory | Default baseURL | Typical models |
|---|---|---|
createOpenAITranscription | https://api.openai.com/v1 | whisper-1, gpt-4o-transcribe, gpt-4o-mini-transcribe |
createDeepgram | https://api.deepgram.com/v1 | nova-3, nova-2, whisper-large |
Key resolution
The same G1 precedence chain as every other module: deps.keyProvider (highest) → factory apiKey → createClient's apiKeys[provider] (lowest). When none of them produce a key, the call throws an AuthenticationError before any network request is made.
import { createClient } from '@deuz-sdk/core';
const client = createClient({
apiKeys: { openai: process.env.OPENAI_API_KEY!, deepgram: process.env.DEEPGRAM_API_KEY! },
});baseUrls[provider] works the same way for the base URL, sitting between the factory setting and the per-wire default.
Observability
A transcription call emits the auxiliary operation.started / operation.completed / operation.failed events under the 'transcription' subsystem with operation: 'transcription.transcribe' — the same shape image, speech, and embedding use. Without an observer the fast path applies: no events are built at all. See Observability.
Errors
The canonical status→class table, shared with every media module: 401/403 → AuthenticationError, 404 → ModelNotFoundError, 429 → RateLimitError (with Retry-After parsed into retryAfterMs), 529 → OverloadedError, other 4xx → InvalidRequestError, 5xx → a retryable APICallError.
Deepgram wraps its failures in { err_code, err_msg } instead of OpenAI's { error: { message } }; the envelope is normalized before mapping, so err_msg lands on error.message and err_code on error.upstreamType.
Two failures happen before the request is ever sent: an unsupported audio shape, and { url } on the OpenAI wire — both InvalidRequestError. A missing API key is the third, from the G1 chain. Failed bodies are read once and parsed as JSON when they can be (a proxy's HTML 502 becomes the message instead of a second exception), and an x-request-id response header lands on error.requestId.
What transcribe does not do
- No streaming, no partial results. Deepgram and OpenAI both offer live/websocket transcription; this module speaks only the batch endpoints. Live captions are out of scope in 2.0.
- No chunking, and no size check. Providers cap how much audio one request may carry, and the SDK neither measures your bytes nor splits them — an over-long file comes back as an
InvalidRequestErrorfrom the provider. Segment it yourself, or hand Deepgram a{ url }so the bytes never pass through you. - No retries, no breaker, no timeout. One plain
fetch, like every media module.maxRetriesdoes not exist here;retryAfterMson the error is advice you act on. Passsignalfor a deadline. - No format conversion or resampling. Whatever bytes you pass are the bytes uploaded. The only transformation is the filename derivation, which exists because OpenAI dispatches on the extension.
- No speaker labels in the canonical shape.
TranscriptionSegmenthas nospeaker; Deepgram diarization output lives inraw. - No capability matrix.
TranscriptionModelis not aLanguageModel, sogetModelCapabilitiesdoes not apply and an unknown slug produces no warning — the provider's404→ModelNotFoundErroris the only signal. - Only two wires. A third STT vendor needs a new
TranscriptionAdapter; today's extension point is pointingbaseURLat a relay that mimics one of these two.
Related
- Speech (TTS) — the text → audio direction.
- Image generation — the other synchronous media module.
- Dependencies — the
fetch/clock/keyProviderinjection seam. - Errors — the canonical error hierarchy.
- Observability — the
operation.*event protocol.