76 lines
4.1 KiB
TypeScript
76 lines
4.1 KiB
TypeScript
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");
|
|
}
|
|
}
|