24 KiB
Win Dictation — Developer Task List
A prioritised, junior-friendly backlog to finish polishing the app. Each task says what, which files, steps, the code (inline, or a pointer to the verbatim block in a companion doc), and how to know it's done.
Companion docs (full code lives here — don't retype, copy from them):
ARCHITECTURE-AND-DEVGUIDE.md— Part B1 (chrome removal), Part B2 (progress + ETA). Verbatim code.MODERN-UI-AND-FIXES.md— the modern UI design + GDI+ helpers.FINDINGS-FIXES-TESTS.md— the test harness (tests/test_core.cpp).
Baseline (already done — do NOT redo): batch record-then-transcribe; crash fixes (null-context guard, g_modelOk, g_initializing, m_cfg_mtx); append behaviour (text_util.h); no-hide + safe auto-paste; Clear button; run_inference/transcribe_sync refactor; modern dark UI (GDI+, rounded panel, owner-draw buttons, custom VU, dark caption); resizable window; test-core CMake target.
Conventions: Effort = XS (<30 min) · S (≤2 h) · M (half-day) · L (1–2 days). Do phases in order; tasks within a phase are mostly independent unless "Depends on" says otherwise. After every task: cmake --build build --config Release must succeed with zero new warnings, and the app must still launch.
Color tokens (already in main.cpp, reuse — never hard-code hex elsewhere): C_BG #0F1115, C_SURFACE #181B22, C_SURFACEHI #20242D, C_BORDER #262B36, C_TEXT #E7E9EE, C_TEXTDIM #9AA0AB, C_ACCENT #6E8BFF, C_DANGER #FF5C5C, C_GOOD #46D39A.
Phase 0 — Repo hygiene (do first; clears traps)
Task 0.1 — Delete the stale src/CMakeLists.txt
- Goal: Remove a build file that references removed APIs (
init(),is_using_gpu()) and atest-audiotarget the real build ignores. The authoritative build is the rootCMakeLists.txt. - Files:
src/CMakeLists.txt(delete). - Steps: Confirm
build.ps1configures from repo root (-S $RepoRoot). It does. Deletesrc/CMakeLists.txt. - Done when: Clean build from root still works; no other file
add_subdirectory(src). - Effort: XS
Task 0.2 — Remove the broken src/test-audio.cpp (replaced in Phase 4)
- Goal:
src/test-audio.cppcallsm_transcriber.init(...)/is_using_gpu()which no longer exist — it cannot compile. It's superseded bytests/test_core.cpp(Task 4.1). - Files:
src/test-audio.cpp(delete),src/record-test-audio.ps1(keep — still useful for capturing WAVs),src/TESTING.md(mark superseded in Task 4.4). - Done when: No target references
test-audio.cpp. - Effort: XS
Task 0.3 — Create the tests/ folder + placeholder
- Goal: The root
CMakeLists.txtalready declaresadd_executable(test-core tests/test_core.cpp ...), but the file doesn't exist yet → configure fails if anyone buildstest-core. - Steps: Create
tests/and addtests/test_core.cpp(full content in Task 4.1). Until then, thetest-coretarget can stay; just don't build it. - Done when:
tests/test_core.cppexists andcmake --build build --target test-corecompiles (after Task 4.1). - Effort: XS · Depends on: 4.1 for real content
Phase 1 — Visual polish & assets
Task 1.1 — App icon asset (custom)
- Goal: Replace the placeholder icon with a clean, modern app icon used for the window, taskbar, and tray.
- Files:
assets/icon-source.png(new, 1024×1024),src/icon.ico(generated),src/convert_icon.py(fix paths),src/win-dictation.rc(already references101 ICON "icon.ico"). - Design spec: Flat, minimal. A single rounded microphone glyph, centered, on a dark charcoal rounded-square (
#15171C). Mic filled with the indigo accent (#6E8BFF), subtle top-down gradient to#5B7BFF. No text. Must read clearly at 16×16. Keep ~12% padding around the glyph.- Ready-to-use generation prompt (AI image tool): "Minimalist modern app icon, a single simple microphone glyph centered on a dark charcoal rounded square, microphone filled indigo #6E8BFF with a soft vertical gradient, flat design, crisp clean edges, no text, high contrast, legible at small sizes, 1024×1024."
- Or design in Figma/Inkscape and export 1024×1024 PNG.
- Steps:
- Put the source PNG at
assets/icon-source.png. - Fix
convert_icon.pyto use real paths and multi-size output:from PIL import Image img = Image.open("assets/icon-source.png").convert("RGBA") img.save("src/icon.ico", format="ICO", sizes=[(256,256),(64,64),(48,48),(32,32),(16,16)]) print("wrote src/icon.ico") - Run it (
python src/convert_icon.pyfrom repo root). Confirmsrc/icon.icoexists. - Rebuild; the resource compiler picks up
src/icon.icovia the.rc.
- Put the source PNG at
- Done when: The new icon shows on the title bar, taskbar, Alt-Tab, and tray — sharp at all sizes.
- Effort: S
Task 1.2 — Tray icon reflects recording state (optional but nice)
- Goal: When recording (window may be hidden), the tray icon turns red so state is visible at a glance.
- Files:
assets/icon-rec-source.png(new),src/icon-rec.ico,src/win-dictation.rc(add102 ICON "icon-rec.ico"),src/main.cpp. - Steps:
- Create a red variant of the icon (mic in
#FF5C5C). Convert tosrc/icon-rec.ico(same sizes), add102 ICON "icon-rec.ico"to the.rc. - In
main.cpp, load both icons once:HICON g_icoIdle, g_icoRec;viaLoadIcon(hInst, MAKEINTRESOURCE(101/102)). - Add a helper
void SetTrayIcon(bool rec){ nid.uFlags = NIF_ICON; nid.hIcon = rec?g_icoRec:g_icoIdle; Shell_NotifyIcon(NIM_MODIFY,&nid); }. - Call
SetTrayIcon(true)when recording starts,SetTrayIcon(false)on stop/cancel/result.
- Create a red variant of the icon (mic in
- Done when: Start recording, hide the window — the tray icon is red; after transcription it returns to normal.
- Effort: S · Depends on: 1.1
Task 1.3 — Kill the button hairlines + focus rectangles (B1.1)
- Goal: Remove the thin light line around Pinned and the left/top lines on Copy/Paste/Clear.
- Files:
src/main.cpp; linkuxtheme.lib. - Steps (full code in
ARCHITECTURE-AND-DEVGUIDE.md§B1.1):- Add
#include <uxtheme.h>and#pragma comment(lib, "uxtheme.lib"). - After creating each owner-draw button (Record, Pin, Copy, Paste, Clear), call
SetWindowTheme(hBtn, L"", L"");(before/afterSetWindowSubclassis fine). - Add
WS_CLIPCHILDRENto the main window style inCreateWindowExW. - After all controls are created (end of the create block), call once:
SendMessageW(hMainWnd, WM_CHANGEUISTATE, MAKEWPARAM(UIS_SET, UISF_HIDEFOCUS), 0);
- Add
- Done when: No hairline around any button; tabbing between controls draws no dotted focus rect.
- Effort: S
Task 1.4 — Replace comboboxes with custom dropdowns (B1.2)
- Goal: Remove the native Windows dropdown button (the "second arrow") on the mic + model selectors.
- Files:
src/main.cpp. - Steps (full code —
DrawSelect,PopupProc,ShowSelectPopup,WM_APP_SELECT— inARCHITECTURE-AND-DEVGUIDE.md§B1.2):- Add label/selection state:
std::vector<std::wstring> g_audioItems; int g_audioSel=0;andg_modelItems/g_modelSel. Populate them inRefreshAudioDevices/RefreshModelList(keep populatingg_modelComboPathsin parallel). - Replace the two
COMBOBOXcreations withBS_OWNERDRAWbuttonsID_SEL_AUDIO/ID_SEL_MODEL; subclass withBtnProc;SetWindowTheme(.., L"", L""). - Add
#define WM_APP_SELECT (WM_USER + 5)and#define ID_SEL_AUDIO/ID_SEL_MODEL. - Paste
DrawSelect, route both IDs inWM_DRAWITEM. PastePopupProc+ShowSelectPopup. OnWM_COMMANDfor the two IDs callShowSelectPopup(...). HandleWM_APP_SELECTto apply the choice (setcapture_id/ reload model). - Delete the now-unused
DrawCombo,WM_MEASUREITEMcombo branch, andWM_CTLCOLORLISTBOX. - Update
LayoutControlsto positionID_SEL_AUDIO/ID_SEL_MODELwhere the combos were.
- Add label/selection state:
- Done when: Each selector shows exactly one (our) chevron, opens a dark rounded popup, hover highlights rows, selecting reloads the model / switches mic, and clicking elsewhere dismisses it.
- Effort: M · Depends on: 1.3 (shared
SetWindowTheme)
Phase 2 — Progress feedback & control
Task 2.1 — Transcription progress + ETA (B2)
- Goal: Replace the static "Transcribing…" with a moving progress bar + live status like "Transcribing 1:40 · 45% · ~9s left".
- Files:
src/transcriber.h,src/transcriber.cpp,src/main.cpp. - Steps (full code in
ARCHITECTURE-AND-DEVGUIDE.md§B2.1–§B2.3):transcriber.h: addset_progress_callback,audio_seconds(), privatem_on_progress,m_audio_seconds, and the statics_progresstrampoline.transcriber.cpp: implements_progress; inrun_inferencesetm_audio_seconds,wp.progress_callback = &Transcriber::s_progress; wp.progress_callback_user_data = this;.main.cpp: add#define WM_APP_PROGRESS (WM_USER + 6),std::atomic<int> g_progress{0};,DWORD g_busyStart;. Registerset_progress_callback([](int p){ PostMessage(hMainWnd, WM_APP_PROGRESS, p, 0); }).- Set
g_busyStart = GetTickCount(); g_progress = 0;right beforestop_and_transcribe(). - Handle
WM_APP_PROGRESS(store + invalidateg_vuRect). Replace theis_busy()branch ofUpdateStatuswith the ETA formatter. AddDrawProgressand, inWM_PAINT, draw the progress bar ing_vuRectwhile busy (VU otherwise). InWM_TIMER, also invalidateg_vuRectwhile busy so the bar/ETA tick.
- Edge cases:
progress < 3%→ show "Transcribing m:ss of audio…" (ETA not stable yet). Clampremain >= 0. - Done when: A 1–2 min clip shows a filling bar + percentage + shrinking ETA and completes; short clips still feel instant.
- Effort: M
Task 2.2 — Cancel a running transcription (B2.4)
- Goal: Let the user abort a long/incorrect transcription instead of waiting it out.
- Files:
src/transcriber.h/.cpp,src/main.cpp. - Steps:
transcriber.h: addvoid request_cancel(){ m_abort = true; }, privatestd::atomic<bool> m_abort{false};, statics_abort.transcriber.cpp: inrun_inference,m_abort = false;at the top, and setwp.abort_callback = &Transcriber::s_abort; wp.abort_callback_user_data = this;(skip if yourwhisper.hlacksabort_callback).main.cpp: at the very top of theHK_TOGGLEhandler addif (g_tx.is_busy()) { g_tx.request_cancel(); SetStatus(hWnd, L"Cancelling…"); break; }— so the Record button becomes "cancel" while busy.- In
WM_APP_RESULT, when the result is empty and a cancel was requested, show "Cancelled" instead of "No speech detected" (track ag_cancelRequestedflag, reset each Stop).
- Done when: Pressing the button (or hotkey) mid-transcription stops it within ~1 s and the status reads "Cancelled".
- Effort: S · Depends on: 2.1
Phase 3 — Robustness & persistence
Task 3.1 — Persist settings between launches
- Goal: Remember mic, model, pin state, auto-paste, auto-hide, and window position. Today everything resets each launch.
- Files:
src/settings.h(new, header-only),src/main.cpp. - Code (
src/settings.h):#pragma once #include <windows.h> #include <string> struct AppSettings { int captureId = 0; std::wstring modelFile; // e.g. L"models\\ggml-tiny.en.bin" ("" = auto) bool pinned = true, autoPaste = true, autoHide = false; int winX = CW_USEDEFAULT, winY = CW_USEDEFAULT, winW = 400, winH = 340; }; inline std::wstring SettingsPath() { wchar_t buf[MAX_PATH]; GetModuleFileNameW(nullptr, buf, MAX_PATH); std::wstring p(buf); p = p.substr(0, p.find_last_of(L"\\/")); return p + L"\\win-dictation.ini"; } inline int GetIni(const wchar_t* k, int d){ return GetPrivateProfileIntW(L"app", k, d, SettingsPath().c_str()); } inline void PutIni(const wchar_t* k, int v){ wchar_t b[32]; wsprintfW(b, L"%d", v); WritePrivateProfileStringW(L"app", k, b, SettingsPath().c_str()); } inline void LoadSettings(AppSettings& s){ s.captureId = GetIni(L"captureId", s.captureId); s.pinned = GetIni(L"pinned", s.pinned) != 0; s.autoPaste = GetIni(L"autoPaste", s.autoPaste) != 0; s.autoHide = GetIni(L"autoHide", s.autoHide) != 0; s.winX = GetIni(L"winX", s.winX); s.winY = GetIni(L"winY", s.winY); s.winW = GetIni(L"winW", s.winW); s.winH = GetIni(L"winH", s.winH); wchar_t m[MAX_PATH]; GetPrivateProfileStringW(L"app", L"modelFile", L"", m, MAX_PATH, SettingsPath().c_str()); s.modelFile = m; } inline void SaveSettings(const AppSettings& s){ PutIni(L"captureId", s.captureId); PutIni(L"pinned", s.pinned); PutIni(L"autoPaste", s.autoPaste); PutIni(L"autoHide", s.autoHide); PutIni(L"winX", s.winX); PutIni(L"winY", s.winY); PutIni(L"winW", s.winW); PutIni(L"winH", s.winH); WritePrivateProfileStringW(L"app", L"modelFile", s.modelFile.c_str(), SettingsPath().c_str()); } - Wiring (
main.cpp):- Add a global
AppSettings g_set;CallLoadSettings(g_set);before creating the window. - Apply: use
g_set.winX/Y/W/HinCreateWindowExW(validate on-screen; fall back toCW_USEDEFAULTif off all monitors). Setg_pinned = g_set.pinned,g_autoPaste = g_set.autoPaste,g_autoHide = g_set.autoHide,g_config.capture_id = g_set.captureId. Ifg_set.modelFileis non-empty and the file exists, use it instead ofSelectOptimalModel. - Save on change: after toggling pin/auto-paste/auto-hide, after a model/mic change, and on
WM_EXITSIZEMOVE(window moved/resized → store rect) andWM_DESTROY(final save). Avoid PersistNow()that copies the live globals intog_setthenSaveSettings(g_set)keeps it DRY.
- Add a global
- Done when: Change mic/model, move/resize the window, toggle pin, quit, relaunch → everything is restored. Deleting the
.inirestores defaults. - Effort: M
Task 3.2 — Quick toggles in the tray menu
- Goal: Expose Auto-paste, Always-on-top, and Auto-hide without building a settings panel.
- Files:
src/main.cpp(ShowContextMenu,WM_COMMAND). - Steps: Add checkable items to the tray popup (
MF_STRING | (flag?MF_CHECKED:0)) with new IDs (ID_TRAY_AUTOPASTE,ID_TRAY_TOPMOST,ID_TRAY_AUTOHIDE). InWM_COMMAND, flip the matching global, apply (for top-most callSetWindowPos(... HWND_TOPMOST/NOTOPMOST ...)), thenPersistNow(). - Done when: Right-click tray → toggles show check state, take effect immediately, and survive a relaunch.
- Effort: S · Depends on: 3.1
Task 3.3 — Bound the recording length
- Goal: A forgotten recording shouldn't grow memory without limit (~1.9 MB/30 s today, uncapped).
- Files:
src/transcriber.h/.cpp,src/main.cpp. - Steps:
transcriber.h: addfloat recorded_seconds() const;returningm_capturesize /WHISPER_SAMPLE_RATEunderm_capture_mtx(or maintain an atomic sample counter incremented inon_audio).main.cppWM_TIMER(recording branch): ifg_tx.recorded_seconds() >= kMaxRecordSeconds(e.g. 600), auto-stop by posting the same path as a manual Stop, and set status "Max length reached — transcribing".
- Done when: Recording auto-stops at the cap and transcribes what was captured; normal short clips unaffected.
- Effort: S
Task 3.4 — Surface hotkey-registration failures + make hotkeys configurable
- Goal: Today
RegisterHotKeyreturn values are ignored — if another app ownsCtrl+Shift+Space, the hotkey silently dies. Also allow remapping. - Files:
src/main.cpp,src/settings.h. - Steps:
- Capture the return of both
RegisterHotKeycalls. If either fails, show a non-blocking status ("Hotkey in use — set another in win-dictation.ini") and still allow the on-screen Record button to work. - Read modifiers + key from the INI (
hkMods,hkVk, defaulting toMOD_CONTROL|MOD_SHIFT+VK_SPACE/'H'); register those. (Full remap UI is a Phase 5 stretch — INI is enough now.)
- Capture the return of both
- Done when: With a conflicting global hotkey registered by another app, the app launches, warns, and the button still records; editing the INI changes the hotkey.
- Effort: S · Depends on: 3.1
Task 3.5 — Lightweight logging
- Goal: One small log file so field issues are diagnosable without a debugger.
- Files:
src/logging.h(new),src/main.cpp,src/transcriber.cpp. - Code (
src/logging.h):#pragma once #include <windows.h> #include <cstdio> #include <string> inline void LogLine(const char* msg) { wchar_t buf[MAX_PATH]; GetModuleFileNameW(nullptr, buf, MAX_PATH); std::wstring p(buf); p = p.substr(0, p.find_last_of(L"\\/")) + L"\\win-dictation.log"; FILE* f = _wfopen(p.c_str(), L"a"); if (!f) return; SYSTEMTIME t; GetLocalTime(&t); fprintf(f, "%04d-%02d-%02d %02d:%02d:%02d %s\n", t.wYear,t.wMonth,t.wDay,t.wHour,t.wMinute,t.wSecond, msg); fclose(f); } - Log at minimum: startup (model path, threads, GPU on/off), model load success/failure, mic-open failure, and each transcription (audio seconds, elapsed ms, output char count). Keep messages one line, ASCII.
- Optional: if the file exceeds ~1 MB at startup, rename to
.log.1(simple 1-file rotation). - Done when:
win-dictation.logappears next to the exe and records a startup line + one line per transcription. - Effort: S
Phase 4 — Tests, docs & packaging
Task 4.1 — Create tests/test_core.cpp
- Goal: Headless regression tests that catch the bugs we already fixed and lock in progress reporting.
- Files:
tests/test_core.cpp(new — full content inFINDINGS-FIXES-TESTS.md§4.2). - Steps:
- Copy the test program from
FINDINGS-FIXES-TESTS.md§4.2 intotests/test_core.cpp. - Add the progress assertion from
ARCHITECTURE-AND-DEVGUIDE.md§B3 (max progress ≥95, monotonic). - Build + run:
cmake --build build --config Release --target test-core cd build\bin\Release copy ..\..\..\deps\SDL2-2.28.5\lib\x64\SDL2.dll . # if missing .\test-core.exe models\ggml-tiny.en.bin ..\..\..\samples\jfk.wav
- Copy the test program from
- Tests covered: append rule; bad model path → no crash + empty; real clip → contains "country"; short audio → no crash; progress reaches ~100% and is non-decreasing.
- Done when:
test-core.exeprints "ALL TESTS PASSED" and exits 0. - Effort: S · Depends on: 2.1 (for the progress test)
Task 4.2 — (Optional) CI workflow
- Goal: Run the build +
test-coreon every push. - Files:
.github/workflows/build.yml(new). - Steps: Windows runner → configure CMake (CPU-only) → build
win-dictation+test-core→ downloadggml-tiny.en.bin→ runtest-corewithjfk.wav. Fail the job on non-zero exit. - Done when: A pushed branch shows a green check that actually ran the tests.
- Effort: M · Depends on: 4.1
Task 4.3 — Desktop / Start-Menu shortcut on install
- Goal: One-click launch (the user already pins to taskbar; a shortcut makes first run easy).
- Files:
src/package.ps1(extend) or a newinstall-shortcut.ps1. - Steps: After packaging, create a
.lnkviaWScript.ShellwithTargetPath= the exe andWorkingDirectory= its folder (somodels\resolves even though we also useexe_dir()),IconLocation= the exe. (Snippet inMODERN-UI-AND-FIXES.md§9 / spec doc §9.) - Done when: Running the script creates a working Desktop shortcut that launches the app with the icon.
- Effort: S
Task 4.4 — Fix the documentation (it describes the OLD app)
- Goal:
README.md,src/README.md,src/CHANGES.md,src/CUDA-SETUP.md,src/QUICK-REBUILD-GPU.md,src/TESTING.md,src/DESIGN.md,src/FIXES-APPLIED.mdstill describe the streaming / ring-buffer / 24-thread / Ctrl+Shift+R design and RTX-3090 benchmarks — all now wrong/misleading. - Steps:
- Rewrite the top-level
README.mdto describe the current app: push-to-talk batch transcription,Ctrl+Shift+Spaceto record/stop,Ctrl+Shift+Hto hide, tray, always-on-top, copy + auto-paste, model/mic selectors, CPU-tuned (physical-core threads, tiny.en default). Remove ring-buffer/VAD/24-thread/streaming claims and the RTX benchmarks (or move GPU notes to an "optional" aside). - Update
build.ps1end-of-run messages ("Hotkey: Ctrl+Shift+R", "Model: base.en") to match reality. - Mark
CHANGES.md,FIXES-APPLIED.md,TESTING.md,QUICK-REBUILD-GPU.md,CUDA-SETUP.mdas historical/superseded (a one-line banner at top), or fold the still-true bits into the README and delete the rest. KeepDESIGN.mdonly if updated to the batch architecture.
- Rewrite the top-level
- Done when: A new reader following
README.mdgets accurate hotkeys, model behaviour, and build steps; no doc claims a ring buffer or 24 threads. - Effort: M
Phase 5 — Stretch features (nice-to-have)
Task 5.1 — Hold-to-talk mode
- Goal: Option to record only while a key is held (vs. toggle).
- Files:
src/main.cpp,src/settings.h. - Steps: Add a low-level keyboard hook (
SetWindowsHookEx(WH_KEYBOARD_LL, ...)); on key-down of the chosen key start recording, on key-upstop_and_transcribe; debounce auto-repeat with a flag. Gate behind aholdToTalkINI setting; keep toggle as default. (Outline in the original spec doc §7.) - Done when: With the setting on, holding the key records and releasing transcribes; toggle mode still available.
- Effort: M
Task 5.2 — Settings panel (graduate from tray toggles + INI)
- Goal: A small in-app settings popup (reuse the custom popup window from Task 1.4) for mic, model, auto-paste, auto-hide, hold-to-talk, and hotkey capture.
- Done when: All settings are editable in-app and persist (Task 3.1).
- Effort: L · Depends on: 1.4, 3.1
Task 5.3 — Export / save transcript
- Goal: Save the transcript box to a
.txt(and timestamped filename) from the tray menu or a button. - Effort: S
Task 5.4 — Multi-language support
- Goal: Allow non-English models + a language selector (currently hard-wired
en). Swap to a multilingual model (ggml-base.bin) and setWhisperConfig.languagefrom a selector. - Effort: M · Depends on: 1.4 (selector), 3.1
Definition of Done (per task)
- Builds clean (
cmake --build build --config Release), zero new warnings. - App launches, records, transcribes, appends, copies/pastes — no regressions.
test-core.exeexits 0 (after Phase 4).- Any new setting persists across relaunch (after Phase 3).
- Change is reflected in
README.mdif user-facing.
Suggested order (fastest path to "feels finished")
- 0.1–0.3 (hygiene) → 1.3 (hairlines, 5-min win) → 2.1 (progress — biggest UX gain).
- 1.1 (icon) → 1.4 (custom dropdowns) → 1.2 (tray state) → 2.2 (cancel).
- 3.1 (persistence) → 3.2 (tray toggles) → 3.4 (hotkey safety) → 3.3 (length cap) → 3.5 (logging).
- 4.1 (tests) → 4.4 (docs) → 4.3 (shortcut) → 4.2 (CI).
- Stretch (5.x) as desired.
Verification matrix (final smoke test)
| Area | Check |
|---|---|
| Crash-free | Record/stop 10× incl. a 2-min clip; window never vanishes; process stable |
| Progress | 2-min clip shows filling bar + % + shrinking ETA; cancel works |
| Chrome | No hairlines/focus rects; selectors have one chevron + dark popup |
| Paste | Hotkey-from-another-app pastes the latest utterance; button = copy only |
| Persistence | mic/model/pin/auto-paste/window pos restored after relaunch |
| Assets | New icon crisp in title bar, taskbar, Alt-Tab, tray; red tray icon while recording |
| Robustness | Missing model → clear message (no crash); hotkey conflict → warned; long record auto-stops |
| Tests/docs | test-core green; README matches actual hotkeys/behaviour |