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:
commit
2c87471fdc
26 files changed
+1177
-21
No files matched your search
@@ -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/);
|
||||
|
||||
Vendored
+9
-4
@@ -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})
|
||||
@@ -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
|
||||
@@ -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),
|
||||
})),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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" });
|
||||
|
||||
Reference in new issue
Block a user