490 lines
17 KiB
TypeScript
490 lines
17 KiB
TypeScript
import { afterAll, describe, expect, test } from "bun:test";
|
|
import { mkdtemp, readdir, rm } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import {
|
|
createSidecar,
|
|
createTranscribeHandlers,
|
|
parseLanguageAndTask,
|
|
type Sidecar,
|
|
type SidecarSegment,
|
|
type SidecarTranscribeRequest,
|
|
} from "../src/server/transcribe";
|
|
import { isTranscribeStreamLine, type TranscribeStreamLine } from "../src/lib/engine";
|
|
import type { SessionUser } from "../src/server/auth";
|
|
|
|
// M5 server engine: HTTP handlers against a fake sidecar, and the real
|
|
// Bun.spawn supervisor against a protocol-faithful Python stand-in
|
|
// (tests/fixtures/fake_sidecar.py) — no faster-whisper needed. The end-to-end
|
|
// run with real inference is manual/opt-in: see tests/server-engine.md.
|
|
|
|
const FIXTURE_SIDECAR = join(import.meta.dir, "fixtures/fake_sidecar.py");
|
|
const REAL_SIDECAR = join(import.meta.dir, "../server-py/sidecar.py");
|
|
|
|
const tempDirs: string[] = [];
|
|
async function makeTempDir(): Promise<string> {
|
|
const dir = await mkdtemp(join(tmpdir(), "transcribe-test-"));
|
|
tempDirs.push(dir);
|
|
return dir;
|
|
}
|
|
afterAll(async () => {
|
|
await Promise.all(tempDirs.map((dir) => rm(dir, { recursive: true, force: true })));
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const USER: SessionUser = { id: 1, username: "tester" };
|
|
const sessionFromCookie = (request: Request): SessionUser | null =>
|
|
request.headers.get("cookie") === "sid=ok" ? USER : null;
|
|
|
|
interface FakeSidecar extends Sidecar {
|
|
requests: SidecarTranscribeRequest[];
|
|
/** Bytes of each request's audio file, captured before cleanup. */
|
|
audioPayloads: Uint8Array[];
|
|
}
|
|
|
|
function fakeSidecar(
|
|
behavior: {
|
|
available?: boolean;
|
|
reason?: string;
|
|
segments?: SidecarSegment[];
|
|
error?: string;
|
|
waitForAbort?: boolean;
|
|
} = {},
|
|
): FakeSidecar {
|
|
const segments = behavior.segments ?? [
|
|
{ start: 0, end: 1.5, text: " Hello" },
|
|
{ start: 1.5, end: 3, text: " world." },
|
|
];
|
|
const sidecar: FakeSidecar = {
|
|
requests: [],
|
|
audioPayloads: [],
|
|
async status() {
|
|
return behavior.available === false
|
|
? { available: false, reason: behavior.reason ?? "nope" }
|
|
: { available: true };
|
|
},
|
|
async transcribe(request, onSegment, signal) {
|
|
sidecar.requests.push(request);
|
|
sidecar.audioPayloads.push(
|
|
new Uint8Array(await Bun.file(request.audioPath).arrayBuffer()),
|
|
);
|
|
if (behavior.error) throw new Error(behavior.error);
|
|
onSegment(segments[0]!);
|
|
if (behavior.waitForAbort) {
|
|
await new Promise<void>((resolve) => {
|
|
if (signal?.aborted) return resolve();
|
|
signal?.addEventListener("abort", () => resolve(), { once: true });
|
|
});
|
|
return { cancelled: true };
|
|
}
|
|
for (const segment of segments.slice(1)) onSegment(segment);
|
|
return { cancelled: false };
|
|
},
|
|
};
|
|
return sidecar;
|
|
}
|
|
|
|
function pcmBody(samples: number[]): Uint8Array<ArrayBuffer> {
|
|
const audio = new Float32Array(samples);
|
|
return new Uint8Array(audio.buffer as ArrayBuffer);
|
|
}
|
|
|
|
function postRequest(
|
|
query: string,
|
|
body: BodyInit | null,
|
|
init: { cookie?: string; signal?: AbortSignal; contentLength?: boolean } = {},
|
|
): Request {
|
|
const headers: Record<string, string> = {
|
|
"Content-Type": "application/octet-stream",
|
|
};
|
|
if (init.cookie !== undefined) headers.cookie = init.cookie;
|
|
return new Request(`http://localhost/api/transcribe${query}`, {
|
|
method: "POST",
|
|
headers,
|
|
body,
|
|
signal: init.signal,
|
|
});
|
|
}
|
|
|
|
async function readStreamLines(response: Response): Promise<TranscribeStreamLine[]> {
|
|
const text = await response.text();
|
|
return text
|
|
.split("\n")
|
|
.filter((line) => line.trim().length > 0)
|
|
.map((line) => {
|
|
const parsed: unknown = JSON.parse(line);
|
|
if (!isTranscribeStreamLine(parsed)) throw new Error(`bad line: ${line}`);
|
|
return parsed;
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// HTTP handlers (fake sidecar)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// parseLanguageAndTask — shared with the WS live route (src/server/live.ts,
|
|
// src/index.ts) so it can't skip the validation the HTTP route enforces.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("parseLanguageAndTask", () => {
|
|
test("defaults to transcribe with no language", () => {
|
|
expect(parseLanguageAndTask(new URL("http://localhost/x"))).toEqual({
|
|
language: undefined,
|
|
task: "transcribe",
|
|
});
|
|
});
|
|
|
|
test("accepts a valid language and task", () => {
|
|
expect(parseLanguageAndTask(new URL("http://localhost/x?language=da&task=translate"))).toEqual(
|
|
{ language: "da", task: "translate" },
|
|
);
|
|
});
|
|
|
|
test('"auto"/empty language means no language override', () => {
|
|
expect(parseLanguageAndTask(new URL("http://localhost/x?language=auto"))).toEqual({
|
|
language: undefined,
|
|
task: "transcribe",
|
|
});
|
|
expect(parseLanguageAndTask(new URL("http://localhost/x?language="))).toEqual({
|
|
language: undefined,
|
|
task: "transcribe",
|
|
});
|
|
});
|
|
|
|
test("rejects an invalid language or task", () => {
|
|
expect(parseLanguageAndTask(new URL("http://localhost/x?language=Danish!"))).toBe(
|
|
"invalid language: Danish!",
|
|
);
|
|
expect(parseLanguageAndTask(new URL("http://localhost/x?task=summarize"))).toBe(
|
|
"invalid task: summarize",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("GET /api/transcribe/status", () => {
|
|
test("401 without a session", async () => {
|
|
const handlers = createTranscribeHandlers({
|
|
sidecar: fakeSidecar(),
|
|
getSessionUser: sessionFromCookie,
|
|
});
|
|
const response = await handlers.status(
|
|
new Request("http://localhost/api/transcribe/status"),
|
|
);
|
|
expect(response.status).toBe(401);
|
|
});
|
|
|
|
test("passes the sidecar's availability through", async () => {
|
|
const handlers = createTranscribeHandlers({
|
|
sidecar: fakeSidecar({ available: false, reason: "python3 missing" }),
|
|
getSessionUser: sessionFromCookie,
|
|
});
|
|
const response = await handlers.status(
|
|
new Request("http://localhost/api/transcribe/status", {
|
|
headers: { cookie: "sid=ok" },
|
|
}),
|
|
);
|
|
expect(response.status).toBe(200);
|
|
expect(await response.json()).toEqual({
|
|
available: false,
|
|
reason: "python3 missing",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("POST /api/transcribe", () => {
|
|
test("401 without a session — the server engine is logged-in only", async () => {
|
|
const sidecar = fakeSidecar();
|
|
const handlers = createTranscribeHandlers({
|
|
sidecar,
|
|
getSessionUser: sessionFromCookie,
|
|
});
|
|
const response = await handlers.post(
|
|
postRequest("?model=onnx-community/whisper-tiny.en", pcmBody([0.1])),
|
|
);
|
|
expect(response.status).toBe(401);
|
|
expect(sidecar.requests).toHaveLength(0);
|
|
});
|
|
|
|
test("400 for missing/unknown model, bad language, bad task", async () => {
|
|
const handlers = createTranscribeHandlers({
|
|
sidecar: fakeSidecar(),
|
|
getSessionUser: sessionFromCookie,
|
|
tempDir: await makeTempDir(),
|
|
});
|
|
const cases = [
|
|
"",
|
|
"?model=someone/other-model",
|
|
"?model=onnx-community/whisper-tiny&language=Danish!",
|
|
"?model=onnx-community/whisper-tiny&task=summarize",
|
|
];
|
|
for (const query of cases) {
|
|
const response = await handlers.post(
|
|
postRequest(query, pcmBody([0.1]), { cookie: "sid=ok" }),
|
|
);
|
|
expect(response.status).toBe(400);
|
|
}
|
|
});
|
|
|
|
test("503 with the reason when the sidecar is unavailable", async () => {
|
|
const sidecar = fakeSidecar({ available: false, reason: "faster-whisper is not installed" });
|
|
const handlers = createTranscribeHandlers({
|
|
sidecar,
|
|
getSessionUser: sessionFromCookie,
|
|
});
|
|
const response = await handlers.post(
|
|
postRequest("?model=onnx-community/whisper-tiny.en", pcmBody([0.1]), {
|
|
cookie: "sid=ok",
|
|
}),
|
|
);
|
|
expect(response.status).toBe(503);
|
|
expect(await response.json()).toEqual({ error: "faster-whisper is not installed" });
|
|
expect(sidecar.requests).toHaveLength(0);
|
|
});
|
|
|
|
test("413 over the upload limit; 400 for empty or non-float32 bodies", async () => {
|
|
const tempDir = await makeTempDir();
|
|
const handlers = createTranscribeHandlers({
|
|
sidecar: fakeSidecar(),
|
|
getSessionUser: sessionFromCookie,
|
|
maxUploadBytes: 16,
|
|
tempDir,
|
|
});
|
|
const over = await handlers.post(
|
|
postRequest("?model=onnx-community/whisper-tiny.en", pcmBody([1, 2, 3, 4, 5]), {
|
|
cookie: "sid=ok",
|
|
}),
|
|
);
|
|
expect(over.status).toBe(413);
|
|
|
|
const empty = await handlers.post(
|
|
postRequest("?model=onnx-community/whisper-tiny.en", new Uint8Array(0), {
|
|
cookie: "sid=ok",
|
|
}),
|
|
);
|
|
expect(empty.status).toBe(400);
|
|
|
|
const misaligned = await handlers.post(
|
|
postRequest("?model=onnx-community/whisper-tiny.en", new Uint8Array(6), {
|
|
cookie: "sid=ok",
|
|
}),
|
|
);
|
|
expect(misaligned.status).toBe(400);
|
|
|
|
// Rejected uploads must not leave temp files behind.
|
|
expect(await readdir(tempDir)).toEqual([]);
|
|
});
|
|
|
|
test("streams segments then the result, maps the model, cleans up", async () => {
|
|
const tempDir = await makeTempDir();
|
|
const sidecar = fakeSidecar();
|
|
const handlers = createTranscribeHandlers({
|
|
sidecar,
|
|
getSessionUser: sessionFromCookie,
|
|
tempDir,
|
|
});
|
|
const audio = [0.25, -0.25, 0.5, -0.5];
|
|
const response = await handlers.post(
|
|
postRequest(
|
|
"?model=onnx-community/whisper-base&language=da&task=translate",
|
|
pcmBody(audio),
|
|
{ cookie: "sid=ok" },
|
|
),
|
|
);
|
|
expect(response.status).toBe(200);
|
|
expect(response.headers.get("content-type")).toContain("application/x-ndjson");
|
|
|
|
const lines = await readStreamLines(response);
|
|
expect(lines).toEqual([
|
|
{ type: "segment", text: " Hello", start: 0, end: 1.5 },
|
|
{ type: "segment", text: " world.", start: 1.5, end: 3 },
|
|
{
|
|
type: "result",
|
|
text: "Hello world.",
|
|
chunks: [
|
|
{ text: " Hello", timestamp: [0, 1.5] },
|
|
{ text: " world.", timestamp: [1.5, 3] },
|
|
],
|
|
},
|
|
]);
|
|
|
|
// Catalog id → faster-whisper name (D-013), options passed through.
|
|
expect(sidecar.requests).toEqual([
|
|
expect.objectContaining({ model: "base", language: "da", task: "translate" }),
|
|
]);
|
|
// The sidecar saw exactly the uploaded PCM bytes…
|
|
expect([...new Float32Array(sidecar.audioPayloads[0]!.buffer.slice(0))]).toEqual(
|
|
audio,
|
|
);
|
|
// …and the temp file is gone once the stream finished.
|
|
expect(await readdir(tempDir)).toEqual([]);
|
|
});
|
|
|
|
test("sidecar errors travel in-stream after the 200 header", async () => {
|
|
const tempDir = await makeTempDir();
|
|
const handlers = createTranscribeHandlers({
|
|
sidecar: fakeSidecar({ error: "model load failed" }),
|
|
getSessionUser: sessionFromCookie,
|
|
tempDir,
|
|
});
|
|
const response = await handlers.post(
|
|
postRequest("?model=onnx-community/whisper-tiny.en", pcmBody([0.1]), {
|
|
cookie: "sid=ok",
|
|
}),
|
|
);
|
|
expect(response.status).toBe(200);
|
|
const lines = await readStreamLines(response);
|
|
expect(lines).toEqual([{ type: "error", message: "model load failed" }]);
|
|
expect(await readdir(tempDir)).toEqual([]);
|
|
});
|
|
|
|
test("an aborted request cancels the sidecar run", async () => {
|
|
const tempDir = await makeTempDir();
|
|
const sidecar = fakeSidecar({ waitForAbort: true });
|
|
const handlers = createTranscribeHandlers({
|
|
sidecar,
|
|
getSessionUser: sessionFromCookie,
|
|
tempDir,
|
|
});
|
|
const controller = new AbortController();
|
|
const response = await handlers.post(
|
|
postRequest("?model=onnx-community/whisper-tiny.en", pcmBody([0.1]), {
|
|
cookie: "sid=ok",
|
|
signal: controller.signal,
|
|
}),
|
|
);
|
|
expect(response.status).toBe(200);
|
|
controller.abort();
|
|
// Cancelled runs still terminate the stream with the partial result.
|
|
const lines = await readStreamLines(response);
|
|
expect(lines.at(-1)?.type).toBe("result");
|
|
expect(await readdir(tempDir)).toEqual([]);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// createSidecar — the real Bun.spawn supervisor against a Python child
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("createSidecar (fake python sidecar)", () => {
|
|
test("handshake, segment streaming, and request/option pass-through", async () => {
|
|
const sidecar = createSidecar(["python3", FIXTURE_SIDECAR]);
|
|
expect(await sidecar.status()).toEqual({ available: true });
|
|
|
|
const audioPath = join(await makeTempDir(), "audio.f32");
|
|
await Bun.write(audioPath, pcmBody([0.1, 0.2, 0.3]));
|
|
|
|
const segments: SidecarSegment[] = [];
|
|
const outcome = await sidecar.transcribe(
|
|
{ model: "ok", audioPath, language: "da", task: "translate" },
|
|
(segment) => segments.push(segment),
|
|
);
|
|
expect(outcome).toEqual({ cancelled: false });
|
|
expect(segments).toHaveLength(2);
|
|
expect(segments[0]).toEqual({ start: 0, end: 1.5, text: " Hello" });
|
|
// The fixture echoes what it received (audio bytes, language, task).
|
|
expect(segments[1]!.text).toContain("model=ok bytes=12 lang=da task=translate");
|
|
});
|
|
|
|
test("sidecar 'error' lines reject that request only", async () => {
|
|
const sidecar = createSidecar(["python3", FIXTURE_SIDECAR]);
|
|
const audioPath = join(await makeTempDir(), "audio.f32");
|
|
await Bun.write(audioPath, pcmBody([0.1]));
|
|
|
|
expect(
|
|
sidecar.transcribe({ model: "boom", audioPath }, () => {}),
|
|
).rejects.toThrow("kaboom");
|
|
// The process survived; the next request works.
|
|
const outcome = await sidecar.transcribe({ model: "ok", audioPath }, () => {});
|
|
expect(outcome).toEqual({ cancelled: false });
|
|
});
|
|
|
|
test("a crash rejects in-flight requests and the next request respawns", async () => {
|
|
const sidecar = createSidecar(["python3", FIXTURE_SIDECAR]);
|
|
const audioPath = join(await makeTempDir(), "audio.f32");
|
|
await Bun.write(audioPath, pcmBody([0.1]));
|
|
|
|
expect(
|
|
sidecar.transcribe({ model: "die", audioPath }, () => {}),
|
|
).rejects.toThrow(/exited unexpectedly/);
|
|
// Supervision: a fresh process serves the next request.
|
|
const segments: SidecarSegment[] = [];
|
|
const outcome = await sidecar.transcribe(
|
|
{ model: "ok", audioPath },
|
|
(segment) => segments.push(segment),
|
|
);
|
|
expect(outcome).toEqual({ cancelled: false });
|
|
expect(segments).toHaveLength(2);
|
|
});
|
|
|
|
test("abort sends a cancel and resolves {cancelled: true}", async () => {
|
|
const sidecar = createSidecar(["python3", FIXTURE_SIDECAR]);
|
|
const audioPath = join(await makeTempDir(), "audio.f32");
|
|
await Bun.write(audioPath, pcmBody([0.1]));
|
|
|
|
const controller = new AbortController();
|
|
const outcome = await sidecar.transcribe(
|
|
{ model: "wait-for-cancel", audioPath },
|
|
() => controller.abort(), // abort as soon as the first segment arrives
|
|
controller.signal,
|
|
);
|
|
expect(outcome).toEqual({ cancelled: true });
|
|
});
|
|
});
|
|
|
|
describe("createSidecar (degradation, D-013)", () => {
|
|
test("the real sidecar without faster-whisper reports a permanent reason", async () => {
|
|
// -S/-I keep site-packages off sys.path, so this behaves like a
|
|
// Bun-only deployment even on machines that have faster-whisper.
|
|
const sidecar = createSidecar(["python3", "-S", "-I", REAL_SIDECAR]);
|
|
const first = await sidecar.status();
|
|
expect(first.available).toBe(false);
|
|
expect(first.reason).toContain("faster-whisper is not installed");
|
|
// Remembered as permanent — no respawn storm, same answer immediately.
|
|
const second = await sidecar.status();
|
|
expect(second).toEqual(first);
|
|
});
|
|
|
|
test("a missing python binary reports unavailable", async () => {
|
|
const sidecar = createSidecar(["definitely-not-a-python-binary"]);
|
|
const status = await sidecar.status();
|
|
expect(status.available).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Opt-in: real faster-whisper end to end (needs python3 + requirements.txt
|
|
// installed and network/cached weights). See tests/server-engine.md.
|
|
// FW_INTEGRATION=1 bun test tests/transcribe.test.ts
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe.skipIf(!process.env.FW_INTEGRATION)("real faster-whisper sidecar", () => {
|
|
test(
|
|
"transcribes PCM through the real sidecar",
|
|
async () => {
|
|
const sidecar = createSidecar();
|
|
const status = await sidecar.status();
|
|
expect(status).toEqual({ available: true });
|
|
|
|
// One second of a 440 Hz tone — not speech, but exercises model load,
|
|
// numpy decode, and the full protocol round-trip.
|
|
const samples = new Float32Array(16_000);
|
|
for (let i = 0; i < samples.length; i++) {
|
|
samples[i] = 0.1 * Math.sin((2 * Math.PI * 440 * i) / 16_000);
|
|
}
|
|
const audioPath = join(await makeTempDir(), "tone.f32");
|
|
await Bun.write(audioPath, new Uint8Array(samples.buffer));
|
|
|
|
const outcome = await sidecar.transcribe(
|
|
{ model: "tiny.en", audioPath },
|
|
() => {},
|
|
);
|
|
expect(outcome.cancelled).toBe(false);
|
|
},
|
|
{ timeout: 300_000 },
|
|
);
|
|
});
|