Merge feat/vocabulary-hint: names-and-terms decoder prompt (spec 001 workstream 2)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	docs/Architecture.md
#	docs/Decisions.md
#	docs/WorkBreakdown.md
#	server-py/sidecar.py
#	src/App.tsx
#	src/server/live.ts
#	src/server/transcribe.ts
#	tests/fixtures/fake_sidecar.py
#	tests/transcribe.test.ts
This commit is contained in:
Lucas Winther committed 2026-10-30 01:35:28 +01:00
commit 2c87471fdc
26 files changed
+1177 -21

No files matched your search

+28
View File
@@ -77,6 +77,17 @@ describe("BrowserEngine", () => {
expect("modelId" in message).toBe(false);
});
test("forwards the vocabulary hint as `prompt`, and omits an empty one (D-WS2a)", () => {
const worker = new FakeWorker();
const engine = new BrowserEngine(worker);
void engine.transcribe(new Float32Array(4), { prompt: "Maxwell\nVoid" });
void engine.transcribe(new Float32Array(4), { prompt: "" });
void engine.transcribe(new Float32Array(4));
expect(worker.posted[0]!.message.prompt).toBe("Maxwell\nVoid");
expect("prompt" in worker.posted[1]!.message).toBe(false);
expect("prompt" in worker.posted[2]!.message).toBe(false);
});
test("asks the worker to stream only when a consumer listens", () => {
const worker = new FakeWorker();
const engine = new BrowserEngine(worker);
@@ -241,6 +252,23 @@ describe("ServerEngine", () => {
expect(url).not.toContain("language");
});
test("sends the vocabulary hint as the `prompt` query param, only when non-empty", async () => {
const urls: string[] = [];
const engine = new ServerEngine(async (input) => {
urls.push(input);
return ndjsonResponse([{ type: "result", text: "", chunks: [] }]);
});
await engine.transcribe(new Float32Array(4), {
modelId: "onnx-community/whisper-tiny",
prompt: "Århus, Voices of the Void",
});
await engine.transcribe(new Float32Array(4), { modelId: "onnx-community/whisper-tiny", prompt: "" });
expect(new URL(urls[0]!, "http://localhost").searchParams.get("prompt")).toBe(
"Århus, Voices of the Void",
);
expect(urls[1]).not.toContain("prompt");
});
test("requires a modelId", () => {
const engine = new ServerEngine(async () => ndjsonResponse([]));
expect(engine.transcribe(new Float32Array(4))).rejects.toThrow(/model id/);
+9 -4
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, task and mode for pass-through assertions), then done
language, task, mode 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,8 +53,12 @@ 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 mode=%s" % (
model, size, message.get("language"), message.get("task"),
message.get("mode"))
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 mode=%s]"
% (model, size, message.get("language"), message.get("task"),
message.get("mode"))})
"text": " world. [%s]" % echo})
emit({"type": "done", "id": request_id, "cancelled": False})
+21
View File
@@ -44,10 +44,26 @@ def _segments(kind, model, kwargs):
yield _Segment(0.0, 1.0, _describe(kind, model, kwargs))
class _Encoding:
def __init__(self, ids):
self.ids = ids
class _Tokenizer:
"""Stand-in for tokenizers.Tokenizer: one id per character."""
def encode(self, text, add_special_tokens=True):
return _Encoding([ord(char) for char in text])
def decode(self, ids):
return "".join(chr(i) for i in ids)
class WhisperModel:
def __init__(self, name, device=None, compute_type=None, download_root=None):
_loads["count"] += 1
self.name = name
self.hf_tokenizer = _Tokenizer()
def transcribe(self, audio, **kwargs):
return _segments("seq", self, kwargs), None
@@ -58,4 +74,9 @@ class BatchedInferencePipeline:
self.model = model
def transcribe(self, audio, **kwargs):
# Like faster-whisper 1.2: the batched pipeline re-encodes
# initial_prompt itself, so it must be text, not token ids.
prompt = kwargs.get("initial_prompt")
if prompt is not None and not isinstance(prompt, str):
raise TypeError("TextInputSequence must be str")
return _segments("batched", self.model, kwargs), None
+64
View File
@@ -0,0 +1,64 @@
import { describe, expect, test } from "bun:test";
import { join } from "node:path";
import { fitHintToBudget, normalizeHint, PROMPT_TOKEN_BUDGET } 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({"budget": sidecar.PROMPT_TOKEN_BUDGET, "results": [
{"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 {
budget: number;
results: { normalized: string; ids: number[] }[];
};
expect(python.budget).toBe(PROMPT_TOKEN_BUDGET);
expect(python.results).toEqual(
CASES.map(({ hint, budget }) => ({
normalized: normalizeHint(hint),
ids: fitHintToBudget(hint, charEncode, budget),
})),
);
});
});
+118
View File
@@ -0,0 +1,118 @@
import { describe, expect, test } from "bun:test";
import {
HINT_STORAGE_KEY,
PROMPT_TOKEN_BUDGET,
fitHintToBudget,
loadHint,
normalizeHint,
saveHint,
} from "../src/lib/hint";
/** One "token" per UTF-16 unit: deterministic, monotone, easy to reason about. */
const charEncode = (text: string) => [...text].map((c) => c.charCodeAt(0));
const charDecode = (ids: number[]) => String.fromCharCode(...ids);
function mapStorage(initial: Record<string, string> = {}) {
const map = new Map(Object.entries(initial));
return {
getItem: (key: string) => map.get(key) ?? null,
setItem: (key: string, value: string) => void map.set(key, value),
map,
};
}
const throwingStorage = {
getItem(): string | null {
throw new Error("denied");
},
setItem(): void {
throw new Error("denied");
},
};
describe("normalizeHint", () => {
test("one term per line becomes a comma-separated list", () => {
expect(normalizeHint(" Maxwell\n\n Voices of the Void \n")).toBe(
"Maxwell, Voices of the Void",
);
});
test("a comma-separated list is unchanged", () => {
expect(normalizeHint("Maxwell, Voices of the Void")).toBe("Maxwell, Voices of the Void");
});
test("control characters are removed and whitespace-only means no hint", () => {
expect(normalizeHint("Max\u0000well\u0007")).toBe("Maxwell");
expect(normalizeHint(" \n\t \r\n")).toBe("");
});
test("Windows line endings and tabs are handled", () => {
expect(normalizeHint("Århus\r\nÆrø\tFestuge")).toBe("Århus, Ærø Festuge");
});
});
describe("hint storage", () => {
test("round-trips through storage", () => {
const storage = mapStorage();
expect(loadHint(storage)).toBe("");
saveHint("Maxwell\nVoid", storage);
expect(storage.map.get(HINT_STORAGE_KEY)).toBe("Maxwell\nVoid");
expect(loadHint(storage)).toBe("Maxwell\nVoid");
});
test("unavailable storage never throws", () => {
expect(loadHint(throwingStorage)).toBe("");
expect(() => saveHint("x", throwingStorage)).not.toThrow();
expect(loadHint(null)).toBe("");
});
});
describe("fitHintToBudget (D-WS2a truncation rule)", () => {
test("empty hint means no prompt", () => {
expect(fitHintToBudget(" \n", charEncode)).toEqual([]);
});
test("a hint within budget is encoded as a space-led sentence ending in a full stop", () => {
// Measured (D-WS2a): an unterminated list prompt suppresses punctuation
// and triggers repetition loops on smaller models; a full stop does not.
expect(fitHintToBudget("Maxwell\nVoices of the Void", charEncode)).toEqual(
charEncode(" Maxwell, Voices of the Void."),
);
});
test("a hint already ending in . ? or ! gets no extra full stop", () => {
expect(charDecode(fitHintToBudget("Is it Maxwell?", charEncode))).toBe(" Is it Maxwell?");
expect(charDecode(fitHintToBudget("Dr. Bao.", charEncode))).toBe(" Dr. Bao.");
});
test("over budget: keeps whole leading words, drops a dangling comma, ends in a full stop", () => {
expect(charDecode(fitHintToBudget("alpha, beta gamma", charEncode, 10))).toBe(" alpha.");
expect(charDecode(fitHintToBudget("alpha, beta gamma", charEncode, 13))).toBe(" alpha, beta.");
});
test("a single word longer than the budget is cut at the budget", () => {
const ids = fitHintToBudget("x".repeat(50), charEncode, 10);
expect(ids).toEqual(charEncode(" xxxxxxxxx"));
expect(ids.length).toBe(10);
});
test("a very long hint fits the 120-token budget and is a word prefix of the hint", () => {
const words = Array.from({ length: 5000 }, (_, i) => `term${i}`);
const hint = words.join(", ");
const ids = fitHintToBudget(hint, charEncode);
expect(PROMPT_TOKEN_BUDGET).toBe(120);
expect(ids.length).toBeLessThanOrEqual(120);
expect(ids.length).toBeGreaterThan(100);
const kept = charDecode(ids);
// Ends on a whole term plus the full stop: the hint continues with a separator there.
expect(kept.endsWith(".")).toBe(true);
const body = kept.slice(0, -1);
expect(` ${hint}`.startsWith(body)).toBe(true);
expect([",", " "]).toContain(` ${hint}`.charAt(body.length));
});
test("is deterministic", () => {
const hint = "Maxwell, ".repeat(200);
expect(fitHintToBudget(hint, charEncode)).toEqual(fitHintToBudget(hint, charEncode));
});
});
+111
View File
@@ -10,6 +10,13 @@ import {
type LiveTranscribeFn,
} from "../src/lib/live";
import { resolveDefaultDtype } from "../src/lib/models";
import {
buildPromptPrefix,
installPromptSupport,
PROMPT_PREFIX_KWARG,
type PromptableModel,
type PromptTokenizer,
} from "../src/worker/prompt";
import { wordErrorRate } from "./dsp";
/**
@@ -266,6 +273,10 @@ describe("live pipeline accuracy (real model, real recordings)", () => {
normalize((await transcribe(whole)).text),
);
// File-mode accuracy gate (spec 001): the upload flow is held to the
// same per-clip bar as live, so a file-mode regression fails here too.
expect(fileWer).toBeLessThanOrEqual(MAX_WER_BY_CLIP[clip.base] ?? MAX_WER);
// The live session, with the injected clock advancing with the audio
// fed so pass scheduling behaves as in real time.
let fedSamples = 0;
@@ -310,3 +321,103 @@ describe("live pipeline accuracy (real model, real recordings)", () => {
);
}
});
// ---------------------------------------------------------------------------
// Vocabulary hint (D-WS2a): the worker's real prompt code on real weights
// ---------------------------------------------------------------------------
const HINT = "Maxwell, Voices of the Void, Slenderman, RuneScape, UFO";
/** Decode the first `seconds` of a clip straight to 16 kHz (no mic path needed). */
async function decode16k(file: string, seconds?: number): Promise<Float32Array> {
const proc = Bun.spawn(
[
ffmpegPath as unknown as string,
...["-hide_banner", "-loglevel", "error", "-i", join(AUDIO_DIR, file)],
...(seconds ? ["-t", String(seconds)] : []),
...["-ac", "1", "-ar", String(RATE), "-f", "f32le", "-"],
],
{ stdout: "pipe", stderr: "pipe" },
);
const [bytes, exitCode] = await Promise.all([new Response(proc.stdout).arrayBuffer(), proc.exited]);
if (exitCode !== 0) throw new Error(`ffmpeg failed on ${file}`);
return new Float32Array(bytes);
}
/** One pass with the inference worker's options and (optionally) its prompt. */
async function transcribeWithHint(
audio: Float32Array,
hint: string,
language = "en",
): Promise<string> {
const asr = await loadAsr();
installPromptSupport(asr.model as unknown as PromptableModel);
const prefix = buildPromptPrefix(asr.tokenizer as unknown as PromptTokenizer, hint);
const output = await asr(audio, {
chunk_length_s: 30,
stride_length_s: 5,
return_timestamps: true,
max_new_tokens: 440,
language,
task: "transcribe",
...(prefix.length > 0 && { [PROMPT_PREFIX_KWARG]: prefix }),
} as Record<string, unknown>);
const single = (Array.isArray(output) ? output[0] : output) as { text?: string };
return single.text ?? "";
}
describe("vocabulary hint (real model, D-WS2a)", () => {
test.skipIf(!available)(
"a hint keeps the transcript intact, keeps the hinted names, and never leaks into the text",
async () => {
// The first 2 min of en_alien: four windows, "Voices of the Void" and
// several "Maxwell"s.
const audio = await decode16k("en_alien.opus", 120);
const plain = await transcribeWithHint(audio, "");
const hinted = await transcribeWithHint(audio, HINT);
const count = (text: string, term: string) => normalize(text).split(term).length - 1;
expect(count(hinted, "maxwell")).toBeGreaterThanOrEqual(count(plain, "maxwell"));
expect(count(hinted, "voices of the void")).toBeGreaterThanOrEqual(1);
// Not garbled: no repetition loop, no dropped windows (measured
// difference between the two runs ≈ punctuation-level).
expect(wordErrorRate(normalize(plain), normalize(hinted))).toBeLessThanOrEqual(0.15);
// The prompt itself is never part of the transcript.
expect(normalize(hinted).startsWith(normalize(HINT))).toBe(false);
expect(normalize(hinted)).not.toContain("slenderman runescape");
},
DOWNLOAD_BUDGET_MS + 120 * 2 * MS_PER_AUDIO_SECOND,
);
test.skipIf(!available)(
"a hint longer than Whisper's prompt budget is cut, not an error or a garbled window",
async () => {
const longHint = Array.from({ length: 200 }, (_, i) => `Project${i} Codename${i}`)
.join(", ")
.slice(0, 1000);
const reference = await Bun.file(join(AUDIO_DIR, "en_aiproblem.txt")).text();
const text = await transcribeWithHint(await decode16k("en_aiproblem.m4a"), longHint);
expect(wordErrorRate(normalize(reference), normalize(text))).toBeLessThanOrEqual(MAX_WER);
expect(text).not.toContain("Codename");
},
DOWNLOAD_BUDGET_MS + 60 * MS_PER_AUDIO_SECOND,
);
test.skipIf(!available)(
"a short, near-silent live window with a hint does not transcribe the hint",
async () => {
// 3 s of faint noise — what a live pass sees in a pause that the
// segmenter did not drop (its near-silence gate is far lower).
let seed = 1;
const noise = new Float32Array(3 * RATE).map(() => {
seed = (seed * 1103515245 + 12345) % 2 ** 31;
return (seed / 2 ** 31 - 0.5) * 0.01;
});
const text = normalize(await transcribeWithHint(noise, HINT));
for (const term of ["maxwell", "voices of the void", "slenderman", "runescape"]) {
expect(text).not.toContain(term);
}
},
DOWNLOAD_BUDGET_MS + 30 * MS_PER_AUDIO_SECOND,
);
});
+124
View File
@@ -0,0 +1,124 @@
import { describe, expect, test } from "bun:test";
import { Tensor } from "@huggingface/transformers";
import {
PROMPT_PREFIX_KWARG,
WHISPER_DECODER_POSITIONS,
buildPromptPrefix,
installPromptSupport,
type PromptableModel,
} from "../src/worker/prompt";
const PREV = 50362;
/**
* Stand-in for a Transformers.js Whisper model: records the kwargs each
* generate() call received and, like the real one, returns the decoder input
* followed by the "generated" tokens as an int64 [1, n] tensor.
*/
function fakeModel(initTokens: number[], generated: number[] = [50365, 400, 401, 50400]) {
const calls: Record<string, unknown>[] = [];
const model: PromptableModel & { calls: typeof calls } = {
calls,
_prepare_generation_config: (_config, kwargs) => ({ fromKwargs: kwargs }),
_retrieve_init_tokens: () => [...initTokens],
async generate(kwargs) {
calls.push(kwargs);
const input = (kwargs.decoder_input_ids as number[] | undefined) ?? initTokens;
const sequence = [...input, ...generated];
return new Tensor("int64", BigInt64Array.from(sequence.map(BigInt)), [1, sequence.length]);
},
};
return model;
}
function tokens(output: unknown): number[] {
return (output as Tensor).tolist()[0]!.map(Number);
}
describe("installPromptSupport (D-WS2a)", () => {
test("without a prompt kwarg, generate is called exactly as before", async () => {
const model = fakeModel([50258, 50259, 50360]);
installPromptSupport(model);
const kwargs = { max_new_tokens: 440, return_timestamps: true };
const output = await model.generate({ ...kwargs });
expect(model.calls).toEqual([kwargs]);
expect(tokens(output)).toEqual([50258, 50259, 50360, 50365, 400, 401, 50400]);
});
test("an empty prefix is the same as no prompt", async () => {
const model = fakeModel([50258]);
installPromptSupport(model);
await model.generate({ max_new_tokens: 440, [PROMPT_PREFIX_KWARG]: [] });
expect(model.calls).toEqual([{ max_new_tokens: 440 }]);
});
test("a prefix is prepended to the init tokens and stripped from the output", async () => {
const model = fakeModel([50258, 50259, 50360]);
installPromptSupport(model);
const output = await model.generate({
max_new_tokens: 440,
language: "en",
[PROMPT_PREFIX_KWARG]: [PREV, 1, 2],
});
const call = model.calls[0]!;
expect(call.decoder_input_ids).toEqual([PREV, 1, 2, 50258, 50259, 50360]);
expect(call).not.toHaveProperty(PROMPT_PREFIX_KWARG);
expect(call.language).toBe("en");
// Prompt tokens would otherwise reach _decode_asr as transcript text.
expect(tokens(output)).toEqual([50258, 50259, 50360, 50365, 400, 401, 50400]);
});
test("works for English-only models (init tokens are just <|startoftranscript|>)", async () => {
const model = fakeModel([50257]);
installPromptSupport(model);
const output = await model.generate({ max_new_tokens: 440, [PROMPT_PREFIX_KWARG]: [50361, 7] });
expect(model.calls[0]!.decoder_input_ids).toEqual([50361, 7, 50257]);
expect(tokens(output)[0]).toBe(50257);
});
test("caps max_new_tokens so prompt + init + new tokens fit Whisper's 448 positions", async () => {
const model = fakeModel([50258, 50259, 50360]);
installPromptSupport(model);
const prefix = [PREV, ...Array.from({ length: 223 }, (_, i) => 1000 + i)];
await model.generate({ max_new_tokens: 440, [PROMPT_PREFIX_KWARG]: prefix });
expect(WHISPER_DECODER_POSITIONS).toBe(448);
expect(model.calls[0]!.max_new_tokens).toBe(448 - 227);
// A short prompt keeps the D-021 value.
await model.generate({ max_new_tokens: 440, [PROMPT_PREFIX_KWARG]: [PREV, 5] });
expect(model.calls[1]!.max_new_tokens).toBe(440);
});
test("installing twice wraps once", async () => {
const model = fakeModel([50258]);
installPromptSupport(model);
installPromptSupport(model);
await model.generate({ max_new_tokens: 440, [PROMPT_PREFIX_KWARG]: [PREV, 5] });
expect(model.calls).toHaveLength(1);
expect(model.calls[0]!.decoder_input_ids).toEqual([PREV, 5, 50258]);
});
});
describe("buildPromptPrefix", () => {
const tokenizer = {
encode: (text: string, _options: { add_special_tokens: false }) =>
[...text].map((c) => c.charCodeAt(0)),
convert_tokens_to_ids: (token: string) => (token === "<|startofprev|>" ? PREV : -1),
};
test("no hint → no prefix", () => {
expect(buildPromptPrefix(tokenizer, "")).toEqual([]);
expect(buildPromptPrefix(tokenizer, " \n ")).toEqual([]);
});
test("a hint → <|startofprev|> then the space-led, full-stopped hint tokens", () => {
expect(buildPromptPrefix(tokenizer, "Max\nVoid")).toEqual([
PREV,
...[..." Max, Void."].map((c) => c.charCodeAt(0)),
]);
});
test("a huge hint stays within the 120-token budget (D-WS2a)", () => {
const prefix = buildPromptPrefix(tokenizer, "Maxwell, ".repeat(500));
expect(prefix.length).toBeLessThanOrEqual(121);
});
});
+4
View File
@@ -35,6 +35,7 @@ describe("isUiToWorkerMessage", () => {
{ type: "transcribe", audio: new Float32Array(8), requestId: "live-3" },
{ type: "transcribe", audio: new Float32Array(8), requestId: "engine-1-1", stream: true },
{ type: "transcribe", audio: new Float32Array(8), stream: false },
{ type: "transcribe", audio: new Float32Array(8), prompt: "Maxwell, Voices of the Void" },
];
for (const message of [...loads, ...transcribes]) {
expect(isUiToWorkerMessage(message)).toBe(true);
@@ -65,6 +66,9 @@ describe("isUiToWorkerMessage", () => {
expect(
isUiToWorkerMessage({ type: "transcribe", audio: new Float32Array(4), stream: "yes" }),
).toBe(false);
expect(
isUiToWorkerMessage({ type: "transcribe", audio: new Float32Array(4), prompt: 5 }),
).toBe(false);
expect(isUiToWorkerMessage({ type: "result", text: "", chunks: [] })).toBe(false);
});
});
+18 -1
View File
@@ -121,7 +121,9 @@ describe("createLiveWebSocket", () => {
handler.message(ws, toBuffer(speech(1)));
handler.message(ws, toBuffer(silence(1.5))); // trailing silence commits the segment
await waitFor(() => sidecar.requests.length > 0);
// The fake records the request before it finishes reading the PCM file —
// wait for the payload itself, or a loaded machine races past it.
await waitFor(() => sidecar.audioPayloads.length > 0);
expect(sidecar.requests[0]).toEqual(
// Live passes are short: sequential decoding, no batching/VAD (D-WS5a).
expect.objectContaining({ model: "base", language: "da", task: "translate", mode: "live" }),
@@ -140,6 +142,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 });
+83
View File
@@ -168,6 +168,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({
@@ -345,6 +375,35 @@ describe("POST /api/transcribe", () => {
expect(text).toContain('"type":"result"');
});
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({
@@ -413,6 +472,18 @@ describe("createSidecar (fake python sidecar)", () => {
const live: SidecarSegment[] = [];
await sidecar.transcribe({ model: "ok", audioPath, mode: "live" }, (s) => live.push(s));
expect(live[1]!.text).toContain("mode=live");
// 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 () => {
@@ -528,6 +599,18 @@ describe("server-py/sidecar.py (stubbed faster-whisper, D-WS5a)", () => {
expect(implicit.texts[0]).toContain("batched model=m1");
});
test("the vocabulary hint reaches the batched pipeline as text, and live passes as ids (D-WS2a, D-WS5a)", async () => {
const sidecar = stubbedSidecar();
const file = await run(sidecar, { model: "m1", mode: "file", prompt: "Maxwell, Voices of the Void" });
expect(file.texts).toHaveLength(1);
expect(file.texts[0]).toContain("batched model=m1");
expect(file.texts[0]).toContain("initial_prompt= Maxwell, Voices of the Void.");
const live = await run(sidecar, { model: "m1", mode: "live", prompt: "Maxwell" });
expect(live.texts[0]).toContain("seq model=m1");
expect(live.texts[0]).toContain("initial_prompt=[32, 77, 97, 120, 119, 101, 108, 108, 46]");
});
test("live mode stays sequential without VAD (short passes, D-WS5a)", async () => {
const sidecar = stubbedSidecar();
const { texts } = await run(sidecar, { model: "m1", mode: "live", task: "translate" });