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
+72
-2
@@ -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
|
||||
|
||||
Reference in new issue
Block a user