feat: add OpenRouter dictation and speech recognition settings

This commit is contained in:
2026-10-26 23:42:44 +03:00
parent 168cad7470
commit ecbd2b0bb9
24 changed files with 867 additions and 72 deletions
+75
View File
@@ -0,0 +1,75 @@
import { OPENROUTER_STT_MODEL, STT_MAX_AUDIO_BYTES, STT_SAMPLE_RATE, WAV_HEADER_BYTES } from "../core/stt.ts";
import { isSttLanguage } from "../core/i18n.ts";
import { keyValue } from "./keys.ts";
const fail = (status: number, message: string) => Response.json({ message }, { status });
/** One complete recording per request. Credentials and audio never go to disk. */
export async function openRouterTranscription(request: Request, key = keyValue("OPENROUTER_API_KEY")): Promise<Response> {
if (!key) return fail(503, "Dictation needs an OpenRouter key");
const params = new URL(request.url).searchParams;
if (params.get("model") !== OPENROUTER_STT_MODEL) return fail(400, "Unknown speech recognition model");
const language = params.get("language");
if (!isSttLanguage(language)) return fail(400, "Invalid speech recognition language");
if (request.headers.get("content-type") !== "audio/wav") return fail(415, "Invalid dictation audio");
if (Number(request.headers.get("content-length")) > STT_MAX_AUDIO_BYTES) return fail(413, "Recording exceeds OpenRouter's 25 MB audio limit");
if (!request.body) return fail(400, "Invalid dictation audio");
const reader = request.body.getReader();
const frames: Uint8Array[] = [];
let size = 0;
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
size += value.byteLength;
if (size > STT_MAX_AUDIO_BYTES) {
await reader.cancel();
return fail(413, "Recording exceeds OpenRouter's 25 MB audio limit");
}
frames.push(value);
}
} catch {
return fail(request.signal.aborted ? 499 : 400, "Could not read dictation audio");
} finally { reader.releaseLock(); }
if (request.signal.aborted) return fail(499, "Transcription cancelled");
const audio = Buffer.concat(frames, size);
// Accept exactly the lossless PCM container produced by our capture graph.
if (size <= WAV_HEADER_BYTES || size % 2 !== 0 || audio.toString("ascii", 0, 4) !== "RIFF"
|| audio.toString("ascii", 8, 12) !== "WAVE" || audio.toString("ascii", 12, 16) !== "fmt "
|| audio.readUInt32LE(4) !== size - 8 || audio.readUInt32LE(16) !== 16 || audio.readUInt16LE(20) !== 1
|| audio.readUInt16LE(22) !== 1 || audio.readUInt32LE(24) !== STT_SAMPLE_RATE
|| audio.readUInt32LE(28) !== STT_SAMPLE_RATE * 2 || audio.readUInt16LE(32) !== 2 || audio.readUInt16LE(34) !== 16
|| audio.toString("ascii", 36, 40) !== "data" || audio.readUInt32LE(40) !== size - WAV_HEADER_BYTES) {
return fail(400, "Invalid dictation audio");
}
const signal = AbortSignal.any([request.signal, AbortSignal.timeout(65_000)]);
try {
const response = await fetch("https://openrouter.ai/api/v1/audio/transcriptions", {
method: "POST",
headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
body: JSON.stringify({
model: OPENROUTER_STT_MODEL,
input_audio: { data: audio.toString("base64"), format: "wav" },
// MAI allows one forced language; automatic detection supports mixed speech.
...(language === "multi" ? {} : { language }),
response_format: "json",
}),
signal,
});
if (!response.ok) {
// Provider diagnostics can contain credentials or echoed audio. Return only app-authored errors.
await response.body?.cancel();
if (response.status === 401 || response.status === 403) return fail(502, "OpenRouter rejected the API key");
if (response.status === 402) return fail(502, "OpenRouter credits are insufficient");
if (response.status === 429) return fail(429, "OpenRouter rate limit reached; try again later");
return fail(502, `OpenRouter transcription failed (${response.status})`);
}
const result = await response.json() as { text?: unknown };
if (typeof result.text !== "string") return fail(502, "OpenRouter returned an invalid transcript");
return Response.json({ text: result.text });
} catch (error) {
if (request.signal.aborted) return fail(499, "Transcription cancelled");
if (signal.aborted || (error instanceof Error && error.name === "TimeoutError")) return fail(504, "OpenRouter transcription timed out");
return fail(502, "OpenRouter transcription is unavailable");
}
}