Files

133 lines
8.4 KiB
TypeScript

import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { afterEach, describe, it } from "node:test";
import { runInNewContext } from "node:vm";
import { DEFAULT_STT_MODEL, OPENROUTER_STT_MODEL, STT_MAX_AUDIO_BYTES, dictationWav } from "../src/core/stt.ts";
import { sanitizeUserSettings } from "../src/core/settings.ts";
import { resolveSettings } from "../src/core/settings-resolution.ts";
import { parseGeneralSettingsBody } from "../src/server/settings-body.ts";
import { openRouterTranscription } from "../src/server/stt.ts";
const originalFetch = globalThis.fetch;
afterEach(() => { globalThis.fetch = originalFetch; });
const frames = [new Uint8Array([0, 128, 255, 127]), new Uint8Array([1, 0, 255, 255])];
function request(language = "multi", extra: RequestInit = {}) {
return new Request(`http://localhost/api/stt/transcribe?model=${OPENROUTER_STT_MODEL}&language=${language}`, {
method: "POST", headers: { "Content-Type": "audio/wav" }, body: dictationWav(frames), ...extra,
});
}
describe("STT configuration and lossless capture", () => {
it("round-trips both STT models without changing languages, hints or update preferences", () => {
for (const sttModel of [DEFAULT_STT_MODEL, OPENROUTER_STT_MODEL]) {
const general = { sttModel, sttLanguage: "multi" as const, sttPreferredLanguages: ["en" as const, "ru" as const], language: "ru" as const, autoUpdate: false };
assert.deepEqual(parseGeneralSettingsBody(general), { ok: true, settings: general });
const saved = sanitizeUserSettings({ version: 1, general });
assert.deepEqual(saved?.general, general);
assert.deepEqual(resolveSettings(saved).general, general);
}
assert.deepEqual(sanitizeUserSettings({ version: 1, general: { sttModel: "other" } })?.general, {});
assert.equal(parseGeneralSettingsBody({ sttModel: "other" }).ok, false);
assert.equal(resolveSettings(null).general.sttModel, undefined, "old records retain the Deepgram default");
});
it("keeps every PCM sample in order, including short final frames", () => {
const wav = Buffer.from(dictationWav(frames));
assert.equal(wav.toString("ascii", 0, 4), "RIFF");
assert.equal(wav.readUInt32LE(4), wav.length - 8);
assert.equal(wav.readUInt32LE(24), 16000);
assert.equal(wav.readUInt16LE(22), 1);
assert.equal(wav.readUInt16LE(34), 16);
assert.equal(wav.readUInt32LE(40), 8);
assert.deepEqual([...wav.subarray(44)], frames.flatMap((frame) => [...frame]));
assert.throws(() => dictationWav([new Uint8Array(1)]), /Invalid dictation audio/);
assert.throws(() => dictationWav([new Uint8Array(STT_MAX_AUDIO_BYTES)]), /25 MB/);
});
it("drains the worklet's incomplete last frame exactly once", () => {
let Processor: new () => { process: (inputs: Float32Array[][]) => void; port: { onmessage: (event: unknown) => void } };
const messages: { type: string; buffer?: Int16Array }[] = [];
runInNewContext(readFileSync(new URL("../public/worklets/pcm-capture.js", import.meta.url), "utf8"), {
AudioWorkletProcessor: class { port = { postMessage: (message: { type: string; buffer?: Int16Array }) => messages.push(message) }; },
registerProcessor: (_name: string, processor: typeof Processor) => { Processor = processor; },
});
const processor = new Processor!();
processor.process([[new Float32Array([0, 1, -1])]]);
assert.equal(messages.filter((message) => message.type === "audio").length, 0);
processor.port.onmessage({ data: { type: "flush" } });
assert.deepEqual(Array.from(messages.find((message) => message.type === "audio")!.buffer!), [0, 32767, -32768]);
assert.equal(messages.at(-1)?.type, "flushed");
processor.port.onmessage({ data: { type: "flush" } });
assert.equal(messages.filter((message) => message.type === "audio").length, 1);
});
});
describe("OpenRouter whole-recording transcription", () => {
for (const language of ["en", "ru", "multi"]) it(`sends one complete WAV using server auth and the correct language (${language})`, async () => {
let calls = 0;
globalThis.fetch = async (input, init) => {
calls++;
assert.equal(String(input), "https://openrouter.ai/api/v1/audio/transcriptions");
assert.equal(new Headers(init?.headers).get("Authorization"), "Bearer test-only-key");
const body = JSON.parse(String(init?.body));
assert.equal(body.model, OPENROUTER_STT_MODEL);
assert.equal(body.response_format, "json");
assert.equal(body.input_audio.format, "wav");
assert.deepEqual(Buffer.from(body.input_audio.data, "base64"), Buffer.from(dictationWav(frames)));
assert.deepEqual(Object.keys(body).sort(), (language === "multi" ? ["model", "input_audio", "response_format"] : ["model", "input_audio", "response_format", "language"]).sort());
assert.equal(body.language, language === "multi" ? undefined : language);
return Response.json({ text: "Привет, hello!", usage: { cost: 0.01 } });
};
const response = await openRouterTranscription(request(language), "test-only-key");
assert.equal(response.status, 200);
assert.deepEqual(await response.json(), { text: "Привет, hello!" });
assert.equal(calls, 1);
});
it("refuses missing keys, unknown models and invalid languages without any provider request", async () => {
globalThis.fetch = async () => { throw new Error("must not reach provider"); };
assert.equal((await openRouterTranscription(request(), "")).status, 503);
assert.equal((await openRouterTranscription(new Request("http://localhost?model=other&language=en", { method: "POST" }), "test")).status, 400);
assert.equal((await openRouterTranscription(request("en,ru"), "test")).status, 400);
});
it("rejects invalid audio and enforces the upload limit before contacting OpenRouter", async () => {
globalThis.fetch = async () => { throw new Error("must not reach provider"); };
assert.equal((await openRouterTranscription(request("multi", { body: "not wav" }), "test")).status, 400);
assert.equal((await openRouterTranscription(request("multi", { headers: { "Content-Type": "audio/wav", "Content-Length": String(STT_MAX_AUDIO_BYTES + 1) } }), "test")).status, 413);
const damaged = dictationWav(frames); damaged[24] = 0;
assert.equal((await openRouterTranscription(request("multi", { body: damaged }), "test")).status, 400);
const stream = new ReadableStream({ start(controller) { controller.enqueue(new Uint8Array(STT_MAX_AUDIO_BYTES + 1)); controller.close(); } });
const streamingRequest = request("multi", { body: stream, duplex: "half" } as RequestInit);
assert.equal((await openRouterTranscription(streamingRequest, "test")).status, 413);
});
for (const status of [401, 403, 402, 429, 500]) it(`handles provider status ${status} without leaking diagnostics or retrying`, async () => {
let calls = 0;
globalThis.fetch = async () => { calls++; return new Response("test-only-key PRIVATE_AUDIO", { status }); };
const response = await openRouterTranscription(request(), "test-only-key");
assert.equal(response.status, status === 429 ? 429 : 502);
assert.doesNotMatch(await response.text(), /test-only-key|PRIVATE_AUDIO/);
assert.equal(calls, 1);
});
it("accepts an empty transcript for silence, but rejects malformed provider output", async () => {
globalThis.fetch = async () => Response.json({ text: "" });
assert.deepEqual(await (await openRouterTranscription(request(), "test")).json(), { text: "" });
globalThis.fetch = async () => Response.json({ text: null });
assert.equal((await openRouterTranscription(request(), "test")).status, 502);
});
it("reports timeouts and respects cancellation", async () => {
globalThis.fetch = async () => { throw new DOMException("timeout", "TimeoutError"); };
assert.equal((await openRouterTranscription(request(), "test")).status, 504);
const controller = new AbortController(); controller.abort();
assert.equal((await openRouterTranscription(request("multi", { signal: controller.signal }), "test")).status, 499);
});
it("propagates cancellation to an in-flight provider request", async () => {
let started: () => void;
const ready = new Promise<void>((resolve) => { started = resolve; });
globalThis.fetch = async (_input, init) => new Promise<Response>((_resolve, reject) => {
init!.signal!.addEventListener("abort", () => reject(new DOMException("cancelled", "AbortError")), { once: true });
started();
});
const controller = new AbortController();
const pending = openRouterTranscription(request("multi", { signal: controller.signal }), "test");
await ready; controller.abort();
assert.equal((await pending).status, 499);
});
});