import type { ProviderMeta, TranscriptionProvider } from "@/server/ai/providers"; /** * Whisper-compatible speech-to-text (Spec §15.1, ARCHITEKTUR §4.5): multipart POST * (`file`, `model`, `language`, `response_format=json`) to `TRANSCRIPTION_API_URL`, answer `{ text }`. * Works with OpenAI `/v1/audio/transcriptions` and self-hosted compatible servers (faster-whisper, * whisper.cpp server, LocalAI …). * * Errors never contain audio or transcript content — only status/kind for the VoiceNote status. */ export const TRANSCRIPTION_TIMEOUT_MS = 120_000; /** Same as the audio upload limit of storeFile (ARCHITEKTUR §4.3); Whisper itself accepts 25 MB. */ export const TRANSCRIPTION_MAX_BYTES = 20 * 1024 * 1024; const DEFAULT_URL = "https://api.openai.com/v1/audio/transcriptions"; const DEFAULT_MODEL = "whisper-1"; const EXTENSION: Record = { "audio/webm": "webm", "audio/ogg": "ogg", "audio/mp4": "m4a", "audio/x-m4a": "m4a", "audio/aac": "aac", "audio/mpeg": "mp3", "audio/wav": "wav", "audio/x-wav": "wav", }; type FetchLike = (url: string, init: RequestInit) => Promise; export class OpenAiCompatibleTranscriptionProvider implements TranscriptionProvider { readonly name = "openai-compatible"; readonly model: string; private readonly url: string; private readonly apiKey: string; private readonly fetchImpl: FetchLike; private readonly timeoutMs: number; private readonly maxBytes: number; constructor(opts: { url?: string; apiKey: string; model?: string; fetchImpl?: FetchLike; timeoutMs?: number; maxBytes?: number }) { this.url = opts.url || DEFAULT_URL; this.apiKey = opts.apiKey; this.model = opts.model || DEFAULT_MODEL; this.fetchImpl = opts.fetchImpl ?? ((url, init) => fetch(url, init)); this.timeoutMs = opts.timeoutMs ?? TRANSCRIPTION_TIMEOUT_MS; this.maxBytes = opts.maxBytes ?? TRANSCRIPTION_MAX_BYTES; } async transcribe(input: { bytes: Buffer; mimeType: string; language: "de" | "en" }): Promise<{ text: string; meta: ProviderMeta }> { if (input.bytes.byteLength === 0) throw new Error("audio is empty"); if (input.bytes.byteLength > this.maxBytes) throw new Error(`audio too large (${input.bytes.byteLength} bytes, limit ${this.maxBytes})`); const mime = input.mimeType.split(";")[0].trim().toLowerCase(); const ext = EXTENSION[mime]; if (!ext) throw new Error(`unsupported audio type ${mime}`); const form = new FormData(); form.append("file", new Blob([new Uint8Array(input.bytes)], { type: mime }), `voice-note.${ext}`); form.append("model", this.model); form.append("language", input.language); form.append("response_format", "json"); let res: Response; try { res = await this.fetchImpl(this.url, { method: "POST", headers: { Authorization: `Bearer ${this.apiKey}` }, body: form, signal: AbortSignal.timeout(this.timeoutMs), }); } catch (err) { const name = (err as Error).name; if (name === "TimeoutError" || name === "AbortError") throw new Error(`transcription timed out after ${this.timeoutMs} ms`); throw new Error(`transcription request failed (${name})`); } if (!res.ok) throw new Error(`transcription API error ${res.status}`); let body: unknown; try { body = await res.json(); } catch { throw new Error("transcription API returned no JSON"); } const text = (body as { text?: unknown })?.text; if (typeof text !== "string") throw new Error("transcription API response without text"); return { text: text.trim(), meta: { provider: this.name, model: this.model } }; } } /** Effective configuration (no secrets) for the transparency page. */ export function transcriptionConfig(): { configured: boolean; provider: string; model: string; host: string | null } { const provider = process.env.TRANSCRIPTION_PROVIDER?.trim().toLowerCase() || "openai-compatible"; const url = process.env.TRANSCRIPTION_API_URL?.trim() || DEFAULT_URL; let host: string | null = null; try { host = new URL(url).host; } catch { host = null; } return { configured: provider === "openai-compatible" && Boolean(process.env.TRANSCRIPTION_API_KEY?.trim()) && host !== null, provider, model: process.env.TRANSCRIPTION_MODEL?.trim() || DEFAULT_MODEL, host, }; } /** Configured transcription provider or `null` (no key / other provider → VoiceNote `disabled`). */ export function getTranscriptionProvider(): TranscriptionProvider | null { const cfg = transcriptionConfig(); if (!cfg.configured) return null; return new OpenAiCompatibleTranscriptionProvider({ url: process.env.TRANSCRIPTION_API_URL?.trim() || DEFAULT_URL, apiKey: process.env.TRANSCRIPTION_API_KEY!.trim(), model: cfg.model, }); }