feat: add OpenRouter dictation and speech recognition settings
This commit is contained in:
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user