evOo-local/packages/adapters/src/openai-compatible-transcription.ts
Claude 3f85cb0420
feat(media): transcribe speech on any OpenAI-compatible server and write subtitles
Transcription has to run on the user's own hardware, and the only speech-to-text
path so far was the OpenAI voice key. Self-hosted whisper servers (whisper.cpp,
faster-whisper based servers, vLLM) all speak the OpenAI transcriptions
protocol, so one provider covers them.

- createOpenAiCompatibleTranscriptionProvider posts a multipart upload to
  {base}/audio/transcriptions asking for timed segments, falls back to plain
  JSON for servers that reject verbose_json, reads text/plain bodies, and
  reports failures in words. It reuses the address-checked fetch and URL
  guards of the other OpenAI-compatible adapters, caps uploads at 25 MiB and
  responses at 8 MiB, and builds the multipart body by hand because the
  undici fetch behind the address checks does not serialize the global
  FormData class.
- An in-process server emulator (OpenAI/whisper.cpp, plain-JSON-only and
  text-only flavours) and an in-process provider back an offline
  conformance suite, plus one loopback test through the real fetch.
- toSrt/toVtt in core turn segments into SubRip and WebVTT, wrapping long
  segments into cues of at most two 42-character lines that share the
  segment's time, and escaping WebVTT cue text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 13:17:17 +00:00

309 lines
12 KiB
TypeScript

