From 65a525aae041bfea54d2712f6a406ce4e6db384a Mon Sep 17 00:00:00 2001 From: Adam Urpsis Date: Fri, 11 Sep 2026 13:26:50 -0600 Subject: [PATCH 1/5] feat(sync): route transcribe() over the live connection and raise the config caps client.sync opens one kind of connection: the chunked-multipart live upload to /v1/transcribe/live. transcribe() resolves the audio as before and sends it as a single chunk over that upload, with the config part first ({} when empty); nothing posts to the buffered /v1/transcribe. The live endpoint is named /v1/transcribe/live (the service also serves /v1/transcribe/stream). SyncTranscribeOptions is { timeout, signal }: a 180 000 ms total deadline from the start of the request, the same basis as the live methods. Config caps match the service: prompt 6000 chars, keyterms_prompt 100 terms and 8000 chars, conversation_context 500 turns and 16000 chars. Co-Authored-By: Claude Fable 5.1 --- src/services/sync/service.ts | 121 +++++++++++++++++++---------------- src/types/sync/index.ts | 22 ++++--- 2 files changed, 79 insertions(+), 64 deletions(-) diff --git a/src/services/sync/service.ts b/src/services/sync/service.ts index 94a3198..0bb1d98 100644 --- a/src/services/sync/service.ts +++ b/src/services/sync/service.ts @@ -31,22 +31,24 @@ import { } from "../../utils/live"; import { SyncLiveSession, checkLiveInput } from "./live"; -// 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 transcribeLiveEndpoint = "/v1/transcribe/stream"; +// The one endpoint the client posts audio to. The service also serves it at +// /v1/transcribe/stream. +const transcribeLiveEndpoint = "/v1/transcribe/live"; 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; -// A fetch deadline spans the whole request, upload included, so the live -// default must clear the 120 s audio cap plus the final segment. +// A fetch deadline spans the whole request, upload included, so this must +// clear the 120 s audio cap plus the final segment. It is the default for +// every transcription request. const defaultLiveTimeoutMs = 180_000; const warmTimeoutMs = 10_000; -const maxPromptLength = 4096; -const maxKeytermsPromptLength = 2048; -const maxContextTurns = 100; -const maxContextLength = 4096; +// Caps mirror the sync service's `config` part. `prompt` and +// `keyterms_prompt` over their caps are rejected; `conversation_context` over +// its caps is trimmed, oldest turns first. +const maxPromptLength = 6000; +const maxKeytermsPromptLength = 8000; +const maxKeytermsCount = 100; +const maxContextTurns = 500; +const maxContextLength = 16000; // The sync route decodes a WAV container or raw S16LE PCM, so no extension // maps to a container content type of its own. const contentTypes: Record = {}; @@ -54,14 +56,16 @@ const contentTypes: Record = {}; const configName = "the config"; /** - * The synchronous transcription service: audio in, transcript out, - * one request. + * The synchronous transcription service: audio in, transcript out, one + * connection. * * 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. + * polls for completion), `SyncTranscriber` posts audio over a live upload to + * the sync API and returns the finished transcript in the HTTP response. + * There is no job id or status to poll. `transcribe()` accepts a local file + * path, raw audio bytes, a Blob, or a readable stream — but not a URL — and + * sends it as a single chunk over the same connection `transcribeLive()` and + * `openLive()` use for audio still being produced. */ export class SyncTranscriber extends BaseService { /** @@ -74,11 +78,19 @@ export class SyncTranscriber extends BaseService { /** * Transcribe audio and return the finished transcript in one request. + * + * The audio travels as a single chunk over the same live upload + * `transcribeLive()` uses — this is the ergonomic shape for audio you + * already hold whole, rather than audio still being produced. * @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. + * @param options - Client-side options: the request deadline and an + * optional abort signal. * @returns A promise that resolves to the finished transcript. + * @throws Error when `audio` is a URL, when raw PCM is missing + * `sample_rate` or `channels`, or when `prompt` or `keyterms_prompt` + * exceeds its cap. * @throws SyncTranscriptError when the request fails. */ async transcribe( @@ -87,47 +99,32 @@ export class SyncTranscriber extends BaseService { options: SyncTranscribeOptions = {}, ): Promise { const { bytes, filename, contentType } = await resolveAudio(audio, config); - - const body = new FormData(); - body.append( - "audio", - new Blob([bytes as BlobPart], { type: contentType }), - filename, + const signal = deadlineSignal( + options.timeout ?? defaultLiveTimeoutMs, + options.signal, ); - 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), + return await this.postLive(singleChunk(bytes), audio, config, signal, { + filename, + contentType, }); - if (response.status !== 200) { - throw await syncError(response); - } - return (await response.json()) as SyncTranscriptResponse; } /** * Transcribe audio uploaded as it is produced. * - * Where `transcribe()` needs the whole clip before it can send anything, - * this starts the request immediately and uploads chunks as they arrive, - * so authorization, the upload and every speech segment but the last - * resolve while the caller is still recording. What is left to wait for - * once they stop is the final segment. + * For audio that is still being produced — a live microphone, an + * in-progress call — this starts the request immediately and uploads + * chunks as they arrive, so authorization, the upload and every speech + * segment but the last resolve while the caller is still recording. What + * is left to wait for once they stop is the final segment. Audio already + * held whole travels the same connection as a single chunk in + * `transcribe()`, the ergonomic shape for that case. * - * That only pays off when the audio is genuinely still being produced: a - * live microphone, an in-progress call. Streaming a file already on disk - * is slower than `transcribe()`, which uploads it in one piece. The saving - * also needs enough audio to have segments to release early; below roughly - * a minute only the elided upload counts. The same 120 s audio cap applies. + * The saving only pays off when the audio is genuinely still being + * produced; audio you already hold whole belongs in `transcribe()`. It + * also needs enough audio to have segments to release early; below + * roughly a minute only the elided upload counts. The same 120 s audio + * cap applies. * * The caller must keep producing: an upload that goes silent for long * enough is aborted server-side. Stop by ending the stream, not by pausing @@ -197,15 +194,18 @@ export class SyncTranscriber extends BaseService { /** * Post a live upload whose audio arrives from `chunks`. `source` is the - * caller's original input, consulted only for a file name. + * caller's original input, consulted only for a file name when `format` is + * not already resolved. */ private async postLive( chunks: AsyncIterable, source: unknown, config: SyncTranscriptionConfig, signal: AbortSignal, + format?: { filename: string; contentType: string }, ): Promise { - const { filename, contentType } = resolveLiveFormat(source, config); + const { filename, contentType } = + format ?? resolveLiveFormat(source, config); const boundary = multipartBoundary(); const body = liveBody({ head: multipartHead({ @@ -284,6 +284,11 @@ type ResolvedAudio = { contentType: string; }; +/** Presents audio that is already complete as the upload's one chunk. */ +async function* singleChunk(bytes: Uint8Array): AsyncGenerator { + yield bytes; +} + /** * Read the audio input into bytes and decide its multipart content type. * @@ -371,7 +376,8 @@ function resolveLiveFormat( * 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. + * when there are no options; the multipart head then sends an empty `{}` + * part. */ function buildConfigJson( config: SyncTranscriptionConfig, @@ -405,6 +411,11 @@ function normalizeKeytermsPrompt( const terms = keytermsPrompt .map((term) => term.trim()) .filter((term) => term.length > 0); + if (terms.length > maxKeytermsCount) { + throw new Error( + `keyterms_prompt exceeds ${maxKeytermsCount} terms (got ${terms.length})`, + ); + } const total = terms.reduce((sum, term) => sum + term.length, 0); if (total > maxKeytermsPromptLength) { throw new Error( diff --git a/src/types/sync/index.ts b/src/types/sync/index.ts index eb4c876..4196494 100644 --- a/src/types/sync/index.ts +++ b/src/types/sync/index.ts @@ -55,19 +55,19 @@ export type SyncTranscriptionConfig = { */ model?: SyncSpeechModel; /** - * Custom transcription instruction. Maximum 4096 characters — longer + * Custom transcription instruction. Maximum 6000 characters — longer * prompts are rejected. */ prompt?: string; /** * Terms to bias the decoder towards. Whitespace is stripped and empty - * terms are dropped. Maximum 2048 characters in total — longer lists are - * rejected. + * terms are dropped. Maximum 100 terms and 8000 characters in total — + * longer lists are rejected. */ keyterms_prompt?: string[]; /** * Prior turns from the same conversation, oldest first, most recent last. - * A single string is treated as one turn. Capped at 100 turns and 4096 + * A single string is treated as one turn. Capped at 500 turns and 16000 * characters in total — over-cap context is trimmed (oldest turns dropped * first), not rejected. */ @@ -103,10 +103,15 @@ export type SyncTranscriptionConfig = { */ export type SyncTranscribeOptions = { /** - * The request timeout in milliseconds. Defaults to 60 000, which is kept - * above the server's 30 s deadline so the client doesn't race it. + * The request deadline in milliseconds, measured from the start of the + * request. Spans the upload and the transcription. Defaults to 180 000; + * the sync API caps audio at 120 seconds. */ timeout?: number; + /** + * An `AbortSignal` that drops the request when aborted. + */ + signal?: AbortSignal; }; /** @@ -116,9 +121,8 @@ export type SyncTranscribeOptions = { export type SyncLiveOptions = { /** * The request deadline in milliseconds, measured from the start of the - * request. Unlike the buffered path this spans the whole upload, so it - * must exceed the length of the recording as well as the transcription. - * Defaults to 180 000; the sync API caps audio at 120 seconds. + * request. Spans the upload and the transcription. Defaults to 180 000; + * the sync API caps audio at 120 seconds. */ timeout?: number; /** From 415fe33196423fca057221f7dd4fd0fb4d13a49e Mon Sep 17 00:00:00 2001 From: Adam Urpsis Date: Fri, 11 Sep 2026 13:26:51 -0600 Subject: [PATCH 2/5] feat(dictation): raise the config caps to match the service stt_prompt 6000 chars; keyterms_prompt 100 terms and 8000 chars in total, with the term count checked ahead of the character total. llm_instruction stays at 2048. Co-Authored-By: Claude Fable 5.1 --- src/services/dictation/service.ts | 12 ++++++++++-- src/types/dictation/index.ts | 6 +++--- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/src/services/dictation/service.ts b/src/services/dictation/service.ts index 0720f0e..1f224e3 100644 --- a/src/services/dictation/service.ts +++ b/src/services/dictation/service.ts @@ -37,8 +37,11 @@ const warmEndpoint = "/v1/warm"; // total request budget; the audio itself is capped at 120 s. const defaultLiveTimeoutMs = 300_000; const warmTimeoutMs = 10_000; -const maxSttPromptLength = 4096; -const maxKeytermsPromptLength = 2048; +// Caps mirror the dictation service's `config` part; a field over its cap is +// rejected before any request is sent. +const maxSttPromptLength = 6000; +const maxKeytermsPromptLength = 8000; +const maxKeytermsCount = 100; const maxLlmInstructionLength = 2048; // What to call the config in the message when raw PCM is missing a field. const configName = "the config"; @@ -411,6 +414,11 @@ function normalizeKeytermsPrompt( const terms = keytermsPrompt .map((term) => term.trim()) .filter((term) => term.length > 0); + if (terms.length > maxKeytermsCount) { + throw new Error( + `keyterms_prompt exceeds ${maxKeytermsCount} terms (got ${terms.length})`, + ); + } const total = terms.reduce((sum, term) => sum + term.length, 0); if (total > maxKeytermsPromptLength) { throw new Error( diff --git a/src/types/dictation/index.ts b/src/types/dictation/index.ts index ca77de1..ffe6ce2 100644 --- a/src/types/dictation/index.ts +++ b/src/types/dictation/index.ts @@ -59,13 +59,13 @@ export type DictationConfig = { * e.g. `"A doctor dictating a patient visit note."`. It describes the * situation rather than instructing the model, and steers the decoder as it * writes the transcript — where `llm_instruction` reshapes the transcript - * afterwards. Maximum 4096 characters; longer prompts are rejected. + * afterwards. Maximum 6000 characters; longer prompts are rejected. */ stt_prompt?: string; /** * Terms to bias the decoder towards. Whitespace is stripped and empty terms - * are dropped. Maximum 2048 characters in total — longer lists are - * rejected. + * are dropped. Maximum 100 terms and 8000 characters in total — lists over + * either cap are rejected. */ keyterms_prompt?: string[]; /** From 2f6c888c8ce3d4c7fef442d2782a7c882724109c Mon Sep 17 00:00:00 2001 From: Adam Urpsis Date: Fri, 11 Sep 2026 13:26:51 -0600 Subject: [PATCH 3/5] fix(live): escape multipart parameters fully and read bare error envelopes escapeFormParam follows the HTML5 form-encoding rules: a double quote becomes %22, a backslash is doubled, and control characters other than ESC are percent-encoded. errorFromResponse takes the message from a bare {"error": "..."} body when detail and message are absent. Co-Authored-By: Claude Fable 5.1 --- src/utils/live.ts | 31 ++++++++++++++++++++----------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/src/utils/live.ts b/src/utils/live.ts index 25007e0..08e78f5 100644 --- a/src/utils/live.ts +++ b/src/utils/live.ts @@ -2,10 +2,9 @@ * Multipart framing, streaming request bodies and the push-style session * behind every live upload route. * - * A buffered request hands fetch a `FormData` and lets it encode the whole - * body up front. A live upload cannot: the audio does not exist yet when the - * request starts. This module emits the framing itself as a `ReadableStream`, - * so the `config` part goes out immediately and audio follows as it arrives. + * The audio does not exist yet when the request starts, so this module emits + * the framing itself as a `ReadableStream`: the `config` part goes out + * immediately and audio follows as it arrives. * * Part order is significant and is enforced here by construction. The server * decodes audio as it lands, so it needs `sample_rate` and `channels` before @@ -32,12 +31,19 @@ export function multipartBoundary(): string { } /** - * Escape a value for a quoted `Content-Disposition` parameter, as - * `FormData` serialization does: `"` becomes `%22`, CR and LF become `%0D` - * and `%0A`, so a file name cannot break out of the part header. + * Escape a value for a quoted `Content-Disposition` parameter, per the HTML5 + * multipart form-encoding rules: `"` becomes `%22`, `\` is doubled to `\\`, + * and every control character U+0000-U+001F other than ESC (U+001B) becomes + * `%XX` (two uppercase hex digits), so a file name cannot break out of the + * part header. */ function escapeFormParam(value: string): string { - return value.replace(/"/g, "%22").replace(/\r/g, "%0D").replace(/\n/g, "%0A"); + // eslint-disable-next-line no-control-regex -- control characters are the escape target + return value.replace(/["\\\x00-\x1a\x1c-\x1f]/g, (char) => { + if (char === '"') return "%22"; + if (char === "\\") return "\\\\"; + return `%${char.charCodeAt(0).toString(16).toUpperCase().padStart(2, "0")}`; + }); } /** @@ -448,7 +454,9 @@ export class LiveSession { /** * Build a product error 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. + * `{error_code, message}` and `{error_code, error}` bodies are also accepted. + * The message is taken from `detail`, then `message`, then `error`, in that + * order of precedence. * @typeParam TError - The product's error class. * @param response - The failed response, whose body is read here. * @param factory - The error class to construct. @@ -479,6 +487,7 @@ export async function errorFromResponse( } if (typeof body.detail === "string") message = body.detail; else if (typeof body.message === "string") message = body.message; + else if (typeof body.error === "string") message = body.error; } } catch { if (text) message = text; @@ -509,8 +518,8 @@ const defaultContentType = "audio/wav"; * `sample_rate`/`channels` are set on the config (the fields these APIs * require only for raw PCM), and both must then be present. Any other suffix * takes the content type `contentTypes` maps it to, and `audio/wav` when it - * is unknown or absent. Needs no audio bytes, so it serves a live upload as - * well as a buffered one. + * is unknown or absent. Decided from the config and file name alone, before + * any audio bytes are available. * @param config - The request config, read for `sample_rate` and `channels`. * @param suffix - The source's lowercased file extension, with the dot, or * `""`. From 31f0cbfb2bb195ff2b69776d08455c39c12e83ed Mon Sep 17 00:00:00 2001 From: Adam Urpsis Date: Fri, 11 Sep 2026 13:26:51 -0600 Subject: [PATCH 4/5] test(sync,dictation): cover the live-only sync path, raised caps and error envelopes Every sync mock targets /v1/transcribe/live and one test drives transcribe(), transcribeLive() and openLive() to assert the buffered endpoint is never requested. Streamed request bodies are drained once per request and cached so several assertions can read one body. Adds the single-chunk framing on a rejected transcribe(), the raised sync and dictation caps including the 100-term count cap, message precedence detail > message > error, and the quote/TAB/ESC escaping cases. Co-Authored-By: Claude Fable 5.1 --- tests/unit/dictation.test.ts | 84 ++++++++- tests/unit/sync-live.test.ts | 23 ++- tests/unit/sync.test.ts | 335 ++++++++++++++++++++++++++++++----- 3 files changed, 395 insertions(+), 47 deletions(-) diff --git a/tests/unit/dictation.test.ts b/tests/unit/dictation.test.ts index 9088d6e..4f79627 100644 --- a/tests/unit/dictation.test.ts +++ b/tests/unit/dictation.test.ts @@ -1,5 +1,6 @@ -import { createReadStream } from "fs"; +import { createReadStream, mkdtempSync, rmSync, writeFileSync } from "fs"; import fetchMock from "jest-fetch-mock"; +import { tmpdir } from "os"; import path from "path"; import { Readable } from "stream"; import { AssemblyAI, DictationError, SyncTranscriptError } from "../../src"; @@ -210,6 +211,37 @@ describe("dictation upload", () => { expect(parts[1].filename).toBe("we%22ird%0D%0Aname.wav"); expect(decode(parts[1].body)).toBe("RIFF"); }); + + it("should escape a tab in a real path file name, passing ESC through", async () => { + mockOk(); + const dir = mkdtempSync(path.join(tmpdir(), "aai-dictation-")); + // A tab and an ESC control character — both valid bytes in a POSIX file + // name. No backslash here: the SDK's basename() splits a path on both + // `/` and `\`, so a path input can never carry one into the file name + // (see the File-based test below for that case). + const weirdName = `b\tc\x1bd.wav`; + const filePath = path.join(dir, weirdName); + writeFileSync(filePath, Buffer.from("RIFFfake")); + try { + await assembly.dictation.transcribeLive(filePath); + const parts = await requestParts(); + expect(parts[1].filename).toBe(`b%09c\x1bd.wav`); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("should escape a quote and tab in a File name", async () => { + mockOk(); + // basename() is applied to a File/Blob `.name` the same as a path, so a + // backslash in it is also split off rather than reaching the escaper — + // there is no public input that carries a backslash through to it. + const file: NamedBlob = new Blob([bytes("RIFFfake") as BlobPart]); + file.name = `b"c\td.wav`; + await assembly.dictation.transcribeLive(file); + const parts = await requestParts(); + expect(parts[1].filename).toBe(`b%22c%09d.wav`); + }); }); describe("dictation audio inputs", () => { @@ -434,12 +466,21 @@ describe("dictation config", () => { it("should reject an oversized stt_prompt before any request", async () => { await expect( assembly.dictation.transcribeLive(chunks(bytes("RIFF")), { - stt_prompt: "a".repeat(4097), + stt_prompt: "a".repeat(6001), }), - ).rejects.toThrow("stt_prompt exceeds 4096 characters (got 4097)"); + ).rejects.toThrow("stt_prompt exceeds"); expect(fetchMock).not.toHaveBeenCalled(); }); + it("should accept a 5000-char stt_prompt", async () => { + mockOk(); + const sttPrompt = "a".repeat(5000); + await assembly.dictation.transcribeLive(chunks(bytes("RIFF")), { + stt_prompt: sttPrompt, + }); + expect(await configPart()).toEqual({ stt_prompt: sttPrompt }); + }); + it("should reject an oversized llm_instruction before any request", async () => { await expect( assembly.dictation.transcribeLive(chunks(bytes("RIFF")), { @@ -452,11 +493,31 @@ describe("dictation config", () => { it("should reject an oversized keyterms_prompt before any request", async () => { await expect( assembly.dictation.transcribeLive(chunks(bytes("RIFF")), { - keyterms_prompt: ["a".repeat(1024), "b".repeat(1025)], + keyterms_prompt: ["a".repeat(4000), "b".repeat(4001)], }), - ).rejects.toThrow("keyterms_prompt exceeds 2048 characters (got 2049)"); + ).rejects.toThrow("characters"); expect(fetchMock).not.toHaveBeenCalled(); }); + + it("should reject more than 100 keyterms_prompt terms", async () => { + const terms = Array.from({ length: 101 }, (_, i) => `term${i}`); + await expect( + assembly.dictation.transcribeLive(chunks(bytes("RIFF")), { + keyterms_prompt: terms, + }), + ).rejects.toThrow("keyterms_prompt exceeds 100 terms"); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("should accept exactly 100 keyterms_prompt terms", async () => { + mockOk(); + const terms = Array.from({ length: 100 }, (_, i) => `term${i}`); + await assembly.dictation.transcribeLive(chunks(bytes("RIFF")), { + keyterms_prompt: terms, + }); + const config = await configPart(); + expect(config.keyterms_prompt).toEqual(terms); + }); }); describe("dictation response", () => { @@ -535,6 +596,19 @@ describe("dictation errors", () => { expect(error.message).toBe("could not decode"); }); + it("should map a bare error/error_code envelope to DictationError", async () => { + mockError(401, { + error: "Invalid API key", + error_code: "unauthorized", + }); + const error = await assembly.dictation + .transcribeLive(chunks(bytes("RIFF"))) + .catch((e) => e); + expect(error).toBeInstanceOf(DictationError); + expect(error.message).toBe("Invalid API key"); + expect(error.errorCode).toBe("unauthorized"); + }); + it("should map a problem-details envelope to DictationError", async () => { mockError(413, { status: 413, diff --git a/tests/unit/sync-live.test.ts b/tests/unit/sync-live.test.ts index 6b4428b..b8134ee 100644 --- a/tests/unit/sync-live.test.ts +++ b/tests/unit/sync-live.test.ts @@ -3,7 +3,7 @@ import fetchMock from "jest-fetch-mock"; import path from "path"; import { Readable } from "stream"; import { SyncTranscriptError } from "../../src"; -import { createClient, requestMatches } from "./utils"; +import { createClient, defaultBaseUrl, requestMatches } from "./utils"; fetchMock.enableMocks(); @@ -20,7 +20,7 @@ const okResponse = { request_time_ms: 243.7, }; -const liveUrl = "/v1/transcribe/stream"; +const liveUrl = "/v1/transcribe/live"; function mockOk() { fetchMock.doMockOnceIf( @@ -530,3 +530,22 @@ describe("sync live session", () => { expect(decode(parts[1].body)).toBe("RIFFfake"); }); }); + +describe("sync routing", () => { + it("should never request the buffered transcription endpoint", async () => { + mockOk(); + mockOk(); + mockOk(); + + await assembly.sync.transcribe(bytes("RIFFfake")); + await assembly.sync.transcribeLive(chunks(bytes("RIFF"))); + const session = assembly.sync.openLive(); + session.write(bytes("RIFF")); + session.close(); + await session.result(); + + expect(fetchMock.mock.calls).toHaveLength(3); + const urls = new Set(fetchMock.mock.calls.map(([url]) => url)); + expect(urls).toEqual(new Set([defaultBaseUrl + liveUrl])); + }); +}); diff --git a/tests/unit/sync.test.ts b/tests/unit/sync.test.ts index 534665d..491c331 100644 --- a/tests/unit/sync.test.ts +++ b/tests/unit/sync.test.ts @@ -1,8 +1,9 @@ -import { createReadStream } from "fs"; +import { createReadStream, mkdtempSync, rmSync, writeFileSync } from "fs"; import fetchMock from "jest-fetch-mock"; +import { tmpdir } from "os"; import path from "path"; import { SyncTranscriptError } from "../../src"; -import { createClient, requestMatches } from "./utils"; +import { createClient, defaultBaseUrl, requestMatches } from "./utils"; fetchMock.enableMocks(); @@ -24,32 +25,127 @@ const okResponse = { request_time_ms: 243.7, }; +// `transcribe()` rides the same chunked-multipart live upload as +// `transcribeLive()` / `openLive()` — the buffered endpoint is never used. +const liveUrl = "/v1/transcribe/live"; + function mockOk() { fetchMock.doMockOnceIf( - requestMatches({ url: "/v1/transcribe", method: "POST" }), + requestMatches({ url: liveUrl, method: "POST" }), JSON.stringify(okResponse), ); } +/** Behaves like a transport: gives up when the request's signal fires. */ +function mockAbortable() { + fetchMock.doMockOnceIf( + requestMatches({ url: liveUrl, method: "POST" }), + async (request) => { + await new Promise((resolve) => { + request.signal.addEventListener("abort", () => resolve(), { + once: true, + }); + }); + throw Object.assign(new Error("The operation was aborted."), { + name: "AbortError", + }); + }, + ); +} + type NamedBlob = Blob & { name?: string }; -function requestBody(): FormData { - return fetchMock.mock.calls[0][1]!.body as FormData; +function requestInit(index = 0): RequestInit & { duplex?: string } { + return fetchMock.mock.calls[index][1] as RequestInit & { duplex?: string }; +} + +function requestHeaders(index = 0): Record { + return requestInit(index).headers as Record; +} + +/** Drains a `ReadableStream` the way a transport would. */ +async function drainStream( + stream: ReadableStream, +): Promise { + const reader = stream.getReader(); + const chunks: Uint8Array[] = []; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + chunks.push(value); + } + const total = chunks.reduce((n, c) => n + c.length, 0); + const out = new Uint8Array(total); + let offset = 0; + for (const c of chunks) { + out.set(c, offset); + offset += c.length; + } + return out; } -function requestHeaders(): Record { - return fetchMock.mock.calls[0][1]!.headers as Record; +// A `ReadableStream` can only be read once, but several helpers (and +// several assertions within one test) may each want the drained bytes — +// cache the drain per request index so any number of calls is safe. +const bodyBytesCache = new Map>(); + +/** Drains the streamed request body the way a transport would. */ +function requestBodyBytes(index = 0): Promise { + let cached = bodyBytesCache.get(index); + if (!cached) { + cached = drainStream(requestInit(index).body as ReadableStream); + bodyBytesCache.set(index, cached); + } + return cached; } -async function configPart(): Promise | null> { - const part = requestBody().get("config"); - if (part === null) return null; - return JSON.parse(await (part as Blob).text()); +type Part = { + name: string; + filename?: string; + type?: string; + body: Uint8Array; +}; + +/** Parses multipart body bytes into their parts, in order. */ +function parseParts(bytes: Uint8Array, index = 0): Part[] { + const contentType = requestHeaders(index)["Content-Type"]; + const boundary = /boundary=([^;]+)/.exec(contentType)![1]; + const text = new TextDecoder("latin1").decode(bytes); + const parts: Part[] = []; + for (const raw of text.split(`--${boundary}`)) { + if (raw === "" || raw.startsWith("--")) continue; + const [headerText, ...rest] = raw.replace(/^\r\n/, "").split("\r\n\r\n"); + const bodyText = rest.join("\r\n\r\n").replace(/\r\n$/, ""); + const name = /name="([^"]*)"/.exec(headerText)![1]; + const filename = /filename="([^"]*)"/.exec(headerText)?.[1]; + const type = /Content-Type: ([^\r\n]+)/.exec(headerText)?.[1]; + parts.push({ + name, + filename, + type, + body: Uint8Array.from(bodyText, (ch) => ch.charCodeAt(0)), + }); + } + return parts; +} + +/** Drains the streamed request body and parses it into parts. */ +async function requestParts(index = 0): Promise { + return parseParts(await requestBodyBytes(index), index); } +/** Parses the JSON `config` part of a request. */ +async function configPart(index = 0): Promise> { + const parts = await requestParts(index); + return JSON.parse(decode(parts[0].body)); +} + +const decode = (bytes: Uint8Array) => new TextDecoder().decode(bytes); + beforeEach(() => { fetchMock.resetMocks(); fetchMock.doMock(); + bodyBytesCache.clear(); }); describe("sync", () => { @@ -68,20 +164,27 @@ describe("sync", () => { const response: Partial = { ...okResponse }; delete response.request_time_ms; fetchMock.doMockOnceIf( - requestMatches({ url: "/v1/transcribe", method: "POST" }), + requestMatches({ url: liveUrl, method: "POST" }), JSON.stringify(response), ); const result = await assembly.sync.transcribe(fakeWavBytes); expect(result.request_time_ms).toBeUndefined(); }); - it("should send the model header and a WAV part", async () => { + it("should stream a chunked multipart body with the config part first", async () => { mockOk(); await assembly.sync.transcribe(fakeWavBytes); + expect(fetchMock.mock.calls[0][0]).toBe(defaultBaseUrl + liveUrl); + expect(requestInit().duplex).toBe("half"); + expect(requestInit().body).toBeInstanceOf(ReadableStream); expect(requestHeaders()["X-AAI-Model"]).toBe("universal-3-5-pro"); - const audio = requestBody().get("audio") as Blob; - expect(audio.type).toBe("audio/wav"); - expect(requestBody().get("config")).toBeNull(); + expect(requestHeaders()["Content-Type"]).toMatch(/^multipart\/form-data;/); + + const parts = await requestParts(); + expect(parts.map((p) => p.name)).toEqual(["config", "audio"]); + expect(decode(parts[0].body)).toBe("{}"); + expect(parts[0].type).toBe("application/json"); + expect(parts[1].type).toBe("audio/wav"); }); it("should send the prompt and normalized keyterms_prompt", async () => { @@ -108,12 +211,12 @@ describe("sync", () => { expect(config).not.toHaveProperty("model"); }); - it("should omit the config part when only the model is set", async () => { + it("should send an empty config part when only the model is set", async () => { mockOk(); await assembly.sync.transcribe(fakeWavBytes, { model: "some-other-model", }); - expect(requestBody().get("config")).toBeNull(); + expect(await configPart()).toEqual({}); }); it("should send conversation_context turns, stripped with empties dropped", async () => { @@ -146,15 +249,15 @@ describe("sync", () => { it("should trim the oldest conversation turns over the char cap", async () => { mockOk(); await assembly.sync.transcribe(fakeWavBytes, { - conversation_context: ["a".repeat(3000), "b".repeat(3000)], + conversation_context: ["a".repeat(10000), "b".repeat(10000)], }); const config = await configPart(); - expect(config?.conversation_context).toEqual(["b".repeat(3000)]); + expect(config?.conversation_context).toEqual(["b".repeat(10000)]); }); it("should trim the oldest conversation turns over the turn cap", async () => { mockOk(); - const turns = Array.from({ length: 120 }, (_, i) => `turn ${i}`); + const turns = Array.from({ length: 520 }, (_, i) => `turn ${i}`); await assembly.sync.transcribe(fakeWavBytes, { conversation_context: turns, }); @@ -165,9 +268,10 @@ describe("sync", () => { it("should trim to nothing when a single turn is over the char cap", async () => { mockOk(); await assembly.sync.transcribe(fakeWavBytes, { - conversation_context: ["a".repeat(5000)], + conversation_context: ["a".repeat(20000)], }); - expect(requestBody().get("config")).toBeNull(); + const config = await configPart(); + expect(config).not.toHaveProperty("conversation_context"); }); it("should send a single-element language_codes list", async () => { @@ -188,10 +292,10 @@ describe("sync", () => { expect(config?.language_codes).toEqual(["en", "es"]); }); - it("should omit the config part for a default config", async () => { + it("should send an empty config part for a default config", async () => { mockOk(); await assembly.sync.transcribe(fakeWavBytes); - expect(requestBody().get("config")).toBeNull(); + expect(await configPart()).toEqual({}); }); it("should send the timestamps flag when opted in", async () => { @@ -212,7 +316,7 @@ describe("sync", () => { ], }; fetchMock.doMockOnceIf( - requestMatches({ url: "/v1/transcribe", method: "POST" }), + requestMatches({ url: liveUrl, method: "POST" }), JSON.stringify(response), ); const result = await assembly.sync.transcribe(fakeWavBytes); @@ -228,8 +332,8 @@ describe("sync", () => { sample_rate: 16000, channels: 1, }); - const audio = requestBody().get("audio") as Blob; - expect(audio.type).toBe("audio/pcm"); + const parts = await requestParts(); + expect(parts[1].type).toBe("audio/pcm"); const config = await configPart(); expect(config).toEqual({ sample_rate: 16000, channels: 1 }); }); @@ -254,9 +358,9 @@ describe("sync", () => { path.join(testDir, "gore-short.wav"), ); expect(result.text).toBe("hello world"); - const audio = requestBody().get("audio") as NamedBlob; - expect(audio.name).toBe("gore-short.wav"); - expect(audio.type).toBe("audio/wav"); + const parts = await requestParts(); + expect(parts[1].filename).toBe("gore-short.wav"); + expect(parts[1].type).toBe("audio/wav"); }); it("should transcribe a Node stream and use its file name", async () => { @@ -264,8 +368,8 @@ describe("sync", () => { const stream = createReadStream(path.join(testDir, "gore-short.wav")); const result = await assembly.sync.transcribe(stream); expect(result.text).toBe("hello world"); - const audio = requestBody().get("audio") as NamedBlob; - expect(audio.name).toBe("gore-short.wav"); + const parts = await requestParts(); + expect(parts[1].filename).toBe("gore-short.wav"); }); it("should transcribe a web ReadableStream", async () => { @@ -287,22 +391,70 @@ describe("sync", () => { file.name = "call.wav"; const result = await assembly.sync.transcribe(file); expect(result.text).toBe("hello world"); - const audio = requestBody().get("audio") as NamedBlob; - expect(audio.name).toBe("call.wav"); + const parts = await requestParts(); + expect(parts[1].filename).toBe("call.wav"); + }); + + it("should escape a tab in a real path file name, passing ESC through", async () => { + mockOk(); + const dir = mkdtempSync(path.join(tmpdir(), "aai-sync-")); + // A tab and an ESC control character — both valid bytes in a POSIX file + // name. No backslash here: the SDK's basename() splits a path on both + // `/` and `\`, so a path input can never carry one into the file name + // (see the File-based test below for that case). + const weirdName = `b\tc\x1bd.wav`; + const filePath = path.join(dir, weirdName); + writeFileSync(filePath, Buffer.from("RIFFfake")); + try { + const result = await assembly.sync.transcribe(filePath); + expect(result.text).toBe("hello world"); + const parts = await requestParts(); + expect(parts[1].filename).toBe(`b%09c\x1bd.wav`); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("should escape a quote and tab in a File name", async () => { + mockOk(); + // basename() is applied to a File/Blob `.name` the same as a path, so a + // backslash in it is also split off rather than reaching the escaper — + // there is no public input that carries a backslash through to it. + const file: NamedBlob = new Blob([fakeWavBytes as BlobPart]); + file.name = `b"c\td.wav`; + await assembly.sync.transcribe(file); + const parts = await requestParts(); + expect(parts[1].filename).toBe(`b%22c%09d.wav`); }); it("should reject an oversized keyterms_prompt", async () => { + const promise = assembly.sync.transcribe(fakeWavBytes, { + keyterms_prompt: ["x".repeat(9000)], + }); + await expect(promise).rejects.toThrow("keyterms_prompt exceeds"); + await expect(promise).rejects.toThrow("characters"); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("should reject more than 100 keyterms_prompt terms", async () => { + const terms = Array.from({ length: 101 }, (_, i) => `term${i}`); await expect( - assembly.sync.transcribe(fakeWavBytes, { - keyterms_prompt: ["x".repeat(3000)], - }), - ).rejects.toThrow("keyterms_prompt exceeds"); + assembly.sync.transcribe(fakeWavBytes, { keyterms_prompt: terms }), + ).rejects.toThrow("keyterms_prompt exceeds 100 terms"); expect(fetchMock).not.toHaveBeenCalled(); }); + it("should accept exactly 100 keyterms_prompt terms", async () => { + mockOk(); + const terms = Array.from({ length: 100 }, (_, i) => `term${i}`); + await assembly.sync.transcribe(fakeWavBytes, { keyterms_prompt: terms }); + const config = await configPart(); + expect(config.keyterms_prompt).toEqual(terms); + }); + it("should reject an oversized prompt", async () => { await expect( - assembly.sync.transcribe(fakeWavBytes, { prompt: "x".repeat(5000) }), + assembly.sync.transcribe(fakeWavBytes, { prompt: "x".repeat(7000) }), ).rejects.toThrow("prompt exceeds"); expect(fetchMock).not.toHaveBeenCalled(); }); @@ -337,6 +489,48 @@ describe("sync", () => { }); }); + it("should map a bare error/error_code envelope to a SyncTranscriptError", async () => { + fetchMock.mockResponseOnce( + JSON.stringify({ + error: "Invalid API key", + error_code: "unauthorized", + }), + { status: 401 }, + ); + await expect(assembly.sync.transcribe(fakeWavBytes)).rejects.toMatchObject({ + message: "Invalid API key", + errorCode: "unauthorized", + }); + }); + + it("should prefer detail over a bare error field", async () => { + fetchMock.mockResponseOnce( + JSON.stringify({ + detail: "detail wins", + error: "error field", + error_code: "x", + }), + { status: 400 }, + ); + await expect(assembly.sync.transcribe(fakeWavBytes)).rejects.toMatchObject({ + message: "detail wins", + }); + }); + + it("should prefer message over a bare error field", async () => { + fetchMock.mockResponseOnce( + JSON.stringify({ + message: "message wins", + error: "error field", + error_code: "x", + }), + { status: 400 }, + ); + await expect(assembly.sync.transcribe(fakeWavBytes)).rejects.toMatchObject({ + message: "message wins", + }); + }); + it("should surface retryAfter on rate limits", async () => { fetchMock.mockResponseOnce( JSON.stringify({ @@ -353,6 +547,46 @@ describe("sync", () => { }); }); + it("should surface a rate-limited response and still upload the whole clip as a single chunk", async () => { + // Drain the body inside the handler, the way a real transport does, so + // the assertion does not race an early response against the writer + // still producing the (single) audio chunk. + let uploaded: Uint8Array | undefined; + fetchMock.doMockOnceIf( + requestMatches({ url: liveUrl, method: "POST" }), + async () => { + uploaded = await requestBodyBytes(); + return { + body: JSON.stringify({ + status: 429, + title: "Too Many Requests", + detail: "slow down", + }), + init: { status: 429, headers: { "Retry-After": "7" } }, + }; + }, + ); + const error = await assembly.sync.transcribe(fakeWavBytes).catch((e) => e); + expect(error).toBeInstanceOf(SyncTranscriptError); + expect(error.status).toBe(429); + expect(error.errorCode).toBe("too_many_requests"); + expect(error.retryAfter).toBe(7); + + // The "single chunk" half of the contract holds even on a rejected + // request: one config part, then one audio part carrying the whole + // input, then the closing boundary. + const parts = parseParts(uploaded!); + expect(parts.map((p) => p.name)).toEqual(["config", "audio"]); + expect(decode(parts[0].body)).toBe("{}"); + expect(decode(parts[1].body)).toBe(decode(fakeWavBytes)); + + const boundary = /boundary=([^;]+)/.exec( + requestHeaders()["Content-Type"], + )![1]; + const raw = new TextDecoder("latin1").decode(uploaded!); + expect(raw.trimEnd().endsWith(`--${boundary}--`)).toBe(true); + }); + it("should map a detail-only envelope without an error code", async () => { fetchMock.mockResponseOnce(JSON.stringify({ detail: "Invalid API key" }), { status: 401, @@ -387,3 +621,24 @@ describe("sync", () => { expect(await assembly.sync.warm()).toBe(false); }); }); + +describe("sync deadlines", () => { + it("should honour an external abort signal on transcribe()", async () => { + mockAbortable(); + const controller = new AbortController(); + const pending = assembly.sync.transcribe( + fakeWavBytes, + {}, + { signal: controller.signal }, + ); + controller.abort(); + await expect(pending).rejects.toThrow("aborted"); + }); + + it("should give up once the timeout elapses on transcribe()", async () => { + mockAbortable(); + await expect( + assembly.sync.transcribe(fakeWavBytes, {}, { timeout: 1 }), + ).rejects.toThrow("aborted"); + }); +}); From 43da34d19fe5c9584ac2759a76b1ba2fffbff1fa Mon Sep 17 00:00:00 2001 From: Adam Urpsis Date: Fri, 11 Sep 2026 13:26:51 -0600 Subject: [PATCH 5/5] docs(sync,dictation): one live connection, raised caps; bump to 4.41.0 Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 9 +++++++++ CLAUDE.md | 38 +++++++++++++++++++++++--------------- README.md | 26 ++++++++++++++------------ package.json | 2 +- 4 files changed, 47 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dec95f1..1d9d0fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## [4.41.0] + +- `client.sync.transcribe()` now sends audio over the same live connection as `transcribeLive()` / `openLive()`: `POST /v1/transcribe/live` (also served at `/v1/transcribe/stream`), chunked multipart, `config` first (always present, `{}` when empty) then `audio`. A clip already held is sent as a single chunk. There is no separate buffered request any more — nothing posts to `/v1/transcribe` +- `transcribe()`'s third argument (`SyncTranscribeOptions`) is now `{ timeout, signal }`, matching the live methods: `timeout` (default 180 000 ms, was 60 000) is a total deadline from the start of the request spanning the upload and the transcription, and `signal` (an `AbortSignal`) drops the request when aborted. `warm()` is unchanged +- Raise sync config caps to match the service: `prompt` to 6000 characters (was 4096), `keyterms_prompt` to 100 terms and 8000 characters total (was 2048 characters, uncapped terms), and `conversation_context` to 500 turns / 16 000 characters (was 100 / 4096) — still trimmed oldest-first rather than rejected. Over-cap `prompt`/`keyterms_prompt` are still rejected before any request is sent +- Raise dictation config caps to match the service: `stt_prompt` to 6000 characters (was 4096) and `keyterms_prompt` to 100 terms and 8000 characters total (was 2048, uncapped terms); `llm_instruction` is unchanged at 2048 +- A failure body of the form `{"error": "...", "error_code": "..."}` now surfaces that `error` string as the thrown error's message, for both `SyncTranscriptError` and `DictationError` +- Multipart file names are now escaped per the HTML5 form-encoding rules, so a `"`, backslash or control character in a file name can no longer break out of the part header + ## [4.40.0] - Add `client.dictation` — dictation transcription for short spoken notes: `transcribeLive(audio, config?, options?)` uploads audio as it is spoken and resolves with one finished transcript, `openLive(config?, options?)` is the push-style counterpart for callback-driven sources, and `warm()` opens the connection ahead of the request. Targets `dictation.assemblyai.com`, overridable with the new `dictationBaseUrl` client option. Audio is WAV or raw 16-bit PCM, up to 120 s diff --git a/CLAUDE.md b/CLAUDE.md index cf59d61..aa9c659 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -185,8 +185,11 @@ const vtt = await client.transcripts.subtitles(id, "vtt"); ## Sync transcription (pre-recorded, single request) -`client.sync` posts a whole audio file and returns the finished transcript in one -round trip — no job id, no polling, no status enum. It targets the sync API host +`client.sync` sends audio over one live connection and returns the finished +transcript in one round trip — no job id, no polling, no status enum. +`transcribe()` sends a clip you already hold as a single chunk over that +connection; `transcribeLive()` / `openLive()` upload it as it is still being +recorded. It targets the sync API host (`sync.assemblyai.com`, override with the `syncBaseUrl` client option), distinct from `client.transcripts`' async job API. Use it for short clips where you want the answer inline; use `client.transcripts` for long-form audio, URLs, or the rich @@ -209,11 +212,11 @@ a Blob/File, or a readable stream. **Not** a URL — pass a path/bytes or use ```typescript const result = await client.sync.transcribe("./call.wav", { - prompt: "Transcribe verbatim. Preserve disfluencies.", // max 4096 chars, rejected over - keyterms_prompt: ["AssemblyAI", "Lemur", "U3-Pro"], // max 2048 chars total, rejected over + prompt: "Transcribe verbatim. Preserve disfluencies.", // max 6000 chars, rejected over + keyterms_prompt: ["AssemblyAI", "Lemur", "U3-Pro"], // max 100 terms, 8000 chars total, rejected over language_codes: ["es"], // or e.g. ["en", "es"] for multilingual; defaults to English; ignored when prompt is set conversation_context: [ - // prior turns oldest-first; capped at 100 turns / 4096 chars — trimmed, not rejected + // prior turns oldest-first; capped at 500 turns / 16 000 chars — trimmed, not rejected "I'd like to book a flight to Denver.", "Sure, what date were you thinking?", ], @@ -257,10 +260,13 @@ it is still being recorded); it returns `true` once the socket is open, `false` a transport failure. Call it shortly before `transcribe()` — the pooled connection idles out after a few seconds. -**Live upload**: `transcribeLive()` and `openLive()` post to `/v1/transcribe/stream` with a -chunked multipart body, so the request starts before the audio exists and chunks upload as they -arrive. Authorization, the upload and every speech segment but the last resolve while the caller -is still recording; only the final segment is left to wait for. `transcribeLive()` pulls from an +**Live upload**: `transcribe()`, `transcribeLive()` and `openLive()` all send audio over one live +connection — `POST /v1/transcribe/live` (also served at `/v1/transcribe/stream`) — with a +chunked multipart body, `config` sent first (always present, `{}` when +empty) then `audio`. `transcribe()` sends a clip already held whole as a single chunk. For +`transcribeLive()`/`openLive()` the request starts before the audio exists and chunks upload as +they arrive, so authorization, the upload and every speech segment but the last resolve while the +caller is still recording; only the final segment is left to wait for. `transcribeLive()` pulls from an async iterable (Node streams included), a sync iterable, or a web `ReadableStream` — bytes, a Blob or a path are rejected with a TypeError pointing at `transcribe()`. `openLive()` is the push-style counterpart for callback-driven sources (microphone library, WebRTC track, telephony @@ -291,9 +297,11 @@ long enough is aborted server-side); auth, rate-limit and capacity failures can through the upload rather than only at the end (a malformed key at once, a key that fails deeper checks a few seconds in), and `warm()` opens the connection but does not validate the key. -**Client-side timeout**: third argument — `client.sync.transcribe(audio, {}, { timeout: 30_000 })` -(default 60 s, kept above the server's 30 s deadline). Live requests take `SyncLiveOptions` -instead — `{ timeout, signal }`. +**Client-side timeout**: third argument, `SyncTranscribeOptions` — `{ timeout, signal }`, e.g. +`client.sync.transcribe(audio, {}, { timeout: 30_000 })`. `timeout` (default 180 000 ms) is a +total deadline from the start of the request, spanning the upload and the transcription — the +same basis `transcribeLive()`/`openLive()` use via `SyncLiveOptions`. `signal` drops the request +when aborted. ## Dictation (short spoken notes, optional LLM rewrite) @@ -345,8 +353,8 @@ const result = await client.dictation.transcribeLive(mic, { sample_rate: 16_000, // raw 16-bit PCM; setting either PCM field requires both; unset for WAV channels: 1, // 1 mono / 2 stereo language_codes: ["es"], // ISO 639-1; or e.g. ["en", "es"]; unset leaves the language to the server - stt_prompt: "A doctor dictating a patient visit note.", // max 4096 chars; describes the recording - keyterms_prompt: ["AssemblyAI", "U3-Pro"], // whitespace stripped, empties dropped, max 2048 chars total + stt_prompt: "A doctor dictating a patient visit note.", // max 6000 chars; describes the recording + keyterms_prompt: ["AssemblyAI", "U3-Pro"], // whitespace stripped, empties dropped, max 100 terms, 8000 chars total llm_instruction: "Format this as a SOAP note.", // max 2048 chars; follow-up LLM pass over the transcript }); ``` @@ -418,7 +426,7 @@ strings, when present). - **speech_models takes an array** with fallback ordering: ["universal-3-5-pro", "universal-2"] - **Streaming uses universal-3-5-pro** as the speech model - **Never expose API keys client-side** — use temporary auth tokens for browser streaming -- **Live upload timeout is a total deadline** — `transcribeLive()`/`openLive()` default to 180 s covering the whole recording plus transcription (not per-response like `transcribe()`'s 60 s), and the sync API still caps audio at 120 s +- **Sync timeout is a total deadline** — `transcribe()`, `transcribeLive()` and `openLive()` all default to a 180 s deadline spanning the whole request (upload plus transcription), and the sync API still caps audio at 120 s - **Dictation timeout is a total 300 s deadline** — `client.dictation` spans the upload, the transcription and the LLM pass in one deadline; the service caps audio at 120 s and accepts WAV or raw 16-bit PCM only - **Node >= 18 required** - **Only runtime dependency**: ws (WebSocket library) diff --git a/README.md b/README.md index 3381e67..7a5634b 100644 --- a/README.md +++ b/README.md @@ -313,11 +313,13 @@ const res = await client.transcripts.delete(transcript.id); ### Transcribe audio synchronously -`client.sync` posts a whole audio file and returns the finished transcript in one -round trip — no job id, no polling — or uploads the audio live, as it is still -being recorded (`transcribeLive()` / `openLive()`). Use it for short clips where -you want the answer inline; use `client.transcripts` for long-form audio, URLs, -or the rich audio-intelligence features the sync API doesn't expose. +`client.sync` sends audio over one live connection and returns the finished +transcript in one round trip — no job id, no polling. `transcribe()` sends a +clip you already hold as a single chunk over that connection; +`transcribeLive()` / `openLive()` upload it as it is still being recorded. Use +it for short clips where you want the answer inline; use `client.transcripts` +for long-form audio, URLs, or the rich audio-intelligence features the sync API +doesn't expose. ```typescript const result = await client.sync.transcribe("./call.wav"); @@ -332,8 +334,8 @@ stream — but not a URL. ```typescript const result = await client.sync.transcribe("./call.wav", { - prompt: "Transcribe verbatim. Preserve disfluencies.", // max 4096 chars - keyterms_prompt: ["AssemblyAI", "Lemur"], // max 2048 chars total + prompt: "Transcribe verbatim. Preserve disfluencies.", // max 6000 chars + keyterms_prompt: ["AssemblyAI", "Lemur"], // max 100 terms, 8000 chars total language_codes: ["es"], // or e.g. ["en", "es"] for multilingual; defaults to English conversation_context: [ // prior turns, oldest first @@ -490,9 +492,9 @@ console.log(result.text); ``` `options.timeout` (default 180 000 ms) is a **total** deadline spanning the -whole upload, unlike `transcribe()`'s 60 s, so it has to exceed the length of -the recording as well as the transcription. `options.signal` drops the request -from outside, the way `abort()` does for a session. +whole upload, so it has to exceed the length of the recording as well as the +transcription. `options.signal` drops the request from outside, the way +`abort()` does for a session. Caveats, for both live entry points: @@ -682,8 +684,8 @@ const result = await client.dictation.transcribeLive(micStream, { sample_rate: 16_000, // raw 16-bit PCM only; required together with channels channels: 1, // 1 mono, 2 stereo; leave both unset for WAV language_codes: ["es"], // ISO 639-1; or e.g. ["en", "es"]; defaults to the server's choice - stt_prompt: "A doctor dictating a patient visit note.", // max 4096 chars - keyterms_prompt: ["AssemblyAI", "Universal-3"], // max 2048 chars total + stt_prompt: "A doctor dictating a patient visit note.", // max 6000 chars + keyterms_prompt: ["AssemblyAI", "Universal-3"], // max 100 terms, 8000 chars total llm_instruction: "Format this as a SOAP note.", // max 2048 chars }); ``` diff --git a/package.json b/package.json index 603a30e..d6a6a7e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "assemblyai", - "version": "4.40.0", + "version": "4.41.0", "description": "The AssemblyAI JavaScript SDK provides an easy-to-use interface for interacting with the AssemblyAI API, which supports async and real-time transcription.", "engines": { "node": ">=18"