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 ===========================================================