diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md index ef957f7..c6b155d 100644 --- a/linux/CHANGELOG.md +++ b/linux/CHANGELOG.md @@ -9,6 +9,21 @@ The version is defined in [`blitztext/__init__.py`](blitztext/__init__.py). ## [Unreleased] +## [2.03.32] - 2026-06-10 + +### Changed +- **Settings dialog navigation redesigned.** Replaced the `Gtk.Notebook` tab bar + with a 170 px left sidebar (`Gtk.ListBox` of flat buttons with section headers) + and a `Gtk.Stack` for the content area. The dialog is now 860 × 700 px by default. + Lazy-loading is preserved: each page is built only on first visit. +- **Input page split into Keyboard and Wakeword.** The former "Input" tab is now + two separate pages — "Keyboard" (input mode, hotkeys, quality gate, audio cues) + and "Wakeword" (enable switch, mic level, test button, silence timeout, cancel/send + words, engine preset selector, engine config card, wakeword sound cues). Either + page may be visited first; the mic level-meter starts on whichever is opened. +- **Benchmark page split into Benchmark — STT and Benchmark — Wakeword.** The + single "Benchmark" tab is now two dedicated pages. All field names and collect + logic are unchanged. ## [2.03.31] - 2026-06-10 ### Added diff --git a/linux/blitztext/__init__.py b/linux/blitztext/__init__.py index b0af300..b4a4c3f 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.31" +__version__ = "2.03.32" diff --git a/linux/blitztext/gtksettings.py b/linux/blitztext/gtksettings.py index 5d39ef0..7c891e3 100644 --- a/linux/blitztext/gtksettings.py +++ b/linux/blitztext/gtksettings.py @@ -1,13 +1,16 @@ -"""GTK settings: dropdown + editor managers for prompt presets and engines. +"""GTK settings: sidebar + stack layout for prompt presets and engines. -Tabs: - Presets - prompt presets (select from a dropdown, edit, add, delete) - Engines - STT and LLM engine presets with online/offline status + STT test - Input - input scheme, keys, quality gate - General - mic (with live level meter), output, language, notifications, autostart - Benchmark- compare STT engines against a reference clip - Log - runtime log output - About - version, source, changelog, and license +Pages: + Presets - prompt presets (select from a dropdown, edit, add, delete) + General - mic (with live level meter), output, language, notifications, autostart + Keyboard - input scheme, keys, quality gate, audio cues + Wakeword - hands-free wakeword, engine config, wakeword sound cues + STT Engines - STT and LLM engine presets with online/offline status + STT test + Benchmark — STT - compare STT engines against a reference clip + Benchmark — WW - stress-test wakeword engines with synthesized speech + Log - runtime log output + Manual - user manual + About - version, source, changelog, and license """ from __future__ import annotations @@ -544,28 +547,6 @@ def _url_field_lb(lb: Gtk.ListBox, label: str, placeholder: str, on_reload, return row_box.get_children()[-2] # fallback: second-to-last (before refresh btn) -def _page(nb: Gtk.Notebook, title: str, icon: str = "") -> Gtk.Box: - outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) - if icon: - tab_box = Gtk.Box(spacing=5) - tab_box.pack_start( - Gtk.Image.new_from_icon_name(icon, Gtk.IconSize.MENU), False, False, 0) - tab_box.pack_start(Gtk.Label(label=title), False, False, 0) - tab_box.show_all() - nb.append_page(outer, tab_box) - else: - nb.append_page(outer, Gtk.Label(label=title)) - sw = Gtk.ScrolledWindow() - sw.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.ALWAYS) - sw.set_overlay_scrolling(False) # always-visible scrollbar, not the overlay kind - outer.pack_start(sw, True, True, 0) - inner = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) - for m in ("top", "bottom", "start", "end"): - getattr(inner, f"set_margin_{m}")(15) - sw.add(inner) - outer._bt_inner = inner - return outer - def _read_first(paths: list[Path], fallback: str = "Not available in this install.") -> str: for path in paths: @@ -755,7 +736,6 @@ class SettingsDialog: self._wf_idx = self._stt_idx = self._llm_idx = 0 self.dlg = Gtk.Dialog(transient_for=parent, modal=True) - self.dlg.set_default_size(740, 700) _header = Gtk.HeaderBar() _header.set_show_close_button(True) @@ -813,18 +793,12 @@ class SettingsDialog: .bt-infobox label { color: @theme_fg_color; } -notebook.bt-nb tab { - padding: 8px 18px; - border-radius: 4px 4px 0 0; +.bt-sidebar-active { + background-color: alpha(@theme_selected_bg_color, 0.15); + border-radius: 6px; } -notebook.bt-nb tab label { - font-size: 14px; - color: mix(@theme_fg_color, @theme_bg_color, 0.35); -} -notebook.bt-nb tab:checked label { - font-size: 14px; - font-weight: bold; - color: #1a73e8; +.sidebar { + background-color: mix(@theme_bg_color, @theme_fg_color, 0.03); } .bt-emoji-btn { font-size: 20px; @@ -844,11 +818,31 @@ notebook.bt-nb tab:checked label { Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION ) - nb = Gtk.Notebook() - nb.get_style_context().add_class("bt-nb") - self.dlg.get_content_area().pack_start(nb, True, True, 0) + # Widen dialog for sidebar + self.dlg.set_default_size(860, 700) - # Resize-grip strip — sits below the notebook, above the button row, so + # Outer horizontal container + _body = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) + self.dlg.get_content_area().pack_start(_body, True, True, 0) + + # Left sidebar + _sidebar_scroll = Gtk.ScrolledWindow() + _sidebar_scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.NEVER) + _sidebar_scroll.set_size_request(170, -1) + _sidebar_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + _sidebar_scroll.add(_sidebar_box) + _sidebar_scroll.get_style_context().add_class("sidebar") + _body.pack_start(_sidebar_scroll, False, False, 0) + + _vsep = Gtk.Separator(orientation=Gtk.Orientation.VERTICAL) + _body.pack_start(_vsep, False, False, 0) + + # Right stack + self._stack = Gtk.Stack() + self._stack.set_transition_type(Gtk.StackTransitionType.NONE) + _body.pack_start(self._stack, True, True, 0) + + # Resize-grip strip — sits below the body, above the button row, so # users notice the window is resizable. _grip_strip = Gtk.Box() _grip = Gtk.DrawingArea() @@ -859,24 +853,73 @@ notebook.bt-nb tab:checked label { _grip_strip.pack_end(_grip, False, False, 0) self.dlg.get_content_area().pack_end(_grip_strip, False, False, 0) - # Build each tab lazily on first view so the dialog opens instantly. The - # heavy bits are the Gtk.FileChooserButton pickers in Input/Benchmark - # (~0.2s each to construct); deferring them until you visit that tab keeps - # the initial open near-instant. _collect() force-builds any unvisited tab - # before saving, so no field is ever missed. - self._pending_tabs: dict = {} - for title, icon, builder in ( - ("Presets", "document-properties-symbolic", self._build_presets), - ("Engines", "network-server-symbolic", self._build_engines), - ("Input", "audio-input-microphone-symbolic", self._build_input), - ("General", "preferences-system-symbolic", self._build_general), - ("Benchmark", "utilities-system-monitor-symbolic",self._build_benchmark), - ("Log", "text-x-generic-symbolic", self._build_log), - ("Manual", "help-contents-symbolic", self._build_manual), - ("About", "help-about-symbolic", self._build_about)): - self._pending_tabs[_page(nb, title, icon)] = builder - self._build_tab(nb.get_nth_page(0)) # the visible tab, eagerly - nb.connect("switch-page", lambda _nb, page, _n: self._build_tab(page)) + # Sidebar helpers + def _add_sidebar_section(label: str) -> None: + lbl = Gtk.Label(xalign=0.0) + lbl.set_markup(f'{GLib.markup_escape_text(label.upper())}') + lbl.set_margin_top(14); lbl.set_margin_bottom(2) + lbl.set_margin_start(12); lbl.set_margin_end(8) + _sidebar_box.pack_start(lbl, False, False, 0) + + def _add_sidebar_row(label: str, icon: str, page_widget) -> Gtk.Button: + btn = Gtk.Button() + btn.get_style_context().add_class("flat") + btn_box = Gtk.Box(spacing=8) + btn_box.set_margin_top(3); btn_box.set_margin_bottom(3) + btn_box.set_margin_start(8); btn_box.set_margin_end(8) + if icon: + img = Gtk.Image.new_from_icon_name(icon, Gtk.IconSize.MENU) + img.set_pixel_size(16) + btn_box.pack_start(img, False, False, 0) + lbl = Gtk.Label(label=label, xalign=0.0) + btn_box.pack_start(lbl, True, True, 0) + btn.add(btn_box) + btn.connect("clicked", lambda _b, pw=page_widget: self._show_page(pw)) + _sidebar_box.pack_start(btn, False, False, 0) + return btn + + def _stack_page(title: str) -> Gtk.Box: + outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + sw = Gtk.ScrolledWindow() + sw.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.ALWAYS) + sw.set_overlay_scrolling(False) + outer.pack_start(sw, True, True, 0) + inner = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) + for m in ("top", "bottom", "start", "end"): + getattr(inner, f"set_margin_{m}")(15) + sw.add(inner) + outer._bt_inner = inner + self._stack.add_named(outer, title) + return outer + + # Build pages lazily on first visit — heavy bits (FileChooserButton etc.) + # are deferred so the dialog opens instantly. + self._pending_pages: dict = {} # page widget → builder + self._sidebar_btns: dict = {} # page widget → sidebar button + + def _reg(section_label, sidebar_label, icon, builder): + if section_label: + _add_sidebar_section(section_label) + pg = _stack_page(sidebar_label) + btn = _add_sidebar_row(sidebar_label, icon, pg) + self._pending_pages[pg] = builder + self._sidebar_btns[pg] = btn + return pg + + _reg(None, "Presets", "document-properties-symbolic", self._build_presets) + _reg("General", "General", "preferences-system-symbolic", self._build_general) + _reg("Input", "Keyboard", "input-keyboard-symbolic", self._build_keyboard) + _reg(None, "Wakeword", "audio-input-microphone-symbolic", self._build_wakeword) + _reg("Engines", "STT Engines", "network-server-symbolic", self._build_engines) + _reg("Benchmark", "Benchmark — STT", "utilities-system-monitor-symbolic", self._build_benchmark_stt) + _reg(None, "Benchmark — Wakeword", "audio-input-microphone-symbolic", self._build_benchmark_ww) + _reg("System", "Log", "text-x-generic-symbolic", self._build_log) + _reg(None, "Manual", "help-contents-symbolic", self._build_manual) + _reg(None, "About", "help-about-symbolic", self._build_about) + + # Show the first page on open + first_page = list(self._pending_pages)[0] + self._show_page(first_page) self._bind_entry = None self._bind_pressed: list[str] = [] @@ -885,18 +928,29 @@ notebook.bt-nb tab:checked label { self.dlg.connect("response", self._on_response) self.dlg.connect("destroy", lambda *_: self._cleanup()) - def _build_tab(self, page: Gtk.Box) -> None: - """Build a notebook tab's contents the first time it's shown (lazy).""" - builder = self._pending_tabs.pop(page, None) + def _show_page(self, page) -> None: + """Switch to page, build it on first visit, update sidebar highlight.""" + self._build_page(page) + self._stack.set_visible_child(page) + for pw, btn in self._sidebar_btns.items(): + ctx = btn.get_style_context() + if pw is page: + ctx.add_class("bt-sidebar-active") + else: + ctx.remove_class("bt-sidebar-active") + + def _build_page(self, page) -> None: + """Build a page's contents the first time it's shown (lazy).""" + builder = self._pending_pages.pop(page, None) if builder is not None: content = getattr(page, "_bt_inner", page) builder(content) - page.show_all() # the dialog may already be on-screen + page.show_all() def _force_build_tabs(self) -> None: - """Build any not-yet-visited tabs so _collect() sees every field.""" - for page in list(self._pending_tabs): - self._build_tab(page) + """Build any not-yet-visited pages so _collect() sees every field.""" + for page in list(self._pending_pages): + self._build_page(page) # ===== Presets ========================================================== def _build_presets(self, page: Gtk.Box) -> None: @@ -1740,12 +1794,12 @@ notebook.bt-nb tab:checked label { self._bind_entry = None return True - # ===== Input ============================================================ - def _build_input(self, page: Gtk.Box) -> None: + # ===== Keyboard ========================================================= + def _build_keyboard(self, page: Gtk.Box) -> None: _infobox(page, "Choose how you start and stop dictating. With the default " "’modifiers’ mode: hold Ctrl and the Windows key to talk, then press " - "Ctrl to stop and paste. Below you can tune the noise filter, set up " - "hands-free wakeword, and pick sounds that confirm start and stop.") + "Ctrl to stop and paste. Below you can tune the noise filter and " + "pick sounds that confirm start and stop.") LW = 170 # ── Input mode card ─────────────────────────────────────────────────── @@ -1776,20 +1830,66 @@ notebook.bt-nb tab:checked label { _switch_row(q_card, "Strip trailing . / ,", self.q_strip, width=LW, description="Remove ending punctuation from pasted text — handy for code insertion.") - # ── Wakeword card ───────────────────────────────────────────────────── - ww_card = _card_section(page, "Hands-free wakeword", icon="audio-input-microphone-symbolic") + # ── Manual dictation sounds card ────────────────────────────────────── + snd_card = _card_section(page, "Audio cues (keyboard / hotkey dictation)", icon="audio-speakers-symbolic") + self.snd_enabled = Gtk.Switch(); self.snd_enabled.set_active(self.cfg.sounds_enabled) + _switch_row(snd_card, "Play audio cues", self.snd_enabled, width=LW, + description="On/off for the manual start/stop chimes below. Does not affect wakeword sounds.") + self.snd_before = self._sound_field(snd_card, "Start sound", self.cfg.sound_before, + "Plays when manual recording starts.", width=LW) + self.snd_after = self._sound_field(snd_card, "Stop sound", self.cfg.sound_after, + "Plays when manual recording stops (paste, paste+Enter, or auto-stop).", width=LW) + + # Start meter if not already running (covers the case where Keyboard is + # visited before General, which is where _start_meter() normally fires). + if self._meter is None: + self._start_meter() + + # ===== Wakeword ========================================================= + def _build_wakeword(self, page: Gtk.Box) -> None: + _infobox(page, + "Set up hands-free wakeword dictation. 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") + LW = 170 + + # ── Wakeword enable card ────────────────────────────────────────────── + ww_card = _card_section(page, "Hands-free wakeword", margin_top=4, icon="audio-input-microphone-symbolic") self.ww_enabled = Gtk.Switch(); self.ww_enabled.set_active(self.cfg.wakeword_enabled) _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") + self.ww_mic_level = Gtk.LevelBar() + self.ww_mic_level.set_min_value(0); self.ww_mic_level.set_max_value(1) + _labeled(ww_card, "Input level", self.ww_mic_level, width=LW, + tooltip="Live microphone level — the bar should move when you speak.") + + self.ww_test_btn = Gtk.Button(label="Test wakeword"); self.ww_test_btn.set_halign(Gtk.Align.START) + self.ww_test_btn.connect("clicked", self._ww_test) + self.ww_test_lbl = Gtk.Label(label=""); self.ww_test_lbl.set_xalign(0.0) + test_box = Gtk.Box(spacing=10) + test_box.pack_start(self.ww_test_btn, False, False, 0) + test_box.pack_start(self.ww_test_lbl, False, False, 0) + _labeled(ww_card, "", test_box, width=LW) + + self.ww_silence = _labeled(ww_card, "Silence to stop (s)", _entry(str(self.cfg.wakeword_silence_seconds)), width=LW, + tooltip="After the wakeword fires, stop recording this many seconds after you stop speaking. Default 2.0.") + self.cancel_keywords, self.ww_key_cancel = self._kw_shortcut_row( + ww_card, "Cancel words", ", ".join(self.cfg.cancel_keywords), + "abbrechen, cancel", self.cfg.key_cancel, + tooltip_kw="Say one of these at the start or end of a clip to DISCARD it — nothing is typed. Empty = off.", + tooltip_key="Keyboard shortcut that cancels dictation (works even during wakeword recording).", + width=LW) + self.send_keywords, self.ww_key_send = self._kw_shortcut_row( + ww_card, "Send words", ", ".join(self.cfg.send_keywords), + "computer send, computer abschicken", self.cfg.key_send, + tooltip_kw="Say one of these to type AND press Enter (spoken ‘submit’). Use a distinctive multi-word phrase. Empty = off.", + tooltip_key="Keyboard shortcut that stops recording and pastes with Enter.", + width=LW) # ── Engine selector bar ─────────────────────────────────────────────── ww_bar = Gtk.Box(spacing=6); ww_bar.set_margin_top(6) @@ -1828,39 +1928,11 @@ notebook.bt-nb tab:checked label { self.ww_model = _labeled(ww_cfg_card, "Model name", _model_combo("Search models…"), width=LW, tooltip="Which wake model to listen for (e.g. okay_computer, hey_jarvis). Press ⟳ on the URI field to load models from the server.") self.ww_cancel_model = _labeled(ww_cfg_card, "Cancel model", _model_combo("none — use text keywords"), width=LW, - tooltip="Optional: a wakeword model that immediately cancels the recording when heard (e.g. a 'stop' model). Faster than the Whisper-based text cancel.") + tooltip="Optional: a wakeword model that immediately cancels the recording when heard (e.g. a ‘stop’ model). Faster than the Whisper-based text cancel.") self.ww_send_model = _labeled(ww_cfg_card, "Send model", _model_combo("none — use text keywords"), width=LW, - tooltip="Optional: a wakeword model that finishes recording and presses Enter (e.g. a 'send it' model).") + tooltip="Optional: a wakeword model that finishes recording and presses Enter (e.g. a ‘send it’ model).") self.ww_combo.connect("changed", self._ww_changed) - self.ww_mic_level = Gtk.LevelBar() - self.ww_mic_level.set_min_value(0); self.ww_mic_level.set_max_value(1) - _labeled(ww_card, "Input level", self.ww_mic_level, width=LW, - tooltip="Live microphone level — the bar should move when you speak.") - - self.ww_test_btn = Gtk.Button(label="Test wakeword"); self.ww_test_btn.set_halign(Gtk.Align.START) - self.ww_test_btn.connect("clicked", self._ww_test) - self.ww_test_lbl = Gtk.Label(label=""); self.ww_test_lbl.set_xalign(0.0) - test_box = Gtk.Box(spacing=10) - test_box.pack_start(self.ww_test_btn, False, False, 0) - test_box.pack_start(self.ww_test_lbl, False, False, 0) - _labeled(ww_card, "", test_box, width=LW) - - self.ww_silence = _labeled(ww_card, "Silence to stop (s)", _entry(str(self.cfg.wakeword_silence_seconds)), width=LW, - tooltip="After the wakeword fires, stop recording this many seconds after you stop speaking. Default 2.0.") - self.cancel_keywords, self.ww_key_cancel = self._kw_shortcut_row( - ww_card, "Cancel words", ", ".join(self.cfg.cancel_keywords), - "abbrechen, cancel", self.cfg.key_cancel, - tooltip_kw="Say one of these at the start or end of a clip to DISCARD it — nothing is typed. Empty = off.", - tooltip_key="Keyboard shortcut that cancels dictation (works even during wakeword recording).", - width=LW) - self.send_keywords, self.ww_key_send = self._kw_shortcut_row( - ww_card, "Send words", ", ".join(self.cfg.send_keywords), - "computer send, computer abschicken", self.cfg.key_send, - tooltip_kw="Say one of these to type AND press Enter (spoken ‘submit’). Use a distinctive multi-word phrase. Empty = off.", - tooltip_key="Keyboard shortcut that stops recording and pastes with Enter.", - width=LW) - self._ww_load_idx(active_idx) # ── Wakeword sound cues ─────────────────────────────────────────────── @@ -1870,17 +1942,7 @@ notebook.bt-nb tab:checked label { self.ww_snd_done = self._sound_field(ww_snd_card, "Speech captured", self.cfg.wakeword_sound_done, "Plays when your spoken command is captured and recording stops.", width=LW) - # ── Manual dictation sounds card ────────────────────────────────────── - snd_card = _card_section(page, "Audio cues (keyboard / hotkey dictation)", icon="audio-speakers-symbolic") - self.snd_enabled = Gtk.Switch(); self.snd_enabled.set_active(self.cfg.sounds_enabled) - _switch_row(snd_card, "Play audio cues", self.snd_enabled, width=LW, - description="On/off for the manual start/stop chimes below. Does not affect wakeword sounds.") - self.snd_before = self._sound_field(snd_card, "Start sound", self.cfg.sound_before, - "Plays when manual recording starts.", width=LW) - self.snd_after = self._sound_field(snd_card, "Stop sound", self.cfg.sound_after, - "Plays when manual recording stops (paste, paste+Enter, or auto-stop).", width=LW) - - # Start meter if not already running (covers the case where Input is + # Start meter if not already running (covers the case where Wakeword is # visited before General, which is where _start_meter() normally fires). if self._meter is None: self._start_meter() @@ -2226,7 +2288,8 @@ notebook.bt-nb tab:checked label { threading.Thread(target=work, daemon=True).start() # ===== Benchmark ======================================================== - def _build_benchmark(self, page: Gtk.Box) -> None: + # ===== Benchmark — STT ================================================== + def _build_benchmark_stt(self, page: Gtk.Box) -> None: _infobox(page, "Compare your speech-to-text engines. Pick a recording (.wav) and a " "text file (.txt) containing exactly what is said, then press Run " "benchmark. The table shows how fast each engine is and how accurate, " @@ -2440,8 +2503,8 @@ notebook.bt-nb tab:checked label { paned.set_size_request(-1, 320) # never collapse below 320px total page.pack_start(paned, True, True, 4) - # --- Wakeword benchmark --------------------------------------------- - page.pack_start(Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL), False, False, 10) + # ===== Benchmark — Wakeword ============================================= + def _build_benchmark_ww(self, page: Gtk.Box) -> None: _infobox(page, "Stress-test the wakeword. It synthesizes short sentences with your " "wakeword spoken in random voices (plus filler with none), streams them to " "your wyoming-openwakeword server, and reports how reliably it fires " @@ -2494,7 +2557,7 @@ notebook.bt-nb tab:checked label { self._wwb_checks[e.name] = cb eng_row.pack_start(cb, False, False, 0) if not self.cfg.wakeword_engines: - eng_row.pack_start(Gtk.Label(label="(no wakeword engines configured — add one in the Input tab)"), + eng_row.pack_start(Gtk.Label(label="(no wakeword engines configured — add one in the Wakeword page)"), False, False, 0) ctrl.pack_start(eng_row, False, False, 0) @@ -2836,7 +2899,7 @@ notebook.bt-nb tab:checked label { self.wwb_store.clear() if not runs: self.wwb_summary.set_markup( - 'No engines configured — add one in the Input tab.') + 'No engines configured — add one in the Wakeword page.') return False 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}