From 2df5be3212aa3f477bfb509d51c5377f32992b3b Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Tue, 9 Jun 2026 20:02:12 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20language=20metadata=20in=20benchmark=20?= =?UTF-8?q?=E2=80=94=20Lang=20column,=20filter=20support=20(v2.02.00)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- linux/blitztext/__init__.py | 2 +- linux/blitztext/benchmark.py | 15 ++++++++ linux/blitztext/gtksettings.py | 66 ++++++++++++++++++++++++++-------- linux/blitztext/stt.py | 44 +++++++++++++++++------ 4 files changed, 101 insertions(+), 26 deletions(-) diff --git a/linux/blitztext/__init__.py b/linux/blitztext/__init__.py index 63aae94..9910d5f 100644 --- a/linux/blitztext/__init__.py +++ b/linux/blitztext/__init__.py @@ -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" diff --git a/linux/blitztext/benchmark.py b/linux/blitztext/benchmark.py index a02e47a..f283080 100644 --- a/linux/blitztext/benchmark.py +++ b/linux/blitztext/benchmark.py @@ -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, diff --git a/linux/blitztext/gtksettings.py b/linux/blitztext/gtksettings.py index 93cc3fc..d71a61b 100644 --- a/linux/blitztext/gtksettings.py +++ b/linux/blitztext/gtksettings.py @@ -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"") + 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"{GLib.markup_escape_text(lang_str)}") 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: diff --git a/linux/blitztext/stt.py b/linux/blitztext/stt.py index 00cd3ff..f22ba9b 100644 --- a/linux/blitztext/stt.py +++ b/linux/blitztext/stt.py @@ -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 []