feat(2.03.32): replace Notebook with sidebar+Stack, split Input and Benchmark pages
Settings dialog navigation redesigned — Gtk.Notebook replaced with a 170px left sidebar (section headers + flat nav buttons) + Gtk.Stack content area (860×700 default). Lazy-loading preserved. Input tab split into Keyboard (hotkeys, quality gate, audio cues) and Wakeword (engine config, cancel/send words, wakeword sound cues). Benchmark tab split into Benchmark — STT and Benchmark — Wakeword. All field names and collect logic unchanged. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
af34360caa
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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'<span size="small" weight="bold" alpha="65%">{GLib.markup_escape_text(label.upper())}</span>')
|
||||
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(
|
||||
'<span foreground="#ff3b30">No engines configured — add one in the Input tab.</span>')
|
||||
'<span foreground="#ff3b30">No engines configured — add one in the Wakeword page.</span>')
|
||||
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}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user