Validate the vocabulary hint server-side and pass it to faster-whisper as initial_prompt

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-10-29 21:45:39 +01:00
1 parent 313892b6df
commit 1c5952f212
8 files changed
+239 -10

No files matched your search

+60 -2
View File
@@ -9,7 +9,8 @@ stdio — one JSON object per line, no framing beyond that:
stdin -> {"type": "transcribe", "id": "1", "model": "tiny.en",
"audio_path": "/tmp/...", "language": "en" | null,
"task": "transcribe" | "translate"}
"task": "transcribe" | "translate",
"prompt": "Maxwell, Voices of the Void" | null}
stdout <- {"type": "segment", "id": "1", "start": 0.0, "end": 4.2,
"text": " Hello world."} # 0..n of these
stdout <- {"type": "done", "id": "1", "cancelled": false}
@@ -43,11 +44,56 @@ as a "fatal" line, which the Bun side surfaces as "server engine unavailable"
import json
import os
import queue
import re
import sys
import threading
SAMPLE_RATE = 16000
# "Names and terms" vocabulary hint (D-WS2a). Twins of normalizeHint /
# fitHintToBudget in src/lib/hint.ts — change both together; parity is tested
# in tests/hint-parity.test.ts. Whisper's prompt budget is half of its 448
# decoder positions minus one (<|startofprev|>).
PROMPT_TOKEN_BUDGET = 223
_CONTROL_CHARS = re.compile("[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]")
def normalize_hint(raw):
"""One term per line or comma-separated -> "a, b, c"; "" means no hint."""
lines = re.split(r"\r?\n|\r", _CONTROL_CHARS.sub("", raw))
parts = [re.sub(r"\s+", " ", line).strip() for line in lines]
return ", ".join(part for part in parts if part)
def fit_prompt_ids(hint, encode, budget=PROMPT_TOKEN_BUDGET):
"""Prompt token ids for `hint`, cut to `budget` tokens at a whole word.
The start of the list is kept and the end dropped; a dangling ","/";" is
removed; a first word longer than the budget is cut at the budget.
`encode` must add no special tokens.
"""
text = normalize_hint(hint)
if not text:
return []
full = encode(" " + text)
if len(full) <= budget:
return full
words = text.split(" ")
def encode_prefix(count):
return encode(" " + re.sub(r"[,;]+$", "", " ".join(words[:count])))
low, high = 0, len(words) - 1
while low < high:
mid = (low + high + 1) // 2
if len(encode_prefix(mid)) <= budget:
low = mid
else:
high = mid - 1
if low == 0:
return encode(" " + words[0])[:budget]
return encode_prefix(low)
def emit(message):
"""Write one JSON line to stdout. Only the main thread emits."""
@@ -115,6 +161,7 @@ def main():
audio_path = request["audio_path"]
language = request.get("language") or None
task = request.get("task") or "transcribe"
prompt = request.get("prompt") or ""
if model_name != cached_name:
cached_model = None # release before loading the replacement
@@ -127,8 +174,19 @@ def main():
cached_name = model_name
audio = np.fromfile(audio_path, dtype=np.float32)
options = {}
prompt_ids = fit_prompt_ids(
prompt,
lambda text: cached_model.hf_tokenizer.encode(
text, add_special_tokens=False
).ids,
)
if prompt_ids:
# D-WS2a: token ids, already cut to the budget by the same
# rule as the browser engine.
options["initial_prompt"] = prompt_ids
segments, _info = cached_model.transcribe(
audio, language=language, task=task
audio, language=language, task=task, **options
)
was_cancelled = False
+1
View File
@@ -153,6 +153,7 @@ async function handleApiRequest(request: Request, server: any): Promise<Response
model: fasterWhisperModel,
language: options.language,
task: options.task,
prompt: options.prompt,
tempDir: tmpdir(),
},
});
+3
View File
@@ -25,6 +25,8 @@ export interface LiveSessionData {
model: string;
language?: string;
task: WhisperTask;
/** Normalized vocabulary hint, applied to every pass (D-WS2a). */
prompt?: string;
tempDir: string;
}
@@ -60,6 +62,7 @@ export function createLiveWebSocket(deps: LiveWebSocketDeps) {
audioPath,
language: ws.data.language,
task: ws.data.task,
prompt: ws.data.prompt,
},
(segment) => {
text.push(segment.text);
+24 -5
View File
@@ -26,6 +26,7 @@
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { TranscribeStreamLine } from "../lib/engine";
import { HINT_MAX_CHARS, normalizeHint } from "../lib/hint";
import { getFasterWhisperModel } from "../lib/models";
import type { TranscriptChunk, WhisperTask } from "../worker/protocol";
import { getSessionUser as appGetSessionUser, type SessionUser } from "./auth.ts";
@@ -47,6 +48,11 @@ export interface SidecarTranscribeRequest {
audioPath: string;
language?: string;
task?: WhisperTask;
/**
* Normalized "Names and terms" hint (D-WS2a); the sidecar truncates it to
* Whisper's prompt budget with faster-whisper's own tokenizer.
*/
prompt?: string;
}
export interface SidecarStatus {
@@ -265,6 +271,7 @@ export function createSidecar(command?: readonly string[]): Sidecar {
audio_path: request.audioPath,
language: request.language ?? null,
task: request.task ?? "transcribe",
prompt: request.prompt ?? null,
});
} catch (error) {
pending.delete(id);
@@ -322,18 +329,23 @@ interface TranscribeParams {
fasterWhisperModel: string;
language?: string;
task: WhisperTask;
prompt?: string;
}
/**
* Parse + validate the `language`/`task` query params shared by both the
* HTTP (`POST /api/transcribe`) and WebSocket (`GET /api/transcribe/live`,
* Parse + validate the `language`/`task`/`prompt` query params shared by both
* the HTTP (`POST /api/transcribe`) and WebSocket (`GET /api/transcribe/live`,
* src/server/live.ts) entry points, so a live session can't forward an
* unvalidated string into the sidecar protocol just because it skips
* `parseParams` below. A string return is the 400 error text.
* `parseParams` below (D-032). A string return is the 400 error text.
*
* `prompt` is the raw "Names and terms" hint (D-WS2a): at most
* HINT_MAX_CHARS (the UI's textarea limit), normalized exactly as the browser
* worker does; empty after normalization = no prompt.
*/
export function parseLanguageAndTask(
url: URL,
): { language?: string; task: WhisperTask } | string {
): { language?: string; task: WhisperTask; prompt?: string } | string {
const rawLanguage = url.searchParams.get("language");
let language: string | undefined;
if (rawLanguage !== null && rawLanguage !== "" && rawLanguage !== "auto") {
@@ -346,7 +358,13 @@ export function parseLanguageAndTask(
return `invalid task: ${rawTask}`;
}
return { language, task: rawTask };
const rawPrompt = url.searchParams.get("prompt") ?? "";
if (rawPrompt.length > HINT_MAX_CHARS) {
return `prompt too long (max ${HINT_MAX_CHARS} characters)`;
}
const prompt = normalizeHint(rawPrompt);
return { language, task: rawTask, ...(prompt && { prompt }) };
}
/** Parse + validate query params; a string is the 400 error text. */
@@ -461,6 +479,7 @@ export function createTranscribeHandlers(deps: TranscribeDeps): TranscribeHandle
audioPath,
language: params.language,
task: params.task,
prompt: params.prompt,
},
(segment) => {
chunks.push({
+8 -3
View File
@@ -5,7 +5,8 @@ Used by tests/transcribe.test.ts to exercise the real Bun-side supervisor
requested model name:
"ok" two segments (the second reports the audio byte count,
language and task for pass-through assertions), then done
language, task and - when sent - prompt for pass-through
assertions), then done
"boom" an error line
"die" exit(1) mid-request (crash simulation)
"wait-for-cancel" one segment, then block until the next stdin line
@@ -52,7 +53,11 @@ for line in sys.stdin:
size = os.path.getsize(message["audio_path"])
emit({"type": "segment", "id": request_id, "start": 0.0, "end": 1.5,
"text": " Hello"})
prompt = message.get("prompt")
echo = "model=%s bytes=%d lang=%s task=%s" % (
model, size, message.get("language"), message.get("task"))
if prompt is not None:
echo += " prompt=%s" % prompt
emit({"type": "segment", "id": request_id, "start": 1.5, "end": 3.0,
"text": " world. [model=%s bytes=%d lang=%s task=%s]"
% (model, size, message.get("language"), message.get("task"))})
"text": " world. [%s]" % echo})
emit({"type": "done", "id": request_id, "cancelled": False})
+60
View File
@@ -0,0 +1,60 @@
import { describe, expect, test } from "bun:test";
import { join } from "node:path";
import { fitHintToBudget, normalizeHint } from "../src/lib/hint";
// D-WS2a: the server engine truncates the hint in Python (server-py/sidecar.py,
// with faster-whisper's tokenizer) and the browser engine in TypeScript
// (src/lib/hint.ts, with the Transformers.js tokenizer). Both must apply the
// same rule; with the same deterministic fake tokenizer they must agree
// exactly. sidecar.py imports faster-whisper only inside main(), so the
// helpers import without it.
const SIDECAR = join(import.meta.dir, "../server-py/sidecar.py");
const CASES: { hint: string; budget: number }[] = [
{ hint: "", budget: 223 },
{ hint: " \n\t ", budget: 223 },
{ hint: "Maxwell\nVoices of the Void", budget: 223 },
{ hint: " Århus\r\n\r\nÆrø, Festuge \n", budget: 223 },
{ hint: "Max\u0000well\u0007", budget: 223 },
{ hint: "alpha, beta gamma", budget: 10 },
{ hint: "alpha; beta; gamma; delta", budget: 14 },
{ hint: "x".repeat(50), budget: 10 },
{ hint: "Maxwell, ".repeat(300), budget: 223 },
{ hint: Array.from({ length: 400 }, (_, i) => `term${i}`).join("\n"), budget: 223 },
];
/** One token per UTF-16 code unit — the same function on both sides. */
const charEncode = (text: string) => [...text].map((c) => c.charCodeAt(0));
const PYTHON = `
import importlib.util, json, sys
spec = importlib.util.spec_from_file_location("sidecar", sys.argv[1])
sidecar = importlib.util.module_from_spec(spec)
spec.loader.exec_module(sidecar)
encode = lambda text: [ord(c) for c in text]
cases = json.load(sys.stdin)
print(json.dumps([
{"normalized": sidecar.normalize_hint(c["hint"]),
"ids": sidecar.fit_prompt_ids(c["hint"], encode, c["budget"])}
for c in cases
]))
`;
describe("hint truncation parity: TypeScript vs sidecar.py", () => {
test("normalize_hint / fit_prompt_ids match normalizeHint / fitHintToBudget", () => {
const proc = Bun.spawnSync(["python3", "-I", "-c", PYTHON, SIDECAR], {
stdin: new TextEncoder().encode(JSON.stringify(CASES)),
stdout: "pipe",
stderr: "pipe",
});
expect(proc.stderr.toString()).toBe("");
const python = JSON.parse(proc.stdout.toString()) as { normalized: string; ids: number[] }[];
expect(python).toEqual(
CASES.map(({ hint, budget }) => ({
normalized: normalizeHint(hint),
ids: fitHintToBudget(hint, charEncode, budget),
})),
);
});
});
+15
View File
@@ -139,6 +139,21 @@ describe("createLiveWebSocket", () => {
await waitFor(async () => (await readdir(tempDir)).length === 0);
});
test("every pass of a session carries the session's vocabulary hint (D-WS2a)", async () => {
const tempDir = await makeTempDir();
const sidecar = fakeSidecar();
const handler = createLiveWebSocket({ sidecar });
const ws = fakeSocket(sessionData({ prompt: "Maxwell, Voices of the Void", tempDir }));
handler.open(ws);
handler.message(ws, toBuffer(speech(1)));
handler.message(ws, toBuffer(silence(1.5)));
await waitFor(() => sidecar.requests.length > 0);
for (const request of sidecar.requests) {
expect(request.prompt).toBe("Maxwell, Voices of the Void");
}
await waitFor(async () => (await readdir(tempDir)).length === 0);
});
test("a sidecar error surfaces on the update stream instead of crashing the session", async () => {
const sidecar = fakeSidecar({ error: "model load failed" });
const handler = createLiveWebSocket({ sidecar });
+68
View File
@@ -165,6 +165,36 @@ describe("parseLanguageAndTask", () => {
});
});
describe("parseLanguageAndTask: vocabulary hint (D-WS2a)", () => {
const parse = (query: string) => parseLanguageAndTask(new URL(`http://localhost/x${query}`));
test("normalizes the hint like the worker does", () => {
expect(parse("?prompt=Maxwell%0A%0AVoices%20of%20the%20Void%0A")).toEqual({
language: undefined,
task: "transcribe",
prompt: "Maxwell, Voices of the Void",
});
});
test("absent, empty, or whitespace-only means no prompt", () => {
for (const query of ["", "?prompt=", "?prompt=%20%0A%09"]) {
const parsed = parse(query);
expect(typeof parsed).toBe("object");
expect("prompt" in (parsed as object)).toBe(false);
}
});
test("keeps non-ASCII names intact", () => {
const params = new URLSearchParams({ prompt: "Århus, Ærø, Festuge" });
expect(parse(`?${params}`)).toMatchObject({ prompt: "Århus, Ærø, Festuge" });
});
test("rejects a hint longer than the UI allows", () => {
expect(parse(`?prompt=${"a".repeat(1000)}`)).toMatchObject({ prompt: "a".repeat(1000) });
expect(parse(`?prompt=${"a".repeat(1001)}`)).toBe("prompt too long (max 1000 characters)");
});
});
describe("GET /api/transcribe/status", () => {
test("401 without a session", async () => {
const handlers = createTranscribeHandlers({
@@ -323,6 +353,35 @@ describe("POST /api/transcribe", () => {
expect(await readdir(tempDir)).toEqual([]);
});
test("passes the vocabulary hint to the sidecar; 400 for an over-long one (D-WS2a)", async () => {
const sidecar = fakeSidecar();
const handlers = createTranscribeHandlers({
sidecar,
getSessionUser: sessionFromCookie,
tempDir: await makeTempDir(),
});
const ok = new URLSearchParams({
model: "onnx-community/whisper-base",
prompt: "Århus\nÆrø",
});
const response = await handlers.post(
postRequest(`?${ok}`, pcmBody([0.1, 0.2]), { cookie: "sid=ok" }),
);
expect(response.status).toBe(200);
await readStreamLines(response);
expect(sidecar.requests[0]!.prompt).toBe("Århus, Ærø");
const tooLong = await handlers.post(
postRequest(
`?model=onnx-community/whisper-base&prompt=${"x".repeat(1001)}`,
pcmBody([0.1]),
{ cookie: "sid=ok" },
),
);
expect(tooLong.status).toBe(400);
expect(sidecar.requests).toHaveLength(1);
});
test("sidecar errors travel in-stream after the 200 header", async () => {
const tempDir = await makeTempDir();
const handlers = createTranscribeHandlers({
@@ -387,6 +446,15 @@ describe("createSidecar (fake python sidecar)", () => {
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");
expect(segments[1]!.text).not.toContain("prompt=");
// The vocabulary hint crosses the JSON-lines protocol intact (D-WS2a).
const prompted: SidecarSegment[] = [];
await sidecar.transcribe(
{ model: "ok", audioPath, prompt: "Århus, Voices of the Void" },
(segment) => prompted.push(segment),
);
expect(prompted[1]!.text).toContain("prompt=Århus, Voices of the Void]");
});
test("sidecar 'error' lines reject that request only", async () => {