feat: language metadata in benchmark — Lang column, filter support (v2.02.00)

- stt.py: add ModelMeta dataclass, fmt_languages(), list_models_meta(),
  detect_remote_device(); refactor list_models() to delegate
- benchmark.py: add languages field to BenchRow; fetch via _get_langs()
  with URL-level caching using list_models_meta()
- gtksettings.py: show language labels per engine in checkbox list;
  add language codes to search filter; add Lang column to results table

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-06-09 20:02:12 +02:00
parent 51ab6d5aff
commit 2df5be3212
4 changed files with 101 additions and 26 deletions

View File

@ -6,4 +6,4 @@ counterpart to the macOS Blitztext menu bar app: it runs natively on the host
(not in a container) so it can type into any application via xdotool.
"""
__version__ = "2.01.03"
__version__ = "2.02.00"

View File

@ -23,6 +23,7 @@ class BenchRow:
model: str
device: str # "CPU" | "CUDA" | "remote"
best_for: str # "Short clips" | "Short / medium" | "Long / batch" | "Streaming"
languages: list[str] # ISO 639-1 codes from /v1/models, empty if unknown
ok: bool
seconds: float
wer: float
@ -111,6 +112,19 @@ def run(engines, wav_path: Path, reference: str, *, language: str = "",
run_list.append(e)
device_cache: dict = {}
meta_cache: dict = {} # url → list[ModelMeta]
def _get_langs(e) -> list[str]:
if e.is_local:
return []
url = e.url
if url not in meta_cache:
meta_cache[url] = stt.list_models_meta(url, e.api_key_env)
for m in meta_cache[url]:
if not e.model or m.id == e.model or m.id.endswith("/" + e.model):
return m.languages
return meta_cache[url][0].languages if meta_cache[url] else []
rows: list[BenchRow] = []
for e in run_list:
tr = get_local_transcriber(e) if (e.is_local and get_local_transcriber) else None
@ -122,6 +136,7 @@ def run(engines, wav_path: Path, reference: str, *, language: str = "",
model=e.model or ("local" if e.is_local else "(default)"),
device=_engine_device(e, tr, device_cache),
best_for=_engine_best_for(e),
languages=_get_langs(e),
ok=res.ok,
seconds=res.seconds,
wer=w,

View File

@ -1919,7 +1919,8 @@ notebook.bt-nb tab:checked label {
self._bench_checks: dict[str, Gtk.CheckButton] = {}
self._bench_dots: dict[str, Gtk.Label] = {}
self._bench_sel_rows: list[tuple[Gtk.Box, str, str]] = [] # (row, name, search_text)
self._bench_lang_labels: dict[str, Gtk.Label] = {}
self._bench_sel_rows: list[tuple[Gtk.Box, str, list[str]]] = [] # (row, name, mutable_search)
for e in self.cfg.stt_engines:
row = Gtk.Box(spacing=6)
@ -1933,24 +1934,51 @@ notebook.bt-nb tab:checked label {
cb.set_active(True)
self._bench_checks[e.name] = cb
row.pack_start(cb, True, True, 0)
lang_lbl = Gtk.Label(xalign=1.0)
lang_lbl.set_markup(f"<small><span foreground='{GREY}'>—</span></small>")
lang_lbl.set_margin_end(4)
self._bench_lang_labels[e.name] = lang_lbl
row.pack_end(lang_lbl, False, False, 0)
sel_list.pack_start(row, False, False, 2)
search_text = f"{e.name} {e.model} {e.url}".lower()
self._bench_sel_rows.append((row, e.name, search_text))
search_parts = [e.name, e.model, e.url]
self._bench_sel_rows.append((row, e.name, search_parts))
def _filter_bench(_e=None):
q = self._bench_filter.get_text().lower()
for row, _name, stext in self._bench_sel_rows:
row.set_visible(not q or q in stext)
for row, _name, parts in self._bench_sel_rows:
row.set_visible(not q or any(q in p.lower() for p in parts))
self._bench_filter.connect("changed", _filter_bench)
# Background reachability check for all engines
# Background: reachability + language metadata
def _check_bench_status():
meta_cache: dict[str, list] = {}
for e in self.cfg.stt_engines:
ok = stt.status(e, timeout=2.0)
color = GREEN if ok else RED
dot = self._bench_dots.get(e.name)
if dot:
GLib.idle_add(dot.set_markup, _dot(color))
# Fetch language metadata for remote engines
if not e.is_local and e.url:
if e.url not in meta_cache:
meta_cache[e.url] = stt.list_models_meta(e.url, e.api_key_env, timeout=4.0)
langs: list[str] = []
for m in meta_cache[e.url]:
if not e.model or m.id == e.model or m.id.endswith("/" + e.model):
langs = m.languages
break
if not langs and meta_cache[e.url]:
langs = meta_cache[e.url][0].languages
if langs:
# Update search parts so filter works on language codes
for row, name, parts in self._bench_sel_rows:
if name == e.name:
parts.extend(langs)
lang_str = stt.fmt_languages(langs)
lbl = self._bench_lang_labels.get(e.name)
if lbl:
GLib.idle_add(lbl.set_markup,
f"<small><span foreground='{GREY}'>{GLib.markup_escape_text(lang_str)}</span></small>")
threading.Thread(target=_check_bench_status, daemon=True).start()
# ── Run controls ──────────────────────────────────────────────────────
@ -1970,22 +1998,29 @@ notebook.bt-nb tab:checked label {
page.pack_start(run_row, False, False, 0)
# ── Resizable pane: engine list (top) ↕ results table (bottom) ───────
self.bench_store = Gtk.ListStore(str, str, str, str, str, str, str, str, str)
# engine, url, model, device, best_for, lang, time, accuracy, output, tooltip
self.bench_store = Gtk.ListStore(str, str, str, str, str, str, str, str, str, str)
bench_sort = Gtk.TreeModelSort(model=self.bench_store)
tree = Gtk.TreeView(model=bench_sort)
tree.set_has_tooltip(True)
tree.set_tooltip_column(8)
for title, i, expand in [("Engine", 0, False), ("URL", 1, False),
("Model", 2, False), ("Device", 3, False),
("Best for", 4, False), ("Time (s)", 5, False),
("Accuracy", 6, False), ("Output", 7, True)]:
tree.set_tooltip_column(9)
for title, i, expand, max_w in [
("Engine", 0, False, 0),
("URL", 1, False, 180),
("Model", 2, False, 0),
("Device", 3, False, 0),
("Best for", 4, False, 0),
("Lang", 5, False, 160),
("Time (s)", 6, False, 0),
("Accuracy", 7, False, 0),
("Output", 8, True, 0)]:
r = Gtk.CellRendererText()
r.set_property("ellipsize", Pango.EllipsizeMode.END)
col = Gtk.TreeViewColumn(title, r, text=i); col.set_resizable(True)
col.set_sort_column_id(i)
col.set_expand(expand)
if i == 1:
col.set_max_width(180)
if max_w:
col.set_max_width(max_w)
tree.append_column(col)
tree_sw = Gtk.ScrolledWindow()
tree_sw.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
@ -2136,8 +2171,9 @@ notebook.bt-nb tab:checked label {
tooltip = row.error
# Strip scheme from URL for display brevity (http://192.168.1.1:8080 → 192.168.1.1:8080)
url_display = row.url.removeprefix("https://").removeprefix("http://").rstrip("/")
lang_display = stt.fmt_languages(row.languages)
self.bench_store.append([row.engine, url_display, row.model, row.device, row.best_for,
f"{row.seconds:.2f}", acc, out_friendly, tooltip])
lang_display, f"{row.seconds:.2f}", acc, out_friendly, tooltip])
return False
def _bench_done(self, rows) -> bool:

View File

@ -61,8 +61,31 @@ def status(engine: STTEngine, timeout: float = 2.0) -> bool:
return reachable(engine.url, timeout)
@dataclass
class ModelMeta:
"""Model id plus optional metadata (languages, etc.) from the server."""
id: str
languages: list[str] = field(default_factory=list)
def fmt_languages(langs: list[str]) -> str:
"""Compact display string for a language list, e.g. 'en, de, fr +45'."""
if not langs:
return ""
if len(langs) >= 50:
return f"multilingual ({len(langs)})"
if len(langs) > 5:
return f"{', '.join(langs[:5])} +{len(langs) - 5}"
return ", ".join(langs)
def list_models(base_url: str, api_key_env: str = "", timeout: float = 5.0) -> list[str]:
"""Fetch model ids from an OpenAI-compatible, Ollama-style, or Riva/NIM /models endpoint."""
return [m.id for m in list_models_meta(base_url, api_key_env, timeout)]
def list_models_meta(base_url: str, api_key_env: str = "", timeout: float = 5.0) -> list[ModelMeta]:
"""Like list_models() but returns ModelMeta with language info when available."""
import os
if not base_url:
@ -81,26 +104,27 @@ def list_models(base_url: str, api_key_env: str = "", timeout: float = 5.0) -> l
except (urllib.error.URLError, json.JSONDecodeError, OSError):
return None
# 1. Standard OpenAI /models
# 1. Standard OpenAI /models — faster-whisper-server also returns "language"
data = _get(base + "/models")
if isinstance(data, dict):
items = data.get("data")
if isinstance(items, list): # OpenAI shape: {"data":[{"id":...}]}
return [m["id"] for m in items if isinstance(m, dict) and m.get("id")]
if isinstance(items, list):
result = [ModelMeta(id=m["id"], languages=m.get("language") or [])
for m in items if isinstance(m, dict) and m.get("id")]
if result:
return result
items = data.get("models")
if isinstance(items, list): # Ollama shape: {"models":[{"name"/"model":...}]}
return [m.get("name") or m.get("model") for m in items
if (m.get("name") or m.get("model"))]
if isinstance(items, list): # Ollama shape
return [ModelMeta(id=m.get("name") or m.get("model", ""))
for m in items if m.get("name") or m.get("model")]
# 2. NVIDIA Riva / NIM — exposes model info at /metadata
# 2. NVIDIA Riva / NIM
data = _get(base + "/metadata")
if isinstance(data, dict):
for info in data.get("modelInfo") or []:
name = info.get("shortName") or info.get("modelUrl") or ""
if name:
# Strip the long tag suffix: keep everything before the first ':'
short = name.split(":")[0]
return [short]
return [ModelMeta(id=name.split(":")[0])]
return []