diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md index ff96ecb..6120019 100644 --- a/linux/CHANGELOG.md +++ b/linux/CHANGELOG.md @@ -9,6 +9,32 @@ The version is defined in [`blitztext/__init__.py`](blitztext/__init__.py). ## [Unreleased] +## [2.03.18] - 2026-06-10 + +### Fixed +- **TTS model dropdown no longer floods with voice names.** Servers like Kokoro + expose each voice as a `/models` entry. The ⟳ button now detects this case + and skips filling the model combo, prompting the user to type the model id + manually (e.g. `kokoro`). The status line shows "type model id manually" as + a hint. + +### Changed +- **Wakeword benchmark runs across all engines and shows per-engine results.** + Previously a callback signature mismatch caused the benchmark to crash when + more than one engine was configured. Now progress shows `[1/3] engine name`, + and the results panel lists Recall / False fires / time per engine. + +## [2.03.17] - 2026-06-10 + +### Added +- **Wakeword model fetch feedback.** The ⟳ button now shows a status line while + connecting; after loading it reports how many models were found (with their + names) or "Unreachable" if the server is down. +- **Wakeword Quickstart covers all four ports.** The Quickstart menu now lists + presets for ports 10400–10403, plus `hey_jarvis` and `alexa` variants. +- **Wakeword info box.** An info banner explains how wyoming-openwakeword works, + where to put model files, and lists the common built-in models. + ## [2.03.16] - 2026-06-10 ### Added diff --git a/linux/blitztext/__init__.py b/linux/blitztext/__init__.py index 8d695ee..d9f0c6d 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.03.16" +__version__ = "2.03.18" diff --git a/linux/blitztext/gtksettings.py b/linux/blitztext/gtksettings.py index f45dfb5..c220017 100644 --- a/linux/blitztext/gtksettings.py +++ b/linux/blitztext/gtksettings.py @@ -89,10 +89,13 @@ _STT_TEMPLATES: list[tuple[str, str, str, str, str, int]] = [ _WW_TEMPLATES: list[tuple[str, str, str]] = [ # (name, uri, model) - ("Local wyoming-openwakeword", "tcp://127.0.0.1:10400", "okay_computer"), - ("Local wyoming (port 10401)", "tcp://127.0.0.1:10401", "okay_computer"), + ("Local :10400 — okay_computer", "tcp://127.0.0.1:10400", "okay_computer"), + ("Local :10401 — okay_computer", "tcp://127.0.0.1:10401", "okay_computer"), + ("Local :10402 — okay_computer", "tcp://127.0.0.1:10402", "okay_computer"), + ("Local :10403 — okay_computer", "tcp://127.0.0.1:10403", "okay_computer"), + ("Local :10400 — hey_jarvis", "tcp://127.0.0.1:10400", "hey_jarvis"), + ("Local :10400 — alexa", "tcp://127.0.0.1:10400", "alexa"), ("Home Assistant wyoming", "tcp://homeassistant.local:10400", "okay_nabu"), - ("Docker wyoming-openwakeword", "tcp://127.0.0.1:10400", "hey_jarvis"), ] # (cat_emoji, label, [emojis]) — standard Unicode categories, WhatsApp-style @@ -1715,6 +1718,15 @@ notebook.bt-nb tab:checked label { _switch_row(ww_card, "Enable wakeword", self.ww_enabled, width=LW, description="Start dictation with a spoken keyword via an external openWakeWord server.") + _infobox(page, + "Each preset connects to one wyoming-openwakeword server instance. " + "Add one preset per server. Press ⟳ to auto-detect the available wakeword models " + "from that server.\n\n" + "To add new wakeword models: place your .onnx model files in the server's " + "model directory and restart it, e.g.:\n" + " docker run … -v ~/wakewords:/data/models homeassistant/wyoming-openwakeword\n" + "Common built-in models: okay_computer · hey_jarvis · alexa · hey_mycroft · computer") + # ── Engine selector bar ─────────────────────────────────────────────── ww_bar = Gtk.Box(spacing=6); ww_bar.set_margin_top(6) self.ww_combo = Gtk.ComboBoxText(); self.ww_combo.set_hexpand(True) @@ -1731,10 +1743,13 @@ notebook.bt-nb tab:checked label { qs.connect("clicked", self._show_ww_templates); ww_bar.pack_start(qs, False, False, 0) for label, cb, tip in ( ("Delete", self._ww_delete, "Remove this wakeword engine preset"), - ("⟳", lambda _b: self._ww_reload(), "Re-check connection and reload models"), + ("⟳", lambda _b: self._ww_reload(), "Re-check connection and reload models from the server"), ): b = Gtk.Button(label=label); b.set_tooltip_text(tip); b.connect("clicked", cb) ww_bar.pack_end(b, False, False, 0) + self.ww_status = Gtk.Label(xalign=0.0) + self.ww_status.get_style_context().add_class("dim-label") + ww_bar.pack_end(self.ww_status, False, False, 4) page.pack_start(ww_bar, False, False, 2) # ── Engine config card ──────────────────────────────────────────────── @@ -2026,6 +2041,8 @@ notebook.bt-nb tab:checked label { def _ww_reload(self) -> None: """Probe the dot and fetch models from the server (⟳ button).""" + if hasattr(self, "ww_status"): + self.ww_status.set_text("Connecting…") self._probe_dot(self.ww_dot, self.ww_uri.get_text(), 10400) self._ww_fetch_models() @@ -2039,7 +2056,7 @@ notebook.bt-nb tab:checked label { host = parsed.hostname or "127.0.0.1" port = parsed.port or 10400 try: - with socket.create_connection((host, port), timeout=2.0) as s: + with socket.create_connection((host, port), timeout=3.0) as s: s.sendall(json.dumps({"type": "describe"}).encode() + b"\n") line = b"" while not line.endswith(b"\n"): @@ -2054,12 +2071,28 @@ notebook.bt-nb tab:checked label { if not chunk: break payload += chunk data = json.loads(payload.decode("utf-8")) - models = [m.get("name") for w in data.get("wake", []) for m in w.get("models", [])] - if models: - cur = _combo_text(self.ww_model) - GLib.idle_add(lambda: _fill_combo(self.ww_model, models, cur or models[0])) - except Exception: - pass # offline — dot already shows red + models = [m.get("name") for w in data.get("wake", []) + for m in w.get("models", []) if m.get("name")] + def apply(models=models): + if models: + cur = _combo_text(self.ww_model) + _fill_combo(self.ww_model, models, cur or models[0]) + if hasattr(self, "ww_status"): + self.ww_status.set_markup( + f'' + f'{len(models)} model{"s" if len(models) != 1 else ""} loaded: ' + f'{", ".join(models)}') + else: + if hasattr(self, "ww_status"): + self.ww_status.set_markup( + 'Connected — no models found') + GLib.idle_add(apply) + except Exception as exc: + def show_err(exc=exc): + if hasattr(self, "ww_status"): + self.ww_status.set_markup( + f'Unreachable: {exc}') + GLib.idle_add(show_err) threading.Thread(target=work, daemon=True).start() def _ww_test(self, _b) -> None: @@ -2503,15 +2536,22 @@ notebook.bt-nb tab:checked label { def work(): models = stt.list_models(url, key) voices = wakeword_bench.list_voices(url, api_key_env=key) + # Kokoro and similar servers expose each voice as a /models entry. + # Skip populating the model combo when all "models" are also voices. + voices_set = set(voices) + real_models = [m for m in models if m not in voices_set] def apply(): - _fill_combo(self.wwb_model, models, self.wwb_model.get_text()) + if real_models: + _fill_combo(self.wwb_model, real_models, self.wwb_model.get_text()) self.wwb_voices.set_models(voices) if voices and not self.wwb_voices.get_text().strip(): self.wwb_voices.set_text(", ".join(voices)) # default: test them all - colour = "#34c759" if (models or voices) else "#ff9f0a" + colour = "#34c759" if (real_models or voices) else "#ff9f0a" + model_hint = (f"{len(real_models)} models, " if real_models + else "type model id manually, ") self.wwb_test_lbl.set_markup( - f"Loaded {len(models)} models, " + f"Loaded {model_hint}" f"{len(voices)} voices.") return False GLib.idle_add(apply) @@ -2553,48 +2593,64 @@ notebook.bt-nb tab:checked label { "and wyoming-openwakeword servers.") def work(): - def prog(done, total, u): - GLib.idle_add(self._wwbench_progress, done, total, u) + def prog(ei, n_eng, eng_name, ui, total, u): + GLib.idle_add(self._wwbench_progress, ei, n_eng, eng_name, ui, total, u) try: - res = wakeword_bench.run( + runs = wakeword_bench.run( self.cfg.wakeword_engines, tts_url=url, tts_api_key_env=key, tts_model=model, voices=voices, language=self.cfg.language, count=count, progress=prog) except Exception as exc: # noqa: BLE001 - surface setup errors GLib.idle_add(self._wwbench_error, str(exc)) return - GLib.idle_add(self._wwbench_done, res) + GLib.idle_add(self._wwbench_done, runs) threading.Thread(target=work, daemon=True).start() - def _wwbench_progress(self, done, total, u) -> bool: + def _wwbench_progress(self, ei, n_eng, eng_name, ui, total, u) -> bool: tag = "✓" if (u.ok and (u.detections > 0) == u.has_wakeword) else ("•" if u.ok else "⚠") kind = "wake" if u.has_wakeword else "filler" + eng_prefix = (f"[{ei}/{n_eng}] {GLib.markup_escape_text(eng_name)} " + if n_eng > 1 else "") self.wwb_summary.set_markup( - f"{done}/{total} {tag} {kind} · {GLib.markup_escape_text(u.voice)}" - + (f" · {GLib.markup_escape_text(u.error)}" if u.error else "")) + f"{eng_prefix}{ui}/{total} {tag} {kind} · {GLib.markup_escape_text(u.voice)}" + + (f" · {GLib.markup_escape_text(u.error)}" + if u.error else "")) return False def _wwbench_error(self, msg: str) -> bool: self.wwb_summary.set_markup(f"{GLib.markup_escape_text(msg)}") return False - def _wwbench_done(self, res) -> bool: - if res.expected == 0: - errs = {u.error for u in res.utterances if u.error} + def _wwbench_done(self, runs: list) -> bool: + if not runs: + self.wwb_summary.set_markup( + 'No engines configured — add one in the Input tab.') + return False + # Check if all engines failed to synthesize + if all(er.result.expected == 0 for er in runs): + errs = {u.error for er in runs for u in er.result.utterances if u.error} hint = (" " + GLib.markup_escape_text(next(iter(errs)))) if errs else "" self.wwb_summary.set_markup( f"No utterances synthesized — check the TTS model/endpoint.{hint}") return False - by_voice = res.recall_by_voice() - voice_bits = " ".join( - f"{GLib.markup_escape_text(v)} {d}/{t}" for v, (d, t) in sorted(by_voice.items())) - colour = "#34c759" if res.recall >= 0.9 and res.false_fires == 0 else ( - "#ff9f0a" if res.recall >= 0.6 else "#ff3b30") - self.wwb_summary.set_markup( - f"Recall {res.recall * 100:.0f}% " - f"({res.detected}/{res.expected} fired) · " - f"False fires: {res.false_fires} in {len(res.filler)} filler · " - f"{res.seconds:.0f}s\nper voice: {voice_bits}") + lines = [] + for er in runs: + r = er.result + name_esc = GLib.markup_escape_text(er.name) + if r.expected == 0: + lines.append(f"{name_esc}: no samples synthesized") + continue + colour = "#34c759" if r.recall >= 0.9 and r.false_fires == 0 else ( + "#ff9f0a" if r.recall >= 0.6 else "#ff3b30") + by_voice = r.recall_by_voice() + voice_bits = " ".join( + f"{GLib.markup_escape_text(v)} {d}/{t}" for v, (d, t) in sorted(by_voice.items())) + lines.append( + f"{name_esc} " + f"Recall {r.recall * 100:.0f}% " + f"({r.detected}/{r.expected}) · False fires: {r.false_fires} · {r.seconds:.0f}s" + + (f"\n per voice: {voice_bits}" if len(by_voice) > 1 else "")) + self.wwb_summary.set_markup("\n".join(lines)) return False # ===== Manual ===========================================================