import { randomUUID } from "node:crypto";
import type {
AdapterDescriptor,
Transcript,
TranscriptionCapabilities,
TranscriptionProvider,
TranscriptionRequest,
TranscriptSegment,
} from "@rakazo/adapter-kit";
import {
assertAllowedOpenAiCompatibleUrl,
assertHttpsForKeyedOpenAiCompatibleUrl,
OPENAI_COMPATIBLE_PROVIDER_ID,
} from "./openai-compatible-url.js";
import { createOpenAiCompatibleFetch } from "./pi-openai-compatible-provider.js";
import { readBodyCapped } from "./web-ssrf.js";
/** The OpenAI upload limit; whisper.cpp and faster-whisper servers accept at least this much. */
export const OPENAI_COMPATIBLE_TRANSCRIPTION_MAX_BYTES = 25 * 1024 * 1024;
/** Used when no model is configured; whisper.cpp ignores it and most servers alias it. */
export const OPENAI_COMPATIBLE_TRANSCRIPTION_DEFAULT_MODEL = "whisper-1";
// CPU-only whisper servers need minutes for a long upload; the job's signal still cancels sooner.
const TRANSCRIPTION_TIMEOUT_MS = 15 * 60_000;
const MAX_TRANSCRIPTION_RESPONSE_BYTES = 8 * 1024 * 1024;
export interface OpenAiCompatibleTranscriptionConfig {
baseUrl: string;
model?: string;
apiKey?: string;
fetch?: typeof globalThis.fetch;
}
type ResponseFormat = "verbose_json" | "json";
/**
* Speech to text from any server that speaks the OpenAI `POST /v1/audio/transcriptions`
* protocol: whisper.cpp's server (with `--inference-path /v1/audio/transcriptions`),
* faster-whisper based servers, vLLM, or a hosted API. Asks for timed segments and falls back
* to plain text when the server only returns that.
*/
export function createOpenAiCompatibleTranscriptionProvider(
config: OpenAiCompatibleTranscriptionConfig,
): TranscriptionProvider {
const baseUrl = assertAllowedOpenAiCompatibleUrl(config.baseUrl);
const apiKey = config.apiKey?.trim() || undefined;
assertHttpsForKeyedOpenAiCompatibleUrl(baseUrl, apiKey);
const model = config.model?.trim() || OPENAI_COMPATIBLE_TRANSCRIPTION_DEFAULT_MODEL;
const endpoint = new URL("audio/transcriptions", `${baseUrl.href}/`).href;
const fetchImpl = createOpenAiCompatibleFetch(config.fetch);
function post(
request: TranscriptionRequest,
format: ResponseFormat,
signal: AbortSignal,
): Promise<Response> {
const fields: Record<string, string> = { model, response_format: format };
const language = languageHint(request.language);
if (language) fields.language = language;
const body = multipartBody(fields, {
name: uploadName(request.fileName, request.mimeType),
mimeType: request.mimeType || "application/octet-stream",
bytes: request.audio,
});
const headers: Record<string, string> = {
Accept: "application/json, text/plain",
"Content-Type": body.contentType,
};
if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
return fetchImpl(endpoint, {
method: "POST",
headers,
body: body.bytes,
redirect: "error",
signal,
});
}
async function transcribeAs(
request: TranscriptionRequest,
format: ResponseFormat,
): Promise<Transcript | { rejectedFormat: true }> {
const timeout = AbortSignal.timeout(TRANSCRIPTION_TIMEOUT_MS);
const signal = request.signal ? AbortSignal.any([request.signal, timeout]) : timeout;
try {
const response = await post(request, format, signal);
const text = new TextDecoder().decode(await readResponse(response, signal));
if (!response.ok) {
const message = errorMessage(text);
// Servers that only implement `json`/`text` reject the timed format; ask again plainly.
if (
format === "verbose_json" &&
(response.status === 400 || response.status === 422) &&
/format|verbose/i.test(message)
) {
return { rejectedFormat: true };
}
throw new Error(httpErrorMessage(response.status, message));
}
return parseTranscriptionResponse(text, response.headers.get("content-type"));
} catch (error) {
if (request.signal?.aborted) throw error;
if (timeout.aborted) throw new Error("Transcription server timed out");
// undici reports refused or reset connections as a bare "fetch failed".
if (error instanceof TypeError && error.message === "fetch failed") {
throw new Error("Transcription server is unreachable");
}
throw error;
}
}
return {
describe(): AdapterDescriptor<TranscriptionCapabilities> {
return {
id: OPENAI_COMPATIBLE_PROVIDER_ID,
contractVersion: "1",
adapterVersion: "0.1.0",
capabilities: {
segments: true,
languageDetection: true,
maxBytes: OPENAI_COMPATIBLE_TRANSCRIPTION_MAX_BYTES,
},
};
},
async transcribe(request: TranscriptionRequest): Promise<Transcript> {
request.signal?.throwIfAborted();
if (request.audio.byteLength === 0) throw new Error("The audio is empty");
if (request.audio.byteLength > OPENAI_COMPATIBLE_TRANSCRIPTION_MAX_BYTES) {
throw new Error("The audio is too large for the transcription server");
}
const timed = await transcribeAs(request, "verbose_json");
if (!("rejectedFormat" in timed)) return timed;
const plain = await transcribeAs(request, "json");
if ("rejectedFormat" in plain) throw new Error("Transcription server rejected the request");
return plain;
},
};
}
async function readResponse(response: Response, signal: AbortSignal): Promise<Uint8Array> {
try {
return await readBodyCapped(response, MAX_TRANSCRIPTION_RESPONSE_BYTES, signal);
} catch (error) {
if (error instanceof Error && error.message === "Response is too large") {
throw new Error("Transcription server returned too much data");
}
throw error;
}
}
function httpErrorMessage(status: number, detail: string): string {
if (status === 401 || status === 403) return "Transcription server rejected the API key";
if (status === 413) return "The audio is too large for the transcription server";
if (status === 404) return "Transcription server has no /audio/transcriptions endpoint";
return detail
? `Transcription server returned ${status}: ${detail}`
: `Transcription server returned ${status}`;
}
/** The server's own message from an OpenAI-style `{error: {message}}` or a short plain body. */
function errorMessage(body: string): string {
let message = body;
try {
const parsed: unknown = JSON.parse(body);
if (isRecord(parsed)) {
const error = parsed.error;
message =
isRecord(error) && typeof error.message === "string"
? error.message
: typeof error === "string"
? error
: typeof parsed.detail === "string"
? parsed.detail
: typeof parsed.message === "string"
? parsed.message
: "";
}
} catch {
// A plain-text error body is used as it is.
}
return message.replace(/\s+/g, " ").trim().slice(0, 200);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
/**
* Reads `verbose_json` (`{text, language, segments: [{start, end, text}]}` in seconds), plain
* `json` (`{text}`) or a `text/plain` body. Malformed segments are dropped rather than trusted.
*/
export function parseTranscriptionResponse(body: string, contentType: string | null): Transcript {
const trimmed = body.trim();
const looksJson = /json/i.test(contentType ?? "") || trimmed.startsWith("{");
if (!looksJson) return { text: trimmed, segments: [] };
let parsed: unknown;
try {
parsed = JSON.parse(trimmed);
} catch {
throw new Error("Transcription server returned invalid JSON");
}
if (!isRecord(parsed) || typeof parsed.text !== "string") {
throw new Error("Transcription server returned no text");
}
const segments: TranscriptSegment[] = [];
if (Array.isArray(parsed.segments)) {
for (const entry of parsed.segments) {
if (!isRecord(entry) || typeof entry.text !== "string") continue;
const start = Number(entry.start);
const end = Number(entry.end);
const text = entry.text.trim();
if (!text || !Number.isFinite(start) || !Number.isFinite(end) || start < 0) continue;
const startMs = Math.round(start * 1000);
const previousEnd = segments.at(-1)?.endMs ?? 0;
segments.push({
startMs: Math.max(startMs, previousEnd),
endMs: Math.max(Math.round(end * 1000), startMs, previousEnd),
text,
});
}
}
const language = languageCode(parsed.language);
return {
text: parsed.text.trim() || segments.map((segment) => segment.text).join(" "),
...(language ? { language } : {}),
segments,
};
}
/** Whisper's language codes; servers report either the code or its English name. */
const WHISPER_LANGUAGES =
"en zh de es ru ko fr ja pt tr pl ca nl ar sv it id hi fi vi he uk el ms cs ro da hu ta no th ur hr bg lt la mi ml cy sk te fa lv bn sr az sl kn et mk br eu is hy ne mn bs kk sq sw gl mr pa si km sn yo so af oc ka be tg sd gu am yi lo uz fo ht ps tk nn mt sa lb my bo tl mg as tt haw ln ha ba jw su yue".split(
" ",
);
let languageNames: Map<string, string> | undefined;
function languageCode(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
const normalized = value.trim().toLowerCase();
if (WHISPER_LANGUAGES.includes(normalized)) return normalized;
if (!languageNames) {
const names = new Intl.DisplayNames(["en"], { type: "language" });
languageNames = new Map(
WHISPER_LANGUAGES.map((code) => [(names.of(code) ?? code).toLowerCase(), code]),
);
}
return languageNames.get(normalized);
}
/** OpenAI takes ISO 639-1 codes; a BCP 47 tag such as `de-AT` becomes `de`. */
function languageHint(language: string | undefined): string | undefined {
const primary = language?.trim().split(/[-_]/)[0]?.toLowerCase();
return primary && /^[a-z]{2,3}$/.test(primary) ? primary : undefined;
}
const EXTENSIONS: Record<string, string> = {
"audio/mpeg": "mp3",
"audio/mp4": "m4a",
"audio/wav": "wav",
"audio/x-wav": "wav",
"audio/webm": "webm",
"audio/ogg": "ogg",
"audio/flac": "flac",
"video/mp4": "mp4",
"video/webm": "webm",
};
/** Servers pick the decoder from the file extension, so the upload name always carries one. */
function uploadName(fileName: string | undefined, mimeType: string): string {
const safe = (fileName ?? "").replace(/[^\w.-]+/g, "_").slice(-100);
if (/\.[a-z0-9]{2,4}$/i.test(safe)) return safe;
const extension = EXTENSIONS[mimeType.split(";")[0]?.trim().toLowerCase() ?? ""] ?? "wav";
return `${safe || "audio"}.${extension}`;
}
/**
* A multipart/form-data body built by hand: the undici fetch used for address-checked requests
* does not serialize the global FormData class, and a byte body works with every fetch.
*/
function multipartBody(
fields: Record<string, string>,
file: { name: string; mimeType: string; bytes: Uint8Array },
): { contentType: string; bytes: Uint8Array<ArrayBuffer> } {
const boundary = `----rakazo${randomUUID().replaceAll("-", "")}`;
const encoder = new TextEncoder();
const parts: Uint8Array[] = [];
for (const [name, value] of Object.entries(fields)) {
parts.push(
encoder.encode(
`--${boundary}\r\nContent-Disposition: form-data; name="${name}"\r\n\r\n${value}\r\n`,
),
);
}
parts.push(
encoder.encode(
`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${file.name}"\r\nContent-Type: ${file.mimeType.replace(/[\r\n]/g, "")}\r\n\r\n`,
),
file.bytes,
encoder.encode(`\r\n--${boundary}--\r\n`),
);
const bytes = new Uint8Array(parts.reduce((total, part) => total + part.byteLength, 0));
let offset = 0;
for (const part of parts) {
bytes.set(part, offset);
offset += part.byteLength;
}
return { contentType: `multipart/form-data; boundary=${boundary}`, bytes };
}