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

+72 -2
View File
@@ -10,7 +10,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",
"mode": "file" | "live"} # optional, default "file"
"mode": "file" | "live", # optional, default "file"
"prompt": "Maxwell, Voices of the Void" | null} # optional (D-WS2a)
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}
@@ -56,6 +57,7 @@ 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
@@ -68,6 +70,56 @@ BATCH_SIZE = 8 # D-WS5a: VAD chunks decoded per batched forward pass
# 2000 ms default (a dict replaces the pipeline's 160 ms preset).
VAD_PARAMETERS = {"threshold": 0.2, "speech_pad_ms": 800}
# "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 would allow 223 tokens; 120 is the
# measured cap at which small models are not garbled (D-WS2a).
PROMPT_TOKEN_BUDGET = 120
_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 _prompt_sentence(text):
return " " + text if re.search(r"[.?!]$", text) else " " + text + "."
def fit_prompt_ids(hint, encode, budget=PROMPT_TOKEN_BUDGET):
"""Prompt token ids for `hint`, cut to `budget` tokens at a whole word.
The prompt is a space-led sentence ending in a full stop (unless it ends
in . ? ! already) - an unterminated list drops punctuation and loops.
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(_prompt_sentence(text))
if len(full) <= budget:
return full
words = text.split(" ")
def encode_prefix(count):
return encode(_prompt_sentence(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."""
@@ -137,6 +189,7 @@ def main():
language = request.get("language") or None
task = request.get("task") or "transcribe"
mode = request.get("mode") or "file"
prompt = request.get("prompt") or ""
if model_name != cached_name:
# Release both before loading the replacement: one model resident.
@@ -153,11 +206,27 @@ 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 mode == "live":
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
)
else:
if prompt_ids:
# The batched pipeline re-encodes initial_prompt itself
# and only accepts text, so hand it the budget-cut ids
# decoded back (same tokens after re-encoding).
options["initial_prompt"] = cached_model.hf_tokenizer.decode(prompt_ids)
segments, _info = cached_pipeline.transcribe(
audio,
language=language,
@@ -165,6 +234,7 @@ def main():
vad_filter=True,
vad_parameters=dict(VAD_PARAMETERS), # fresh: faster-whisper may mutate it
batch_size=BATCH_SIZE,
**options,
)
was_cancelled = False