v2 rebuild: GDI+ single-surface UI, self-calibrating progress, compact push-to-talk, GGML+Whisper integration

This commit is contained in:
Win Dictation Dev
2026-06-11 15:30:19 +12:00
parent 81b3d0073e
commit 1f67c07a77
28 changed files with 11856 additions and 1948 deletions
+321
View File
@@ -0,0 +1,321 @@
# 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 (12 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 a `test-audio` target the real build ignores. The authoritative build is the **root** `CMakeLists.txt`.
- **Files:** `src/CMakeLists.txt` (delete).
- **Steps:** Confirm `build.ps1` configures from repo root (`-S $RepoRoot`). It does. Delete `src/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.cpp` calls `m_transcriber.init(...)` / `is_using_gpu()` which no longer exist — it cannot compile. It's superseded by `tests/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.txt` already declares `add_executable(test-core tests/test_core.cpp ...)`, but the file doesn't exist yet → configure fails if anyone builds `test-core`.
- **Steps:** Create `tests/` and add `tests/test_core.cpp` (full content in Task 4.1). Until then, the `test-core` target can stay; just don't build it.
- **Done when:** `tests/test_core.cpp` exists and `cmake --build build --target test-core` compiles (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 references `101 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:**
1. Put the source PNG at `assets/icon-source.png`.
2. Fix `convert_icon.py` to use real paths and multi-size output:
```python
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")
```
3. Run it (`python src/convert_icon.py` from repo root). Confirm `src/icon.ico` exists.
4. Rebuild; the resource compiler picks up `src/icon.ico` via the `.rc`.
- **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` (add `102 ICON "icon-rec.ico"`), `src/main.cpp`.
- **Steps:**
1. Create a red variant of the icon (mic in `#FF5C5C`). Convert to `src/icon-rec.ico` (same sizes), add `102 ICON "icon-rec.ico"` to the `.rc`.
2. In `main.cpp`, load both icons once: `HICON g_icoIdle, g_icoRec;` via `LoadIcon(hInst, MAKEINTRESOURCE(101/102))`.
3. Add a helper `void SetTrayIcon(bool rec){ nid.uFlags = NIF_ICON; nid.hIcon = rec?g_icoRec:g_icoIdle; Shell_NotifyIcon(NIM_MODIFY,&nid); }`.
4. Call `SetTrayIcon(true)` when recording starts, `SetTrayIcon(false)` on stop/cancel/result.
- **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`; link `uxtheme.lib`.
- **Steps (full code in `ARCHITECTURE-AND-DEVGUIDE.md` §B1.1):**
1. Add `#include <uxtheme.h>` and `#pragma comment(lib, "uxtheme.lib")`.
2. After creating each owner-draw button (Record, Pin, Copy, Paste, Clear), call `SetWindowTheme(hBtn, L"", L"");` (before/after `SetWindowSubclass` is fine).
3. Add `WS_CLIPCHILDREN` to the main window style in `CreateWindowExW`.
4. After all controls are created (end of the create block), call once:
`SendMessageW(hMainWnd, WM_CHANGEUISTATE, MAKEWPARAM(UIS_SET, UISF_HIDEFOCUS), 0);`
- **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` — in `ARCHITECTURE-AND-DEVGUIDE.md` §B1.2):**
1. Add label/selection state: `std::vector<std::wstring> g_audioItems; int g_audioSel=0;` and `g_modelItems`/`g_modelSel`. Populate them in `RefreshAudioDevices`/`RefreshModelList` (keep populating `g_modelComboPaths` in parallel).
2. Replace the two `COMBOBOX` creations with `BS_OWNERDRAW` buttons `ID_SEL_AUDIO`/`ID_SEL_MODEL`; subclass with `BtnProc`; `SetWindowTheme(.., L"", L"")`.
3. Add `#define WM_APP_SELECT (WM_USER + 5)` and `#define ID_SEL_AUDIO/ID_SEL_MODEL`.
4. Paste `DrawSelect`, route both IDs in `WM_DRAWITEM`. Paste `PopupProc` + `ShowSelectPopup`. On `WM_COMMAND` for the two IDs call `ShowSelectPopup(...)`. Handle `WM_APP_SELECT` to apply the choice (set `capture_id` / reload model).
5. Delete the now-unused `DrawCombo`, `WM_MEASUREITEM` combo branch, and `WM_CTLCOLORLISTBOX`.
6. Update `LayoutControls` to position `ID_SEL_AUDIO`/`ID_SEL_MODEL` where the combos were.
- **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):**
1. `transcriber.h`: add `set_progress_callback`, `audio_seconds()`, private `m_on_progress`, `m_audio_seconds`, and the static `s_progress` trampoline.
2. `transcriber.cpp`: implement `s_progress`; in `run_inference` set `m_audio_seconds`, `wp.progress_callback = &Transcriber::s_progress; wp.progress_callback_user_data = this;`.
3. `main.cpp`: add `#define WM_APP_PROGRESS (WM_USER + 6)`, `std::atomic<int> g_progress{0};`, `DWORD g_busyStart;`. Register `set_progress_callback([](int p){ PostMessage(hMainWnd, WM_APP_PROGRESS, p, 0); })`.
4. Set `g_busyStart = GetTickCount(); g_progress = 0;` right before `stop_and_transcribe()`.
5. Handle `WM_APP_PROGRESS` (store + invalidate `g_vuRect`). Replace the `is_busy()` branch of `UpdateStatus` with the ETA formatter. Add `DrawProgress` and, in `WM_PAINT`, draw the progress bar in `g_vuRect` while busy (VU otherwise). In `WM_TIMER`, also invalidate `g_vuRect` while busy so the bar/ETA tick.
- **Edge cases:** `progress < 3%` → show "Transcribing m:ss of audio…" (ETA not stable yet). Clamp `remain >= 0`.
- **Done when:** A 12 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:**
1. `transcriber.h`: add `void request_cancel(){ m_abort = true; }`, private `std::atomic<bool> m_abort{false};`, static `s_abort`.
2. `transcriber.cpp`: in `run_inference`, `m_abort = false;` at the top, and set `wp.abort_callback = &Transcriber::s_abort; wp.abort_callback_user_data = this;` (skip if your `whisper.h` lacks `abort_callback`).
3. `main.cpp`: at the very top of the `HK_TOGGLE` handler add `if (g_tx.is_busy()) { g_tx.request_cancel(); SetStatus(hWnd, L"Cancelling…"); break; }` — so the Record button becomes "cancel" while busy.
4. In `WM_APP_RESULT`, when the result is empty *and* a cancel was requested, show "Cancelled" instead of "No speech detected" (track a `g_cancelRequested` flag, 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`):**
```cpp
#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`):**
1. Add a global `AppSettings g_set;` Call `LoadSettings(g_set);` **before** creating the window.
2. Apply: use `g_set.winX/Y/W/H` in `CreateWindowExW` (validate on-screen; fall back to `CW_USEDEFAULT` if off all monitors). Set `g_pinned = g_set.pinned`, `g_autoPaste = g_set.autoPaste`, `g_autoHide = g_set.autoHide`, `g_config.capture_id = g_set.captureId`. If `g_set.modelFile` is non-empty and the file exists, use it instead of `SelectOptimalModel`.
3. Save on change: after toggling pin/auto-paste/auto-hide, after a model/mic change, and on `WM_EXITSIZEMOVE` (window moved/resized → store rect) and `WM_DESTROY` (final save). A `void PersistNow()` that copies the live globals into `g_set` then `SaveSettings(g_set)` keeps it DRY.
- **Done when:** Change mic/model, move/resize the window, toggle pin, quit, relaunch → everything is restored. Deleting the `.ini` restores 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`). In `WM_COMMAND`, flip the matching global, apply (for top-most call `SetWindowPos(... HWND_TOPMOST/NOTOPMOST ...)`), then `PersistNow()`.
- **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:**
1. `transcriber.h`: add `float recorded_seconds() const;` returning `m_capture` size / `WHISPER_SAMPLE_RATE` under `m_capture_mtx` (or maintain an atomic sample counter incremented in `on_audio`).
2. `main.cpp` `WM_TIMER` (recording branch): if `g_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 `RegisterHotKey` return values are ignored — if another app owns `Ctrl+Shift+Space`, the hotkey silently dies. Also allow remapping.
- **Files:** `src/main.cpp`, `src/settings.h`.
- **Steps:**
1. Capture the return of both `RegisterHotKey` calls. 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.
2. Read modifiers + key from the INI (`hkMods`, `hkVk`, defaulting to `MOD_CONTROL|MOD_SHIFT` + `VK_SPACE` / `'H'`); register those. (Full remap UI is a Phase 5 stretch — INI is enough now.)
- **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`):**
```cpp
#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.log` appears 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 in `FINDINGS-FIXES-TESTS.md` §4.2**).
- **Steps:**
1. Copy the test program from `FINDINGS-FIXES-TESTS.md` §4.2 into `tests/test_core.cpp`.
2. Add the progress assertion from `ARCHITECTURE-AND-DEVGUIDE.md` §B3 (max progress ≥95, monotonic).
3. Build + run:
```powershell
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
```
- **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.exe` prints "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-core` on every push.
- **Files:** `.github/workflows/build.yml` (new).
- **Steps:** Windows runner → configure CMake (CPU-only) → build `win-dictation` + `test-core` → download `ggml-tiny.en.bin` → run `test-core` with `jfk.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 new `install-shortcut.ps1`.
- **Steps:** After packaging, create a `.lnk` via `WScript.Shell` with `TargetPath` = the exe and `WorkingDirectory` = its folder (so `models\` resolves even though we also use `exe_dir()`), `IconLocation` = the exe. (Snippet in `MODERN-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.md` still describe the **streaming / ring-buffer / 24-thread / Ctrl+Shift+R** design and RTX-3090 benchmarks — all now wrong/misleading.
- **Steps:**
1. Rewrite the top-level `README.md` to describe the **current** app: push-to-talk batch transcription, `Ctrl+Shift+Space` to record/stop, `Ctrl+Shift+H` to 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).
2. Update `build.ps1` end-of-run messages ("Hotkey: Ctrl+Shift+R", "Model: base.en") to match reality.
3. Mark `CHANGES.md`, `FIXES-APPLIED.md`, `TESTING.md`, `QUICK-REBUILD-GPU.md`, `CUDA-SETUP.md` as **historical/superseded** (a one-line banner at top), or fold the still-true bits into the README and delete the rest. Keep `DESIGN.md` only if updated to the batch architecture.
- **Done when:** A new reader following `README.md` gets 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-up `stop_and_transcribe`; debounce auto-repeat with a flag. Gate behind a `holdToTalk` INI 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 set `WhisperConfig.language` from 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.exe` exits 0 (after Phase 4).
- [ ] Any new setting persists across relaunch (after Phase 3).
- [ ] Change is reflected in `README.md` if user-facing.
## Suggested order (fastest path to "feels finished")
1. **0.10.3** (hygiene) → **1.3** (hairlines, 5-min win) → **2.1** (progress — biggest UX gain).
2. **1.1** (icon) → **1.4** (custom dropdowns) → **1.2** (tray state) → **2.2** (cancel).
3. **3.1** (persistence) → **3.2** (tray toggles) → **3.4** (hotkey safety) → **3.3** (length cap) → **3.5** (logging).
4. **4.1** (tests) → **4.4** (docs) → **4.3** (shortcut) → **4.2** (CI).
5. 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 |