UNPKG

assemblyai

Version:

The AssemblyAI JavaScript SDK provides an easy-to-use interface for interacting with the AssemblyAI API, which supports async and real-time transcription, as well as the latest LeMUR models.

370 lines (340 loc) 12.7 kB
import { readFile } from "#fs"; import { BaseService } from "../base"; import { BaseServiceParams, SyncAudioInput, SyncTranscribeOptions, SyncTranscriptResponse, SyncTranscriptionConfig, } from "../.."; import { defaultSyncSpeechModel } from "../../types/sync"; import { SyncTranscriptError } from "../../utils/errors/sync"; import { getPath } from "../../utils/path"; // Canonical paths since the sync API gained a /v1 prefix (#18103); the // unprefixed routes remain served for SDK versions that predate it. const transcribeEndpoint = "/v1/transcribe"; const warmEndpoint = "/v1/warm"; const modelHeader = "X-AAI-Model"; // Kept above the server's 30 s deadline so the client doesn't race it. const defaultTimeoutMs = 60_000; const warmTimeoutMs = 10_000; const maxPromptLength = 4096; const maxKeytermsPromptLength = 2048; const maxContextTurns = 100; const maxContextLength = 4096; // Extensions that signal raw S16LE PCM rather than a WAV container. const pcmSuffixes = [".pcm", ".raw"]; /** * The synchronous transcription service: audio in, transcript out, * one request. * * Unlike `client.transcripts` (which submits a job to the async API and * polls for completion), `SyncTranscriber` posts the audio to the sync * API and returns the finished transcript in the HTTP response. There is no * job id or status to poll. Accepts a local file path, raw audio bytes, a * Blob, or a readable stream — but not a URL. */ export class SyncTranscriber extends BaseService { /** * Create a new synchronous transcription service. * @param params - The parameters to use for the service. */ constructor(params: BaseServiceParams) { super(params); } /** * Transcribe audio and return the finished transcript in one request. * @param audio - A local file path, raw audio bytes, a Blob, or a readable * stream. Raw PCM also requires `sample_rate` and `channels` on the config. * @param config - Options for this transcription request. * @param options - Client-side options, such as the request timeout. * @returns A promise that resolves to the finished transcript. * @throws SyncTranscriptError when the request fails. */ async transcribe( audio: SyncAudioInput, config: SyncTranscriptionConfig = {}, options: SyncTranscribeOptions = {}, ): Promise<SyncTranscriptResponse> { const { bytes, filename, contentType } = await resolveAudio(audio, config); const body = new FormData(); body.append( "audio", new Blob([bytes as BlobPart], { type: contentType }), filename, ); const configJson = buildConfigJson(config); if (configJson) { body.append( "config", new Blob([JSON.stringify(configJson)], { type: "application/json" }), ); } const response = await this.fetchResponse(transcribeEndpoint, { method: "POST", body, headers: { [modelHeader]: config.model ?? defaultSyncSpeechModel }, signal: AbortSignal.timeout(options.timeout ?? defaultTimeoutMs), }); if (response.status !== 200) throw await errorFromResponse(response); return (await response.json()) as SyncTranscriptResponse; } /** * Open the connection to the sync API ahead of time. * * The sync API is a single request/response, so a `transcribe()` that * opens its connection on demand pays the full DNS + TCP + TLS handshake * on the critical path. Call `warm()` as soon as you know audio is coming — * typically while the clip is still being recorded — so the next * `transcribe()` reuses the already-open connection. `warm()` is idempotent * and cheap; call it shortly before `transcribe()` so the pooled connection * hasn't idled out. * @param params - Optionally the model to route the probe to, so the warmed * connection lands on the same backend as the eventual transcription. * @returns A promise that resolves to `true` once the connection is open * (any HTTP response — even a non-200 — means the socket is * established), or `false` if the connection could not be opened. */ async warm(params?: { model?: string }): Promise<boolean> { try { await this.fetchResponse(warmEndpoint, { method: "GET", headers: { [modelHeader]: params?.model ?? defaultSyncSpeechModel }, signal: AbortSignal.timeout(warmTimeoutMs), }); return true; } catch { return false; } } } type ResolvedAudio = { bytes: Uint8Array; filename: string; contentType: string; }; /** * Read the audio input into bytes and decide its multipart content type. * * PCM is selected when the source has a `.pcm`/`.raw` extension or when * `sample_rate`/`channels` are set on the config (the fields the sync API * requires only for raw PCM) — and both must then be present. Everything * else is treated as a WAV container. URLs are rejected — the sync API has * no URL ingestion. */ async function resolveAudio( input: SyncAudioInput, config: SyncTranscriptionConfig, ): Promise<ResolvedAudio> { let bytes: Uint8Array; let filename: string | undefined; let suffix = ""; if (typeof input === "string") { if (/^https?:\/\//i.test(input)) { throw new Error( "SyncTranscriber does not accept URLs. Pass a local file path or " + "audio bytes, or use client.transcripts for URL/async transcription.", ); } if (input.startsWith("data:")) { bytes = dataUrlToBytes(input); } else { const path = getPath(input) ?? input; bytes = await readStream(await readFile(path)); filename = basename(path); suffix = extname(filename); } } else if (input instanceof Uint8Array) { bytes = input; } else if (input instanceof ArrayBuffer) { bytes = new Uint8Array(input); } else if (input instanceof Blob) { bytes = new Uint8Array(await input.arrayBuffer()); // File instances carry a name; the File global itself needs Node >= 20. const name = (input as { name?: string }).name; if (name) { filename = basename(name); suffix = extname(filename); } } else if (isWebReadableStream(input)) { bytes = await readStream(input); } else if (isAsyncIterable(input)) { bytes = await readAsyncIterable(input); // fs.ReadStream carries the path it was opened from. const path = (input as { path?: string | Buffer }).path; if (typeof path === "string") { filename = basename(path); suffix = extname(filename); } } else { throw new TypeError("unsupported audio input type"); } const wantsPcm = config.sample_rate !== undefined || config.channels !== undefined; const isPcm = pcmSuffixes.includes(suffix) || wantsPcm; if ( isPcm && (config.sample_rate === undefined || config.channels === undefined) ) { throw new Error( "raw PCM audio requires both sample_rate and channels in the config", ); } const contentType = isPcm ? "audio/pcm" : "audio/wav"; if (!filename) filename = isPcm ? "audio.pcm" : "audio.wav"; return { bytes, filename, contentType }; } /** * Serialize the config to the JSON `config` part, validating and normalizing * field values to match the server's caps. The routing `model` is never * included — it travels in the `X-AAI-Model` header. Returns `undefined` * when there is nothing to send, so the part can be omitted entirely. */ function buildConfigJson( config: SyncTranscriptionConfig, ): Record<string, unknown> | undefined { if (config.prompt !== undefined && config.prompt.length > maxPromptLength) { throw new Error( `prompt exceeds ${maxPromptLength} characters (got ${config.prompt.length})`, ); } const json: Record<string, unknown> = {}; if (config.prompt !== undefined) json["prompt"] = config.prompt; const keytermsPrompt = normalizeKeytermsPrompt(config.keyterms_prompt); if (keytermsPrompt) json["keyterms_prompt"] = keytermsPrompt; const context = normalizeConversationContext(config.conversation_context); if (context) json["conversation_context"] = context; if (config.language_codes !== undefined) json["language_codes"] = config.language_codes; if (config.sample_rate !== undefined) json["sample_rate"] = config.sample_rate; if (config.channels !== undefined) json["channels"] = config.channels; if (config.timestamps !== undefined) json["timestamps"] = config.timestamps; return Object.keys(json).length > 0 ? json : undefined; } function normalizeKeytermsPrompt( keytermsPrompt?: string[], ): string[] | undefined { if (!keytermsPrompt) return undefined; const terms = keytermsPrompt .map((term) => term.trim()) .filter((term) => term.length > 0); const total = terms.reduce((sum, term) => sum + term.length, 0); if (total > maxKeytermsPromptLength) { throw new Error( `keyterms_prompt exceeds ${maxKeytermsPromptLength} characters (got ${total})`, ); } return terms.length > 0 ? terms : undefined; } function normalizeConversationContext( context?: string | string[], ): string[] | undefined { if (context === undefined) return undefined; let turns = (typeof context === "string" ? [context] : context) .map((turn) => turn.trim()) .filter((turn) => turn.length > 0); let total = turns.reduce((sum, turn) => sum + turn.length, 0); // Over-cap context is trimmed oldest-first, never rejected. while ( turns.length > 0 && (turns.length > maxContextTurns || total > maxContextLength) ) { total -= turns[0].length; turns = turns.slice(1); } return turns.length > 0 ? turns : undefined; } /** * Build a SyncTranscriptError from a non-200 response. The primary format * is an RFC 9457 problem-details body (`status`/`title`/`detail`); legacy * `{error_code, message}` and `{detail}`-only bodies are also accepted. */ async function errorFromResponse( response: Response, ): Promise<SyncTranscriptError> { let errorCode: string | undefined; let message: string | undefined; const text = await response.text(); try { const body = JSON.parse(text); if (body && typeof body === "object" && !Array.isArray(body)) { if (typeof body.error_code === "string") errorCode = body.error_code; if (errorCode === undefined && typeof body.title === "string") { errorCode = body.title.toLowerCase().replace(/ /g, "_"); } if (typeof body.detail === "string") message = body.detail; else if (typeof body.message === "string") message = body.message; } } catch { if (text) message = text; } if (!message) { message = `sync transcription failed with status ${response.status}`; } const retryHeader = response.headers.get("retry-after"); const retryAfter = retryHeader && /^\d+$/.test(retryHeader) ? parseInt(retryHeader, 10) : undefined; return new SyncTranscriptError( message, response.status, errorCode, retryAfter, ); } function basename(path: string): string { return path.split(/[\\/]/).pop() ?? path; } function extname(filename: string): string { const dotIndex = filename.lastIndexOf("."); return dotIndex > 0 ? filename.slice(dotIndex).toLowerCase() : ""; } function dataUrlToBytes(dataUrl: string): Uint8Array { const base64 = dataUrl.split(",")[1]; const binary = atob(base64); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); return bytes; } function isWebReadableStream( input: unknown, ): input is ReadableStream<Uint8Array> { return typeof (input as ReadableStream<Uint8Array>)?.getReader === "function"; } function isAsyncIterable(input: unknown): input is AsyncIterable<Uint8Array> { return ( typeof (input as AsyncIterable<Uint8Array>)?.[Symbol.asyncIterator] === "function" ); } async function readStream( stream: ReadableStream<Uint8Array>, ): Promise<Uint8Array> { const chunks: Uint8Array[] = []; const reader = stream.getReader(); for (;;) { const { done, value } = await reader.read(); if (done) break; chunks.push(value); } return concatChunks(chunks); } async function readAsyncIterable( iterable: AsyncIterable<Uint8Array>, ): Promise<Uint8Array> { const chunks: Uint8Array[] = []; for await (const chunk of iterable) chunks.push(chunk); return concatChunks(chunks); } function concatChunks(chunks: Uint8Array[]): Uint8Array { const total = chunks.reduce((sum, chunk) => sum + chunk.length, 0); const bytes = new Uint8Array(total); let offset = 0; for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.length; } return bytes; }