133 lines
8.4 KiB
TypeScript
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);
|
|
});
|
|
});
|