Docs refresh: add user manual & architecture review; update README with accurate details

This commit is contained in:
Win Dictation Dev
2026-06-11 20:52:24 +12:00
parent e04b003bf8
commit a94ba824d6
19 changed files with 1609 additions and 466 deletions
+425
View File
@@ -0,0 +1,425 @@
# Win Dictation — Findings, Fixes & Test Guide
A consolidated record of everything diagnosed and changed while turning the slow,
crashing build into a fast, stable push-to-talk dictation tool. Four parts:
1. **Findings** — what was actually wrong, in the order we discovered it.
2. **Fixes required** — the concrete code changes, by file.
3. **Reasoning** — why each fix is correct (the non-obvious calls).
4. **How to build & run tests** — making the core testable, plus regression tests for every bug here.
Target machine: Dell, Intel Core i5 7th-gen (2 cores / 4 threads), 16 GB RAM, CPU-only.
Stack: native C++ / Win32 + SDL2 + whisper.cpp (`examples/win-dictation`).
---
## Part 1 — Findings
### F1. The original app was slow because of its *architecture*, not its model
The first build used whisper.cpp's live-streaming pattern: it re-ran `whisper_full` on a
rolling 56 second window every ~0.4 s (`length_ms` / `step_ms` in `worker_loop`). To keep
up, the CPU must transcribe ~6 s of audio in well under 1 s — i.e. **>6× real-time**.
- The README's "1015× real-time" numbers were measured on a **24-thread** box.
- A 2-core i5-7th-gen does tiny.en at roughly **24× real-time**.
So each 6 s window took ~1.52.5 s while a new one was demanded every 0.4 s → the buffer
backed up without bound, audio was re-transcribed in overlapping chunks (duplicated/garbled
text), the CPU pegged, and 4 Whisper threads on 2 physical cores starved the UI. tiny.en was
already the right model — **the streaming design was the bottleneck.**
**Conclusion:** for dictation (not live captioning), switch to **batch / push-to-talk**:
buffer audio cheaply while recording, run `whisper_full` exactly **once** on Stop.
### F2. Build 1 crashed on Stop — null Whisper context
After the batch rewrite, the app recorded fine (VU moved) but crashed the instant you hit
Stop. Two defects combined:
1. The preload thread set `g_modelLoaded = true` **even when `preload()` returned false**
(model file not found), so the UI showed "Ready" and let you record with no model.
2. `transcribe_worker()` called `whisper_reset_timings(m_ctx)` **before** the `if (m_ctx)`
check. With `m_ctx == nullptr` that's an access violation. Recording never touches Whisper
— which is exactly why capture/VU worked and only Stop crashed.
The empty model dropdown in the first screenshot was the tell: no model files were found at
the new exe-relative path, so `preload()` failed silently.
### F3. Build 1 — window wouldn't resize, and looked boxy
- The style was `WS_POPUP | WS_CAPTION | WS_SYSMENU`**no sizing border** — and there was
**no `WM_SIZE` handler**, so controls never reflowed. Frozen at ~360×200, Copy/Paste cut off.
- The gray 3-D system buttons + chunky title bar were the "boxy Windows" look the brief
wanted to avoid.
### F4. Build 2 — startup data race (found and fixed by the user)
`RefreshModelList()` calls `CB_SETCURSEL`, which fires `CBN_SELCHANGE`. That spawned a
`reload()` background thread that raced the initial `preload()` thread — both writing
`m_cfg` (a `std::string`) and `m_ctx` at once. The corrupted context pointer made
`whisper_full` crash ~15 s into processing. Fixed with a `g_initializing` guard (skip the
combo handler during startup) and an `m_cfg_mtx` mutex around `m_cfg`/`m_ctx` writes.
### F5. Build 3 — the "crash after transcribing" was **not a crash**
Reported: "crashes after transcribing; relaunching the exe shows the last transcription, and
a second run replaced the first instead of appending."
That behavior is **impossible for a crashed process** — a dead process can't remember the
last transcription (nothing is persisted to disk/registry), and a *fresh* launch couldn't
show it. The only explanation: **the original process never died.**
What actually happened:
1. On a successful result, `WM_APP_RESULT` ran `ShowWindow(hWnd, SW_HIDE)` in the auto-paste
branch → the window **vanished** (looked like a crash).
2. Double-clicking the `.exe` again hit the **single-instance guard**, which
`PostMessage(WM_APP_SHOW)` to the existing **hidden** window → it re-appeared, still holding
the last transcription.
3. `SetDlgItemTextW` **replaced** the edit text, so run #2 overwrote run #1.
Also: clicking the **Record button** (vs. the global hotkey) set `g_prevForeground` to the
dictation window *itself*, so it hid and "pasted" into its own read-only box — nothing landed
in a useful app.
10-second confirmation: when it "crashes," the tray icon is still present and
`win-dictation.exe` is still in Task Manager.
### F6. The test harness is stale and never built
`src/test-audio.cpp` calls `m_transcriber.init(...)` and `is_using_gpu()` — methods that the
batch rewrite **removed** (`transcriber.h` now exposes `preload`/`reload`, no `init`). So it
won't compile against the current code. And the **root** `CMakeLists.txt` (the one `build.ps1`
actually uses) defines only the `win-dictation` target — the `test-audio` target lives only in
the unused `src/CMakeLists.txt`. So there are currently **no working tests**. Part 4 fixes this.
---
## Part 2 — Fixes required
Status legend: ✅ done in current code · ⬜ still to apply.
### `transcriber.cpp` / `transcriber.h`
-**Batch architecture**`start_recording()` buffers audio cheaply; `stop_and_transcribe()`
runs `whisper_full` once on a worker thread; result delivered via `PostMessage` (F1).
-**Null-context guard** in `transcribe_worker``if (!m_ctx) { m_busy=false; if(m_on_result) m_on_result(""); return; }`,
and the `whisper_reset_timings` call removed (F2).
-**`reload()`** frees + re-inits under a mutex; **`m_cfg_mtx`** protects `m_cfg`/`m_ctx`;
**`threads()`** getter (F4 + thread-count display).
-**Refactor for testability** — extract the inference core so it can be called
synchronously from a test (see Part 4):
```cpp
// transcriber.h (public)
std::string transcribe_sync(std::vector<float> audio); // headless / tests
// transcriber.h (private)
std::string run_inference(std::vector<float>& audio);
```
```cpp
// transcriber.cpp
std::string Transcriber::run_inference(std::vector<float>& audio) {
if (!m_ctx) return "";
if (m_cfg.trim_silence) trim_silence(audio);
whisper_full_params wp = whisper_full_default_params(WHISPER_SAMPLING_GREEDY);
wp.print_progress = wp.print_realtime = wp.print_timestamps = false;
wp.no_timestamps = true; wp.translate = false;
wp.language = m_cfg.language.c_str();
wp.n_threads = m_cfg.n_threads;
wp.no_context = true; wp.suppress_blank = true; wp.suppress_nst = true;
wp.temperature = 0.0f;
std::string out;
if (whisper_full(m_ctx, wp, audio.data(), (int)audio.size()) == 0) {
int n = whisper_full_n_segments(m_ctx);
for (int i = 0; i < n; ++i) { const char* t = whisper_full_get_segment_text(m_ctx, i); if (t) out += t; }
out = clean_text(out);
}
return out;
}
void Transcriber::transcribe_worker(std::vector<float> audio) {
std::string out = run_inference(audio);
m_busy = false;
if (m_on_result) m_on_result(out);
}
std::string Transcriber::transcribe_sync(std::vector<float> audio) {
return run_inference(audio); // assumes preload() already succeeded
}
```
### `main.cpp`
- ✅ **Honest load state** — separate `g_modelOk` atomic; record is gated and shows
"Model not found: …\models\ggml-tiny.en.bin" instead of crashing (F2).
- ✅ **Resizable** — `WS_OVERLAPPEDWINDOW` (has `WS_THICKFRAME`), `WM_SIZE → LayoutControls`,
`WM_GETMINMAXINFO` min size (F3).
- ✅ **Startup race guard** — `g_initializing` skips the model-combo handler during startup (F4).
- ⬜ **`WM_APP_RESULT` rewrite** — the F5 fix: **don't hide**, **append** (not replace),
clipboard = latest utterance, paste only into a *different* valid window:
```cpp
case WM_APP_RESULT:
{
std::string* res = (std::string*)wParam;
if (res && !res->empty()) {
HWND hEdit = GetDlgItem(hWnd, ID_EDIT_TEXT);
int len = GetWindowTextLengthW(hEdit);
std::wstring cur;
if (len > 0) { cur.resize(len + 1); int g = GetWindowTextW(hEdit, &cur[0], len + 1); cur.resize(g); }
std::wstring combined = cur;
if (!combined.empty()) combined += L" ";
combined += to_w(*res);
SetWindowTextW(hEdit, combined.c_str());
SendMessageW(hEdit, EM_SETSEL, (WPARAM)combined.size(), (LPARAM)combined.size());
SendMessageW(hEdit, EM_SCROLLCARET, 0, 0);
UpdatePlaceholder(hWnd);
SetClipboardTextUtf8(hWnd, *res); // clipboard = just the latest utterance
bool pasted = false;
if (g_autoPaste && g_prevForeground &&
g_prevForeground != hWnd && IsWindow(g_prevForeground)) {
PasteIntoWindow(g_prevForeground);
pasted = true;
}
SetStatus(hWnd, pasted ? L"Pasted ✓" : L"Copied ✓");
} else {
SetStatus(hWnd, L"No speech detected");
}
delete res;
}
break;
```
- ⬜ **Clear button** — `ID_BTN_CLEAR` is handled in `WM_COMMAND` but no button is created in
the modern layout, so the (now appending) transcript can't be cleared. Create it owner-drawn,
place it in `LayoutControls`, and call `UpdatePlaceholder(hWnd)` after clearing.
- ⬜ **Optional `g_autoHide`** (default `false`) — only if you want the window to tuck away after
a successful paste into another app: `if (pasted && g_autoHide) ShowWindow(hWnd, SW_HIDE);`
### Modern UI (separate doc)
The flat dark restyle (GDI+ owner-drawn buttons, rounded panel, custom VU, dark caption +
rounded corners) is fully specified in **`MODERN-UI-AND-FIXES.md`**, Part 2.
---
## Part 3 — Reasoning
**Why batch beats streaming here (F1).** Streaming only wins if you need words *as you speak*
(live captioning). Dictation doesn't: you speak a sentence, then want the text. Batch removes
the real-time deadline entirely — Whisper processes each second of audio exactly once, at its
own pace, after you stop. That means: near-zero CPU while speaking (just buffering), predictable
"a few seconds after Stop" latency that **doesn't grow with clip length**, no overlap
re-processing, and better accuracy (full context, no chunk-boundary word splits). On 2 cores
this is the difference between unusable and snappy.
**Why threads = physical cores (2), not logical (4).** Whisper's matmuls are memory-bandwidth
bound. Two extra hyperthreads share the same execution ports and cache, so they add little
throughput while stealing cycles from the UI/audio threads (janky window, laggy VU). Two
threads leaves headroom for a responsive UI.
**Why the Stop crash was a null deref (F2), and why the guard is the right fix.** Recording is
pure SDL + a `std::vector` append — it never calls into Whisper, which is why only Stop crashed.
`whisper_reset_timings(nullptr)` dereferences the context. The guard makes the worker a no-op
when no model is loaded; the separate `g_modelOk` flag makes that state *visible* (a MessageBox
with the path) instead of letting you walk into it. Defensive + diagnostic.
**Why the "crash after transcribing" was actually a hide (F5) — the logic is conclusive.** A
crashed process loses all memory. There is no on-disk persistence in this app. Therefore a
re-launched process **cannot** display the previous transcription, and a second run **cannot**
replace text in "the same box." The observed behavior (prior text reappears; second replaces
first) is only possible if it's the *same* live process — which means it hid, and the
single-instance guard re-showed it. This is why "is the process still in Task Manager?" is the
decisive test, not "did the window disappear?"
**Why clipboard = latest utterance but the box appends.** Two different jobs. The on-screen box
is your running log (you want history). The clipboard is what you paste into another app — you
want *just what you last said*, not the whole session. Splitting them gives both.
**Why paste must exclude our own window.** Auto-paste replays Ctrl+V into the foreground window
captured before the popup. If you triggered recording by clicking the Record button, that
"previous" window is the dictation app itself — pasting into its read-only box is a no-op and
(with the old hide) made the app appear to swallow the text. Restricting to
`g_prevForeground != hWnd && IsWindow(...)` makes the global-hotkey workflow the reliable path
and the button a safe "copy only."
---
## Part 4 — How to build & run tests
The goal: catch the bugs above automatically, without a human watching a window. UI behavior
(resize, hide, paste) needs a short **manual** checklist, but the **core** (model load,
transcription, null-safety, text append) can and should be tested headlessly.
### 4.1 Make the core testable
Apply the `run_inference` / `transcribe_sync` refactor from Part 2 so a test can feed a buffer
and get text synchronously. Also extract the append rule as a pure function so it can be tested
with zero Win32:
```cpp
// text_util.h (new, tiny, UI-free)
#pragma once
#include <string>
inline std::wstring append_transcript(const std::wstring& cur, const std::wstring& add) {
if (add.empty()) return cur;
if (cur.empty()) return add;
return cur + L" " + add;
}
```
Use it in `WM_APP_RESULT` (`combined = append_transcript(cur, to_w(*res));`) so the UI and the
test exercise the *same* rule.
### 4.2 The test program (`tests/test_core.cpp`)
Replaces the stale `src/test-audio.cpp`. Compiles against the **current** API and tests the real
code path (`preload` + `transcribe_sync`).
```cpp
#include "transcriber.h"
#include "text_util.h"
#include <cstdio>
#include <cstdint>
#include <fstream>
#include <vector>
#include <string>
#include <cctype>
static int g_fail = 0;
#define CHECK(cond, msg) do { if (!(cond)) { printf(" FAIL: %s\n", msg); ++g_fail; } \
else printf(" ok: %s\n", msg); } while(0)
// Minimal 16-bit PCM mono WAV loader -> float [-1,1]. Assumes 16 kHz mono (whisper.cpp samples are).
static bool load_wav(const std::string& path, std::vector<float>& out) {
std::ifstream f(path, std::ios::binary);
if (!f) return false;
char hdr[44];
f.read(hdr, 44);
if (std::string(hdr, 4) != "RIFF") return false;
std::vector<int16_t> pcm((std::istreambuf_iterator<char>(f)), {}); // crude: rest of file
out.clear(); out.reserve(pcm.size());
for (int16_t s : pcm) out.push_back(s / 32768.0f);
return !out.empty();
}
static std::string lower(std::string s) { for (char& c : s) c = (char)tolower((unsigned char)c); return s; }
int main(int argc, char** argv) {
std::string model = (argc > 1) ? argv[1] : "models/ggml-tiny.en.bin";
std::string wav = (argc > 2) ? argv[2] : "samples/jfk.wav";
// --- Pure logic: append rule (no model needed) ---
CHECK(append_transcript(L"", L"hello") == L"hello", "append: empty + a");
CHECK(append_transcript(L"hello", L"world") == L"hello world", "append: a + b spaced");
CHECK(append_transcript(L"hello", L"") == L"hello", "append: a + empty");
// --- Regression F2: bad model path must NOT crash, returns "" ---
{
Transcriber t; WhisperConfig bad; bad.model_path = "models\\does-not-exist.bin";
bool ok = t.preload(bad);
CHECK(!ok, "bad model path: preload returns false");
std::vector<float> a(16000, 0.0f); // 1 s of silence
std::string r = t.transcribe_sync(a); // must be a safe no-op
CHECK(r.empty(), "bad model path: transcribe_sync returns empty, no crash");
}
// --- Happy path: real model + known clip -> non-empty, expected words ---
{
Transcriber t; WhisperConfig cfg; cfg.model_path = model; cfg.n_threads = 4;
bool ok = t.preload(cfg);
CHECK(ok, "model loads");
if (ok) {
std::vector<float> audio;
bool loaded = load_wav(wav, audio);
CHECK(loaded, "wav loads");
if (loaded) {
std::string text = lower(t.transcribe_sync(audio));
CHECK(!text.empty(), "transcription is non-empty");
// jfk.wav: "...ask not what your country can do for you..."
CHECK(text.find("country") != std::string::npos, "transcription contains 'country'");
}
}
}
// --- Edge: sub-300ms / empty audio -> "" , no crash ---
{
Transcriber t; WhisperConfig cfg; cfg.model_path = model;
if (t.preload(cfg)) {
std::vector<float> tiny(100, 0.1f);
std::string r = t.transcribe_sync(tiny);
CHECK(true, "short audio did not crash"); // reaching here = no crash
}
}
printf("\n%s (%d failure%s)\n", g_fail ? "TESTS FAILED" : "ALL TESTS PASSED",
g_fail, g_fail == 1 ? "" : "s");
return g_fail ? 1 : 0;
}
```
> The WAV loader above is deliberately minimal (good enough for whisper.cpp's 16 kHz mono
> `samples/*.wav`). If you test arbitrary WAVs, parse the `fmt `/`data` chunks properly.
### 4.3 Build the tests (root `CMakeLists.txt`)
The active build file builds only `win-dictation`. Add a test target next to it:
```cmake
# --- tests ---
add_executable(test-core
tests/test_core.cpp
src/transcriber.cpp
src/transcriber.h
)
target_link_libraries(test-core PRIVATE whisper ${SDL2_LIBRARIES})
target_include_directories(test-core PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/src
${CMAKE_CURRENT_SOURCE_DIR}/common
)
target_compile_definitions(test-core PRIVATE UNICODE _UNICODE)
```
(`transcriber.cpp` references SDL symbols even though the test never opens a device, so the test
still links `${SDL2_LIBRARIES}`.)
Build and run:
```powershell
cmake --build build --config Release --target test-core
# run from a dir where SDL2.dll + models/ + samples/ are reachable:
cd build\bin\Release
copy ..\..\..\deps\SDL2-2.28.5\lib\x64\SDL2.dll . # if not already beside the exe
.\test-core.exe models\ggml-tiny.en.bin ..\..\..\samples\jfk.wav
```
Exit code `0` = all passed (usable in CI / a pre-commit hook).
### 4.4 What each test guards
| Test | Guards against |
|---|---|
| append rule (3 cases) | F5 regression — replace-instead-of-append |
| bad model path → false + empty | F2 — null-context crash on Stop |
| model loads / clip → non-empty, has "country" | core transcription works end to end |
| short/empty audio → no crash | the `< 0.3 s` guard + null-safety |
### 4.5 Manual UI checklist (can't be unit-tested)
- [ ] **Not a crash:** after Stop, the window **stays visible**; if it ever vanishes, the tray
icon / `win-dictation.exe` in Task Manager confirms whether it's hiding vs. truly gone.
- [ ] **Append:** two dictations in a row → second is appended after the first, view scrolls down.
- [ ] **Clipboard:** after a dictation, paste into Notepad → only the *latest* utterance appears.
- [ ] **Auto-paste:** focus Notepad, press the global hotkey, speak, press it again → text lands
in Notepad. Triggering via the Record button instead → "Copied ✓" (no self-paste).
- [ ] **Resize:** drag edges; transcript grows; min size respected.
- [ ] **Performance:** a ~10 s clip transcribes in a few seconds and the UI stays responsive;
latency does **not** grow with longer recordings.
### 4.6 Performance smoke test (optional)
`test-core` can also print timing — wrap `transcribe_sync` in `std::chrono::steady_clock` and
assert `elapsed < audio_seconds * k` for a sanity bound (e.g. `k = 1.0`, i.e. faster than
real-time on tiny.en). Useful to catch a future regression that quietly reintroduces the
streaming-style slowdown.
---
## Part 5 — Status & remaining work
**Verified fixed:** F1 (batch), F2 (null-context crash), F3 (resize), F4 (startup race).
**Apply next (⬜ in Part 2):**
1. `run_inference` / `transcribe_sync` refactor (unblocks tests).
2. `WM_APP_RESULT` rewrite (the F5 fix — no-hide + append + safe paste). **Highest priority** —
it's what's making the app *look* like it crashes.
3. Add the Clear button (needed now that text appends).
4. Add `tests/test_core.cpp` + the CMake target; run it.
**Then re-run** the Part 4 manual checklist. Expected result: the window no longer disappears,
transcriptions append, the clipboard holds the latest utterance, and `test-core.exe` exits `0`.
+425
View File
@@ -0,0 +1,425 @@
# Win Dictation — Findings, Fixes & Test Guide
A consolidated record of everything diagnosed and changed while turning the slow,
crashing build into a fast, stable push-to-talk dictation tool. Four parts:
1. **Findings** — what was actually wrong, in the order we discovered it.
2. **Fixes required** — the concrete code changes, by file.
3. **Reasoning** — why each fix is correct (the non-obvious calls).
4. **How to build & run tests** — making the core testable, plus regression tests for every bug here.
Target machine: Dell, Intel Core i5 7th-gen (2 cores / 4 threads), 16 GB RAM, CPU-only.
Stack: native C++ / Win32 + SDL2 + whisper.cpp (`examples/win-dictation`).
---
## Part 1 — Findings
### F1. The original app was slow because of its *architecture*, not its model
The first build used whisper.cpp's live-streaming pattern: it re-ran `whisper_full` on a
rolling 56 second window every ~0.4 s (`length_ms` / `step_ms` in `worker_loop`). To keep
up, the CPU must transcribe ~6 s of audio in well under 1 s — i.e. **>6× real-time**.
- The README's "1015× real-time" numbers were measured on a **24-thread** box.
- A 2-core i5-7th-gen does tiny.en at roughly **24× real-time**.
So each 6 s window took ~1.52.5 s while a new one was demanded every 0.4 s → the buffer
backed up without bound, audio was re-transcribed in overlapping chunks (duplicated/garbled
text), the CPU pegged, and 4 Whisper threads on 2 physical cores starved the UI. tiny.en was
already the right model — **the streaming design was the bottleneck.**
**Conclusion:** for dictation (not live captioning), switch to **batch / push-to-talk**:
buffer audio cheaply while recording, run `whisper_full` exactly **once** on Stop.
### F2. Build 1 crashed on Stop — null Whisper context
After the batch rewrite, the app recorded fine (VU moved) but crashed the instant you hit
Stop. Two defects combined:
1. The preload thread set `g_modelLoaded = true` **even when `preload()` returned false**
(model file not found), so the UI showed "Ready" and let you record with no model.
2. `transcribe_worker()` called `whisper_reset_timings(m_ctx)` **before** the `if (m_ctx)`
check. With `m_ctx == nullptr` that's an access violation. Recording never touches Whisper
— which is exactly why capture/VU worked and only Stop crashed.
The empty model dropdown in the first screenshot was the tell: no model files were found at
the new exe-relative path, so `preload()` failed silently.
### F3. Build 1 — window wouldn't resize, and looked boxy
- The style was `WS_POPUP | WS_CAPTION | WS_SYSMENU`**no sizing border** — and there was
**no `WM_SIZE` handler**, so controls never reflowed. Frozen at ~360×200, Copy/Paste cut off.
- The gray 3-D system buttons + chunky title bar were the "boxy Windows" look the brief
wanted to avoid.
### F4. Build 2 — startup data race (found and fixed by the user)
`RefreshModelList()` calls `CB_SETCURSEL`, which fires `CBN_SELCHANGE`. That spawned a
`reload()` background thread that raced the initial `preload()` thread — both writing
`m_cfg` (a `std::string`) and `m_ctx` at once. The corrupted context pointer made
`whisper_full` crash ~15 s into processing. Fixed with a `g_initializing` guard (skip the
combo handler during startup) and an `m_cfg_mtx` mutex around `m_cfg`/`m_ctx` writes.
### F5. Build 3 — the "crash after transcribing" was **not a crash**
Reported: "crashes after transcribing; relaunching the exe shows the last transcription, and
a second run replaced the first instead of appending."
That behavior is **impossible for a crashed process** — a dead process can't remember the
last transcription (nothing is persisted to disk/registry), and a *fresh* launch couldn't
show it. The only explanation: **the original process never died.**
What actually happened:
1. On a successful result, `WM_APP_RESULT` ran `ShowWindow(hWnd, SW_HIDE)` in the auto-paste
branch → the window **vanished** (looked like a crash).
2. Double-clicking the `.exe` again hit the **single-instance guard**, which
`PostMessage(WM_APP_SHOW)` to the existing **hidden** window → it re-appeared, still holding
the last transcription.
3. `SetDlgItemTextW` **replaced** the edit text, so run #2 overwrote run #1.
Also: clicking the **Record button** (vs. the global hotkey) set `g_prevForeground` to the
dictation window *itself*, so it hid and "pasted" into its own read-only box — nothing landed
in a useful app.
10-second confirmation: when it "crashes," the tray icon is still present and
`win-dictation.exe` is still in Task Manager.
### F6. The test harness is stale and never built
`src/test-audio.cpp` calls `m_transcriber.init(...)` and `is_using_gpu()` — methods that the
batch rewrite **removed** (`transcriber.h` now exposes `preload`/`reload`, no `init`). So it
won't compile against the current code. And the **root** `CMakeLists.txt` (the one `build.ps1`
actually uses) defines only the `win-dictation` target — the `test-audio` target lives only in
the unused `src/CMakeLists.txt`. So there are currently **no working tests**. Part 4 fixes this.
---
## Part 2 — Fixes required
Status legend: ✅ done in current code · ⬜ still to apply.
### `transcriber.cpp` / `transcriber.h`
-**Batch architecture**`start_recording()` buffers audio cheaply; `stop_and_transcribe()`
runs `whisper_full` once on a worker thread; result delivered via `PostMessage` (F1).
-**Null-context guard** in `transcribe_worker``if (!m_ctx) { m_busy=false; if(m_on_result) m_on_result(""); return; }`,
and the `whisper_reset_timings` call removed (F2).
-**`reload()`** frees + re-inits under a mutex; **`m_cfg_mtx`** protects `m_cfg`/`m_ctx`;
**`threads()`** getter (F4 + thread-count display).
-**Refactor for testability** — extract the inference core so it can be called
synchronously from a test (see Part 4):
```cpp
// transcriber.h (public)
std::string transcribe_sync(std::vector<float> audio); // headless / tests
// transcriber.h (private)
std::string run_inference(std::vector<float>& audio);
```
```cpp
// transcriber.cpp
std::string Transcriber::run_inference(std::vector<float>& audio) {
if (!m_ctx) return "";
if (m_cfg.trim_silence) trim_silence(audio);
whisper_full_params wp = whisper_full_default_params(WHISPER_SAMPLING_GREEDY);
wp.print_progress = wp.print_realtime = wp.print_timestamps = false;
wp.no_timestamps = true; wp.translate = false;
wp.language = m_cfg.language.c_str();
wp.n_threads = m_cfg.n_threads;
wp.no_context = true; wp.suppress_blank = true; wp.suppress_nst = true;
wp.temperature = 0.0f;
std::string out;
if (whisper_full(m_ctx, wp, audio.data(), (int)audio.size()) == 0) {
int n = whisper_full_n_segments(m_ctx);
for (int i = 0; i < n; ++i) { const char* t = whisper_full_get_segment_text(m_ctx, i); if (t) out += t; }
out = clean_text(out);
}
return out;
}
void Transcriber::transcribe_worker(std::vector<float> audio) {
std::string out = run_inference(audio);
m_busy = false;
if (m_on_result) m_on_result(out);
}
std::string Transcriber::transcribe_sync(std::vector<float> audio) {
return run_inference(audio); // assumes preload() already succeeded
}
```
### `main.cpp`
- ✅ **Honest load state** — separate `g_modelOk` atomic; record is gated and shows
"Model not found: …\models\ggml-tiny.en.bin" instead of crashing (F2).
- ✅ **Resizable** — `WS_OVERLAPPEDWINDOW` (has `WS_THICKFRAME`), `WM_SIZE → LayoutControls`,
`WM_GETMINMAXINFO` min size (F3).
- ✅ **Startup race guard** — `g_initializing` skips the model-combo handler during startup (F4).
- ⬜ **`WM_APP_RESULT` rewrite** — the F5 fix: **don't hide**, **append** (not replace),
clipboard = latest utterance, paste only into a *different* valid window:
```cpp
case WM_APP_RESULT:
{
std::string* res = (std::string*)wParam;
if (res && !res->empty()) {
HWND hEdit = GetDlgItem(hWnd, ID_EDIT_TEXT);
int len = GetWindowTextLengthW(hEdit);
std::wstring cur;
if (len > 0) { cur.resize(len + 1); int g = GetWindowTextW(hEdit, &cur[0], len + 1); cur.resize(g); }
std::wstring combined = cur;
if (!combined.empty()) combined += L" ";
combined += to_w(*res);
SetWindowTextW(hEdit, combined.c_str());
SendMessageW(hEdit, EM_SETSEL, (WPARAM)combined.size(), (LPARAM)combined.size());
SendMessageW(hEdit, EM_SCROLLCARET, 0, 0);
UpdatePlaceholder(hWnd);
SetClipboardTextUtf8(hWnd, *res); // clipboard = just the latest utterance
bool pasted = false;
if (g_autoPaste && g_prevForeground &&
g_prevForeground != hWnd && IsWindow(g_prevForeground)) {
PasteIntoWindow(g_prevForeground);
pasted = true;
}
SetStatus(hWnd, pasted ? L"Pasted ✓" : L"Copied ✓");
} else {
SetStatus(hWnd, L"No speech detected");
}
delete res;
}
break;
```
- ⬜ **Clear button** — `ID_BTN_CLEAR` is handled in `WM_COMMAND` but no button is created in
the modern layout, so the (now appending) transcript can't be cleared. Create it owner-drawn,
place it in `LayoutControls`, and call `UpdatePlaceholder(hWnd)` after clearing.
- ⬜ **Optional `g_autoHide`** (default `false`) — only if you want the window to tuck away after
a successful paste into another app: `if (pasted && g_autoHide) ShowWindow(hWnd, SW_HIDE);`
### Modern UI (separate doc)
The flat dark restyle (GDI+ owner-drawn buttons, rounded panel, custom VU, dark caption +
rounded corners) is fully specified in **`MODERN-UI-AND-FIXES.md`**, Part 2.
---
## Part 3 — Reasoning
**Why batch beats streaming here (F1).** Streaming only wins if you need words *as you speak*
(live captioning). Dictation doesn't: you speak a sentence, then want the text. Batch removes
the real-time deadline entirely — Whisper processes each second of audio exactly once, at its
own pace, after you stop. That means: near-zero CPU while speaking (just buffering), predictable
"a few seconds after Stop" latency that **doesn't grow with clip length**, no overlap
re-processing, and better accuracy (full context, no chunk-boundary word splits). On 2 cores
this is the difference between unusable and snappy.
**Why threads = physical cores (2), not logical (4).** Whisper's matmuls are memory-bandwidth
bound. Two extra hyperthreads share the same execution ports and cache, so they add little
throughput while stealing cycles from the UI/audio threads (janky window, laggy VU). Two
threads leaves headroom for a responsive UI.
**Why the Stop crash was a null deref (F2), and why the guard is the right fix.** Recording is
pure SDL + a `std::vector` append — it never calls into Whisper, which is why only Stop crashed.
`whisper_reset_timings(nullptr)` dereferences the context. The guard makes the worker a no-op
when no model is loaded; the separate `g_modelOk` flag makes that state *visible* (a MessageBox
with the path) instead of letting you walk into it. Defensive + diagnostic.
**Why the "crash after transcribing" was actually a hide (F5) — the logic is conclusive.** A
crashed process loses all memory. There is no on-disk persistence in this app. Therefore a
re-launched process **cannot** display the previous transcription, and a second run **cannot**
replace text in "the same box." The observed behavior (prior text reappears; second replaces
first) is only possible if it's the *same* live process — which means it hid, and the
single-instance guard re-showed it. This is why "is the process still in Task Manager?" is the
decisive test, not "did the window disappear?"
**Why clipboard = latest utterance but the box appends.** Two different jobs. The on-screen box
is your running log (you want history). The clipboard is what you paste into another app — you
want *just what you last said*, not the whole session. Splitting them gives both.
**Why paste must exclude our own window.** Auto-paste replays Ctrl+V into the foreground window
captured before the popup. If you triggered recording by clicking the Record button, that
"previous" window is the dictation app itself — pasting into its read-only box is a no-op and
(with the old hide) made the app appear to swallow the text. Restricting to
`g_prevForeground != hWnd && IsWindow(...)` makes the global-hotkey workflow the reliable path
and the button a safe "copy only."
---
## Part 4 — How to build & run tests
The goal: catch the bugs above automatically, without a human watching a window. UI behavior
(resize, hide, paste) needs a short **manual** checklist, but the **core** (model load,
transcription, null-safety, text append) can and should be tested headlessly.
### 4.1 Make the core testable
Apply the `run_inference` / `transcribe_sync` refactor from Part 2 so a test can feed a buffer
and get text synchronously. Also extract the append rule as a pure function so it can be tested
with zero Win32:
```cpp
// text_util.h (new, tiny, UI-free)
#pragma once
#include <string>
inline std::wstring append_transcript(const std::wstring& cur, const std::wstring& add) {
if (add.empty()) return cur;
if (cur.empty()) return add;
return cur + L" " + add;
}
```
Use it in `WM_APP_RESULT` (`combined = append_transcript(cur, to_w(*res));`) so the UI and the
test exercise the *same* rule.
### 4.2 The test program (`tests/test_core.cpp`)
Replaces the stale `src/test-audio.cpp`. Compiles against the **current** API and tests the real
code path (`preload` + `transcribe_sync`).
```cpp
#include "transcriber.h"
#include "text_util.h"
#include <cstdio>
#include <cstdint>
#include <fstream>
#include <vector>
#include <string>
#include <cctype>
static int g_fail = 0;
#define CHECK(cond, msg) do { if (!(cond)) { printf(" FAIL: %s\n", msg); ++g_fail; } \
else printf(" ok: %s\n", msg); } while(0)
// Minimal 16-bit PCM mono WAV loader -> float [-1,1]. Assumes 16 kHz mono (whisper.cpp samples are).
static bool load_wav(const std::string& path, std::vector<float>& out) {
std::ifstream f(path, std::ios::binary);
if (!f) return false;
char hdr[44];
f.read(hdr, 44);
if (std::string(hdr, 4) != "RIFF") return false;
std::vector<int16_t> pcm((std::istreambuf_iterator<char>(f)), {}); // crude: rest of file
out.clear(); out.reserve(pcm.size());
for (int16_t s : pcm) out.push_back(s / 32768.0f);
return !out.empty();
}
static std::string lower(std::string s) { for (char& c : s) c = (char)tolower((unsigned char)c); return s; }
int main(int argc, char** argv) {
std::string model = (argc > 1) ? argv[1] : "models/ggml-tiny.en.bin";
std::string wav = (argc > 2) ? argv[2] : "samples/jfk.wav";
// --- Pure logic: append rule (no model needed) ---
CHECK(append_transcript(L"", L"hello") == L"hello", "append: empty + a");
CHECK(append_transcript(L"hello", L"world") == L"hello world", "append: a + b spaced");
CHECK(append_transcript(L"hello", L"") == L"hello", "append: a + empty");
// --- Regression F2: bad model path must NOT crash, returns "" ---
{
Transcriber t; WhisperConfig bad; bad.model_path = "models\\does-not-exist.bin";
bool ok = t.preload(bad);
CHECK(!ok, "bad model path: preload returns false");
std::vector<float> a(16000, 0.0f); // 1 s of silence
std::string r = t.transcribe_sync(a); // must be a safe no-op
CHECK(r.empty(), "bad model path: transcribe_sync returns empty, no crash");
}
// --- Happy path: real model + known clip -> non-empty, expected words ---
{
Transcriber t; WhisperConfig cfg; cfg.model_path = model; cfg.n_threads = 4;
bool ok = t.preload(cfg);
CHECK(ok, "model loads");
if (ok) {
std::vector<float> audio;
bool loaded = load_wav(wav, audio);
CHECK(loaded, "wav loads");
if (loaded) {
std::string text = lower(t.transcribe_sync(audio));
CHECK(!text.empty(), "transcription is non-empty");
// jfk.wav: "...ask not what your country can do for you..."
CHECK(text.find("country") != std::string::npos, "transcription contains 'country'");
}
}
}
// --- Edge: sub-300ms / empty audio -> "" , no crash ---
{
Transcriber t; WhisperConfig cfg; cfg.model_path = model;
if (t.preload(cfg)) {
std::vector<float> tiny(100, 0.1f);
std::string r = t.transcribe_sync(tiny);
CHECK(true, "short audio did not crash"); // reaching here = no crash
}
}
printf("\n%s (%d failure%s)\n", g_fail ? "TESTS FAILED" : "ALL TESTS PASSED",
g_fail, g_fail == 1 ? "" : "s");
return g_fail ? 1 : 0;
}
```
> The WAV loader above is deliberately minimal (good enough for whisper.cpp's 16 kHz mono
> `samples/*.wav`). If you test arbitrary WAVs, parse the `fmt `/`data` chunks properly.
### 4.3 Build the tests (root `CMakeLists.txt`)
The active build file builds only `win-dictation`. Add a test target next to it:
```cmake
# --- tests ---
add_executable(test-core
tests/test_core.cpp
src/transcriber.cpp
src/transcriber.h
)
target_link_libraries(test-core PRIVATE whisper ${SDL2_LIBRARIES})
target_include_directories(test-core PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/src
${CMAKE_CURRENT_SOURCE_DIR}/common
)
target_compile_definitions(test-core PRIVATE UNICODE _UNICODE)
```
(`transcriber.cpp` references SDL symbols even though the test never opens a device, so the test
still links `${SDL2_LIBRARIES}`.)
Build and run:
```powershell
cmake --build build --config Release --target test-core
# run from a dir where SDL2.dll + models/ + samples/ are reachable:
cd build\bin\Release
copy ..\..\..\deps\SDL2-2.28.5\lib\x64\SDL2.dll . # if not already beside the exe
.\test-core.exe models\ggml-tiny.en.bin ..\..\..\samples\jfk.wav
```
Exit code `0` = all passed (usable in CI / a pre-commit hook).
### 4.4 What each test guards
| Test | Guards against |
|---|---|
| append rule (3 cases) | F5 regression — replace-instead-of-append |
| bad model path → false + empty | F2 — null-context crash on Stop |
| model loads / clip → non-empty, has "country" | core transcription works end to end |
| short/empty audio → no crash | the `< 0.3 s` guard + null-safety |
### 4.5 Manual UI checklist (can't be unit-tested)
- [ ] **Not a crash:** after Stop, the window **stays visible**; if it ever vanishes, the tray
icon / `win-dictation.exe` in Task Manager confirms whether it's hiding vs. truly gone.
- [ ] **Append:** two dictations in a row → second is appended after the first, view scrolls down.
- [ ] **Clipboard:** after a dictation, paste into Notepad → only the *latest* utterance appears.
- [ ] **Auto-paste:** focus Notepad, press the global hotkey, speak, press it again → text lands
in Notepad. Triggering via the Record button instead → "Copied ✓" (no self-paste).
- [ ] **Resize:** drag edges; transcript grows; min size respected.
- [ ] **Performance:** a ~10 s clip transcribes in a few seconds and the UI stays responsive;
latency does **not** grow with longer recordings.
### 4.6 Performance smoke test (optional)
`test-core` can also print timing — wrap `transcribe_sync` in `std::chrono::steady_clock` and
assert `elapsed < audio_seconds * k` for a sanity bound (e.g. `k = 1.0`, i.e. faster than
real-time on tiny.en). Useful to catch a future regression that quietly reintroduces the
streaming-style slowdown.
---
## Part 5 — Status & remaining work
**Verified fixed:** F1 (batch), F2 (null-context crash), F3 (resize), F4 (startup race).
**Apply next (⬜ in Part 2):**
1. `run_inference` / `transcribe_sync` refactor (unblocks tests).
2. `WM_APP_RESULT` rewrite (the F5 fix — no-hide + append + safe paste). **Highest priority** —
it's what's making the app *look* like it crashes.
3. Add the Clear button (needed now that text appends).
4. Add `tests/test_core.cpp` + the CMake target; run it.
**Then re-run** the Part 4 manual checklist. Expected result: the window no longer disappears,
transcriptions append, the clipboard holds the latest utterance, and `test-core.exe` exits `0`.
@@ -0,0 +1,78 @@
# Win Dictation — Fix Note 01
**Every control renders as the blue Record pill**
**Applies to:** the single-surface UI rewrite described in `UI-and-Progress-Rebuild.md` (Part 1), after the first build.
**Status:** root cause confirmed, one-line fix below.
---
## Symptom
After wiring up the single-surface paint, every control — **Record**, **Pin**, the **mic** and **model** selects, and **Copy / Paste / Clear** — renders as the *same* blue accent pill with a white dot. No labels, no distinct styles. (The window chrome, card, and scrollbar are correct; only the widgets are wrong.)
## The mistake
`Widget::kind` is declared without an initializer, and the widget array has static storage duration, so the whole array is zero-initialized:
```cpp
struct Widget {
WK kind; // <-- no initializer
RectF r;
bool hover = false;
bool pressed = false;
float anim = 0.0f;
};
static Widget g_w[ (int)WK::Transcript + 1 ]; // zero-filled → every kind == 0
```
`WK::RecordHero` is the first enumerator, i.e. value `0`. So **every** slot's `kind` reads as `RecordHero`. `LayoutWidgets()` assigns each slot's *rectangle* (`g_w[(int)WK::Pin].r = …`, etc.) but never its `kind`. The paint loop dispatches on `kind`:
```cpp
for (auto& w : g_w) {
switch (w.kind) { // 0 for every widget
case WK::RecordHero: DrawHero(g, w); break; // <-- all eight land here
case WK::Copy: DrawGhost(g, w, L"Copy"); break; // unreachable
case WK::SelAudio: DrawSelectSurface(...); break; // unreachable
...
}
}
```
Result: `DrawHero` paints all eight widgets → eight identical blue pills.
## The required change
The array is **indexed by enum value** (`g_w[(int)WK::Pin]`, `g_w[(int)WK::Copy]`, …), so slot `i` must carry `kind == (WK)i`. Stamp the kinds once, at the top of `LayoutWidgets()` — it runs before the first paint and again on every resize / DPI change, so the invariant is always re-asserted:
```cpp
void LayoutWidgets(int W, int H) {
for (int i = 0; i < (int)std::size(g_w); ++i) // <-- ADD THESE TWO LINES
g_w[i].kind = (WK)i; // slot index == kind
for (auto& w : g_w) w.r = RectF(0, 0, 0, 0);
float s = g_dpiScale;
// ... unchanged ...
}
```
Equivalent alternative: an `InitWidgets()` helper called once in `wWinMain` before the first `LayoutWidgets`/paint. Doing it *inside* `LayoutWidgets` is the most robust, because nothing can paint before layout has run.
**Invariant to preserve:** `g_w[i].kind == (WK)i`. As long as rectangles keep being assigned via `g_w[(int)WK::X].r`, the slot index always equals the enum value and this holds.
After this change, `DrawGhost` (Copy/Paste/Clear), `DrawPinSurface`, and `DrawSelectSurface` (mic/model) take over their slots, and you get the intended distinct, borderless, label-bearing controls.
## Secondary issue noticed (cosmetic — not fixed here)
The transcript **placeholder** ("Your transcription will appear here…") won't appear. `PaintSurface` draws it into the transcript rect, but the real multiline `EDIT` child window covers that rect and is opaque, so it hides the parent-painted text. `EM_SETCUEBANNER` only works on *single-line* edits. Clean fix: subclass the multiline `EDIT` and draw the cue in its own `WM_PAINT` when the control is empty and unfocused. Tracked as a separate follow-up.
## Verify after rebuilding
1. **Top row:** a wide blue **Record** pill + a quiet **Pin** toggle (accent only when pinned).
2. **Bottom:** **mic** and **model** selects (label + chevron, faint at rest) and quiet **Copy / Paste / Clear** ghost buttons (text only at rest).
3. **Hover** a ghost button → a soft fill fades in; **press** → slightly darker. No resting borders, no hairlines.
4. Independent of this fix, run a transcription and confirm the progress **%** rises smoothly and the **"… s left"** value counts **down** (not up).
---
*Companion to `UI-and-Progress-Rebuild.md`. This note documents a correction to that guide's Part 1 scaffold; the guide itself is left unchanged.*
@@ -0,0 +1,214 @@
# Win Dictation — Fix Note 02
**All text missing from the interface (fills render, labels don't)**
**Applies to:** the single-surface UI after applying Fix Note 01 (`g_w[i].kind` stamping).
**Status:** root cause confirmed. One pattern to remove, used in five places. Two smaller adjacent issues documented below.
---
## Symptom
Widgets now render in their correct *shapes* — blue Record pill with white dot, two select fields with chevrons, the card, the dark scrollbar — but **no text appears anywhere**:
- Record pill has no "Record" label
- Pin is completely invisible (empty space right of the pill)
- Copy / Paste / Clear are completely invisible (empty strip at the bottom)
- Mic / model selects are empty except for the chevron
- No status line ("Ready · 2 threads" etc.)
The tell: everything drawn with Graphics *primitives* (`FillRound`, `FillEllipse`, `DrawLine`, `DrawPath`) renders; everything drawn with `DrawTextC` doesn't. Pin and the ghost buttons are text-only at rest, so they vanish entirely.
## What you missed: `Graphics::GetHDC()` locks the Graphics object
Every text call sits inside this pattern (from `DrawHero`, and repeated in the other draw functions):
```cpp
HDC hdc = g.GetHDC(); // <-- locks `g`
Font f(hdc, g_fUISemi); // (constructing the Font is fine)
RectF tb(...);
DrawTextC(g, L"Record", f, ...); // <-- call on locked `g` → fails silently
g.ReleaseHDC(hdc); // <-- unlock, too late
```
`Graphics::GetHDC()` is documented to put the Graphics object into a **locked state**: between `GetHDC()` and `ReleaseHDC()`, *any* method called on that `Graphics` fails with `Status::ObjectBusy`. GDI+ reports errors via return codes, not exceptions — so `g.DrawString(...)` inside `DrawTextC` returns an error and draws nothing, with no crash and no debugger output. The result is exactly what you see: silent, total text loss, while every primitive drawn *outside* a lock window renders fine.
This also explains two details that look confusing at first:
1. **Why the chevrons survive in `DrawSelectSurface`:** the two `g.DrawLine(...)` calls happen *after* `g.ReleaseHDC(hdc)` — outside the lock — so they render.
2. **Why the *old* owner-draw code's text worked:** it wrote `Font f(d->hDC, g_fUISemi)` using the **raw owner-draw HDC** it already had. Constructing a `Font` from an HDC does not lock anything — only `Graphics::GetHDC()` does. (Same reason the popup's `Font f(mem, g_fUI)` still works: `mem` is the raw memory HDC, not a `GetHDC()` result.)
The `GetHDC()` calls were added because the new draw functions receive only a `Graphics&` and the `Font(HDC, HFONT)` constructor needs an HDC. The intent was right; the mechanism poisons the Graphics.
### Affected call sites (all five must change)
| Function | What's invisible |
|---|---|
| `DrawHero` | "Record" / "Stop" label |
| `DrawGhost` | Copy / Paste / Clear (entire control) |
| `DrawPinSurface` | Pin / Pinned (entire control) |
| `DrawSelectSurface` | mic / model value text |
| `PaintSurface` | status line, transcript placeholder |
## The fix: cached GDI+ fonts, zero `GetHDC()` calls
Create GDI+ `Font` objects **once** from the existing HFONTs using a *screen* DC (never the Graphics), cache them, and use them everywhere. This removes every `GetHDC()`/`ReleaseHDC()` pair and is also a per-frame win — no font construction inside a 60 fps paint loop.
### 1. Add globals (near the HFONT globals)
```cpp
Gdiplus::Font* g_gpUI = nullptr; // labels (15px)
Gdiplus::Font* g_gpUISemi = nullptr; // hero label (15px semibold)
Gdiplus::Font* g_gpSmall = nullptr; // status line (12px)
Gdiplus::Font* g_gpText = nullptr; // transcript placeholder (16px)
```
### 2. Add the builder and fold it into `RecreateFonts`
```cpp
static Gdiplus::Font* GdipFontFromHFont(HFONT hf) {
HDC sdc = GetDC(nullptr); // screen DC — no Graphics involved
Gdiplus::Font* f = new Gdiplus::Font(sdc, hf);
ReleaseDC(nullptr, sdc);
if (f->GetLastStatus() != Ok) { delete f; return nullptr; }
return f;
}
static void RebuildGdipFonts() {
delete g_gpUI; delete g_gpUISemi; delete g_gpSmall; delete g_gpText;
g_gpUI = GdipFontFromHFont(g_fUI);
g_gpUISemi = GdipFontFromHFont(g_fUISemi);
g_gpSmall = GdipFontFromHFont(g_fSmall);
g_gpText = GdipFontFromHFont(g_fText);
// safety net if HFONT conversion ever fails:
if (!g_gpUI) g_gpUI = new Gdiplus::Font(L"Segoe UI", 15.0f * g_dpiScale, FontStyleRegular, UnitPixel);
if (!g_gpUISemi) g_gpUISemi = new Gdiplus::Font(L"Segoe UI", 15.0f * g_dpiScale, FontStyleBold, UnitPixel);
if (!g_gpSmall) g_gpSmall = new Gdiplus::Font(L"Segoe UI", 12.0f * g_dpiScale, FontStyleRegular, UnitPixel);
if (!g_gpText) g_gpText = new Gdiplus::Font(L"Segoe UI", 16.0f * g_dpiScale, FontStyleRegular, UnitPixel);
}
```
At the **end of `RecreateFonts(float scale)`**, add:
```cpp
RebuildGdipFonts();
```
So HFONTs and GDI+ fonts always change together (startup and `WM_DPICHANGED`).
### 3. Call it at startup — ordering matters
`RebuildGdipFonts` needs GDI+ started (it already is — `GdiplusStartup` runs first) and benefits from the real DPI. In `wWinMain`, right after the DPI is known:
```cpp
g_dpiScale = GetDpiForWindow(hMainWnd) / 96.0f;
if (g_dpiScale <= 0.0f) g_dpiScale = 1.0f;
RecreateFonts(g_dpiScale); // <-- ADD: rebuilds HFONTs at real DPI + GDI+ fonts
```
(See "Adjacent issue A" below for why `RecreateFonts` belongs here anyway.)
### 4. Shutdown ordering — GDI+ objects must die before `GdiplusShutdown`
In the shutdown block, delete the GDI+ fonts **before** `GdiplusShutdown(g_gdipToken)`:
```cpp
delete g_gpUI; delete g_gpUISemi; delete g_gpSmall; delete g_gpText;
g_gpUI = g_gpUISemi = g_gpSmall = g_gpText = nullptr;
GdiplusShutdown(g_gdipToken);
DeleteObject(g_fUI); ... // HFONTs are plain GDI; their order is fine as-is
```
(Destroying GDI+ objects after shutdown is undefined behavior — worth getting right even though it "usually" doesn't crash.)
### 5. Strip the lock pattern from all five call sites
**`DrawHero` — before:**
```cpp
HDC hdc = g.GetHDC();
Font f(hdc, g_fUISemi);
RectF tb((REAL)(pill.X + (int)(36 * g_dpiScale)), (REAL)pill.Y,
(REAL)(pill.Width - (int)(44 * g_dpiScale)), (REAL)pill.Height);
DrawTextC(g, rec ? L"Stop" : L"Record", f, Color(255, 255, 255, 255),
tb, StringAlignmentNear, StringAlignmentCenter);
g.ReleaseHDC(hdc);
```
**`DrawHero` — after:**
```cpp
RectF tb((REAL)(pill.X + (int)(36 * g_dpiScale)), (REAL)pill.Y,
(REAL)(pill.Width - (int)(44 * g_dpiScale)), (REAL)pill.Height);
DrawTextC(g, rec ? L"Stop" : L"Record", *g_gpUISemi, Color(255, 255, 255, 255),
tb, StringAlignmentNear, StringAlignmentCenter);
```
Apply the identical transformation to the rest — delete the `GetHDC`/`Font(hdc, …)`/`ReleaseHDC` lines and pass the cached font:
| Call site | Replace local `Font f(hdc, …)` with |
|---|---|
| `DrawGhost` | `*g_gpUI` |
| `DrawPinSurface` | `*g_gpUI` |
| `DrawSelectSurface` | `*g_gpUI` |
| `PaintSurface` (status line) | `*g_gpSmall` |
| `PaintSurface` (placeholder) | `*g_gpText` |
After this, there must be **zero** calls to `g.GetHDC()` anywhere in the paint path. (The popup's `Font f(mem, g_fUI)` is fine and can stay — `mem` is a raw HDC; consider migrating it to `*g_gpUI` later for consistency.)
---
## Adjacent issue A — fonts are built at the wrong DPI on startup
In `wWinMain`, the HFONTs are created with `g_dpiScale` still at its initial `1.0f` (the window doesn't exist yet), and `g_dpiScale` is only set *after* `CreateWindowExW`. Nothing recreates the fonts at startup, so on a 125%/150% display, all text is undersized until the first `WM_DPICHANGED`. The `RecreateFonts(g_dpiScale)` call added in step 3 fixes this — and because it runs *before* the child controls are created, the `EDIT` receives the correctly-scaled `g_fText` at creation.
## Adjacent issue B — transient status messages are now invisible
`SetStatus(...)` writes to the `ID_STATIC_STATUS` control — which is hidden (`SW_HIDE`). The painted status line in `PaintSurface` derives its text purely from state (recording/busy/ready/loading), so these messages can never appear: **"Copied", "Pasted", "Cancelled", "No speech detected", "Microphone error", "Hotkey in use — edit win-dictation.ini"**.
Minimal repair — route `SetStatus` into the painted surface as a transient override:
```cpp
std::wstring g_statusOverride; // shown instead of the derived idle status
DWORD g_statusOverrideUntil = 0; // GetTickCount() deadline
void SetStatus(HWND hwnd, const wchar_t* text) {
g_statusOverride = text;
g_statusOverrideUntil = GetTickCount() + 2500; // visible for 2.5s
InvalidateRect(hwnd, nullptr, FALSE);
}
```
In `PaintSurface`'s status-text branch, prefer the override when idle:
```cpp
} else if (g_modelLoaded.load()) {
if (!g_statusOverride.empty() && GetTickCount() < g_statusOverrideUntil) {
wcscpy_s(statusBuf, g_statusOverride.c_str());
} else if (!g_modelOk.load()) {
swprintf_s(statusBuf, L"Model not found — check models folder");
} else {
swprintf_s(statusBuf, L"Ready • %d threads", g_tx.threads());
}
}
```
(Recording/busy branches still win, which is correct — those states are more important than a stale "Copied".)
---
## Verify after rebuilding
1. **Labels everywhere:** "Record" on the pill; "Pinned" top-right in accent; "Copy / Paste / Clear" as dim labels; mic + model names in the selects; "Loading model…" → "Ready · 2 threads" on the status line.
2. **Hover** Copy/Paste/Clear → soft fill fades in, label brightens.
3. **Copy something** → status briefly shows "Copied" (issue B fix).
4. On a HiDPI display, text is correctly sized at first launch (issue A fix).
5. Run a transcription → smooth rising % and counting-down ETA (unrelated path, but confirm while you're there).
## Optional cleanup (safe to defer)
The legacy chrome is now dead weight: the nine hidden child windows and their `Create…`/`SetWindowTheme`/`SetWindowSubclass`/`ShowWindow(SW_HIDE)` calls, `LayoutControls`, `BtnProc`, `DrawRecordButton`, `DrawFlatButton`, `DrawSelect`, `UpdatePlaceholder`'s STATIC logic, and the `WM_DRAWITEM`/`WM_MEASUREITEM` handlers (keep the `EDIT` and everything for it). Removing them deletes ~150 lines and removes the double layout work in `WM_SIZE` (`LayoutControls` + `LayoutWidgets` both run and both `MoveWindow` the EDIT). Functionally harmless today, so treat as a tidy-up pass, not part of this fix.
---
*Companion to `UI-and-Progress-Rebuild.md` and `UI-Progress-Rebuild-Fix-01.md`. Per project convention, this note is a new document; prior documents are unchanged.*
@@ -0,0 +1,630 @@
# Win Dictation — UI & Progress Rebuild Guide
A design-led plan to (1) kill the "thin lines" problem at its architectural root rather than patching it, and (2) replace the broken progress bar with a self-calibrating, smoothly-animated estimator that learns this machine's transcription speed and fuses whisper's own progress signal.
This is implementation guidance with concrete code. You build on Windows; nothing here is compiled or tested in place.
---
## Table of contents
1. [Part 1 — The interface](#part-1--the-interface)
- [1.1 Why the lines are really there](#11-why-the-lines-are-really-there)
- [1.2 The architectural fix: one surface](#12-the-architectural-fix-one-surface)
- [1.3 Two tiers: GDI+ vs Direct2D](#13-two-tiers-gdi-vs-direct2d)
- [1.4 A real design language](#14-a-real-design-language)
- [1.5 Component specs](#15-component-specs)
- [1.6 Rendering scaffold + hit-testing (code)](#16-rendering-scaffold--hit-testing-code)
- [1.7 The transcript field & DPI](#17-the-transcript-field--dpi)
- [1.8 Migration order from today's main.cpp](#18-migration-order-from-todays-maincpp)
2. [Part 2 — The progress system](#part-2--the-progress-system)
- [2.1 Why it's broken today](#21-why-its-broken-today)
- [2.2 The plan: predict, then correct](#22-the-plan-predict-then-correct)
- [2.3 Persistent per-model timing history](#23-persistent-per-model-timing-history)
- [2.4 The live estimator (smooth countdown + fusion)](#24-the-live-estimator-smooth-countdown--fusion)
- [2.5 `timing.h` — full code](#25-timingh--full-code)
- [2.6 Wiring into main.cpp](#26-wiring-into-maincpp)
- [2.7 Tuning & edge cases](#27-tuning--edge-cases)
3. [Part 3 — Cleanup checklist](#part-3--cleanup-checklist)
4. [Part 4 — Suggested build order](#part-4--suggested-build-order)
---
# Part 1 — The interface
## 1.1 Why the lines are really there
The hairlines aren't one bug; they're an emergent property of how the window is built. Today the UI is roughly **nine separate child windows** living on top of the main window:
- `BUTTON` (owner-draw): Record, Pin, Copy, Paste, Clear
- `BUTTON` (owner-draw) used as selects: mic, model
- `EDIT` (multiline): the transcript
- `STATIC`: status + placeholder
Each child is its own HWND with its own device context, its own paint timing, and — because the parent uses `WS_CLIPCHILDREN` — its own **hard-clipped rectangle punched out of the parent's paint**. That single fact is the source of the lines:
1. **Seams at every child boundary.** The parent paints its background/panel, then Windows clips out each child rectangle and the child paints itself. The boundary between "parent pixels" and "child pixels" is a 1px hard edge. Any difference in rounding, antialiasing, or color across that edge reads as a hairline — even when both sides *intend* to be the same dark color.
2. **Theme chrome you didn't ask for.** The `EDIT` control draws its own themed 1px border and a **light-mode scrollbar** (the pale bar on the right of your screenshot). `SetWindowTheme(h, L"", L"")` on the buttons disables visual styles but doesn't make seams go away.
3. **Square corners around round shapes.** Your chips are drawn rounded, but the *child window* is rectangular, so the artifact rectangle has sharp corners that don't follow the chip — which is exactly what's visible around the Record pill and the Copy/Paste/Clear buttons.
So removing `StrokeRound(..., C_BORDER, ...)` only removes the *intentional* borders. The *structural* hairlines (items 13) remain. That's why it feels like a band-aid: **you can't fully remove seams while compositing many themed child windows.**
> **Root cause, one sentence:** the window is assembled from many separate themed/owner-draw child HWNDs, and the boundaries between them can never be made perfectly seamless. The fix is to stop having those boundaries.
## 1.2 The architectural fix: one surface
Render the **entire window as a single double-buffered surface**, immediate-mode:
- The parent's `WM_PAINT` draws *everything* — background, the card, every button, the selects, the status line, the VU/progress strip — onto **one off-screen bitmap**, then blits it once. (You already do this for the background and panel; we extend it to cover all chrome.)
- **There are no child windows for chrome.** "Buttons" become **painted regions** described by a small data model (a rect + a kind + interaction state). There is exactly one surface, so there are zero inter-window seams. Antialiasing, radii, spacing, shadows, and animation are all under your control.
- **Interaction** is handled in the parent: `WM_MOUSEMOVE` / `WM_LBUTTONDOWN` / `WM_LBUTTONUP` hit-test against the widget rects; you track hover/pressed/focus yourself and invalidate. (The window is tiny — invalidating the whole client area each frame is cheap.)
- **The one exception is the transcript**, which stays a real `EDIT` child because you genuinely want selection, caret, scrolling, and IME. We make it *visually chrome-less* and inset it inside the painted card so the card is the only visible frame (see [1.7](#17-the-transcript-field--dpi)).
This is the same "retained data model + immediate-mode paint" approach used by every good custom-drawn desktop UI. Separation between elements comes from **fills, spacing, and elevation — not outlines.** Once outlines stop being load-bearing, the hairline problem is gone by construction.
## 1.3 Two tiers: GDI+ vs Direct2D
You said you'll happily take more effort for a result that looks genuinely good. Here are the two honest options.
### Tier 1 — GDI+ single-surface (recommended baseline)
- Keep GDI+ (already in the project). Move all drawing into one parent paint routine that renders to a 32-bit DIB back-buffer, then `BitBlt`.
- Reuse your existing helpers (`FillRound`, `StrokeRound`, `DrawTextC`) — they're good. You're changing *what hosts them*, not the primitives.
- Add an animation clock + hover/press state.
- **Effort:** moderate. **Payoff:** the seams disappear, you get full control of spacing/elevation/motion, and it will look clean and modern. This removes 100% of the reported problem.
- **Limitations:** GDI+ has no true GPU compositing; soft drop-shadows must be faked (pre-blurred bitmap or layered alpha), and very large blurs are slow. For a 400×340 utility this is a non-issue.
### Tier 2 — Direct2D + DirectWrite (premium path)
- GPU-accelerated geometry with flawless antialiasing, real `ID2D1Effect` drop shadows / Gaussian blur, per-primitive opacity layers, and **DirectWrite** text with subpixel positioning (noticeably crisper labels, especially at fractional DPI).
- Pairs naturally with a swap-chain or a DC render target; integrates with DWM for tear-free animation at the monitor refresh rate.
- Optionally add **Windows.UI.Composition / DirectComposition** for soft shadows and an acrylic/mica backdrop — a true Windows 11 feel.
- **Effort:** higher (COM lifetimes, device-lost handling, more setup). **Payoff:** the highest visual ceiling and the best foundation if this app grows.
- You can still keep the `EDIT` child for the transcript layered above the D2D surface.
**Recommendation:** Build **Tier 1 now** — it eliminates the actual defect and looks great, and almost all of the work (the design language, the widget model, the interaction layer, the progress system in Part 2) is *identical* regardless of renderer. If you later want the extra polish, swapping the draw calls to Direct2D is a contained change because the data model and layout stay the same. The rest of this guide is written renderer-agnostic with GDI+ code samples.
## 1.4 A real design language
The current look is "many bordered boxes." The target look is **one calm, elevated card** where hierarchy comes from type, spacing, and a single light source — not lines.
### Tokens (define once)
```cpp
// ---- color tokens (ARGB) ----
const Color T_BG (255, 0x0E, 0x10, 0x14); // app backdrop (near-black)
const Color T_CARD (255, 0x16, 0x19, 0x20); // elevated card
const Color T_CARD_HI (255, 0x1E, 0x22, 0x2B); // hovered surface
const Color T_CARD_LO (255, 0x12, 0x15, 0x1B); // pressed surface / wells
const Color T_TEXT (255, 0xEC, 0xEE, 0xF2); // primary text
const Color T_DIM (255, 0x8A, 0x90, 0x9C); // secondary text
const Color T_FAINT (255, 0x5A, 0x60, 0x6C); // tertiary / icons at rest
const Color T_ACCENT (255, 0x6E, 0x8B, 0xFF); // primary action
const Color T_ACCENT_HI (255, 0x83, 0x9C, 0xFF); // accent hover
const Color T_DANGER (255, 0xFF, 0x5C, 0x5C); // recording
const Color T_GOOD (255, 0x46, 0xD3, 0x9A); // level / success
// The ONLY "edge" allowed: a low-alpha top highlight on the card,
// to read as "lit from above." Never a full gray rectangle.
const Color T_TOPLIGHT (26, 0xFF, 0xFF, 0xFF); // ~10% white
```
**Principle:** elements are distinguished by *fill* (`T_CARD` vs `T_CARD_HI`), by *space* (generous padding), and by *elevation* (the card sits on the backdrop, optionally with a soft shadow). Outlines are reserved for nothing, or at most one hairline-as-toplight on the card itself.
### Type scale (Segoe UI Variable, which you already load)
| Role | Size (logical px) | Weight | Color |
|------|------|--------|-------|
| Primary state ("Record" / "Stop" / "Transcribing") | 16 | SemiBold | white on accent / `T_TEXT` |
| Body / transcript | 16 | Regular | `T_TEXT` |
| Buttons (ghost) | 14 | Medium | `T_DIM``T_TEXT` on hover |
| Status caption | 12.5 | Regular | `T_DIM` |
| Micro (threads, %, ETA) | 11.5 | Regular | `T_FAINT` |
### Elevation & radius
- Card radius **16**; inner controls radius **1011**; progress/level pill radius = half-height.
- Optional soft shadow under the card (Tier 1: a pre-rendered blurred rounded-rect bitmap at ~22% alpha, offset y+6, blur ~18; Tier 2: a D2D shadow effect). Subtle — it should read as depth, not drama.
### Motion (this is what makes it feel "good", not just look good)
- Hover/press fills cross-fade over **120160ms**, ease-out-cubic.
- Recording state: a **1.2s sine "breathing"** on the record pill + a live waveform (see below).
- Progress: bar width and the % label are **eased**, never snapped (except the final 100%).
- Drive all of it from one animation clock (Section 1.6). Run the timer at ~16ms **only while something is animating**, and idle otherwise (don't burn CPU on a 2-core machine when nothing moves).
## 1.5 Component specs
**Record (hero).** Full-width pill, `T_ACCENT` fill, white glyph + label. States:
- *Idle:* circle glyph + "Record". Hover → `T_ACCENT_HI`. Press → ×0.9 brightness.
- *Recording:* `T_DANGER`, breathing alpha, square "stop" glyph, label "Stop", and a **live waveform** drawn across the pill or in the strip below.
- Keep it the visual anchor; everything else is quieter.
**Ghost actions (Copy / Paste / Clear).** No resting fill, no border — just a Medium-weight label in `T_DIM`. On hover, a `T_CARD_HI` rounded fill fades in and text lifts to `T_TEXT`; on press, `T_CARD_LO`. Because there's no resting border, there are no hairlines; separation is purely spacing. (Add small 16px line icons before labels for a more finished feel.)
**Pin.** An icon toggle (pin glyph), `T_ACCENT` when active, `T_FAINT` when not. No label needed.
**Selects (mic / model).** Quiet rows: small dim label on top ("Microphone"), value below in `T_TEXT`, a small chevron at the right; hover = `T_CARD_HI` fill. **Consider relocating both behind a small gear/settings affordance** — a dictation utility doesn't need model internals on the main face. If you keep them visible, give them the same fill-on-hover, no-border treatment.
**Status + progress strip (unified).** One horizontal zone under the hero that changes by state:
- *Idle:* `"Ready · 2 threads"` in `T_DIM`.
- *Recording:* live waveform + `mm:ss` timer.
- *Transcribing:* the progress bar (Part 2) with smooth % and a **counting-down** ETA.
**Level / waveform.** Replace the 14-segment VU (reads as "old") with either a smooth antialiased waveform (ring buffer of recent RMS samples drawn as a filled path) or a single breathing level pill. Color `T_GOOD`, riding on `T_CARD_LO`.
**Empty state.** Centered mic glyph + "Your transcription will appear here" in `T_DIM`, drawn *inside* the card (not as a separate STATIC) so it shares the surface.
## 1.6 Rendering scaffold + hit-testing (code)
The whole UI becomes a small list of widgets plus one paint routine and one interaction handler. Skeleton (GDI+, Tier 1):
```cpp
enum class WK { RecordHero, Pin, Copy, Paste, Clear, SelAudio, SelModel, Transcript };
struct Widget {
WK kind;
RectF r; // logical rect, filled by Layout()
bool hover = false;
bool pressed = false;
float anim = 0.0f; // 0..1 eased hover/press amount
};
static Widget g_w[ (int)WK::Transcript + 1 ];
static int g_hot = -1; // index under cursor
static int g_active = -1; // index pressed
// --- one animation clock ---
static DWORD g_lastFrame = 0;
static bool AnyAnimating(); // true if any widget anim is mid-transition, or recording, or busy
// Advance eased states; call from the render timer.
void StepAnimations(float dt) {
for (auto& w : g_w) {
float target = (g_active == (&w - g_w) ) ? 1.0f : (w.hover ? 0.6f : 0.0f);
// ease toward target
w.anim += (target - w.anim) * std::min(1.0f, dt * 12.0f);
}
}
// --- layout: compute rects from client size & DPI scale ---
void Layout(int W, int H, float s /*dpi scale*/);
// --- paint: ONE surface ---
void Paint(HWND hwnd) {
PAINTSTRUCT ps; HDC hdc = BeginPaint(hwnd, &ps);
RECT rc; GetClientRect(hwnd, &rc);
int W = rc.right, H = rc.bottom;
HDC mem = CreateCompatibleDC(hdc);
HBITMAP bmp = CreateCompatibleBitmap(hdc, W, H);
HBITMAP old = (HBITMAP)SelectObject(mem, bmp);
{
Graphics g(mem);
g.SetSmoothingMode(SmoothingModeAntiAlias);
g.SetTextRenderingHint(TextRenderingHintClearTypeGridFit);
SolidBrush bg(T_BG); g.FillRectangle(&bg, 0, 0, W, H);
DrawCardWithShadow(g, g_cardRect, 16); // optional soft shadow + T_CARD fill + T_TOPLIGHT edge
for (auto& w : g_w) {
switch (w.kind) {
case WK::RecordHero: DrawHero(g, w); break;
case WK::Copy: DrawGhost(g, w, L"Copy"); break;
case WK::Paste: DrawGhost(g, w, L"Paste"); break;
case WK::Clear: DrawGhost(g, w, L"Clear"); break;
case WK::Pin: DrawPin(g, w); break;
case WK::SelAudio: DrawSelect(g, w, g_audioVal); break;
case WK::SelModel: DrawSelect(g, w, g_modelVal); break;
case WK::Transcript: /* the EDIT child paints itself; we just leave its inset */ break;
}
}
DrawStatusStrip(g, g_stripRect); // idle / recording waveform / progress
}
BitBlt(hdc, 0, 0, W, H, mem, 0, 0, SRCCOPY);
SelectObject(mem, old); DeleteObject(bmp); DeleteDC(mem);
EndPaint(hwnd, &ps);
}
// --- interaction: hit-test in the parent ---
int HitTest(POINT p) {
for (int i = 0; i < (int)std::size(g_w); ++i)
if (g_w[i].kind != WK::Transcript && g_w[i].r.Contains((REAL)p.x, (REAL)p.y)) return i;
return -1;
}
LRESULT CALLBACK WndProc(HWND h, UINT m, WPARAM w, LPARAM l) {
switch (m) {
case WM_MOUSEMOVE: {
POINT p{ GET_X_LPARAM(l), GET_Y_LPARAM(l) };
int hot = HitTest(p);
if (hot != g_hot) {
if (g_hot >= 0) g_w[g_hot].hover = false;
g_hot = hot;
if (g_hot >= 0) g_w[g_hot].hover = true;
TRACKMOUSEEVENT t{ sizeof(t), TME_LEAVE, h, 0 }; TrackMouseEvent(&t);
EnsureAnimating(h);
}
return 0;
}
case WM_MOUSELEAVE:
if (g_hot >= 0) { g_w[g_hot].hover = false; g_hot = -1; EnsureAnimating(h); }
return 0;
case WM_LBUTTONDOWN:
g_active = g_hot;
if (g_active >= 0) { g_w[g_active].pressed = true; SetCapture(h); EnsureAnimating(h); }
return 0;
case WM_LBUTTONUP: {
ReleaseCapture();
POINT p{ GET_X_LPARAM(l), GET_Y_LPARAM(l) };
if (g_active >= 0 && HitTest(p) == g_active) OnClick(h, g_w[g_active].kind);
if (g_active >= 0) g_w[g_active].pressed = false;
g_active = -1; EnsureAnimating(h);
return 0;
}
case WM_ERASEBKGND: return 1; // we paint everything
case WM_PAINT: Paint(h); return 0;
case WM_SIZE: Layout(LOWORD(l), HIWORD(l), g_dpiScale); InvalidateRect(h, nullptr, FALSE); return 0;
// ... WM_TIMER drives StepAnimations + InvalidateRect while AnyAnimating()
}
return DefWindowProc(h, m, w, l);
}
```
Notes:
- `DrawGhost` simply lerps its fill alpha by `w.anim` between transparent → `T_CARD_HI`, and text color between `T_DIM``T_TEXT`. No `StrokeRound`. That's the whole trick.
- `EnsureAnimating(h)` starts the 16ms timer if it isn't running; the timer stops itself when `AnyAnimating()` returns false to spare the CPU.
- Keyboard focus (for accessibility / Tab) can be added later by tracking a `g_focus` index and painting a soft focus ring on the focused widget only — still no native chrome.
## 1.7 The transcript field & DPI
**Transcript = the one real child window.** Keep `EDIT` (multiline, read-only) for free selection/caret/scroll/IME, but strip its chrome:
1. **No border:** create without `WS_BORDER`/`WS_EX_CLIENTEDGE` (already the case). To suppress the *themed* edit border entirely, either `SetWindowTheme(hEdit, L"", L"")` (kills the theme, gives a classic flat look) or subclass and handle `WM_NCPAINT` to no-op. Prefer the dark-mode route below so the scrollbar also matches.
2. **Dark background:** you already return `g_brSurface` from `WM_CTLCOLOREDIT`; set it to `T_CARD`/`T_CARD_LO` so the field is invisible against the card.
3. **Dark (or custom) scrollbar — this removes the pale bar in your screenshot:**
- Easiest: enable app dark mode then theme the control:
```cpp
// once, after the process starts (uxtheme, undocumented but widely used):
// AllowDarkModeForApp(true); SetPreferredAppMode(AllowDark);
SetWindowTheme(hEdit, L"DarkMode_Explorer", nullptr); // dark scrollbar
```
- Most control: hide the native scrollbar (`ShowScrollBar(hEdit, SB_VERT, FALSE)` or `WM_NCCALCSIZE`) and **paint a slim custom scrollbar on the parent surface**, driven by `EM_GETFIRSTVISIBLELINE` / line count. Best looking, more work.
4. **Inset it inside the card** by ~1416px so the card's rounded surface is the visible frame and the EDIT contributes no edges of its own.
**DPI awareness (do this — it's part of "looks good").** Today metrics are fixed pixels; on a HiDPI panel they blur/misalign.
- Declare **Per-Monitor-V2** via the app manifest (preferred) or `SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2)` at startup.
- Compute `g_dpiScale = GetDpiForWindow(hwnd) / 96.0f`; multiply every metric (padding, radii, font sizes, widget sizes) by it.
- Recreate fonts and re-`Layout()` on `WM_DPICHANGED`, and use the suggested rect it passes for repositioning.
## 1.8 Migration order from today's main.cpp
You can do this incrementally without a rewrite:
1. **Stop creating chrome child windows.** Delete the `CreateWindow(L"BUTTON", …)` calls for Record/Pin/Copy/Paste/Clear and the two selects, and the two `STATIC`s. Keep only the `EDIT`.
2. **Add the `Widget` array + `Layout()`** computing the same rectangles your `LayoutControls` used (reuse the math; just store `RectF`s instead of `MoveWindow`-ing HWNDs).
3. **Move your existing draw functions** (`DrawRecordButton`→`DrawHero`, `DrawFlatButton`→`DrawGhost`, `DrawSelect`) to take a `Widget&` and draw into the shared `Graphics&` — and **delete every `StrokeRound(..., T_BORDER/C_BORDER, …)`**. Replace the panel border with the card + optional shadow + toplight.
4. **Route interaction** through `WndProc` hit-testing (Section 1.6). `OnClick(kind)` calls your existing handlers (toggle record, copy, paste, clear, open the popup for selects).
5. **Add the animation clock**; convert hover from per-HWND `GWLP_USERDATA` to `w.anim`.
6. **Theme the EDIT + scrollbar**, inset it, and add DPI scaling.
7. The popup list (`PopupProc`) can stay as-is — it's already a single custom-painted surface and looks consistent.
Result: one surface, zero seams, full control. The "thin lines" cannot come back because nothing draws them and there are no child boundaries to leak them.
---
# Part 2 — The progress system
## 2.1 Why it's broken today
Three separate problems, all visible in your 1:27 example.
**(a) The ETA counts *up*.** In `UpdateStatus`:
```cpp
float elapsed = (GetTickCount() - g_busyStart) / 1000.0f;
float est = elapsed * 100.0f / (float)p; // total, derived from stale p
float remain = est - elapsed; // = elapsed * (100 - p) / p
```
`remain` is recomputed every 50ms, but `p` (whisper's progress) only changes at chunk boundaries. With `p` held constant and `elapsed` rising, `remain = elapsed·(100p)/p` **increases over time** — the ETA climbs until the next `p` update, then snaps down when `p` jumps. That's precisely "counts up, then jumps to 20s, then counts up again."
**(b) The percentage jumps in big steps.** whisper.cpp calls its progress callback at most **once per 30-second audio chunk**. 1:27 = 87s ≈ **three chunks**, so `p` arrives roughly as `0 → 33 → 67 → 100`. The 34% and 72% you saw are those chunk boundaries (off slightly due to seek rounding). The bar can't be smooth if its only input updates 3 times.
**(c) Dead air at the start.** For 87s of audio the first callback only fires after the *first* 30s chunk finishes decoding — several seconds on a 2-core CPU — so nothing moves at first (you read it as "model loading"). The model is actually already preloaded; it's first-chunk latency with no fallback signal.
**Conclusion:** whisper's callback is a *coarse, occasional measurement*, not a progress source. We need our own continuous prediction, corrected by that measurement.
## 2.2 The plan: predict, then correct
Exactly your idea, formalized:
1. **Predict** total processing time the instant recording stops, from a **history of how long this machine took** for clips of various lengths (per model). This drives a smooth bar from frame 1 — even for sub-30s clips that get *zero* whisper updates.
2. **Correct** that prediction as whisper reports progress: each callback implies a *measured* total time; we fuse it into our estimate with exponential smoothing so accuracy improves **without jumps**.
3. **Display** a strictly **counting-down** remaining time and a **smoothly rising** percent derived from the same model, ease to **95%**, and **snap to 100%** when the real result arrives.
4. **Learn:** on completion, record `(audio_seconds, actual_processing_seconds)` and persist it, so the next prediction is better.
## 2.3 Persistent per-model timing history
Processing time vs audio length is, to first order, **linear**: `proc ≈ a + b·audio`, where `b` is roughly the inverse real-time factor and `a` is fixed overhead. We fit `a, b` per model (tiny.en and base.en behave very differently) with an **online least-squares** accumulator, with a gentle decay so the model adapts to thermal throttling / machine load.
- **Key by model filename** (e.g. `ggml-tiny.en.bin`), since speed is model-dependent.
- **Cold start:** before we have ≥2 samples, use baked-in defaults (rough seeds for a 2-core i5-7th-gen; they self-correct after a run or two):
- tiny.en: `a ≈ 0.3s`, `b ≈ 0.45` (≈2.2× real-time)
- base.en: `a ≈ 0.5s`, `b ≈ 1.1` (≈0.9× real-time)
- (These are only seeds; the regression takes over quickly.)
- **Persist** alongside the existing `win-dictation.ini` using the same `WritePrivateProfileString` style you already use in `settings.h`, one section per model holding the five accumulators.
## 2.4 The live estimator (smooth countdown + fusion)
State: `T_hat` (current best total-time estimate), `disp_rem` (displayed remaining, monotonic), `t` (seconds since start).
- **begin(T_pred):** `T_hat = disp_rem = max(0.4, T_pred)`, `t = 0`.
- **on_whisper(t_now, p):** ignore `p < 5` (noisy). Else measured total `T_meas = 100·t_now / p`; fuse: `T_hat = (1−α)·T_hat + α·T_meas` with `α ≈ 0.5`. This is where whisper "adjusts our countdown" — it moves the estimate, not the displayed number directly, so there's never a visible jump.
- **tick(dt):** the smoothing rules that make it feel solid:
1. Always count down in real time: `disp_rem -= dt`.
2. Pull toward the model's `raw_rem = max(0, T_hat t)`, but **only ever downward**, and **rate-limited**:
- `err = raw_rem disp_rem`
- if `err < 0` (we're behind → need to speed up): `disp_rem += max(err, maxCatchUp·dt)` (bounded extra shrink, no snap)
- if `err ≥ 0` (we have more headroom than shown): **do nothing** — never push remaining up. The bar simply keeps easing and parks near 95% if we under-predicted.
3. Clamp `disp_rem ≥ 0`.
4. Derive fraction from the same numbers: `frac = t / (t + disp_rem)`, clamp to **0.95**. Because `t` only rises and `disp_rem` only falls, `frac` only rises — smooth, monotonic, no jumps.
- **on_result:** snap `frac → 1.0`; record `(audio_seconds, t)` into the timing model and persist.
This guarantees: **ETA only counts down** (bug fixed), **% only rises smoothly** (no 34→72 jumps), whisper's coarse measurements **gently re-aim** the countdown, and there's **motion from frame 1** (no dead start). On a sub-30s clip with no whisper updates, it runs purely on the learned prediction — exactly what you asked for.
## 2.5 `timing.h` — full code
```cpp
#pragma once
#include <windows.h>
#include <string>
#include <cmath>
#include <algorithm>
// ---------------------------------------------------------------------------
// Online linear model: proc_sec ~= a + b * audio_sec, fitted per whisper model.
// Decayed least squares so it adapts to thermal / load drift over time.
// ---------------------------------------------------------------------------
struct TimingModel {
double n=0, sx=0, sy=0, sxx=0, sxy=0; // decayed accumulators
double a=0, b=0; // fitted intercept / slope
bool fitted=false;
double def_a=0.4, def_b=0.6; // cold-start seeds (set per model)
void recompute() {
if (n >= 2.0) {
double denom = n*sxx - sx*sx;
if (std::fabs(denom) > 1e-9) {
double bb = (n*sxy - sx*sy) / denom;
double aa = (sy - bb*sx) / n;
if (bb < 0.02) bb = def_b; // guard against degenerate fits
if (aa < 0.0) aa = 0.0;
a=aa; b=bb; fitted=true; return;
}
}
a=def_a; b=def_b; fitted=false;
}
double predict(double audio_sec) const {
double t = (fitted ? a : def_a) + (fitted ? b : def_b) * audio_sec;
return std::max(0.4, t);
}
void add_sample(double audio_sec, double proc_sec) {
const double decay = 0.97; // ~30-sample memory
n*=decay; sx*=decay; sy*=decay; sxx*=decay; sxy*=decay;
n+=1; sx+=audio_sec; sy+=proc_sec;
sxx+=audio_sec*audio_sec; sxy+=audio_sec*proc_sec;
recompute();
}
};
// ---------------------------------------------------------------------------
// Live estimator: smooth, monotonic countdown fused with whisper's progress.
// ---------------------------------------------------------------------------
struct ProgressEstimator {
double T_hat=1.0, disp_rem=1.0, t=0.0;
bool done=false;
void begin(double T_pred) {
T_hat = std::max(0.4, T_pred);
disp_rem = T_hat; t = 0.0; done=false;
}
void on_whisper(double t_now, int p) { // p in (0,100]
if (done || p < 5) return;
double T_meas = 100.0 * t_now / (double)p;
const double alpha = 0.5; // how much we trust the measurement
T_hat = (1.0-alpha)*T_hat + alpha*T_meas;
if (T_hat < t_now) T_hat = t_now; // never imply we're already done
}
// dt seconds since last tick. Outputs eased fraction [0,1] and remaining secs.
void tick(double dt, float& out_frac, float& out_remaining) {
if (done) { out_frac=1.0f; out_remaining=0.0f; return; }
t += dt;
disp_rem -= dt; // (1) real-time countdown
double raw_rem = std::max(0.0, T_hat - t);
const double maxCatchUp = 2.5; // cap speed-up (×realtime)
double err = raw_rem - disp_rem;
if (err < 0) disp_rem += std::max(err, -maxCatchUp*dt); // (2) shrink only
if (disp_rem < 0) disp_rem = 0; // (3)
double frac = (t + disp_rem > 1e-6) ? t/(t+disp_rem) : 0.0;
if (frac > 0.95) frac = 0.95; // (4) hold until result
out_frac = (float)frac;
out_remaining = (float)disp_rem;
}
void finish(float& out_frac, float& out_remaining) {
done=true; out_frac=1.0f; out_remaining=0.0f;
}
};
// ---------------------------------------------------------------------------
// Persistence (same ini style as settings.h). Section = model base filename.
// ---------------------------------------------------------------------------
inline std::wstring TimingIniPath() {
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 std::wstring SectionFor(const std::string& modelPath) {
std::string base = modelPath.substr(modelPath.find_last_of("\\/")+1);
return L"timing-" + std::wstring(base.begin(), base.end());
}
inline void PutD(const std::wstring& sec, const wchar_t* k, double v) {
wchar_t b[64]; swprintf_s(b, L"%.6f", v);
WritePrivateProfileStringW(sec.c_str(), k, b, TimingIniPath().c_str());
}
inline double GetD(const std::wstring& sec, const wchar_t* k, double d) {
wchar_t b[64]; swprintf_s(b, L"%.6f", d);
wchar_t out[64];
GetPrivateProfileStringW(sec.c_str(), k, b, out, 64, TimingIniPath().c_str());
return wcstod(out, nullptr);
}
inline void LoadTiming(TimingModel& m, const std::string& modelPath) {
auto s = SectionFor(modelPath);
m.n=GetD(s,L"n",0); m.sx=GetD(s,L"sx",0); m.sy=GetD(s,L"sy",0);
m.sxx=GetD(s,L"sxx",0); m.sxy=GetD(s,L"sxy",0);
m.recompute();
}
inline void SaveTiming(const TimingModel& m, const std::string& modelPath) {
auto s = SectionFor(modelPath);
PutD(s,L"n",m.n); PutD(s,L"sx",m.sx); PutD(s,L"sy",m.sy);
PutD(s,L"sxx",m.sxx); PutD(s,L"sxy",m.sxy);
}
// Set per-model cold-start seeds when (re)loading a model.
inline void SeedDefaults(TimingModel& m, const std::string& modelPath) {
std::string p = modelPath;
auto has = [&](const char* s){ return p.find(s)!=std::string::npos; };
if (has("tiny")) { m.def_a=0.3; m.def_b=0.45; }
else if (has("base")) { m.def_a=0.5; m.def_b=1.10; }
else if (has("small")){ m.def_a=0.8; m.def_b=3.00; }
else { m.def_a=0.5; m.def_b=1.00; }
m.recompute();
}
```
## 2.6 Wiring into main.cpp
Add globals and capture the audio length **before** `stop_and_transcribe()` swaps the buffer away:
```cpp
TimingModel g_timing;
ProgressEstimator g_est;
double g_lastAudioLen = 0.0; // seconds of the clip being transcribed
DWORD g_lastTick = 0;
```
**At model load / model switch** (where you set `g_config.model_path`), seed + load history:
```cpp
SeedDefaults(g_timing, g_config.model_path);
LoadTiming(g_timing, g_config.model_path);
```
**On STOP → transcribe** (the `HK_TOGGLE` stop branch and the max-length branch):
```cpp
g_lastAudioLen = g_tx.recorded_seconds(); // BEFORE stop swaps the buffer
g_busyStart = GetTickCount();
g_lastTick = g_busyStart;
g_est.begin(g_timing.predict(g_lastAudioLen)); // bar moves from frame 1
g_progress = 0;
g_cancelRequested = false;
g_tx.stop_and_transcribe();
```
**whisper progress** (`WM_APP_PROGRESS`) becomes a *correction*, not the display source:
```cpp
case WM_APP_PROGRESS: {
double t_now = (GetTickCount() - g_busyStart) / 1000.0;
g_est.on_whisper(t_now, (int)wParam);
InvalidateRect(hWnd, &g_vuRect, FALSE);
return 0;
}
```
**The UI timer** (`WM_TIMER`, while `g_tx.is_busy()`) advances the estimator and paints:
```cpp
DWORD now = GetTickCount();
float dt = (now - g_lastTick) / 1000.0f; g_lastTick = now;
float frac, remain;
g_est.tick(dt, frac, remain);
g_progressFrac = frac; // float 0..1 used by DrawProgress
g_progressRemain = remain; // seconds, for the ETA label
InvalidateRect(hWnd, &g_vuRect, FALSE);
```
**Status text** (replaces the counts-up math entirely):
```cpp
int mm = (int)g_lastAudioLen/60, ss=(int)g_lastAudioLen%60;
int pct = (int)(g_progressFrac*100.0f + 0.5f);
int rem = (int)(g_progressRemain + 0.5f);
swprintf_s(buf, L"Transcribing %d:%02d · %d%% · %ds left", mm, ss, pct, rem);
```
**On result** (`WM_APP_RESULT`): snap, learn, persist:
```cpp
float frac, remain; g_est.finish(frac, remain);
g_progressFrac = 1.0f; g_progressRemain = 0.0f;
double actual = (GetTickCount() - g_busyStart) / 1000.0;
if (g_lastAudioLen > 0.5 && actual > 0.2 && !g_cancelRequested) {
g_timing.add_sample(g_lastAudioLen, actual);
SaveTiming(g_timing, g_config.model_path);
}
```
**`DrawProgress`** already takes a fraction — just feed `g_progressFrac` instead of `g_progress/100.0f`, and optionally add a subtle animated shimmer on the fill for life.
## 2.7 Tuning & edge cases
- **`alpha` (whisper trust)** 0.5 is a good start. Lower (0.3) = smoother but slower to correct; higher (0.7) = snappier, slightly jumpier.
- **`maxCatchUp`** (2.5× real-time) caps how fast the countdown may accelerate when we over-predicted, so a correction never looks like a snap. Raise for faster catch-up, lower for calmer motion.
- **Under-prediction** (transcription takes longer than estimated): `frac` parks at 95% and the ETA sits at a small floor until the result lands — which is the honest, expected behavior.
- **Decay `0.97`** ≈ last ~30 runs dominate. Increase toward 0.99 for steadier long-term averages, decrease for faster adaptation to a throttling machine.
- **Cancel / no-speech:** call `g_est.finish(...)`, reset `g_progressFrac=0`, and **don't** record a sample.
- **Model switch mid-history:** because history is keyed per model, switching tiny.en↔base.en uses the right curve automatically.
- **Sanity clamp:** keep `predict()`'s floor (0.4s) so ultra-short clips still show a brief, graceful sweep rather than instant 100%.
- **Optional richer model:** if you ever want better fits on very short vs long clips, swap the linear `a+b·x` for a two-segment fit (sub-30s vs ≥30s) — the accumulators and API stay the same; just keep two `TimingModel`s.
---
# Part 3 — Cleanup checklist
Smaller items that make the project cleaner and the app feel finished:
- [ ] **Kill the pale scrollbar** (dark-mode theme or custom slim scrollbar) — Section 1.7.
- [ ] **Delete all owner-draw chrome child windows**; keep only the transcript `EDIT` — Section 1.8.
- [ ] **Remove every `StrokeRound(..., C_BORDER, …)`**; rely on fills + elevation.
- [ ] **One animation clock** that idles when nothing moves (protect the 2-core CPU).
- [ ] **DPI Per-Monitor-V2** + scaled metrics + font reload on `WM_DPICHANGED`.
- [ ] **Reconcile the docs with reality.** `README.md`, `src/README.md`, and `src/CHANGES.md` still describe the *old* streaming architecture — "ring buffer", "24 threads", "VAD", "step_ms/length_ms", "<1s real-time GPU". The app is now **push-to-talk batch, CPU, physical-core threads, `whisper_full` once on stop**. Update or archive those docs so future-you isn't misled. (The `CUDA-SETUP.md` / `QUICK-REBUILD-GPU.md` RTX-3090 guides don't apply to the target Dell i5 either.)
- [ ] **Status copy:** "Ready · N threads" is good; make the idle/recording/transcribing strings come from one place.
- [ ] **Remove dead members** once streaming is gone (any leftover `step_ms`/`length_ms`/VAD config that no longer feeds `whisper_full`).
---
# Part 4 — Suggested build order
Do them in this sequence so each step is verifiable on its own:
1. **Progress system first** (Part 2). It's self-contained, low-risk, and immediately fixes the most visible "is it even working?" problem. You'll see a smooth countdown the same day.
2. **DPI + tokens** (1.4 / 1.7). Small, mechanical, and everything after looks better for it.
3. **Single-surface conversion** (1.6 / 1.8): convert one widget at a time — start with the ghost buttons (highest hairline payoff), then the hero, then the selects, then retire the STATICs.
4. **Transcript chrome + scrollbar** (1.7).
5. **Motion polish** (1.4): hover cross-fades, recording breathing, waveform, progress shimmer.
6. **Docs reconciliation** (Part 3).
7. *(Optional later)* **Direct2D/DirectWrite** (1.3) if you want the premium ceiling — the data model and Part 2 carry over unchanged.
---
*Build target reminder: native Win32 C++, MSVC Release, CPU-only on a 2-core / 4-thread i5-7th-gen. Keep the idle CPU near zero — animate only when something is actually moving.*
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,790 @@
# Win Dictation — Crash Fixes & Modern UI (Build 2)
This document has two parts:
- **Part 1 — Critical fixes** (do these first): the Stop crash, resizing, model switching, thread count, model path.
- **Part 2 — Modern UI**: replace the boxy Win32 look with a flat, rounded, dark "web-app" interface (GDI+ custom drawing).
Notes before you start:
- All snippets target your current `main.cpp` / `transcriber.cpp` / `transcriber.h`.
- I can't compile on my side — integrate a piece at a time and build often. If a `DWMWA_*` or glyph constant is missing, see the `#define` block in §2.3.
- Part 2's `LayoutControls()` (§2.12) is the final layout and supersedes any basic one from Part 1.
---
# Part 1 — Critical Fixes
## 1.1 The Stop crash (null Whisper context)
**Symptom:** record + VU work, then it crashes the moment you hit Stop.
**Root cause:** two defects combine.
1. In `wWinMain`, the preload thread sets `g_modelLoaded = true` *even when `preload()` returns false* (model file not found). So the UI shows "Ready" and lets you record with no model loaded.
2. `transcribe_worker()` calls `whisper_reset_timings(m_ctx)` **before** the `if (m_ctx)` check. With `m_ctx == nullptr` that's an immediate access violation. Recording never touches Whisper (that's why capture + VU work) — the crash only fires when transcription starts.
The empty model dropdown in your screenshot confirms it: `RefreshModelList()` found no model files, so `preload()` failed silently.
### Fix A — make the worker null-safe (`transcriber.cpp`)
Replace the top of `transcribe_worker` and delete the `whisper_reset_timings` call entirely (it isn't needed for one-shot transcription):
```cpp
void Transcriber::transcribe_worker(std::vector<float> audio) {
// Hard guard: if the model failed to load, never touch whisper.
if (!m_ctx) {
m_busy = false;
if (m_on_result) m_on_result(""); // UI will show "No speech / not loaded"
return;
}
if (m_cfg.trim_silence) trim_silence(audio);
whisper_full_params wp = whisper_full_default_params(WHISPER_SAMPLING_GREEDY);
wp.print_progress = false; wp.print_realtime = false; wp.print_timestamps = false;
wp.no_timestamps = true; wp.translate = false;
wp.language = m_cfg.language.c_str();
wp.n_threads = m_cfg.n_threads;
wp.no_context = true; wp.suppress_blank = true; wp.suppress_nst = true;
wp.temperature = 0.0f;
// (whisper_reset_timings removed — it was the crash site and is unnecessary)
std::string out;
if (whisper_full(m_ctx, wp, audio.data(), (int)audio.size()) == 0) {
int n = whisper_full_n_segments(m_ctx);
for (int i = 0; i < n; ++i) {
const char* t = whisper_full_get_segment_text(m_ctx, i);
if (t) out += t;
}
out = clean_text(out);
}
m_busy = false;
if (m_on_result) m_on_result(out);
}
```
### Fix B — track load success honestly (`main.cpp`)
Add an atomic next to `g_modelLoaded`:
```cpp
std::atomic<bool> g_modelLoaded{false};
std::atomic<bool> g_modelOk{false};
```
Fix the preload lambda so failure is recorded:
```cpp
std::thread([] {
bool ok = g_tx.preload(g_config); // false if the .bin isn't found
g_modelOk = ok;
g_modelLoaded = true;
}).detach();
```
### Fix C — refuse to record without a model (`main.cpp`, `WM_HOTKEY`)
This both prevents the crash path and tells you *why* nothing happens:
```cpp
case WM_HOTKEY:
if (wParam == HK_TOGGLE) {
if (g_tx.is_busy()) break; // mid-transcription: ignore
if (!g_tx.is_recording()) {
if (!g_modelLoaded.load()) { SetStatus(hWnd, L"Loading model…"); break; }
if (!g_modelOk.load()) {
std::wstring m = L"Model not found:\n" + to_w(g_config.model_path)
+ L"\n\nPut the .bin there and restart.";
MessageBoxW(hWnd, m.c_str(), L"Dictation", MB_OK | MB_ICONWARNING);
break;
}
g_prevForeground = GetForegroundWindow();
ShowWindow(hWnd, SW_SHOWNA);
g_recordingSecs = 0;
if (g_tx.start_recording()) {
SetDlgItemText(hWnd, ID_BTN_RECORD, L"■ Stop");
SetStatus(hWnd, L"Recording…");
} else SetStatus(hWnd, L"Microphone error");
} else {
g_tx.stop_and_transcribe();
SetDlgItemText(hWnd, ID_BTN_RECORD, L"Record");
SetStatus(hWnd, L"Transcribing…");
}
} else if (wParam == HK_HIDE) {
if (g_tx.is_recording()) g_tx.cancel();
ShowWindow(hWnd, SW_HIDE);
}
break;
```
After this, a missing model shows **"Model not found: …\models\ggml-tiny.en.bin"** instead of crashing.
## 1.2 Verify the model file
The app now loads the model from a path relative to the **.exe**:
`exe_dir() + "\\models\\ggml-tiny.en.bin"`. Confirm it's actually there:
```
dir build\bin\Release\models
```
You should see `ggml-tiny.en.bin`. If it's missing, re-run `build.ps1`, or drop the `.bin` into that `models\` folder manually. (Your CMake post-build step copies `models/` next to the exe — if the source `models/` had no `.bin` at build time, nothing got copied.)
## 1.3 Resizable window
Two reasons it's frozen: the window style has no sizing border, and there's no `WM_SIZE` handler so controls never reflow.
### Window style (`main.cpp`, `CreateWindowExW`)
> If you do Part 2 Option A (§2.3), keep a normal frame as shown here. If you do the borderless Option B (appendix), that section replaces this.
```cpp
hMainWnd = CreateWindowExW(
WS_EX_TOPMOST,
L"WhisperDictationClass", L"Dictation",
WS_OVERLAPPEDWINDOW, // caption + sysmenu + THICKFRAME + min/max = resizable
CW_USEDEFAULT, CW_USEDEFAULT, 400, 340,
nullptr, nullptr, hInstance, nullptr);
```
### Handlers (`main.cpp`, `WndProc`)
```cpp
case WM_SIZE:
LayoutControls(hWnd, LOWORD(lParam), HIWORD(lParam)); // defined in §2.12
return 0;
case WM_GETMINMAXINFO:
((MINMAXINFO*)lParam)->ptMinTrackSize.x = 340;
((MINMAXINFO*)lParam)->ptMinTrackSize.y = 280;
return 0;
```
Control positions in `InitializeUI` no longer matter — `LayoutControls` owns geometry. Add one explicit call right after `ShowWindow` so the first frame is laid out:
```cpp
RECT rc; GetClientRect(hMainWnd, &rc);
LayoutControls(hMainWnd, rc.right, rc.bottom);
```
## 1.4 Model switching + correct thread count
Two real bugs you'll hit:
- The model dropdown only lists files that **exist**, so its selected index does **not** map to `kModelFiles[]`.
- `preload()` early-returns when `m_ctx` is already set, so switching models **never reloads**.
- The status bar shows `hardware_concurrency()` (4) instead of the threads actually used (2).
### transcriber.h — add a reload + a threads getter
```cpp
bool reload(const WhisperConfig& cfg); // free + re-init with a new model
int threads() const { return m_cfg.n_threads; }
```
### transcriber.cpp — implement reload
```cpp
bool Transcriber::reload(const WhisperConfig& cfg) {
if (m_recording.load() || m_busy.load()) return false; // not mid-use
if (m_worker.joinable()) m_worker.join();
if (m_ctx) { whisper_free(m_ctx); m_ctx = nullptr; }
return preload(cfg);
}
```
### main.cpp — track the real file paths the combo shows
Replace the static arrays + `RefreshModelList` + the `ID_COMBO_MODEL` handler:
```cpp
static const wchar_t* kModelNames[] = { L"tiny.en", L"tiny.en-q8_0", L"base.en-q5_1", L"base.en" };
static const char* kModelFiles[] = {
"models\\ggml-tiny.en.bin", "models\\ggml-tiny.en-q8_0.bin",
"models\\ggml-base.en-q5_1.bin", "models\\ggml-base.en.bin",
};
std::vector<std::string> g_modelComboPaths; // parallel to combo entries
void RefreshModelList(HWND hwnd) {
HWND hCombo = GetDlgItem(hwnd, ID_COMBO_MODEL);
SendMessage(hCombo, CB_RESETCONTENT, 0, 0);
g_modelComboPaths.clear();
std::string dir = exe_dir();
for (int i = 0; i < 4; ++i) {
std::string full = dir + "\\" + kModelFiles[i];
if (GetFileAttributesA(full.c_str()) != INVALID_FILE_ATTRIBUTES) {
SendMessage(hCombo, CB_ADDSTRING, 0, (LPARAM)kModelNames[i]);
g_modelComboPaths.push_back(kModelFiles[i]); // remember the real file
}
}
if (!g_modelComboPaths.empty()) SendMessage(hCombo, CB_SETCURSEL, 0, 0);
}
```
```cpp
case ID_COMBO_MODEL:
if (HIWORD(wParam) == CBN_SELCHANGE) {
int idx = (int)SendMessage(GetDlgItem(hWnd, ID_COMBO_MODEL), CB_GETCURSEL, 0, 0);
if (idx >= 0 && idx < (int)g_modelComboPaths.size()) {
g_config.model_path = exe_dir() + "\\" + g_modelComboPaths[idx];
g_modelLoaded = false; g_modelOk = false;
SetStatus(hWnd, L"Loading model…");
std::thread([] {
bool ok = g_tx.reload(g_config); // <- reload, not preload
g_modelOk = ok; g_modelLoaded = true;
}).detach();
}
}
break;
```
### Correct thread count in the status (`UpdateStatus`)
```cpp
} else if (g_modelLoaded.load()) {
if (!g_modelOk.load()) { SetStatus(hwnd, L"Model not found — check models folder"); return; }
swprintf_s(buf, L"Ready • %d threads", g_tx.threads());
SetStatus(hwnd, buf);
}
```
---
# Part 2 — Modern UI (flat, rounded, dark)
## 2.1 Design language
The "boxy" feeling comes from three things: the gray 3-D system buttons, the chunky title bar, and zero breathing room. We fix all three.
- **Palette** — near-black canvas, one elevated surface tone, a single indigo accent, hairline borders. (Linear/Raycast register.)
- **Shape** — everything rounded: 10px buttons/fields, 14px panels, rounded window corners.
- **Type** — Segoe UI Variable / Segoe UI, a clear size hierarchy, no monospace for the transcript (sans reads more "app").
- **Space** — 16px outer margin, 1012px gaps, the transcript is the hero and grows with the window.
- **Motion** — a soft pulse on the record button while recording; subtle hover/press on buttons.
| Token | Hex | Use |
|---|---|---|
| Bg | `#0F1115` | window canvas |
| Surface | `#181B22` | fields, transcript panel |
| SurfaceHi | `#20242D` | hover |
| Border | `#262B36` | 1px hairlines |
| Text | `#E7E9EE` | primary text |
| TextDim | `#9AA0AB` | status, placeholders |
| Accent | `#6E8BFF` | record idle, focus |
| AccentHi | `#5B7BFF` | accent hover |
| Danger | `#FF5C5C` | recording / stop |
| Good | `#46D39A` | VU meter |
## 2.2 How it's built
- **GDI+** does the drawing — it anti-aliases rounded rectangles and supports alpha, so we get smooth corners without Direct2D.
- **Owner-drawn child buttons** for Record / Copy / Paste / Pin (we paint them; Windows still gives us click + focus).
- **Parent-painted** background, transcript panel frame, and the VU meter (in `WM_PAINT`).
- **Native EDIT** stays for the transcript (so selection/scroll work) but flat-themed with padding; a STATIC shows placeholder text when empty.
- **Owner-drawn comboboxes** for mic/model so they match the dark theme.
- A tiny **button subclass** gives reliable hover.
This keeps real controls (accessibility, IME, scrolling) while looking custom.
## 2.3 Setup: GDI+ and a dark, rounded window (Option A — recommended)
Option A keeps the native frame but recolors it dark and rounds the corners — low-risk and modern. (Option B, fully borderless custom title bar, is in the appendix.)
At the top of `main.cpp`:
```cpp
#include <gdiplus.h>
#pragma comment(lib, "gdiplus.lib")
using namespace Gdiplus;
// DWM attributes (define in case your SDK headers are older)
#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE
#define DWMWA_USE_IMMERSIVE_DARK_MODE 20
#endif
#ifndef DWMWA_BORDER_COLOR
#define DWMWA_BORDER_COLOR 34
#endif
#ifndef DWMWA_CAPTION_COLOR
#define DWMWA_CAPTION_COLOR 35
#endif
#ifndef DWMWA_TEXT_COLOR
#define DWMWA_TEXT_COLOR 36
#endif
#ifndef DWMWA_WINDOW_CORNER_PREFERENCE
#define DWMWA_WINDOW_CORNER_PREFERENCE 33
#endif
#ifndef DWMWCP_ROUND
#define DWMWCP_ROUND 2
#endif
```
Start/stop GDI+ in `wWinMain`:
```cpp
ULONG_PTR g_gdipToken = 0;
// near the top of wWinMain, before creating the window:
GdiplusStartupInput gdipIn;
GdiplusStartup(&g_gdipToken, &gdipIn, nullptr);
// ... after the message loop, before return:
GdiplusShutdown(g_gdipToken);
```
After the window is created, theme the frame (Win11 honors all of these; Win10 ignores caption/border color but keeps dark mode):
```cpp
BOOL dark = TRUE;
DwmSetWindowAttribute(hMainWnd, DWMWA_USE_IMMERSIVE_DARK_MODE, &dark, sizeof(dark));
COLORREF cap = RGB(0x0F,0x11,0x15), bord = RGB(0x26,0x2B,0x36), txt = RGB(0xE7,0xE9,0xEE);
DwmSetWindowAttribute(hMainWnd, DWMWA_CAPTION_COLOR, &cap, sizeof(cap));
DwmSetWindowAttribute(hMainWnd, DWMWA_BORDER_COLOR, &bord, sizeof(bord));
DwmSetWindowAttribute(hMainWnd, DWMWA_TEXT_COLOR, &txt, sizeof(txt));
int corner = DWMWCP_ROUND;
DwmSetWindowAttribute(hMainWnd, DWMWA_WINDOW_CORNER_PREFERENCE, &corner, sizeof(corner));
```
## 2.4 Theme constants + fonts (`main.cpp`)
```cpp
// GDI+ colors
static const Color C_BG (255,0x0F,0x11,0x15);
static const Color C_SURFACE (255,0x18,0x1B,0x22);
static const Color C_SURFACEHI(255,0x20,0x24,0x2D);
static const Color C_BORDER (255,0x26,0x2B,0x36);
static const Color C_TEXT (255,0xE7,0xE9,0xEE);
static const Color C_TEXTDIM (255,0x9A,0xA0,0xAB);
static const Color C_ACCENT (255,0x6E,0x8B,0xFF);
static const Color C_ACCENTHI (255,0x5B,0x7B,0xFF);
static const Color C_DANGER (255,0xFF,0x5C,0x5C);
static const Color C_GOOD (255,0x46,0xD3,0x9A);
// COLORREF mirrors for GDI (WM_CTLCOLOR*)
#define CR_BG RGB(0x0F,0x11,0x15)
#define CR_SURFACE RGB(0x18,0x1B,0x22)
#define CR_TEXT RGB(0xE7,0xE9,0xEE)
HFONT g_fUI=nullptr, g_fUISemi=nullptr, g_fSmall=nullptr, g_fText=nullptr;
HBRUSH g_brBg=nullptr, g_brSurface=nullptr;
static HFONT MakeFont(int px, int weight) {
return CreateFontW(-px,0,0,0,weight,FALSE,FALSE,FALSE,DEFAULT_CHARSET,
OUT_DEFAULT_PRECIS,CLIP_DEFAULT_PRECIS,CLEARTYPE_QUALITY,
DEFAULT_PITCH|FF_DONTCARE,L"Segoe UI Variable Display");
}
// build once in wWinMain:
// g_fUI = MakeFont(15, FW_NORMAL); g_fUISemi = MakeFont(15, FW_SEMIBOLD);
// g_fSmall = MakeFont(12, FW_NORMAL); g_fText = MakeFont(16, FW_NORMAL);
// g_brBg = CreateSolidBrush(CR_BG); g_brSurface = CreateSolidBrush(CR_SURFACE);
// (If "Segoe UI Variable Display" is unavailable it falls back to Segoe UI.)
```
## 2.5 GDI+ helpers
```cpp
static void RoundPath(GraphicsPath& p, const Rect& r, int rad) {
int d = rad * 2;
p.AddArc(r.X, r.Y, d, d, 180, 90);
p.AddArc(r.GetRight()-d, r.Y, d, d, 270, 90);
p.AddArc(r.GetRight()-d, r.GetBottom()-d, d, d, 0, 90);
p.AddArc(r.X, r.GetBottom()-d, d, d, 90, 90);
p.CloseFigure();
}
static void FillRound(Graphics& g, const Color& c, const Rect& r, int rad) {
GraphicsPath p; RoundPath(p, r, rad); SolidBrush b(c); g.FillPath(&b, &p);
}
static void StrokeRound(Graphics& g, const Color& c, const Rect& r, int rad, REAL w=1.0f) {
GraphicsPath p; RoundPath(p, r, rad); Pen pen(c, w); g.DrawPath(&pen, &p);
}
static void DrawTextC(Graphics& g, const wchar_t* s, const Font& f, const Color& c,
const RectF& box, StringAlignment h, StringAlignment v) {
StringFormat sf; sf.SetAlignment(h); sf.SetLineAlignment(v);
sf.SetTrimming(StringTrimmingEllipsisCharacter);
SolidBrush b(c); g.DrawString(s, -1, &f, box, &sf, &b);
}
```
## 2.6 Reliable hover (button subclass)
Owner-draw buttons don't reliably get hover. This subclass tracks it and stores a 0/1 hover flag in the window's user data, repainting on enter/leave.
```cpp
LRESULT CALLBACK BtnProc(HWND h, UINT m, WPARAM w, LPARAM l, UINT_PTR, DWORD_PTR) {
if (m == WM_MOUSEMOVE) {
if (!GetWindowLongPtr(h, GWLP_USERDATA)) {
SetWindowLongPtr(h, GWLP_USERDATA, 1);
TRACKMOUSEEVENT t{ sizeof(t), TME_LEAVE, h, 0 };
TrackMouseEvent(&t);
InvalidateRect(h, nullptr, FALSE);
}
} else if (m == WM_MOUSELEAVE) {
SetWindowLongPtr(h, GWLP_USERDATA, 0);
InvalidateRect(h, nullptr, FALSE);
}
return DefSubclassProc(h, m, w, l); // needs <commctrl.h> (already included)
}
// after creating each owner-draw button:
// SetWindowSubclass(hBtn, BtnProc, 1, 0);
```
(Add `#pragma comment(lib, "comctl32.lib")` is already present; `SetWindowSubclass` lives in `<commctrl.h>`.)
## 2.7 The record button (hero, pulsing)
`ID_BTN_RECORD` is an owner-draw button drawn as a rounded pill: accent "● Record" when idle, danger "■ Stop" with a soft brightness pulse while recording. The dot/square are drawn shapes (no icon-font dependency).
```cpp
void DrawRecordButton(LPDRAWITEMSTRUCT d) {
Graphics g(d->hDC); g.SetSmoothingMode(SmoothingModeAntiAlias);
Rect rc(d->rcItem.left, d->rcItem.top,
d->rcItem.right-d->rcItem.left, d->rcItem.bottom-d->rcItem.top);
// paint the parent canvas in the corners first
SolidBrush bg(C_BG); g.FillRectangle(&bg, rc);
bool rec = g_tx.is_recording();
bool hover = GetWindowLongPtr(d->hwndItem, GWLP_USERDATA) != 0;
bool pressed = (d->itemState & ODS_SELECTED) != 0;
Color fill = rec ? C_DANGER : (hover ? C_ACCENTHI : C_ACCENT);
if (rec) { // gentle pulse
double ph = (GetTickCount() % 1400) / 1400.0;
int add = (int)(18 * (0.5 + 0.5 * sin(ph * 6.2831853)));
fill = Color(255, min(255,0xFF), min(255,0x5C+add), min(255,0x5C+add));
}
if (pressed) fill = Color(255, GetRValue(0)+ (BYTE)(fill.GetR()*0.85),
(BYTE)(fill.GetG()*0.85), (BYTE)(fill.GetB()*0.85));
Rect pill = rc; pill.Inflate(-1,-1);
FillRound(g, fill, pill, pill.Height/2); // full pill
// icon: filled circle (idle) or rounded square (recording)
int cx = pill.X + 22, cy = pill.Y + pill.Height/2;
SolidBrush white(Color(255,255,255,255));
if (rec) { Rect sq(cx-7, cy-7, 14, 14); FillRound(g, Color(255,255,255,255), sq, 3); }
else { g.FillEllipse(&white, cx-7, cy-7, 14, 14); }
Font f(d->hDC, g_fUISemi);
RectF tb((REAL)(pill.X+36), (REAL)pill.Y, (REAL)(pill.Width-44), (REAL)pill.Height);
DrawTextC(g, rec ? L"Stop" : L"Record", f, Color(255,255,255,255),
tb, StringAlignmentNear, StringAlignmentCenter);
}
```
While recording, repaint it each tick so the pulse animates — in `WM_TIMER`:
```cpp
if (g_tx.is_recording()) InvalidateRect(GetDlgItem(hWnd, ID_BTN_RECORD), nullptr, FALSE);
```
## 2.8 Flat buttons (Copy / Paste / Pin)
Generic owner-draw for secondary buttons — a flat surface chip that lifts on hover, accent text. Pin shows a filled dot when active.
```cpp
void DrawFlatButton(LPDRAWITEMSTRUCT d, const wchar_t* label, bool active=false) {
Graphics g(d->hDC); g.SetSmoothingMode(SmoothingModeAntiAlias);
Rect rc(d->rcItem.left, d->rcItem.top,
d->rcItem.right-d->rcItem.left, d->rcItem.bottom-d->rcItem.top);
SolidBrush bg(C_BG); g.FillRectangle(&bg, rc);
bool hover = GetWindowLongPtr(d->hwndItem, GWLP_USERDATA) != 0;
bool pressed = (d->itemState & ODS_SELECTED) != 0;
Rect chip = rc; chip.Inflate(-1,-1);
FillRound(g, pressed ? C_SURFACE : (hover ? C_SURFACEHI : C_SURFACE), chip, 10);
StrokeRound(g, C_BORDER, chip, 10, 1.0f);
Font f(d->hDC, g_fUI);
Color tc = active ? C_ACCENT : C_TEXT;
RectF tb((REAL)chip.X, (REAL)chip.Y, (REAL)chip.Width, (REAL)chip.Height);
DrawTextC(g, label, f, tc, tb, StringAlignmentCenter, StringAlignmentCenter);
}
```
In `WM_DRAWITEM`, route each control:
```cpp
case WM_DRAWITEM: {
LPDRAWITEMSTRUCT d = (LPDRAWITEMSTRUCT)lParam;
switch (d->CtlID) {
case ID_BTN_RECORD: DrawRecordButton(d); return TRUE;
case ID_BTN_COPY: DrawFlatButton(d, L"Copy"); return TRUE;
case ID_BTN_PASTE: DrawFlatButton(d, L"Paste"); return TRUE;
case ID_BTN_PIN: DrawFlatButton(d, g_pinned ? L"Pinned" : L"Pin", g_pinned); return TRUE;
case ID_COMBO_AUDIO:
case ID_COMBO_MODEL: DrawCombo(d); return TRUE; // §2.9
}
break;
}
```
Make Copy/Paste/Pin owner-draw too (add `BS_OWNERDRAW`) and subclass them for hover:
```cpp
// in InitializeUI, create with BS_OWNERDRAW, then:
SetWindowSubclass(hCopy, BtnProc, 1, 0);
SetWindowSubclass(hPaste, BtnProc, 1, 0);
SetWindowSubclass(hPin, BtnProc, 1, 0);
SetWindowSubclass(GetDlgItem(hwnd, ID_BTN_RECORD), BtnProc, 1, 0);
```
## 2.9 Mic & model selectors (dark combos)
Make both combos `CBS_DROPDOWNLIST | CBS_OWNERDRAWFIXED | CBS_HASSTRINGS`. Set row height once, then draw field + list items dark with a drawn chevron.
```cpp
case WM_MEASUREITEM: {
auto* mi = (LPMEASUREITEMSTRUCT)lParam;
if (mi->CtlType == ODT_COMBOBOX) mi->itemHeight = 26;
return TRUE;
}
```
```cpp
void DrawCombo(LPDRAWITEMSTRUCT d) {
Graphics g(d->hDC); g.SetSmoothingMode(SmoothingModeAntiAlias);
Rect rc(d->rcItem.left, d->rcItem.top,
d->rcItem.right-d->rcItem.left, d->rcItem.bottom-d->rcItem.top);
bool inField = (d->itemState & ODS_COMBOBOXEDIT) != 0; // the closed field
bool sel = (d->itemState & ODS_SELECTED) != 0;
SolidBrush bg(inField ? C_SURFACE : (sel ? C_SURFACEHI : C_SURFACE));
g.FillRectangle(&bg, rc);
if (inField) { Rect b=rc; b.Inflate(-1,-1); StrokeRound(g, C_BORDER, b, 9); }
wchar_t txt[256] = L"";
if ((int)d->itemID >= 0)
SendMessageW(d->hwndItem, CB_GETLBTEXT, d->itemID, (LPARAM)txt);
Font f(d->hDC, g_fUI);
RectF tb((REAL)rc.X+10, (REAL)rc.Y, (REAL)(rc.Width-28), (REAL)rc.Height);
DrawTextC(g, txt, f, C_TEXT, tb, StringAlignmentNear, StringAlignmentCenter);
if (inField) { // chevron
int cx = rc.X + rc.Width - 16, cy = rc.Y + rc.Height/2;
Pen pen(C_TEXTDIM, 1.6f);
g.DrawLine(&pen, cx-4, cy-2, cx, cy+2);
g.DrawLine(&pen, cx, cy+2, cx+4, cy-2);
}
}
```
Keep your existing `WM_CTLCOLORLISTBOX` returning the surface brush so the dropdown list background is dark too.
> Minimal-by-default option: hide both combos and reveal them only when a small "settings" toggle in the header is clicked — so the resting UI is just record + transcript. Skip if you'd rather keep them always visible.
## 2.10 Transcript field (flat panel + placeholder)
Keep the native multiline EDIT but make it flat: no client edge, surface background, light text, inner padding. Draw a rounded surface panel behind it in `WM_PAINT`; inset the EDIT a few px inside that panel so the rounded corners show.
Create it without `WS_EX_CLIENTEDGE` (flat) and give it inner margins:
```cpp
HWND hEdit = CreateWindowExW(0, L"EDIT", L"",
WS_CHILD | WS_VISIBLE | WS_VSCROLL | ES_MULTILINE | ES_AUTOVSCROLL | ES_READONLY,
0,0,0,0, hwnd, (HMENU)ID_EDIT_TEXT, hInst, nullptr);
SendMessage(hEdit, WM_SETFONT, (WPARAM)g_fText, TRUE);
SendMessage(hEdit, EM_SETMARGINS, EC_LEFTMARGIN|EC_RIGHTMARGIN, MAKELONG(10,10));
```
Theme it (you already have `WM_CTLCOLOREDIT` — point it at the surface):
```cpp
case WM_CTLCOLOREDIT: {
HDC hdc=(HDC)wParam; SetTextColor(hdc, CR_TEXT); SetBkColor(hdc, CR_SURFACE);
return (LRESULT)g_brSurface;
}
```
Placeholder via a STATIC shown only when empty (`ID_STATIC_PLACEHOLDER`), created after the edit so it sits on top:
```cpp
CreateWindowW(L"STATIC", L"Your transcription will appear here…",
WS_CHILD | WS_VISIBLE | SS_LEFT, 0,0,0,0, hwnd,
(HMENU)ID_STATIC_PLACEHOLDER, hInst, nullptr);
// font g_fText; color via WM_CTLCOLORSTATIC -> C_TEXTDIM on C_SURFACE
// toggle it:
void UpdatePlaceholder(HWND hwnd) {
bool empty = GetWindowTextLengthW(GetDlgItem(hwnd, ID_EDIT_TEXT)) == 0;
ShowWindow(GetDlgItem(hwnd, ID_STATIC_PLACEHOLDER), empty ? SW_SHOW : SW_HIDE);
}
// call after setting/clearing transcript text (WM_APP_RESULT, Clear).
```
## 2.11 Custom VU meter
Drop the `PROGRESS_CLASS` control; draw a row of rounded segments in `WM_PAINT` that light up green with the input level. Store its rect in a global from `LayoutControls`.
```cpp
RECT g_vuRect = {0,0,0,0};
float g_energy = 0.0f; // updated in WM_TIMER from g_tx.get_audio_energy()
void DrawVU(Graphics& g, const RECT& r, float level) {
const int N = 14, gap = 3;
int w = (r.right-r.left); int segW = (w - gap*(N-1)) / N;
int h = r.bottom - r.top;
int lit = (int)(level * N + 0.5f);
for (int i = 0; i < N; ++i) {
int x = r.left + i*(segW+gap);
Rect seg(x, r.top, segW, h);
Color c = (i < lit) ? C_GOOD : C_SURFACEHI;
FillRound(g, c, seg, 2);
}
}
```
In `WM_TIMER`, refresh the level and invalidate just the VU rect:
```cpp
g_energy = g_tx.is_recording() ? g_tx.get_audio_energy() : 0.0f;
InvalidateRect(hWnd, &g_vuRect, FALSE);
```
## 2.12 Responsive layout (final)
This is the single source of truth for geometry — referenced by §1.3.
```cpp
void LayoutControls(HWND h, int W, int H) {
const int M = 16, row = 36, gap = 10;
int x = M, y = M, innerW = W - 2*M;
// Row 1: Record (left, prominent) + Pin (right)
int pinW = 78, recW = innerW - pinW - gap;
MoveWindow(GetDlgItem(h, ID_BTN_RECORD), x, y, recW, row, TRUE);
MoveWindow(GetDlgItem(h, ID_BTN_PIN), x + recW + gap, y, pinW, row, TRUE);
// Row 2: VU meter (custom-painted) — reserve a slim strip
y += row + gap;
g_vuRect = { x, y, x + innerW, y + 8 };
// Row 3: status line
y += 8 + gap;
MoveWindow(GetDlgItem(h, ID_STATIC_STATUS), x, y, innerW, 18, TRUE);
// Row 4: transcript panel (hero) — fills the middle
y += 18 + gap;
int bottom = (row + gap) * 2; // selectors row + copy/paste row
int panelTop = y;
int textH = H - y - M - bottom;
if (textH < 70) textH = 70;
// EDIT inset 10px inside the rounded panel drawn in WM_PAINT
MoveWindow(GetDlgItem(h, ID_EDIT_TEXT), x+10, panelTop+10, innerW-20, textH-20, TRUE);
MoveWindow(GetDlgItem(h, ID_STATIC_PLACEHOLDER), x+16, panelTop+16, innerW-32, 22, TRUE);
// Row 5: mic + model selectors
y += textH + gap;
int halfW = (innerW - gap) / 2;
MoveWindow(GetDlgItem(h, ID_COMBO_AUDIO), x, y, halfW, row, TRUE);
MoveWindow(GetDlgItem(h, ID_COMBO_MODEL), x + halfW + gap, y, halfW, row, TRUE);
// Row 6: Copy + Paste
y += row + gap;
MoveWindow(GetDlgItem(h, ID_BTN_COPY), x, y, halfW, row, TRUE);
MoveWindow(GetDlgItem(h, ID_BTN_PASTE), x + halfW + gap, y, halfW, row, TRUE);
// remember the transcript panel rect for WM_PAINT
extern RECT g_panelRect; g_panelRect = { x, panelTop, x+innerW, panelTop+textH };
InvalidateRect(h, nullptr, FALSE);
}
```
## 2.13 Putting it together (paint + flicker-free)
Flicker-free background and the painted bits (canvas, transcript panel frame, VU):
```cpp
RECT g_panelRect = {0,0,0,0};
case WM_ERASEBKGND: return 1; // we paint everything in WM_PAINT
case WM_PAINT: {
PAINTSTRUCT ps; HDC hdc = BeginPaint(hWnd, &ps);
RECT rc; GetClientRect(hWnd, &rc);
// double buffer
HDC mem = CreateCompatibleDC(hdc);
HBITMAP bmp = CreateCompatibleBitmap(hdc, rc.right, rc.bottom);
HBITMAP old = (HBITMAP)SelectObject(mem, bmp);
{
Graphics g(mem); g.SetSmoothingMode(SmoothingModeAntiAlias);
SolidBrush bg(C_BG); g.FillRectangle(&bg, 0,0,rc.right,rc.bottom);
// transcript panel
Rect panel(g_panelRect.left, g_panelRect.top,
g_panelRect.right-g_panelRect.left, g_panelRect.bottom-g_panelRect.top);
FillRound(g, C_SURFACE, panel, 14);
StrokeRound(g, C_BORDER, panel, 14, 1.0f);
// VU
DrawVU(g, g_vuRect, g_energy);
}
BitBlt(hdc, 0,0, rc.right, rc.bottom, mem, 0,0, SRCCOPY);
SelectObject(mem, old); DeleteObject(bmp); DeleteDC(mem);
EndPaint(hWnd, &ps);
return 0;
}
```
> Child controls (EDIT, combos, buttons) paint themselves on top of this — they're opaque where they sit, so the panel fill shows only in its 10px inset border. Because `WM_ERASEBKGND` returns 1 and we double-buffer, there's no flicker on resize.
`WM_CTLCOLORSTATIC` should return the bg brush for the status line and the surface brush for the placeholder:
```cpp
case WM_CTLCOLORSTATIC: {
HDC hdc=(HDC)wParam;
if (GetDlgCtrlID((HWND)lParam) == ID_STATIC_PLACEHOLDER) {
SetTextColor(hdc, RGB(0x9A,0xA0,0xAB)); SetBkColor(hdc, CR_SURFACE);
return (LRESULT)g_brSurface;
}
SetTextColor(hdc, CR_TEXT); SetBkColor(hdc, CR_BG);
return (LRESULT)g_brBg;
}
```
Define `#define ID_STATIC_PLACEHOLDER 1014` with your other IDs, and remember to call `UpdatePlaceholder(hWnd)` after `WM_APP_RESULT` sets text and after Clear.
---
# Part 3 — Build & checklist
- **Link GDI+:** the `#pragma comment(lib,"gdiplus.lib")` covers MSVC; no CMake change needed. (`comctl32` for `SetWindowSubclass` is already linked.)
- **Build:** `cmake --build build --config Release` as before.
- **Verify, in order:**
- [ ] Launch → window has rounded corners + dark title bar, no gray 3-D buttons.
- [ ] Record pill fills accent; hover lightens it; recording turns it red and it pulses; VU segments track your voice.
- [ ] Stop → transcript appears in the flat panel; placeholder hides; "Copied/Pasted".
- [ ] **No crash on Stop.** If you see "Model not found", fix the path (§1.2).
- [ ] Drag the window edges → it resizes and the transcript grows; min size respected.
- [ ] Switch model in the dropdown → status shows "Loading…" then "Ready • 2 threads"; next transcription uses it.
- **OS notes:** caption color / rounded corners need Windows 11 (build 22000+). On Windows 10 you still get dark mode (immersive) but square corners and the default caption color — everything else is identical since it's all custom-drawn.
---
# Appendix — Option B: fully borderless custom title bar (advanced)
If you want **zero** system chrome (no native title bar at all), use the "extend client over the caption" technique instead of Option A's recoloring. Keep `WS_OVERLAPPEDWINDOW` (so resize/snap/shadow/rounding stay native) and reclaim the caption area:
```cpp
case WM_NCCALCSIZE:
if (wParam) {
NCCALCSIZE_PARAMS* p = (NCCALCSIZE_PARAMS*)lParam;
int top = p->rgrc[0].top;
LRESULT r = DefWindowProc(hWnd, message, wParam, lParam); // default frame calc
p->rgrc[0].top = top; // give the caption height back to the client area
return r;
}
break;
case WM_NCHITTEST: {
LRESULT ht = DefWindowProc(hWnd, message, wParam, lParam); // handles resize edges
if (ht != HTCLIENT) return ht;
POINT pt{ GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam) };
ScreenToClient(hWnd, &pt);
const int HEADER = 40;
if (pt.y < HEADER) {
// exclude your custom min/close/pin hit-rects here, else:
return HTCAPTION; // drag region
}
return HTCLIENT;
}
```
Then draw your own header (title text on the left, custom min/close buttons on the right) in `WM_PAINT`, and shift `LayoutControls`' starting `y` down by `HEADER`. This is more finicky across DPI/OS — only take it on if Option A's dark caption isn't minimal enough for you.
+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 |
+509
View File
@@ -0,0 +1,509 @@
# Fix 03 — History drop-down: draw above everything + dynamic scrolling
**Files touched:** `src/main.cpp` only. No changes to `history.h`, `CMakeLists.txt`, or any other file.
**Estimated effort:** 12 hours including testing.
**Convention reminder:** this is a companion fix note (Fix-01, Fix-02, …) — do not edit older docs.
---
## 1. Why the drop-down draws *behind* the transcript box (read this first)
The history drop-down is currently **painted onto the main window's surface** inside
`PaintSurface()` (the block that starts `if (g_histOpen >= 0 && g_view == View::Main)`).
The transcript box, however, is **not** part of that painted surface. It is a real Win32
child window — the `EDIT` control with ID `ID_EDIT_TEXT`. Two Win32 rules make the current
approach impossible to fix by re-ordering paint calls:
1. **Child windows always render above the parent's client-area painting.** Whatever the
parent draws in its own `WM_PAINT` sits *underneath* every child HWND. There is no
"draw later so it ends up on top" — the EDIT is simply not on our canvas.
2. The main window is created with **`WS_CLIPCHILDREN`** (see `CreateWindowExW` in
`wWinMain`). That flag explicitly removes the EDIT's rectangle from the region the
parent is even allowed to paint into. Our GDI+ drawing inside the EDIT's rect is
silently clipped away. That is exactly why you only see the strip of drop-down in the
gap *below* the EDIT in the screenshot.
A surface-painted drop-down also can never extend past the app window's edge, and we are
re-implementing hover/scroll/click routing by hand. All three problems go away with the
same fix.
## 2. The fix — strategy
**Stop painting the drop-down on the surface. Use a real top-level popup window instead.**
The app already contains exactly the right mechanism and we reuse it:
- The **microphone selector** already opens a small floating window of class `DictPopup`
(`PopupProc` + `ShowSelectPopup` in `main.cpp`). It is created with
`WS_POPUP | WS_EX_TOPMOST | WS_EX_TOOLWINDOW`, i.e. a top-level window that floats
above the app, above the EDIT control, above *everything*, and is not clipped by the
app window's edges at all.
- The selection plumbing for history **already exists**: in `WndProc`, the
`WM_APP_SELECT` handler already has an `ID_SEL_HISTORY` branch that archives the
current text and loads the chosen entry. It was wired up but never used. We will not
touch it — we just finally route clicks into it.
So the work is:
1. Upgrade the shared popup so it can **scroll** (max ~8 visible rows, mouse wheel,
scrollbar thumb), positions itself **above or below** the anchor depending on screen
space, and uses **DPI-scaled** row heights.
2. Point the **History button** at `ShowSelectPopup(...)` with `ID_SEL_HISTORY`.
3. **Delete** every line of the old surface-painted drop-down.
Bonus: the mic selector automatically gains scrolling and screen-edge handling too.
---
## 3. Step 1 — Replace `PopupState`
In `src/main.cpp`, find this (one line plus the variable under it):
```cpp
struct PopupState { std::vector<std::wstring> items; int sel; int hot; HWND owner; int ctrlId; };
static PopupState g_pop;
```
Replace it with:
```cpp
struct PopupState {
std::vector<std::wstring> items;
int sel = -1; // index drawn in accent colour (current selection), -1 = none
int hot = -1; // ABSOLUTE index of the row under the mouse, -1 = none
HWND owner = nullptr;
int ctrlId = 0; // posted back in WM_APP_SELECT wParam
int scroll = 0; // index of the first visible row
int visRows = 0; // number of rows visible in the popup
int rowH = 30; // row height in px (DPI-scaled at open time)
int pad = 3; // inner padding in px
int wheelAccum = 0; // accumulates wheel deltas < 120 (trackpads)
};
static PopupState g_pop;
static const int kPopupMaxVisible = 8; // rows shown before the list scrolls
```
> Note `hot` and the value posted back are now **absolute** item indices (index into the
> full list), not visible-row indices. This matters once the list scrolls.
## 4. Step 2 — Replace `PopupProc` (and add one helper above it)
Find the existing `LRESULT CALLBACK PopupProc(HWND h, UINT m, WPARAM w, LPARAM l)` and
replace the **entire function** with the code below. Also add the small
`PopupRowFromY` helper **immediately above** `PopupProc` (it must be defined before it).
```cpp
// Convert a client-area Y coordinate inside the popup to an ABSOLUTE item index.
// Returns -1 if the point is not on a row.
static int PopupRowFromY(int y) {
int row = (y - g_pop.pad) / g_pop.rowH; // visible row 0..visRows-1
if (row < 0 || row >= g_pop.visRows) return -1;
int abs = g_pop.scroll + row; // absolute item index
if (abs >= (int)g_pop.items.size()) return -1;
return abs;
}
LRESULT CALLBACK PopupProc(HWND h, UINT m, WPARAM w, LPARAM l) {
switch (m) {
case WM_MOUSEMOVE: {
int row = PopupRowFromY(GET_Y_LPARAM(l));
if (row != g_pop.hot) { g_pop.hot = row; InvalidateRect(h, nullptr, FALSE); }
TRACKMOUSEEVENT t{ sizeof(t), TME_LEAVE, h, 0 }; TrackMouseEvent(&t);
return 0;
}
case WM_MOUSELEAVE:
g_pop.hot = -1; InvalidateRect(h, nullptr, FALSE);
return 0;
case WM_MOUSEWHEEL: {
int maxScroll = (int)g_pop.items.size() - g_pop.visRows;
if (maxScroll <= 0) return 0; // everything fits: nothing to scroll
g_pop.wheelAccum += GET_WHEEL_DELTA_WPARAM(w);
int steps = g_pop.wheelAccum / WHEEL_DELTA; // whole notches only
if (steps != 0) {
g_pop.wheelAccum -= steps * WHEEL_DELTA;
g_pop.scroll -= steps * 3; // 3 rows per wheel notch
g_pop.scroll = std::max(0, std::min(g_pop.scroll, maxScroll));
POINT pt; GetCursorPos(&pt); ScreenToClient(h, &pt);
g_pop.hot = PopupRowFromY(pt.y); // keep hover correct after scroll
InvalidateRect(h, nullptr, FALSE);
}
return 0;
}
case WM_KEYDOWN:
if (w == VK_ESCAPE) DestroyWindow(h); // Esc closes the popup
return 0;
case WM_LBUTTONUP: {
int row = PopupRowFromY(GET_Y_LPARAM(l));
if (row >= 0)
PostMessageW(g_pop.owner, WM_APP_SELECT, (WPARAM)g_pop.ctrlId, (LPARAM)row);
DestroyWindow(h);
return 0;
}
case WM_ACTIVATE:
if (LOWORD(w) == WA_INACTIVE) DestroyWindow(h); // click-away closes
return 0;
case WM_ERASEBKGND: return 1;
case WM_PAINT: {
PAINTSTRUCT ps; HDC hdc = BeginPaint(h, &ps);
RECT rc; GetClientRect(h, &rc);
HDC mem = CreateCompatibleDC(hdc);
HBITMAP bmp = CreateCompatibleBitmap(hdc, rc.right, rc.bottom);
HBITMAP old = (HBITMAP)SelectObject(mem, bmp);
{
Graphics g(mem);
g.SetSmoothingMode(SmoothingModeAntiAlias);
g.SetTextRenderingHint(TextRenderingHintClearTypeGridFit);
Rect all(0, 0, rc.right, rc.bottom);
FillRound(g, T_CARD, all, 10);
StrokeRound(g, T_FAINT, all, 10, 1.0f);
int n = (int)g_pop.items.size();
bool hasBar = n > g_pop.visRows;
int barW = std::max(3, (int)(4 * g_dpiScale));
int rowW = rc.right - 2 * g_pop.pad - (hasBar ? barW + g_pop.pad : 0);
int last = std::min(n, g_pop.scroll + g_pop.visRows);
for (int i = g_pop.scroll; i < last; ++i) {
int vis = i - g_pop.scroll;
Rect row(g_pop.pad, g_pop.pad + vis * g_pop.rowH, rowW, g_pop.rowH - 2);
if (i == g_pop.hot) FillRound(g, T_CARD_HI, row, 7);
RectF tb((REAL)row.X + 9, (REAL)row.Y, (REAL)row.Width - 12, (REAL)row.Height);
DrawTextC(g, g_pop.items[i].c_str(), *g_gpUI,
(i == g_pop.sel) ? T_ACCENT : T_TEXT,
tb, StringAlignmentNear, StringAlignmentCenter);
}
if (hasBar) {
int trackH = rc.bottom - 2 * g_pop.pad;
int thumbH = std::max((int)(20 * g_dpiScale), trackH * g_pop.visRows / n);
int maxScroll = n - g_pop.visRows;
int thumbY = g_pop.pad + (trackH - thumbH) * g_pop.scroll / maxScroll;
Rect thumb(rc.right - g_pop.pad - barW, thumbY, barW, thumbH);
FillRound(g, T_DIM, thumb, barW / 2);
}
}
BitBlt(hdc, 0, 0, rc.right, rc.bottom, mem, 0, 0, SRCCOPY);
SelectObject(mem, old); DeleteObject(bmp); DeleteDC(mem);
EndPaint(h, &ps);
return 0;
}
}
return DefWindowProc(h, m, w, l);
}
```
Two deliberate changes from the old version, so you don't think something was lost:
- The old paint code built a `Font f(mem, g_fUI)` every frame. We now use the cached
`*g_gpUI` GDI+ font — same convention as Fix-02 (cached `g_gp*` fonts everywhere).
- The mouse-wheel handler works because the popup has keyboard focus
(`SetFocus(p)` is called when it opens) — Windows delivers `WM_MOUSEWHEEL` to the
focused window. Don't remove the `SetFocus` call in Step 3.
## 5. Step 3 — Replace `ShowSelectPopup`
Replace the **entire** existing `ShowSelectPopup` function with:
```cpp
void ShowSelectPopup(HWND owner, int ctrlId, const std::vector<std::wstring>& items, int sel, const RectF& anchor) {
static bool reg = false;
if (!reg) {
WNDCLASSEXW wc{ sizeof(wc) };
wc.lpfnWndProc = PopupProc;
wc.hInstance = hInst;
wc.hCursor = LoadCursor(nullptr, IDC_ARROW);
wc.hbrBackground = CreateSolidBrush(CR_SURFACE);
wc.lpszClassName = L"DictPopup";
RegisterClassExW(&wc);
reg = true;
}
if (items.empty()) return;
float s = g_dpiScale;
int rowH = (int)(30 * s);
int pad = (int)(3 * s); if (pad < 3) pad = 3;
int n = (int)items.size();
int visRows = std::min(n, kPopupMaxVisible);
g_pop = PopupState{}; // reset everything (incl. scroll/wheelAccum)
g_pop.items = items;
g_pop.sel = sel;
g_pop.owner = owner;
g_pop.ctrlId = ctrlId;
g_pop.visRows = visRows;
g_pop.rowH = rowH;
g_pop.pad = pad;
if (sel >= 0 && n > visRows) // scroll so the current selection is visible
g_pop.scroll = std::max(0, std::min(sel - visRows / 2, n - visRows));
int h = visRows * rowH + 2 * pad;
int wdt = std::max((int)anchor.Width, (int)(260 * s)); // never narrower than 260px
// Anchor rect is in CLIENT coordinates; convert its top-left to screen.
POINT tl{ (LONG)anchor.X, (LONG)anchor.Y };
ClientToScreen(owner, &tl);
int anchorTop = tl.y;
int anchorBottom = tl.y + (int)anchor.Height;
int x = tl.x;
// Place below the anchor if it fits on the monitor's work area, otherwise above.
HMONITOR mon = MonitorFromWindow(owner, MONITOR_DEFAULTTONEAREST);
MONITORINFO mi{ sizeof(mi) };
GetMonitorInfo(mon, &mi);
int y;
if (anchorBottom + 2 + h <= mi.rcWork.bottom) y = anchorBottom + 2; // open downward
else y = anchorTop - h - 2; // flip upward
if (y < mi.rcWork.top) y = mi.rcWork.top;
if (x + wdt > mi.rcWork.right) x = mi.rcWork.right - wdt;
if (x < mi.rcWork.left) x = mi.rcWork.left;
HWND p = CreateWindowExW(WS_EX_TOOLWINDOW | WS_EX_TOPMOST, L"DictPopup", L"",
WS_POPUP, x, y, wdt, h, owner, nullptr, hInst, nullptr);
if (!p) return;
int corner = DWMWCP_ROUND;
DwmSetWindowAttribute(p, DWMWA_WINDOW_CORNER_PREFERENCE, &corner, sizeof(corner));
ShowWindow(p, SW_SHOWNA);
SetFocus(p); // required: wheel + Esc are delivered to the focused window
}
```
Notes:
- The old debug `MessageBoxW("Popup failed: ...")` is intentionally removed (it was
scaffolding). Failure now just silently returns.
- Because the popup is a top-level window, it may legitimately extend **past the app
window's edges** — that is correct and expected. It only clamps to the *monitor's*
work area.
- It positions below the button when there is monitor space, otherwise flips above —
standard combo-box behaviour. Since the History button sits near the bottom of the
app window, it will usually still have screen room below and will open downward,
floating *over* whatever is beneath. If you want it to **always open upward** like
the current build, replace the placement `if/else` with just:
`int y = anchorTop - h - 2; if (y < mi.rcWork.top) y = mi.rcWork.top;`
## 6. Step 4 — Rewire the History button
In `OnClick(HWND hWnd, WK kind)`, find the `case WK::History:` block:
```cpp
case WK::History: {
if (g_histOpen >= 0) { g_histOpen = -1; InvalidateRect(hWnd, nullptr, FALSE); break; }
if (g_history.empty()) { SetStatus(hWnd, L"No history yet"); break; }
g_histOpen = (int)g_history.size();
g_histScroll = 0; g_histHot = -1;
InvalidateRect(hWnd, nullptr, FALSE);
break;
}
```
Replace it with:
```cpp
case WK::History: {
g_history = LoadHistoryIndex(); // refresh in case files changed
if (g_history.empty()) { SetStatus(hWnd, L"No history yet"); break; }
std::vector<std::wstring> labels;
labels.reserve(g_history.size());
for (const auto& e : g_history) labels.push_back(e.label);
ShowSelectPopup(hWnd, ID_SEL_HISTORY, labels, -1, g_w[(int)WK::History].r);
break;
}
```
**Do not write any new selection-handling code.** When the user clicks a row, the popup
posts `WM_APP_SELECT` with `wParam = ID_SEL_HISTORY` and `lParam = absolute index`. The
existing `else if (ctrlId == ID_SEL_HISTORY ...)` branch inside `WndProc`'s
`WM_APP_SELECT` handler already does everything (archive current text, load the entry,
set `g_lastLoadedText`, refresh the index, update the placeholder, show "Loaded from
history"). Leave it exactly as it is.
## 7. Step 5 — Delete the legacy painted drop-down (6 deletions)
All in `src/main.cpp`. Delete **only** what is quoted — the surrounding code stays.
**5a — globals.** Between `int g_setHot = -1;` and `Downloader g_dl;`, delete these three lines:
```cpp
int g_histOpen = -1;
int g_histScroll = 0;
int g_histHot = -1;
```
(Keep `g_setHot`, keep `std::vector<HistoryEntry> g_history;`, keep `g_lastLoadedText`.)
**5b — the paint block in `PaintSurface`.** Delete this whole block (it sits right
before `RECT vr = g_vuRect;`):
```cpp
if (g_histOpen >= 0 && g_view == View::Main) {
RectF& anchor = g_w[(int)WK::History].r;
...
if (g_histOpen > maxVis) {
...
FillRound(g, T_DIM, thumb, 3);
}
}
```
(~30 lines, from `if (g_histOpen >= 0 ...` down to the matching closing brace.)
**5c — the hit-test function.** Delete the entire `HistDropHitTest` function (it sits
just above `int HitTest(POINT p)`):
```cpp
static int HistDropHitTest(POINT p) {
...
return realIdx;
}
```
**5d — in `WM_MOUSEMOVE`.** Keep the `POINT p{...};` line (it is used by `HitTest(p)`
below); delete only this block:
```cpp
if (g_histOpen > 0) {
int dh = HistDropHitTest(p);
if (dh != g_histHot) { g_histHot = dh; InvalidateRect(hWnd, nullptr, FALSE); }
}
```
**5e — in `WM_MOUSELEAVE`.** Delete this line:
```cpp
if (g_histHot >= 0) { g_histHot = -1; InvalidateRect(hWnd, nullptr, FALSE); }
```
**5f — in `WM_LBUTTONDOWN`.** Delete this block (keep `g_active = g_hot;` and below):
```cpp
if (g_histOpen > 0) {
POINT p{ GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam) };
if (HistDropHitTest(p) < 0) { g_histOpen = -1; g_histHot = -1; g_active = -1; InvalidateRect(hWnd, nullptr, FALSE); return 0; }
}
```
**5g — in `WM_LBUTTONUP`.** Delete this entire block (the popup + `WM_APP_SELECT`
path replaces it; keep the settings branch above it and `ReleaseCapture()` below it):
```cpp
if (g_histOpen > 0) {
POINT p{ GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam) };
int dh = HistDropHitTest(p);
if (dh >= 0 && dh < (int)g_history.size()) {
std::wstring cur = GetEditText(hWnd);
if (!cur.empty() && cur != g_lastLoadedText)
ArchiveSession(cur);
std::wstring text = ReadFileUtf8(g_history[dh].path);
SetWindowTextW(GetDlgItem(hWnd, ID_EDIT_TEXT), text.c_str());
g_lastLoadedText = text;
g_editDirty = false;
g_history = LoadHistoryIndex();
UpdatePlaceholder(hWnd);
SetStatus(hWnd, L"Loaded from history");
}
g_histOpen = -1; g_histHot = -1;
InvalidateRect(hWnd, nullptr, FALSE);
return 0;
}
```
**5h — in `WM_MOUSEWHEEL`.** Delete this block (keep the `View::Settings` scroll block
above it):
```cpp
if (g_histOpen > 6) {
g_histScroll -= GET_WHEEL_DELTA_WPARAM(wParam) / 60;
g_histScroll = std::max(0, std::min(g_histScroll, g_histOpen - 6));
InvalidateRect(hWnd, nullptr, FALSE);
}
```
**Verification after Step 5:** Ctrl+F the file for `g_hist` — the only remaining
matches must be `g_history` (the vector). Search for `HistDropHitTest` — zero matches.
If anything else matches, you missed a deletion and the compiler will tell you too
(undeclared identifier `g_histOpen`).
## 8. Step 6 — (Optional polish) make the button a true toggle
Known small quirk of popup-based menus: clicking the History button **while the popup is
open** first closes it (the click deactivates the popup → `WM_ACTIVATE` destroys it),
then the button's click-handler immediately reopens it. So the button acts as
"reopen", not "toggle closed". The mic selector has always behaved this way; if nobody
has complained, skip this step. To make it a real toggle:
Add next to `g_pop`:
```cpp
static DWORD g_popClosedTick = 0; // when the popup last closed
static int g_popClosedId = 0; // which ctrlId it was showing
```
Add a case to `PopupProc`:
```cpp
case WM_DESTROY:
g_popClosedTick = GetTickCount();
g_popClosedId = g_pop.ctrlId;
return 0;
```
Then make the first line of `case WK::History:` (and optionally `WK::SelAudio`):
```cpp
if (g_popClosedId == ID_SEL_HISTORY && GetTickCount() - g_popClosedTick < 250) {
g_popClosedId = 0; // this click was the user toggling the popup shut — swallow it
break;
}
```
## 9. Build & test checklist
Build exactly as usual (`cmake --build build --config Release --target win-dictation`).
Then verify, in order:
1. **Core bug:** with 3+ history entries, click History. The list must appear **fully on
top of the transcript box** (and on top of the window edge if it extends past it).
Nothing hidden behind the EDIT control.
2. **Scrolling:** create 10+ entries (record short clips and press Clear between them, or
copy-paste extra `.txt` files in the `history\` folder using the same
`YYYY-MM-DD_HHMMSS.txt` name format). Open History: max 8 rows visible, scrollbar
thumb on the right, mouse wheel scrolls 3 rows per notch, hover highlight stays
correct while scrolling.
3. **Selection:** click an entry → transcript loads it, status shows "Loaded from
history", and the text that was in the box beforehand got archived (check the
`history\` folder gained a file).
4. **Dismiss:** click anywhere outside → closes. Press Esc → closes. Click an entry →
closes.
5. **Regression — mic selector:** the Microphone popup must still open and select
devices correctly (it shares all this code). It now also scrolls if a machine has
9+ capture devices and never clips at the screen edge.
6. **Placement:** drag the app window to the very bottom of the screen → popup flips
upward. Drag it high up → popup opens downward (or stays upward if you chose the
always-up variant).
7. **Few items:** with 12 entries the popup is exactly that tall, no scrollbar.
8. **Empty:** with an empty `history\` folder, clicking History shows "No history yet"
and no popup.
9. **DPI:** if you can, test at 125%/150% scaling — hover highlight must line up with
the cursor (row height is now DPI-scaled; it previously was hard-coded 30px).
## 10. Tuning knobs (all in one place)
| What | Where | Default |
|---|---|---|
| Visible rows before scrolling | `kPopupMaxVisible` | 8 |
| Rows per wheel notch | `g_pop.scroll -= steps * 3;` in `PopupProc` | 3 |
| Minimum popup width | `std::max((int)anchor.Width, (int)(260 * s))` | 260 px |
| Open direction | placement `if/else` in `ShowSelectPopup` | below, flip up if no room |
| Row height | `rowH = (int)(30 * s)` | 30 px @ 100% DPI |
## 11. Do NOT touch
- `#define ID_SEL_HISTORY 1019` — stays.
- The `ID_SEL_HISTORY` branch inside `WM_APP_SELECT` in `WndProc` — stays as-is; it is
the selection handler now.
- `g_history`, `g_lastLoadedText`, `LoadHistoryIndex()`, `ArchiveSession()`, all of
`history.h` — unchanged.
- The `WK::History` widget rect / `DrawSelectSurface(g, w, L"History")` painting of the
button itself — unchanged; only what happens on click changes.
@@ -0,0 +1,366 @@
# Fix 06 — Delete individual history entries from the popup (✕ on hover)
**Builds on:** Fix-03/04 (popup) and **Fix-05 (live session archiving) — apply Fix-05 first.**
One block below references `g_sessionPath` from Fix-05; it is clearly marked in case you
must build without it.
**Files touched:** `src/main.cpp` only. `history.h` unchanged.
**Estimated effort:** 4560 minutes including testing.
---
## 1. What we're building
- Hovering a row in the **History** popup reveals a small `✕` at the row's right edge.
- Clicking the `✕` deletes that entry's file from `history\` and refreshes the list
**in place — the popup stays open**, so you can clean out several entries in one go.
- Clicking anywhere else on the row still loads the entry (unchanged behaviour).
- The `Delete` key also deletes the hovered row (the popup already owns keyboard focus).
- The **microphone popup is untouched** — it shares the same code, so the feature is
gated by a `canDelete` flag that is only true for `ID_SEL_HISTORY`.
### Design decisions (so the dev doesn't relitigate them)
1. **Popup stays open after a delete.** Closing after each delete would make cleaning
up 1050 entries miserable. The list, scrollbar, and popup height all refresh in
place; deleting the last entry closes the popup.
2. **No confirmation dialog — and this is load-bearing, not laziness.** The popup
destroys itself on `WM_ACTIVATE / WA_INACTIVE` (that's the click-away-to-close
behaviour). A `MessageBox` shown from inside `PopupProc` would *deactivate the
popup, destroy it mid-handler, and then resume the handler with a dead `HWND`* —
undefined behaviour. **Never open a modal dialog from `PopupProc`.** If a safety
net is wanted later, use the soft-delete variant in §8 instead.
3. **Deleting the LIVE session's entry detaches the session** (see §7) so Clear/exit
don't instantly resurrect the file you just deleted.
---
## 2. Step 1 — Extend `PopupState`
Find the `PopupState` struct and add the two marked fields:
```cpp
struct PopupState {
std::vector<std::wstring> items;
int sel = -1;
int hot = -1;
HWND owner = nullptr;
int ctrlId = 0;
int scroll = 0;
int visRows = 0;
int rowH = 30;
int pad = 3;
int wheelAccum = 0;
bool canDelete = false; // NEW: rows show a ✕ delete button (history popup only)
int hotX = -1; // NEW: ABSOLUTE index of the row whose ✕ is hovered, -1 = none
};
```
(`g_pop = PopupState{};` in `ShowSelectPopup` already resets the new fields each open —
no extra reset code needed.)
## 3. Step 2 — Three small helpers
Paste these **between `PopupRowFromY` and `PopupProc`** (they must be above `PopupProc`;
`PopupRowFromY` itself is unchanged):
```cpp
// The ✕ hit square for a VISIBLE row (0..visRows-1), in popup client coords.
// Sits at the right edge of the row, inside the scrollbar gutter if present.
static Rect PopupDeleteRect(const RECT& rc, int visIdx) {
int n = (int)g_pop.items.size();
bool hasBar = n > g_pop.visRows;
int barW = std::max(3, (int)(4 * g_dpiScale));
int rowW = rc.right - 2 * g_pop.pad - (hasBar ? barW + g_pop.pad : 0);
int xW = g_pop.rowH; // square zone, one row tall
return Rect(g_pop.pad + rowW - xW, g_pop.pad + visIdx * g_pop.rowH, xW, g_pop.rowH - 2);
}
// Recompute hot row + hovered-✕ from the current cursor position. Used after
// scrolling and after a delete (the list moved under a stationary cursor).
static void PopupRefreshHotFromCursor(HWND h) {
POINT pt; GetCursorPos(&pt); ScreenToClient(h, &pt);
int row = PopupRowFromY(pt.y);
int hx = -1;
if (g_pop.canDelete && row >= 0) {
RECT rc; GetClientRect(h, &rc);
if (PopupDeleteRect(rc, row - g_pop.scroll).Contains(pt.x, pt.y)) hx = row;
}
g_pop.hot = row; g_pop.hotX = hx;
}
// Delete the history file behind ABSOLUTE row `row`, then refresh the popup
// in place. The popup stays open so several entries can be deleted in a row.
static void PopupDeleteRow(HWND h, int row) {
if (row < 0 || row >= (int)g_history.size()) return;
std::wstring path = g_history[row].path;
bool ok = DeleteFileW(path.c_str()) != FALSE;
// ---- Requires Fix-05 (g_sessionPath). Omit this block ONLY if building
// ---- without Fix-05, and apply it when Fix-05 lands.
if (ok && path == g_sessionPath) {
// The LIVE session's file was deleted: detach the session so Clear/exit
// don't immediately re-archive the same text. If the user dictates MORE,
// a new file is created — history always mirrors the transcript box.
g_sessionPath.clear();
g_lastLoadedText = GetEditText(g_pop.owner);
}
// ---- end Fix-05-dependent block
g_history = LoadHistoryIndex(); // re-scan the folder
g_pop.items.clear();
g_pop.items.reserve(g_history.size());
for (const auto& e : g_history) g_pop.items.push_back(e.label);
SetStatus(g_pop.owner, ok ? L"Deleted" : L"Delete failed");
int n = (int)g_pop.items.size();
if (n == 0) { DestroyWindow(h); return; } // nothing left to show
// Shrink the popup when fewer rows remain than were visible. It opens
// upward, so keep the BOTTOM edge fixed and move the top edge down.
if (n < g_pop.visRows) {
g_pop.visRows = n;
int newH = n * g_pop.rowH + 2 * g_pop.pad;
RECT wr; GetWindowRect(h, &wr);
SetWindowPos(h, nullptr, wr.left, wr.bottom - newH,
wr.right - wr.left, newH, SWP_NOZORDER | SWP_NOACTIVATE);
}
int maxScroll = std::max(0, n - g_pop.visRows);
g_pop.scroll = std::min(g_pop.scroll, maxScroll);
PopupRefreshHotFromCursor(h);
InvalidateRect(h, nullptr, FALSE);
}
```
Why this is safe to do from `PopupProc`: everything here runs on the UI thread,
`g_history` / `g_sessionPath` / `g_lastLoadedText` are main-thread globals declared
earlier in the file, and `GetEditText` / `SetStatus` / `LoadHistoryIndex` are all
declared above the popup code already. Indices stay valid because `g_pop.items` is
rebuilt 1:1 from the freshly reloaded `g_history` — the existing `WM_APP_SELECT`
handler keeps working unchanged.
## 4. Step 3 — Replace `PopupProc` (entire function)
Replace the whole `LRESULT CALLBACK PopupProc(...)` with the version below. Changes vs
Fix-04: ✕ hover tracking in `WM_MOUSEMOVE`, `WM_MOUSEWHEEL` now uses
`PopupRefreshHotFromCursor`, `WM_KEYDOWN` gains `VK_DELETE`, `WM_LBUTTONUP` routes
✕-clicks to `PopupDeleteRow` (and does NOT close), and `WM_PAINT` draws the ✕ and
reserves label space for it. Everything else is byte-for-byte the Fix-04 code.
```cpp
LRESULT CALLBACK PopupProc(HWND h, UINT m, WPARAM w, LPARAM l) {
switch (m) {
case WM_MOUSEMOVE: {
int row = PopupRowFromY(GET_Y_LPARAM(l));
int hx = -1;
if (g_pop.canDelete && row >= 0) {
RECT rc; GetClientRect(h, &rc);
if (PopupDeleteRect(rc, row - g_pop.scroll).Contains(GET_X_LPARAM(l), GET_Y_LPARAM(l)))
hx = row;
}
if (row != g_pop.hot || hx != g_pop.hotX) {
g_pop.hot = row; g_pop.hotX = hx;
InvalidateRect(h, nullptr, FALSE);
}
TRACKMOUSEEVENT t{ sizeof(t), TME_LEAVE, h, 0 }; TrackMouseEvent(&t);
return 0;
}
case WM_MOUSELEAVE:
g_pop.hot = -1; g_pop.hotX = -1; InvalidateRect(h, nullptr, FALSE);
return 0;
case WM_MOUSEWHEEL: {
int maxScroll = (int)g_pop.items.size() - g_pop.visRows;
if (maxScroll <= 0) return 0;
g_pop.wheelAccum += GET_WHEEL_DELTA_WPARAM(w);
int steps = g_pop.wheelAccum / WHEEL_DELTA;
if (steps != 0) {
g_pop.wheelAccum -= steps * WHEEL_DELTA;
g_pop.scroll -= steps * 3;
g_pop.scroll = std::max(0, std::min(g_pop.scroll, maxScroll));
PopupRefreshHotFromCursor(h);
InvalidateRect(h, nullptr, FALSE);
}
return 0;
}
case WM_KEYDOWN:
if (w == VK_ESCAPE) DestroyWindow(h);
else if (w == VK_DELETE && g_pop.canDelete && g_pop.hot >= 0)
PopupDeleteRow(h, g_pop.hot);
return 0;
case WM_LBUTTONUP: {
int row = PopupRowFromY(GET_Y_LPARAM(l));
if (g_pop.canDelete && row >= 0) {
RECT rc; GetClientRect(h, &rc);
if (PopupDeleteRect(rc, row - g_pop.scroll).Contains(GET_X_LPARAM(l), GET_Y_LPARAM(l))) {
PopupDeleteRow(h, row); // delete — popup STAYS OPEN
return 0;
}
}
if (row >= 0)
PostMessageW(g_pop.owner, WM_APP_SELECT, (WPARAM)g_pop.ctrlId, (LPARAM)row);
DestroyWindow(h);
return 0;
}
case WM_ACTIVATE:
if (LOWORD(w) == WA_INACTIVE) DestroyWindow(h);
return 0;
case WM_ERASEBKGND: return 1;
case WM_PAINT: {
PAINTSTRUCT ps; HDC hdc = BeginPaint(h, &ps);
RECT rc; GetClientRect(h, &rc);
HDC mem = CreateCompatibleDC(hdc);
HBITMAP bmp = CreateCompatibleBitmap(hdc, rc.right, rc.bottom);
HBITMAP old = (HBITMAP)SelectObject(mem, bmp);
{
Graphics g(mem);
g.SetSmoothingMode(SmoothingModeAntiAlias);
g.SetTextRenderingHint(TextRenderingHintClearTypeGridFit);
Rect all(0, 0, rc.right, rc.bottom);
FillRound(g, T_CARD, all, 10);
StrokeRound(g, T_FAINT, all, 10, 1.0f);
int n = (int)g_pop.items.size();
bool hasBar = n > g_pop.visRows;
int barW = std::max(3, (int)(4 * g_dpiScale));
int rowW = rc.right - 2 * g_pop.pad - (hasBar ? barW + g_pop.pad : 0);
int reserve = g_pop.canDelete ? g_pop.rowH : 0; // keep labels clear of the ✕ zone
int last = std::min(n, g_pop.scroll + g_pop.visRows);
for (int i = g_pop.scroll; i < last; ++i) {
int vis = i - g_pop.scroll;
Rect row(g_pop.pad, g_pop.pad + vis * g_pop.rowH, rowW, g_pop.rowH - 2);
if (i == g_pop.hot) FillRound(g, T_CARD_HI, row, 7);
RectF tb((REAL)row.X + 9, (REAL)row.Y, (REAL)(row.Width - 12 - reserve), (REAL)row.Height);
DrawTextC(g, g_pop.items[i].c_str(), *g_gpUI,
(i == g_pop.sel) ? T_ACCENT : T_TEXT,
tb, StringAlignmentNear, StringAlignmentCenter);
if (g_pop.canDelete && i == g_pop.hot) { // ✕ only on the hovered row
Rect xr = PopupDeleteRect(rc, vis);
if (i == g_pop.hotX) FillRound(g, T_CARD_LO, xr, 6);
RectF xb((REAL)xr.X, (REAL)xr.Y, (REAL)xr.Width, (REAL)xr.Height);
DrawTextC(g, L"✕", *g_gpUI,
(i == g_pop.hotX) ? T_DANGER : T_FAINT,
xb, StringAlignmentCenter, StringAlignmentCenter);
}
}
if (hasBar) {
int trackH = rc.bottom - 2 * g_pop.pad;
int thumbH = std::max((int)(20 * g_dpiScale), trackH * g_pop.visRows / n);
int maxScroll = n - g_pop.visRows;
int thumbY = g_pop.pad + (trackH - thumbH) * g_pop.scroll / maxScroll;
Rect thumb(rc.right - g_pop.pad - barW, thumbY, barW, thumbH);
FillRound(g, T_DIM, thumb, barW / 2);
}
}
BitBlt(hdc, 0, 0, rc.right, rc.bottom, mem, 0, 0, SRCCOPY);
SelectObject(mem, old); DeleteObject(bmp); DeleteDC(mem);
EndPaint(h, &ps);
return 0;
}
}
return DefWindowProc(h, m, w, l);
}
```
Visual spec, matching the app's design language: the ✕ (`✕`) appears only on the
hovered row, drawn in `T_FAINT`; when the cursor is over the ✕ itself it turns
`T_DANGER` red on a subtle `T_CARD_LO` chip. Labels always reserve the ✕ column in the
history popup so text doesn't shift when hover reveals the button.
## 5. Step 4 — Enable it for the History popup only
In `ShowSelectPopup`, find:
```cpp
g_pop.ctrlId = ctrlId;
```
Add one line directly below it:
```cpp
g_pop.ctrlId = ctrlId;
g_pop.canDelete = (ctrlId == ID_SEL_HISTORY); // ✕ delete buttons: history only
```
That's the entire gating — the mic popup keeps `canDelete == false` and renders/behaves
exactly as before.
## 6. No other changes
- `OnClick` (History / SelAudio cases), `ShowSelectPopup`'s sizing/placement,
`WM_APP_SELECT`, `history.h` — all untouched.
- No new message IDs, no new globals beyond the two `PopupState` fields.
## 7. Interaction with the live session (Fix-05) — read this
Fix-05 keeps the current dictation session mirrored to a history file
(`g_sessionPath`). If the user deletes THAT entry from the popup:
- Without special handling, `FinalizeSession` would re-archive the box text on the next
Clear/exit — the file the user just deleted would instantly come back.
- The marked block in `PopupDeleteRow` prevents that: it clears `g_sessionPath` and sets
`g_lastLoadedText` to the current box text, so Clear/exit treat the box content as
"already accounted for" and do NOT re-archive it. (This is the same guard mechanism
Fix-05 documented — used deliberately here, because the user explicitly said
"forget this".)
- **Documented behaviour, not a bug:** if the user deletes the live entry and then
dictates *more*, a NEW history file is created containing the box's full text —
history always mirrors the transcript box. To make a session vanish completely:
delete the entry, then Clear.
## 8. Optional variant — soft delete (trash folder)
If you ever want an undo path, change ONE line in `PopupDeleteRow`. Replace:
```cpp
bool ok = DeleteFileW(path.c_str()) != FALSE;
```
with:
```cpp
std::wstring trashDir = HistoryDir() + L"\\trash";
CreateDirectoryW(trashDir.c_str(), nullptr);
std::wstring dest = trashDir + path.substr(path.find_last_of(L'\\'));
bool ok = MoveFileExW(path.c_str(), dest.c_str(), MOVEFILE_REPLACE_EXISTING) != FALSE;
```
`LoadHistoryIndex` only scans `history\*.txt` (not subfolders), so trashed entries
disappear from the popup but remain recoverable by hand. Not wired to any UI — purely a
safety net. Skip this for v1 unless asked.
## 9. Build & test checklist
1. **Reveal:** open History, move the mouse down the rows — a faint ✕ appears at the
right edge of the hovered row only; it turns red when the cursor reaches it.
2. **Delete:** click a ✕ → the row disappears, the file is gone from the `history\`
folder, status shows "Deleted", and the **popup stays open**.
3. **Multi-delete:** delete 34 entries in a row without the popup closing; hover
highlight stays correct after each (the list shifts under the cursor).
4. **Row click still loads:** clicking a row anywhere left of the ✕ loads the entry and
closes the popup, exactly as before.
5. **Scrollbar transition:** with 9+ entries, delete down to 8 → the scrollbar
disappears and rows widen slightly; scrolled state stays sane (no blank gaps).
6. **Shrink:** with ≤7 entries left, delete more — the popup gets shorter with its
bottom edge fixed just above the selects row (it shrinks downward-in-place, never
floats away).
7. **Last entry:** deleting the final entry closes the popup; clicking History again
shows "No history yet".
8. **Delete key:** hover a row, press `Delete` → same as clicking its ✕.
9. **Live session:** dictate (entry appears per Fix-05) → open History → delete that
entry → press Clear → status "Cleared" and the entry does NOT come back. Then
dictate again → a new entry appears (documented in §7).
10. **Mic popup regression:** the Microphone popup shows NO ✕, full-width labels,
selection works — completely unchanged.
11. **Esc / click-away:** still close the popup; no delete is triggered.
12. **DPI 125/150%:** ✕ hit zone lines up with the drawn glyph.
## 10. Do NOT touch / do NOT add
- **Do NOT add a `MessageBox` confirmation inside `PopupProc`** — the popup destroys
itself on deactivation (`WM_ACTIVATE`/`WA_INACTIVE`), so a modal dialog kills the
popup mid-handler and the code resumes with a destroyed `HWND`. If confirmation is
ever required, use the §8 trash variant or build it into the popup's own surface.
- `PopupRowFromY`, `ShowSelectPopup` (besides the one-line flag), `history.h`,
`WM_APP_SELECT`, `FinalizeSession` — unchanged.
- `g_pop.sel` needs no remapping on delete: the history popup always opens with
`sel = -1`, and the mic popup (which uses `sel`) can't delete.
@@ -0,0 +1,317 @@
# Fix 05 — New transcriptions never reach history (+ live session archiving)
**Builds on:** Fix-03/04 (popup history list — those are fine and unchanged).
**Files touched:** `src/main.cpp` only. `history.h` already has everything we need.
**Estimated effort:** 3045 minutes including testing.
---
## 1. Root cause — one line poisons every archive path
History files are only ever written by `ArchiveSession()`, which is called from three
places, all guarded the same way:
| Where | Guard |
|---|---|
| Clear button (`OnClick``WK::Clear`) | `if (!cur.empty() && cur != g_lastLoadedText)` |
| Picking a history entry (`WM_APP_SELECT``ID_SEL_HISTORY`) | same |
| App exit (`WM_DESTROY`) | same |
`g_lastLoadedText` exists for ONE purpose: it remembers text that was loaded **FROM**
history, so that flipping between entries doesn't re-archive an unmodified copy and
create duplicates. It is supposed to be set in exactly one place — the history-load
handler.
But look at `WM_APP_RESULT` (the handler that runs when a transcription finishes).
After inserting the new text into the transcript box it does:
```cpp
UpdatePlaceholder(hWnd);
g_lastLoadedText = GetEditText(hWnd); // ← THE BUG
SetClipboardTextUtf8(hWnd, *res);
```
That line stamps the freshly-transcribed text as "this came from history". From that
moment, `cur == g_lastLoadedText` is true, so:
- **Clear** skips the archive — but still shows the (now lying) "Saved to history" status;
- **loading another history entry** silently discards the current transcription;
- **exiting the app** discards it too.
Net effect: exactly what you reported — no new transcription ever lands in `history\`.
You can confirm the diagnosis before fixing: transcribe → **manually type one extra
character** in the box → press Clear → the entry DOES appear (the edit makes
`cur != g_lastLoadedText` again).
## 2. Second problem — sessions only archived at boundaries the user rarely hits
Even with that line deleted, a session is only archived on Clear / entry-switch / exit.
The real dictation workflow is: hotkey → speak → auto-paste → keep working, app lives in
the tray. Clear is optional, exit is rare. So history would still feel "missing" most of
the time, and a crash would lose the whole session.
**Fix: live session archiving.** Every successful transcription immediately writes the
session's history file:
- the **first** clip of a session **creates** a new timestamped file (via the existing
`ArchiveSession`, which already returns the path it wrote);
- every **subsequent** clip **rewrites that same file** with the full transcript —
one file per session, never duplicates;
- Clear / entry-switch / exit just *finalize* the session (capture any manual edits made
after the last clip, then start a fresh session).
Result: open the History popup right after dictating and the session is already there,
at the top, kept current as you append. Crash-safe for free.
---
## 3. Step 1 — Add the session-path global
In `src/main.cpp`, find:
```cpp
std::vector<HistoryEntry> g_history;
std::wstring g_lastLoadedText;
```
Add one line below them:
```cpp
std::vector<HistoryEntry> g_history;
std::wstring g_lastLoadedText;
std::wstring g_sessionPath; // history file backing the CURRENT session ("" = none yet)
```
**Do NOT delete `g_lastLoadedText`** — it still guards against re-archiving an
unmodified entry that was loaded from history (Step 4 below still uses it).
## 4. Step 2 — Add two small helpers
Paste these **immediately above** `void OnClick(HWND hWnd, WK kind)` (right after the
`SelectsRowFullWidthAnchor()` helper from Fix-04):
```cpp
// True if the string contains anything that isn't whitespace.
static bool HasInk(const std::wstring& s) {
for (wchar_t c : s) if (!iswspace(c)) return true;
return false;
}
// End the current session: make sure whatever is in the transcript box is in
// history (including manual edits made after the last clip), then reset the
// session so the next transcription starts a new history file.
// Returns true if the session is saved in history.
static bool FinalizeSession(HWND hWnd) {
std::wstring cur = GetEditText(hWnd);
bool saved = false;
if (!g_sessionPath.empty()) {
// Live archiving already created the file; just capture any edits
// the user made after the last transcription.
if (HasInk(cur)) WriteFileUtf8(g_sessionPath, cur);
saved = true;
} else if (HasInk(cur) && cur != g_lastLoadedText) {
// Text that was typed (never transcribed) — archive it once.
// The g_lastLoadedText guard stops unmodified loaded entries
// from being archived a second time.
saved = !ArchiveSession(cur).empty();
}
g_sessionPath.clear();
return saved;
}
```
(`WriteFileUtf8` and `ArchiveSession` are both `inline` in `history.h`, which
`main.cpp` already includes — nothing to add there.)
## 5. Step 3 — Fix `WM_APP_RESULT` (delete the bug, add live archiving)
In `WndProc`'s `WM_APP_RESULT` case, find these four lines:
```cpp
SendMessageW(hEdit, EM_SCROLLCARET, 0, 0);
UpdatePlaceholder(hWnd);
g_lastLoadedText = GetEditText(hWnd);
SetClipboardTextUtf8(hWnd, *res);
```
Replace with:
```cpp
SendMessageW(hEdit, EM_SCROLLCARET, 0, 0);
UpdatePlaceholder(hWnd);
{
// Live-archive: keep this session's history file current.
// First clip creates the file; later clips rewrite it.
std::wstring all = GetEditText(hWnd);
if (g_sessionPath.empty()) g_sessionPath = ArchiveSession(all);
else WriteFileUtf8(g_sessionPath, all);
}
SetClipboardTextUtf8(hWnd, *res);
```
Two things happened here — make sure both did:
1. `g_lastLoadedText = GetEditText(hWnd);` is **GONE**. This is the actual bug fix.
Do not move it somewhere else; it must only ever be assigned in the history-load
handler (Step 5b) and cleared on Clear.
2. The live-archive block was added in its place.
## 6. Step 4 — Route the three session boundaries through `FinalizeSession`
### 4a. Clear button — `OnClick`, `case WK::Clear`
Find:
```cpp
case WK::Clear: {
std::wstring cur = GetEditText(hWnd);
if (!cur.empty() && cur != g_lastLoadedText)
ArchiveSession(cur);
g_history = LoadHistoryIndex();
g_lastLoadedText.clear();
g_editDirty = false;
SetDlgItemText(hWnd, ID_EDIT_TEXT, L"");
UpdatePlaceholder(hWnd);
SetStatus(hWnd, L"Saved to history");
break;
}
```
Replace with:
```cpp
case WK::Clear: {
bool saved = FinalizeSession(hWnd);
g_history = LoadHistoryIndex();
g_lastLoadedText.clear();
g_editDirty = false;
SetDlgItemText(hWnd, ID_EDIT_TEXT, L"");
UpdatePlaceholder(hWnd);
SetStatus(hWnd, saved ? L"Saved to history" : L"Cleared");
break;
}
```
(Bonus fix: the status no longer claims "Saved to history" when nothing was saved —
clearing an empty box now honestly says "Cleared".)
### 4b. Picking a history entry — `WM_APP_SELECT`, the `ID_SEL_HISTORY` branch
Find:
```cpp
} else if (ctrlId == ID_SEL_HISTORY && idx >= 0 && idx < (int)g_history.size()) {
std::wstring cur = GetEditText(hWnd);
if (!cur.empty() && cur != g_lastLoadedText)
ArchiveSession(cur);
std::wstring text = ReadFileUtf8(g_history[idx].path);
```
Replace the first three body lines with one call (the rest of the branch stays):
```cpp
} else if (ctrlId == ID_SEL_HISTORY && idx >= 0 && idx < (int)g_history.size()) {
FinalizeSession(hWnd); // save the in-progress session before swapping
std::wstring text = ReadFileUtf8(g_history[idx].path);
```
Leave `g_lastLoadedText = text;` and everything after it in this branch exactly as it
is — this is the ONE place `g_lastLoadedText` is supposed to be assigned.
Note: loading an entry does NOT make it the live session (`FinalizeSession` cleared
`g_sessionPath`). If you dictate on top of a loaded entry, the next clip archives the
combined text as a **new** file — old history entries are never mutated.
### 4c. App exit — `WM_DESTROY`
Find:
```cpp
case WM_DESTROY: {
std::wstring cur = GetEditText(hWnd);
if (!cur.empty() && cur != g_lastLoadedText) ArchiveSession(cur);
PersistNow();
PostQuitMessage(0);
break;
}
```
Replace with:
```cpp
case WM_DESTROY: {
FinalizeSession(hWnd);
PersistNow();
PostQuitMessage(0);
break;
}
```
### 4d. Legacy hidden Clear button — `WM_COMMAND`, `case ID_BTN_CLEAR`
This branch belongs to a hidden legacy child button and never fires, but update it to
match 4a anyway so the two Clear paths can't drift apart:
```cpp
case ID_BTN_CLEAR: {
bool saved = FinalizeSession(hWnd);
g_history = LoadHistoryIndex();
g_lastLoadedText.clear();
g_editDirty = false;
SetDlgItemText(hWnd, ID_EDIT_TEXT, L"");
UpdatePlaceholder(hWnd);
SetStatus(hWnd, saved ? L"Saved to history" : L"Cleared");
break;
}
```
## 7. How the pieces behave now (mental model for the dev)
```
record clip 1 ──► WM_APP_RESULT ──► ArchiveSession(text) → creates 2026-06-11_HHMMSS.txt, g_sessionPath = that file
record clip 2 ──► WM_APP_RESULT ──► WriteFileUtf8(sessionPath) → same file rewritten with full text
edit by hand ──► (nothing yet — captured at the next clip or at finalize)
Clear / pick entry / exit ──► FinalizeSession → final write incl. edits, g_sessionPath = ""
next clip ──► new session file
```
- One file per session. Appending clips never creates duplicates.
- Cancelled clips / "No speech detected" change nothing (the result is empty, so the
live-archive block isn't reached).
- The session file keeps its creation timestamp/filename while it grows — it sorts in
the popup by when the session *started*. That's intended.
- `ArchiveSession` already refuses whitespace-only text and prunes to 100 files; both
behaviors are reused untouched.
## 8. Build & test checklist
1. **The headline fix:** launch with an empty box → dictate one clip → open History
(no Clear!) → the new session is the top entry with the right preview.
2. **Appending:** dictate a second clip → open History → still ONE entry for this
session, now containing both clips. No duplicate rows.
3. **Clear:** press Clear → "Saved to history" → box empties → dictate again → History
now shows TWO entries (old session + new session).
4. **Clear with empty box:** status says "Cleared" and no empty file appears in `history\`.
5. **Exit:** dictate → edit a word by hand → Exit via tray → relaunch → the entry
contains the hand-edit.
6. **Switching:** dictate → open History → pick an older entry → the in-progress
session was saved (visible in the list) and the older text loads.
7. **No duplicate on unmodified load:** load an entry, change nothing, press Clear →
no new file is created (status "Cleared"); the entry appears once in the list.
8. **Dictating onto a loaded entry:** load an entry → dictate → a NEW combined entry is
created; the original old file is unchanged.
9. **Typed-only session:** type text manually without dictating → Clear → archived once.
10. **Cancel:** record → hotkey again mid-transcription to cancel → no history file.
11. **Diagnosis confirmation (optional, before applying the fix):** on the OLD build,
transcribe → type one character → Clear → entry appears. That proves the
`g_lastLoadedText` poisoning was the culprit.
## 9. Do NOT touch
- `history.h``ArchiveSession`, `WriteFileUtf8`, `PruneHistory`, `LoadHistoryIndex`
all unchanged.
- The popup code from Fix-03/04 (`PopupProc`, `ShowSelectPopup`,
`SelectsRowFullWidthAnchor`) — unchanged.
- `g_lastLoadedText` — keep it; it is still assigned in the history-load branch and
cleared on Clear. Just never assign it anywhere else (that was the bug).
- `g_editDirty` — currently informational only; leave as is.
+294
View File
@@ -0,0 +1,294 @@
# Fix 04 — History popup: full app width, always opens upward, no squashed text
**Builds on:** Fix-03 (the popup-window history list). Apply this only after Fix-03 is in.
**Files touched:** `src/main.cpp` (plus one optional line in `src/history.h`).
**Estimated effort:** ~30 minutes including testing.
**Important:** `PopupProc` (the popup's message handler) needs **NO changes**. All the
scroll / hover / click machinery from Fix-03 stays exactly as it is.
---
## 1. What's actually wrong (two separate root causes)
### 1a. The "squashed" rows are caused by TEXT WRAPPING, not just narrowness
Look closely at the screenshot: each history row shows the timestamp on one line and a
clipped second line of preview text under it. That is GDI+ **wrapping** the label.
`DrawTextC()` builds a `StringFormat` and sets ellipsis trimming, but never sets
`StringFormatFlagsNoWrap`. GDI+ `DrawString` **wraps by default** when given a layout
rectangle. So a long label inside a 30px-tall row wraps to a second line, which doesn't
fit vertically, and gets clipped → the cramped, squashed look. The ellipsis trimming only
kicks in on the wrapped last line, which is why you see `…` mid-text.
This must be fixed at the `DrawTextC` level. Widening the popup alone is NOT enough — a
long enough entry would still wrap and squash again.
### 1b. The popup is anchored to the half-width History button, and prefers opening down
- `ShowSelectPopup` is called with `g_w[(int)WK::History].r` as the anchor, so the popup
is only as wide as the History button (half the content width, min 260px).
- The Fix-03 placement logic opens **downward** whenever the monitor has room below the
button, which is almost always (the screenshot shows it covering Copy/Paste and
spilling below the window). The requirement is now: **always open upward**, over the
transcript, inside the app's footprint.
## 2. The fix — summary
1. Add `StringFormatFlagsNoWrap` to `DrawTextC` → every label renders on exactly one
line and ellipsizes cleanly. (Safe app-wide: every `DrawTextC` caller — buttons,
status line, settings rows, stats lines, popup rows — is a single-line label. Nothing
in the app intentionally wraps.)
2. Replace `ShowSelectPopup`'s placement logic: **always upward**, with a
shrink-to-fit fallback if the window sits near the top of the screen, and use the
anchor's width **exactly** (drop the 260px minimum — the anchor itself becomes
full-width in step 3).
3. Anchor the popup to a **full-content-width rect at the selects row** (from the left
edge of the mic select to the right edge of the History select) instead of to the
individual button. Apply to History (required) and to the mic selector (same row, same
one-liner — keeps the two popups consistent and stops long device names truncating).
---
## 3. Step 1 — Stop GDI+ wrapping in `DrawTextC`
In `src/main.cpp`, find:
```cpp
static void DrawTextC(Graphics& g, const wchar_t* s, const Font& f, const Color& c,
const RectF& box, StringAlignment h, StringAlignment v) {
StringFormat sf; sf.SetAlignment(h); sf.SetLineAlignment(v);
sf.SetTrimming(StringTrimmingEllipsisCharacter);
SolidBrush b(c); g.DrawString(s, -1, &f, box, &sf, &b);
}
```
Replace with (one added line):
```cpp
static void DrawTextC(Graphics& g, const wchar_t* s, const Font& f, const Color& c,
const RectF& box, StringAlignment h, StringAlignment v) {
StringFormat sf; sf.SetAlignment(h); sf.SetLineAlignment(v);
sf.SetFormatFlags(StringFormatFlagsNoWrap); // single line: ellipsize, never wrap
sf.SetTrimming(StringTrimmingEllipsisCharacter);
SolidBrush b(c); g.DrawString(s, -1, &f, box, &sf, &b);
}
```
Why this is safe globally: `DrawTextC` is used for the Record/Copy/Paste/Clear labels,
the status line, the settings catalog rows, the statistics lines, the placeholder, and
the popup rows. Every one of those is a one-line label drawn into a one-line box. None
of them relies on wrapping. This change also future-proofs the rest of the UI against
the same bug.
## 4. Step 2 — Replace `ShowSelectPopup` (always upward + exact anchor width)
Replace the **entire** `ShowSelectPopup` function with:
```cpp
void ShowSelectPopup(HWND owner, int ctrlId, const std::vector<std::wstring>& items, int sel, const RectF& anchor) {
static bool reg = false;
if (!reg) {
WNDCLASSEXW wc{ sizeof(wc) };
wc.lpfnWndProc = PopupProc;
wc.hInstance = hInst;
wc.hCursor = LoadCursor(nullptr, IDC_ARROW);
wc.hbrBackground = CreateSolidBrush(CR_SURFACE);
wc.lpszClassName = L"DictPopup";
RegisterClassExW(&wc);
reg = true;
}
if (items.empty()) return;
float s = g_dpiScale;
int rowH = (int)(30 * s);
int pad = (int)(3 * s); if (pad < 3) pad = 3;
int n = (int)items.size();
// Anchor rect is in CLIENT coordinates; convert its top-left to screen.
POINT tl{ (LONG)anchor.X, (LONG)anchor.Y };
ClientToScreen(owner, &tl);
int anchorTop = tl.y;
int x = tl.x;
HMONITOR mon = MonitorFromWindow(owner, MONITOR_DEFAULTTONEAREST);
MONITORINFO mi{ sizeof(mi) };
GetMonitorInfo(mon, &mi);
// ALWAYS open upward. If the window sits so close to the top of the screen
// that the popup wouldn't fit, show fewer rows instead of spilling off-screen
// (the wheel still reaches every item).
int visRows = std::min(n, kPopupMaxVisible);
int spaceAbove = anchorTop - mi.rcWork.top - 2; // px available above the row
int fitRows = (spaceAbove - 2 * pad) / rowH;
if (fitRows < 1) fitRows = 1;
if (visRows > fitRows) visRows = fitRows;
g_pop = PopupState{};
g_pop.items = items;
g_pop.sel = sel;
g_pop.owner = owner;
g_pop.ctrlId = ctrlId;
g_pop.visRows = visRows;
g_pop.rowH = rowH;
g_pop.pad = pad;
if (sel >= 0 && n > visRows)
g_pop.scroll = std::max(0, std::min(sel - visRows / 2, n - visRows));
int h = visRows * rowH + 2 * pad;
int wdt = (int)anchor.Width; // use the anchor's width EXACTLY
int y = anchorTop - h - 2; // bottom edge sits just above the anchor row
if (y < mi.rcWork.top) y = mi.rcWork.top;
if (x + wdt > mi.rcWork.right) x = mi.rcWork.right - wdt;
if (x < mi.rcWork.left) x = mi.rcWork.left;
HWND p = CreateWindowExW(WS_EX_TOOLWINDOW | WS_EX_TOPMOST, L"DictPopup", L"",
WS_POPUP, x, y, wdt, h, owner, nullptr, hInst, nullptr);
if (!p) return;
int corner = DWMWCP_ROUND;
DwmSetWindowAttribute(p, DWMWA_WINDOW_CORNER_PREFERENCE, &corner, sizeof(corner));
ShowWindow(p, SW_SHOWNA);
SetFocus(p);
}
```
Deliberate changes vs Fix-03 — so nothing looks accidentally lost:
- The downward-first / flip-up `if/else` is **gone**: it now always computes
`y = anchorTop - h - 2` (upward).
- New shrink-to-fit block: if the selects row is near the top of the *monitor*, the
popup shows fewer rows rather than going off-screen. Scrolling still reaches all items
because `PopupProc` keys off `g_pop.visRows`.
- `int wdt = std::max((int)anchor.Width, (int)(260 * s));` became
`int wdt = (int)anchor.Width;` — the 260px minimum is no longer needed because the
anchor itself is now full content width (Step 3).
- Everything else (DPI row height, scroll-to-selected, monitor clamps, window style,
rounded corners, `SetFocus` for wheel/Esc) is unchanged.
## 5. Step 3 — Full-width anchor for the selects row
### 5a. Add a tiny helper
Paste this **immediately above** `void OnClick(HWND hWnd, WK kind)`:
```cpp
// Full content-width anchor at the selects row: spans from the left edge of the
// mic select to the right edge of the History select. Used so the popups open
// as wide as the app's content area, not as wide as one button.
static RectF SelectsRowFullWidthAnchor() {
const RectF& a = g_w[(int)WK::SelAudio].r; // leftmost widget on the row
const RectF& b = g_w[(int)WK::History].r; // rightmost widget on the row
return RectF(a.X, b.Y, (b.X + b.Width) - a.X, b.Height);
}
```
(These two rects are laid out by `LayoutWidgets`: SelAudio starts at the left margin,
History ends at the right margin, so the result is exactly the inner content width —
and it stays correct automatically when the window is resized or DPI changes.)
### 5b. Use it at all three call sites
**Call site 1 — `OnClick`, the History case.** Find:
```cpp
ShowSelectPopup(hWnd, ID_SEL_HISTORY, labels, -1, g_w[(int)WK::History].r);
```
Replace with:
```cpp
ShowSelectPopup(hWnd, ID_SEL_HISTORY, labels, -1, SelectsRowFullWidthAnchor());
```
**Call site 2 — `OnClick`, the mic selector case.** Find:
```cpp
case WK::SelAudio:
ShowSelectPopup(hWnd, ID_SEL_AUDIO, g_audioItems, g_audioSel, g_w[(int)WK::SelAudio].r);
break;
```
Replace with:
```cpp
case WK::SelAudio:
ShowSelectPopup(hWnd, ID_SEL_AUDIO, g_audioItems, g_audioSel, SelectsRowFullWidthAnchor());
break;
```
(Same row, same treatment — keeps the two popups visually consistent and stops long
device names like "Microphone (Realtek High Definition Audio)" being truncated.)
**Call site 3 — `WM_COMMAND`, near the bottom of the big switch.** Find:
```cpp
case ID_SEL_AUDIO:
ShowSelectPopup(hWnd, ID_SEL_AUDIO, g_audioItems, g_audioSel, g_w[(int)WK::SelAudio].r);
break;
```
Replace with the same `SelectsRowFullWidthAnchor()` version. (This branch is vestigial —
it belongs to a hidden legacy child button and never fires — but update it anyway so a
future grep doesn't find two different anchoring styles.)
## 6. Optional polish — longer previews (now that there's room)
`history.h` truncates each preview to 28 characters, which was sized for the old
half-width dropdown. With the full-width popup there's room for roughly double that.
In `src/history.h`, inside `LoadHistoryIndex()`, find:
```cpp
std::wstring preview = ReadFileUtf8(e.path).substr(0, 28);
```
Change `28` to `60`. No migration needed — labels are rebuilt from the files every time
`LoadHistoryIndex()` runs, so existing history files immediately show longer previews.
Anything that still doesn't fit ellipsizes on one line (thanks to Step 1).
## 7. Build & test checklist
1. **No more squash:** open History — every row is exactly ONE line; entries too long
for the row end in a clean `…`. No second clipped line anywhere.
2. **Full width:** the popup spans from the left edge of the Microphone select to the
right edge of the History select (the whole content width), at any window size.
Resize the window wider → reopen → popup matches the new width.
3. **Always upward:** the popup's bottom edge sits just above the selects row and the
list extends UP over the transcript. It never opens downward, no matter where the
window is on the screen (test with the window at the bottom, middle, and top of the
monitor).
4. **Near the top of the screen:** drag the window so the selects row is close to the
top of the monitor → the popup shows fewer rows instead of going off-screen, and the
wheel still scrolls through all entries.
5. **Scrolling regression (from Fix-03):** with 10+ entries — max 8 rows, thumb on the
right, wheel scrolls 3 rows/notch, hover highlight tracks correctly.
6. **Mic selector:** also opens full-width and upward; picking a device still works.
7. **Dismissal:** click-away and Esc still close the popup; clicking an entry loads it
("Loaded from history") and archives whatever was in the box.
8. **NoWrap regression sweep:** glance over the rest of the UI — status line, Record
pill, settings model rows, statistics lines. All should look identical to before
(none of them ever wrapped). If any text now shows `…` where it used to wrap onto a
second line, that was the same bug manifesting there — widen that box, don't remove
NoWrap.
9. **DPI sanity:** 125%/150% — rows align with the cursor, popup width matches the row.
## 8. Tuning knobs
| What | Where | Default |
|---|---|---|
| Visible rows before scrolling | `kPopupMaxVisible` | 8 |
| Gap between popup and the row | `y = anchorTop - h - 2` | 2 px |
| Rows per wheel notch | `PopupProc` (`steps * 3`) — unchanged | 3 |
| Preview length in labels | `history.h` `substr(0, 60)` (optional step) | 60 chars |
## 9. Do NOT touch
- **`PopupProc` and `PopupRowFromY`** — completely unchanged from Fix-03. The scroll,
wheel-accumulator, hover, Esc, and click-away logic all still work because they read
`g_pop.visRows` / `rowH` / `pad`, which this fix still populates.
- `PopupState`, `kPopupMaxVisible` — unchanged.
- The `WM_APP_SELECT` handler (including the `ID_SEL_HISTORY` branch) — unchanged.
- `LayoutWidgets` — unchanged; the helper in Step 3 derives the full-width rect from the
existing widget rects, so there is nothing new to lay out.
@@ -0,0 +1,593 @@
# Win Dictation 2.0 — Rebuild Spec (fast, compact, push-to-talk)
## 1. Summary & Assumptions
This spec rebuilds the existing `whisper.cpp/examples/win-dictation` C++/Win32 app into a **fast, compact, push-to-talk dictation tool**. The headline change is architectural, not cosmetic: stop doing live streaming transcription and instead **record audio cheaply, then run Whisper exactly once when you stop**. That single change is what makes it usable on a 2-core CPU.
**Assumptions made** (you didn't pick on the two questions — flip any of these freely):
- **Transcription model: batch / record-then-transcribe.** You hit record, speak, hit stop; ~12s later the text appears. No live word-by-word feed. This is the big performance win and is also more accurate.
- **Output: copy to clipboard + auto-paste into the app you were last in.** A toggle lets you fall back to copy-only.
- **English-only**, CPU-only, model `ggml-tiny.en.bin` by default (with an easy switch to `base.en` / quantized).
- **Toggle hotkey** (press once to start, again to stop) rather than hold-to-talk. Hold-to-talk is included as an optional add-on in §7.
**What changes, at a glance:**
| Area | Today | 2.0 |
|---|---|---|
| Inference | Rolling 56s window every ~0.4s (needs many cores) | One `whisper_full` call per utterance |
| CPU while speaking | Pegged (continuous inference) | ~0% (just buffering audio) |
| Threads | 4 on 2 physical cores (UI starves) | = physical cores (default 2), UI stays responsive |
| Model load | On first record (blocks UI) | Preloaded in background at startup |
| Window | 720×600, not on top | Compact ~360×180, always-on-top, pin toggle |
| Get text out | Manually select + Ctrl+C | Auto-copied; optional auto-paste into last app |
| Launch | exe | exe + desktop shortcut, single-instance, start-to-tray |
**I cannot build or test Windows binaries in my environment** — every snippet below is written against the whisper.cpp API your code already uses (`whisper_init_from_file_with_params`, `whisper_full`, …) and standard Win32/SDL2. Build on your machine and send me any compiler errors; I'll fix them.
## 2. Root Cause — why it's slow today
Your `transcriber.cpp` `worker_loop` implements the classic whisper.cpp *stream* pattern:
- `length_ms` ≈ 50006000 → every inference transcribes a **56 second** window.
- `step_ms` ≈ 4001000 → it tries to do that **every 0.41s**, keeping a 200ms overlap.
For this to keep up, your CPU must transcribe 6s of audio in well under 1s — i.e. **>6× real-time**. The README's reference numbers (1015× real-time) were measured on a **24-thread** machine. Your i5-7th-gen is **2 cores / 4 threads** and will do tiny.en at roughly **24× real-time** at best. Consequences:
1. **Unbounded backlog.** Each 6s window takes ~1.52.5s to process, but a new one is requested every 0.4s. The ring buffer fills, latency grows the longer you talk, and `get_buffer_fullness()` climbs toward 100%.
2. **Wasted re-work.** The sliding window + overlap re-transcribes much of the same audio repeatedly, and chunk boundaries split words → duplicated/garbled output.
3. **UI starvation.** `n_threads = hardware_concurrency()` = 4 Whisper threads on 2 physical cores. Whisper's matmuls are memory-bandwidth bound, so the hyperthreads add little throughput but do steal cycles from the UI/audio threads → janky window, laggy VU meter.
**Key insight:** for *dictation* (as opposed to live captioning) you never needed streaming. Record the whole utterance, transcribe once. Whisper then runs at its own pace with **no deadline**, processes each second of audio exactly once, and produces cleaner text. A 10s utterance at 3× real-time = ~3.3s of processing **after** you stop talking — predictable and fine. While you're *speaking*, CPU is near-idle because you're only copying samples into a buffer.
## 3. Target architecture
Three threads, a simple state machine, and one-shot inference.
```
┌─ UI thread (Win32 message loop) ──────────────┐
│ • owns the window, hotkeys, tray, buttons │
│ • owns Transcriber │
│ • receives result via PostMessage │
└───────────────────────────────────────────────┘
│ start_recording() ▲ WM_APP_RESULT (text)
▼ │
┌─ Audio thread (SDL callback) ─┐ │
│ • appends f32 samples to │ │
│ m_capture (mutex) │ │
│ • updates VU energy (atomic) │ │
└───────────────────────────────┘ │
│ stop_and_transcribe() │
▼ │
┌─ Worker thread (spawned on stop) ─────────────┐
│ • optional silence trim │
│ • whisper_full(...) ONCE │
│ • clean text → PostMessage to UI ────────────┘
└────────────────────────────────────────────────
```
**State machine:**
```
Idle ──(hotkey/Record)──▶ Recording ──(hotkey/Stop)──▶ Transcribing ──(result)──▶ Idle
▲ │
└────────────────────────────(cancel / Esc)─────────────────────────────────────┘
```
Guards: ignore Start while `Transcribing`; `stop_and_transcribe` swaps the capture buffer out under the mutex and hands it to the worker by value, so the audio thread can't race the reader. The whole ring-buffer / overlap machinery from the current `transcriber.cpp` is **deleted**.
## 4. transcriber.h (new)
Drop the ring buffer, `step_ms`/`length_ms`, buffer-fullness, etc. New surface:
```cpp
#pragma once
#include <string>
#include <vector>
#include <thread>
#include <mutex>
#include <atomic>
#include <functional>
struct whisper_context;
struct WhisperConfig {
std::string model_path = "models/ggml-tiny.en.bin";
std::string language = "en";
int n_threads = 0; // 0 = auto (physical cores)
bool use_gpu = false; // CPU on this machine
int capture_id = 0; // SDL capture device index
bool trim_silence = true; // cheap VAD on the captured clip
};
class Transcriber {
public:
using ResultCb = std::function<void(const std::string&)>;
Transcriber() = default;
~Transcriber();
bool preload(const WhisperConfig& cfg); // load model off the UI thread
bool is_loaded() const { return m_ctx != nullptr; }
bool start_recording(); // open mic, begin capture (cheap)
void stop_and_transcribe(); // stop mic, kick ONE transcription
void cancel(); // abort recording, no transcription
bool is_recording() const { return m_recording.load(); }
bool is_busy() const { return m_busy.load(); } // transcribing
float get_audio_energy() const { return m_energy.load(); }// 0..1 VU
void set_result_callback(ResultCb cb) { m_on_result = std::move(cb); }
void on_audio(const float* samples, int n); // called by SDL C shim
static std::vector<std::string> get_audio_devices();
private:
void transcribe_worker(std::vector<float> audio);
static int default_threads();
WhisperConfig m_cfg;
whisper_context* m_ctx = nullptr;
unsigned int m_dev = 0;
std::vector<float> m_capture; // grows while recording
std::mutex m_capture_mtx;
std::atomic<bool> m_recording{false};
std::atomic<bool> m_busy{false};
std::atomic<float> m_energy{0.0f};
std::thread m_worker;
ResultCb m_on_result;
};
```
Memory note: 16kHz × 4 bytes = 64 KB/s, so a 2-minute clip ≈ 7.6 MB. Reserve ~30s up front; optionally cap recording length (e.g. 5 min) to bound memory.
## 5. transcriber.cpp — capture & one-shot transcription
```cpp
#include "transcriber.h"
#include "whisper.h"
#include <SDL.h>
#include <SDL_audio.h>
#include <algorithm>
#include <cmath>
#include <cstring>
Transcriber::~Transcriber() {
cancel();
if (m_worker.joinable()) m_worker.join();
if (m_ctx) whisper_free(m_ctx);
}
int Transcriber::default_threads() {
unsigned hc = std::thread::hardware_concurrency(); // 4 on 2c/4t
if (hc <= 2) return (int)std::max(1u, hc);
return (int)(hc / 2); // 4 logical -> 2 physical
}
bool Transcriber::preload(const WhisperConfig& cfg) {
m_cfg = cfg;
if (m_cfg.n_threads <= 0) m_cfg.n_threads = default_threads();
if (m_ctx) return true;
whisper_context_params cp = whisper_context_default_params();
cp.use_gpu = m_cfg.use_gpu;
m_ctx = whisper_init_from_file_with_params(m_cfg.model_path.c_str(), cp);
return m_ctx != nullptr;
}
static void sdl_capture_cb(void* user, Uint8* stream, int len) {
auto* self = static_cast<Transcriber*>(user);
self->on_audio(reinterpret_cast<float*>(stream), len / (int)sizeof(float));
}
bool Transcriber::start_recording() {
if (m_recording.load() || m_busy.load()) return false;
{ std::lock_guard<std::mutex> lk(m_capture_mtx);
m_capture.clear(); m_capture.reserve(WHISPER_SAMPLE_RATE * 30); }
SDL_AudioSpec want{}, have{};
want.freq = WHISPER_SAMPLE_RATE; // 16000
want.format = AUDIO_F32SYS;
want.channels = 1;
want.samples = 1024;
want.callback = sdl_capture_cb;
want.userdata = this;
const char* dev = SDL_GetAudioDeviceName(m_cfg.capture_id, SDL_TRUE);
m_dev = SDL_OpenAudioDevice(dev, SDL_TRUE, &want, &have, 0);
if (!m_dev) return false;
m_energy = 0.0f;
m_recording = true;
SDL_PauseAudioDevice(m_dev, 0); // start capturing
return true;
}
void Transcriber::on_audio(const float* s, int n) {
if (n <= 0 || !m_recording.load()) return;
double sq = 0.0;
for (int i = 0; i < n; ++i) sq += (double)s[i] * s[i];
float rms = (float)std::sqrt(sq / n);
float e = m_energy.load();
m_energy = std::min(1.0f, e * 0.6f + (rms * 4.0f) * 0.4f); // smoothed
std::lock_guard<std::mutex> lk(m_capture_mtx);
m_capture.insert(m_capture.end(), s, s + n);
}
void Transcriber::cancel() {
if (!m_recording.load()) return;
m_recording = false;
if (m_dev) { SDL_PauseAudioDevice(m_dev, 1); SDL_CloseAudioDevice(m_dev); m_dev = 0; }
std::lock_guard<std::mutex> lk(m_capture_mtx);
m_capture.clear();
m_energy = 0.0f;
}
void Transcriber::stop_and_transcribe() {
if (!m_recording.load()) return;
m_recording = false;
if (m_dev) { SDL_PauseAudioDevice(m_dev, 1); SDL_CloseAudioDevice(m_dev); m_dev = 0; }
m_energy = 0.0f;
std::vector<float> audio;
{ std::lock_guard<std::mutex> lk(m_capture_mtx); audio.swap(m_capture); }
if (audio.size() < (size_t)(WHISPER_SAMPLE_RATE * 0.3)) { // <300ms
if (m_on_result) m_on_result("");
return;
}
if (m_worker.joinable()) m_worker.join();
m_busy = true;
m_worker = std::thread(&Transcriber::transcribe_worker, this, std::move(audio));
}
```
**Silence trim + text cleanup helpers** (file-local statics):
```cpp
static void trim_silence(std::vector<float>& a, float thresh = 0.01f) {
const size_t win = 1600; // 100ms
auto loud = [&](size_t i){
float m = 0.f;
for (size_t k=i; k<std::min(a.size(), i+win); ++k) m = std::max(m, std::fabs(a[k]));
return m > thresh;
};
size_t s = 0, e = a.size();
while (s + win < a.size() && !loud(s)) s += win;
while (e > win && !loud(e - win)) e -= win;
if (s + win <= e) a.assign(a.begin()+ (s>win? s-win:0), a.begin()+e); // keep 100ms pad
}
static std::string clean_text(std::string s) {
const char* junk[] = {"[BLANK_AUDIO]","[NOISE]","(blank)","(noise)","[ Silence ]"};
for (auto j : junk) { size_t p; while ((p=s.find(j))!=std::string::npos) s.erase(p, strlen(j)); }
size_t b = s.find_first_not_of(" \t\r\n");
size_t e = s.find_last_not_of(" \t\r\n");
return (b==std::string::npos) ? "" : s.substr(b, e-b+1);
}
```
**The one-shot worker:**
```cpp
void Transcriber::transcribe_worker(std::vector<float> audio) {
if (m_cfg.trim_silence) trim_silence(audio);
whisper_full_params wp = whisper_full_default_params(WHISPER_SAMPLING_GREEDY);
wp.print_progress = false;
wp.print_realtime = false;
wp.print_timestamps = false;
wp.no_timestamps = true;
wp.translate = false;
wp.language = m_cfg.language.c_str();
wp.n_threads = m_cfg.n_threads;
wp.no_context = true;
wp.suppress_blank = true;
wp.temperature = 0.0f;
// greedy + temperature 0 = fastest, deterministic. (Optionally set
// wp.suppress_nst = true on newer whisper.cpp to drop non-speech tokens.)
std::string out;
if (m_ctx && whisper_full(m_ctx, wp, audio.data(), (int)audio.size()) == 0) {
int n = whisper_full_n_segments(m_ctx);
for (int i = 0; i < n; ++i) {
const char* t = whisper_full_get_segment_text(m_ctx, i);
if (t) out += t;
}
out = clean_text(out);
}
m_busy = false;
if (m_on_result) m_on_result(out); // runs on worker thread -> PostMessage in UI
}
```
`get_audio_devices()` stays as-is from your current file (it already enumerates SDL capture devices). Call `SDL_Init(SDL_INIT_AUDIO)` once at app startup (and `SDL_Quit()` at exit) rather than per-call.
## 6. Clipboard & auto-paste
This is the feature that makes it actually useful: text lands on the clipboard automatically, and (optionally) gets pasted straight into whatever app you were in before the popup.
**UTF-8 → UTF-16 + set clipboard:**
```cpp
static std::wstring to_w(const std::string& s) {
if (s.empty()) return L"";
int n = MultiByteToWideChar(CP_UTF8, 0, s.c_str(), -1, nullptr, 0);
std::wstring w(n ? n-1 : 0, L'\0');
if (n) MultiByteToWideChar(CP_UTF8, 0, s.c_str(), -1, &w[0], n);
return w;
}
bool SetClipboardTextUtf8(HWND owner, const std::string& utf8) {
std::wstring w = to_w(utf8);
if (!OpenClipboard(owner)) return false;
EmptyClipboard();
size_t bytes = (w.size() + 1) * sizeof(wchar_t);
HGLOBAL h = GlobalAlloc(GMEM_MOVEABLE, bytes);
if (h) {
void* p = GlobalLock(h);
memcpy(p, w.c_str(), bytes);
GlobalUnlock(h);
SetClipboardData(CF_UNICODETEXT, h); // clipboard now owns h; don't free
}
CloseClipboard();
return h != nullptr;
}
```
**Auto-paste into the previously focused window.** Capture the target HWND at the moment your hotkey fires (before you steal focus — see §7), then:
```cpp
static void send_ctrl_v() {
INPUT in[4] = {};
in[0].type = INPUT_KEYBOARD; in[0].ki.wVk = VK_CONTROL;
in[1].type = INPUT_KEYBOARD; in[1].ki.wVk = 'V';
in[2].type = INPUT_KEYBOARD; in[2].ki.wVk = 'V'; in[2].ki.dwFlags = KEYEVENTF_KEYUP;
in[3].type = INPUT_KEYBOARD; in[3].ki.wVk = VK_CONTROL; in[3].ki.dwFlags = KEYEVENTF_KEYUP;
SendInput(4, in, sizeof(INPUT));
}
void PasteIntoWindow(HWND target) {
if (!target || !IsWindow(target)) return;
DWORD me = GetCurrentThreadId();
DWORD other = GetWindowThreadProcessId(target, nullptr);
AttachThreadInput(me, other, TRUE); // bypass foreground-lock
SetForegroundWindow(target);
SetFocus(target);
AttachThreadInput(me, other, FALSE);
Sleep(40); // let focus settle
send_ctrl_v();
}
```
**Alternative — type the text directly** (no clipboard touched; works in apps with quirky paste handling). Surrogate pairs are handled because each UTF-16 code unit is sent as its own scan code:
```cpp
void TypeUnicode(const std::wstring& text) {
std::vector<INPUT> in; in.reserve(text.size()*2);
for (wchar_t c : text) {
INPUT d{}; d.type = INPUT_KEYBOARD; d.ki.wScan = c; d.ki.dwFlags = KEYEVENTF_UNICODE;
INPUT u = d; u.ki.dwFlags |= KEYEVENTF_KEYUP;
in.push_back(d); in.push_back(u);
}
if (!in.empty()) SendInput((UINT)in.size(), in.data(), sizeof(INPUT));
}
```
**Caveats to bake in:**
- **Elevation/UIPI:** a non-elevated app cannot `SendInput` into an elevated (Run-as-Admin) window. If you dictate into elevated apps, ship an elevation manifest — otherwise leave it un-elevated (recommended) and it'll just work for normal apps.
- Recommend **clipboard + Ctrl+V** as the default (fast, preserves formatting-free text); offer **TypeUnicode** as a fallback toggle for stubborn targets.
- Consider saving/restoring the user's previous clipboard contents if you want to be polite (optional).
## 7. main.cpp — window, hotkeys, tray, single-instance
Targeted changes to your existing `main.cpp`. New message IDs:
```cpp
#define WM_APP_RESULT (WM_APP + 1) // worker -> UI: transcription text
#define WM_APP_SHOW (WM_APP + 2) // 2nd instance -> existing window
#define HK_TOGGLE 1
#define HK_HIDE 2
static Transcriber g_tx;
static WhisperConfig g_config;
static HWND g_prevForeground = nullptr; // app to paste back into
static bool g_autoPaste = true;
```
**Single instance** (very top of `wWinMain`, before creating the window):
```cpp
HANDLE hMutex = CreateMutexW(nullptr, TRUE, L"WhisperDictation_SingleInstance");
if (GetLastError() == ERROR_ALREADY_EXISTS) {
HWND existing = FindWindowW(L"WhisperDictationClass", nullptr);
if (existing) PostMessage(existing, WM_APP_SHOW, 0, 0);
return 0; // a copy is already running (and owns the hotkeys)
}
```
**Compact, always-on-top window** (replace the big `CreateWindowEx`):
```cpp
hMainWnd = CreateWindowExW(
WS_EX_TOPMOST | WS_EX_TOOLWINDOW, // on top, no taskbar button
L"WhisperDictationClass", L"Dictation",
WS_POPUP | WS_CAPTION | WS_SYSMENU, // small, draggable by caption
CW_USEDEFAULT, CW_USEDEFAULT, 360, 180,
nullptr, nullptr, hInstance, nullptr);
void SetAlwaysOnTop(HWND h, bool on) {
SetWindowPos(h, on ? HWND_TOPMOST : HWND_NOTOPMOST, 0,0,0,0, SWP_NOMOVE|SWP_NOSIZE);
}
```
Suggested compact layout (3 rows): **[ ● Record / ■ Stop ] [ 📌 pin ]** · status line ("Ready • tiny.en • 2 threads" / "Recording 0:04" / "Transcribing…" / "Copied ✓") · a small read-only multiline EDIT showing the last result + a **Copy** and **Paste** button. Keep the VU meter — it's cheap and reassures you the mic is live.
**Preload the model in the background** (after the window exists, so first record is instant):
```cpp
std::thread([]{ g_tx.preload(g_config); }).detach();
g_tx.set_result_callback([](const std::string& t){
PostMessage(hMainWnd, WM_APP_RESULT, (WPARAM)new std::string(t), 0);
});
```
**Global hotkeys** (after window creation):
```cpp
RegisterHotKey(hMainWnd, HK_TOGGLE, MOD_CONTROL | MOD_SHIFT, VK_SPACE); // show + record/stop
RegisterHotKey(hMainWnd, HK_HIDE, MOD_CONTROL | MOD_SHIFT, 'H'); // hide to tray
```
**Message handling:**
```cpp
case WM_HOTKEY:
if (wParam == HK_TOGGLE) {
if (!g_tx.is_recording() && !g_tx.is_busy()) {
g_prevForeground = GetForegroundWindow(); // capture BEFORE we steal focus
ShowWindow(hWnd, SW_SHOWNA); // show without stealing focus
if (g_tx.start_recording()) SetStatus(hWnd, L"Recording…");
} else if (g_tx.is_recording()) {
g_tx.stop_and_transcribe();
SetStatus(hWnd, L"Transcribing…");
}
} else if (wParam == HK_HIDE) {
if (g_tx.is_recording()) g_tx.cancel();
ShowWindow(hWnd, SW_HIDE);
}
break;
case WM_APP_SHOW:
ShowWindow(hWnd, SW_SHOW); SetForegroundWindow(hWnd);
break;
case WM_APP_RESULT: {
std::string* res = (std::string*)wParam;
if (res && !res->empty()) {
SetDlgItemTextW(hWnd, ID_EDIT_TEXT, to_w(*res).c_str());
SetClipboardTextUtf8(hWnd, *res);
if (g_autoPaste && g_prevForeground) {
ShowWindow(hWnd, SW_HIDE); // get out of the way first
PasteIntoWindow(g_prevForeground);
}
SetStatus(hWnd, g_autoPaste ? L"Pasted ✓" : L"Copied ✓");
} else {
SetStatus(hWnd, L"No speech detected");
}
delete res;
} break;
```
**Recording timer / VU:** keep a lightweight `WM_TIMER` (e.g. 50ms) that, while `is_recording()`, updates the VU meter from `get_audio_energy()` and shows elapsed seconds. Drop the buffer-fullness bar (no longer meaningful).
**Tray:** keep your existing tray setup; `WM_CLOSE` hides to tray (as today). Add a "Start in tray" option: `ShowWindow(hMainWnd, startHidden ? SW_HIDE : nCmdShow);`. On `WM_DESTROY`, also `ReleaseMutex(hMutex)`.
**Optional — hold-to-talk** (instead of toggle): `RegisterHotKey` only fires on key-down, so true push-and-hold needs a low-level keyboard hook:
```cpp
// SetWindowsHookEx(WH_KEYBOARD_LL, LowLevelKbProc, hInst, 0);
// In the proc: on your chosen key WM_KEYDOWN -> start_recording (once),
// on WM_KEYUP -> stop_and_transcribe. Debounce auto-repeat with a flag.
```
Keep it behind a setting; the toggle hotkey matches your "hit record" description and is simpler/robust.
## 8. WhisperConfig tuning for the i5-7th-gen
**Threads.** Default to physical cores (2). Whisper's matmuls are memory-bandwidth bound, so 4 threads on 2 cores buys little throughput and steals from the UI/audio threads. Try 2 (default) vs 3 and keep whichever feels best — expose it in settings.
**Model choice (CPU, English).** All from `download-ggml-model`:
| Model | Size | Rel. speed on 2c | Accuracy | Use when |
|---|---|---|---|---|
| `tiny.en` | 75 MB | ★★★★★ fastest | ok | **default** — snappy dictation |
| `tiny.en-q8_0` | ~42 MB | ★★★★★ | ≈ tiny | low RAM / similar speed |
| `base.en-q5_1` | ~57 MB | ★★★☆ | better | want more accuracy, can wait ~2× |
| `base.en` | 142 MB | ★★★ | better | accuracy over latency |
| `small.en` | 466 MB | ★★ slow | best | only for short clips / patience |
Quantized (`q5_1`/`q8_0`) models are smaller and can be a touch faster on a bandwidth-limited CPU for a small accuracy cost — worth A/B testing `tiny.en` vs `tiny.en-q8_0` and `base.en-q5_1`. Add a model dropdown in the UI that re-runs `preload()` on a background thread.
**Whisper params** (already in §5): greedy sampling, `temperature = 0`, `no_context = true`, `no_timestamps = true`. These are the fastest, most deterministic settings. Avoid beam search.
**Build flags.** Your CMake already enables AVX2/FMA/F16C (the `WHISPER_NO_*` options default OFF) and MSVC `/O2 /GL` + `/LTCG`. That's correct for Kaby Lake — keep it. Confirm you're building **Release**, not Debug (Debug whisper is multiples slower). Optional extras, in rough order of effort/value:
- **OpenBLAS** (`-DGGML_BLAS=ON` with a BLAS vendor) — sometimes helps CPU matmul; measure, it's not always a win for tiny.
- **Vulkan on the iGPU** (`-DGGML_VULKAN=ON`) — your HD/UHD 620 *can* run it, but for tiny.en it's often no faster than CPU and adds driver/DLL complexity. Low priority; the batch redesign already solves the felt problem.
**Path robustness.** `model_path` is relative (`models/...`), so the app only works when the working directory is the exe folder. Resolve it from the exe location so a desktop shortcut always works:
```cpp
std::string exe_dir() {
char buf[MAX_PATH]; GetModuleFileNameA(nullptr, buf, MAX_PATH);
std::string p(buf); return p.substr(0, p.find_last_of("\\/"));
}
// g_config.model_path = exe_dir() + "\\models\\ggml-tiny.en.bin";
```
## 9. Build, paths & desktop shortcut
**Files touched:** `src/transcriber.h`, `src/transcriber.cpp`, `src/main.cpp`. The clipboard/paste helpers can live inside `main.cpp` (no new translation unit needed). If you split them into `src/clipboard.cpp`, add it to both `add_executable(...)` lists in `CMakeLists.txt` and `src/CMakeLists.txt`. No new third-party dependencies.
**Build (unchanged):**
```powershell
cmake -B build -DWHISPER_SDL2=ON
cmake --build build --config Release
# build\bin\Release\win-dictation.exe
```
Your `build.ps1` already downloads SDL2 + models and deploys DLLs; keep using it.
**Desktop shortcut** (double-click to launch). The `WorkingDirectory` must be the exe folder so `models/` resolves — unless you adopt the `exe_dir()` fix in §8, in which case it doesn't matter:
```powershell
$exe = "C:\code\whisper.cpp\examples\win-dictation\build\bin\Release\win-dictation.exe"
$ws = New-Object -ComObject WScript.Shell
$sc = $ws.CreateShortcut("$env:USERPROFILE\Desktop\Dictation.lnk")
$sc.TargetPath = $exe
$sc.WorkingDirectory = Split-Path $exe
$sc.IconLocation = "$exe,0"
$sc.Save()
```
**Start with Windows** (optional): drop that same `.lnk` into `shell:startup`, or add a `Run` registry value. Combined with start-to-tray, it's always one hotkey away.
**Icon:** you already have `win-dictation.rc` / `IDI_ICON1`, so the exe and tray icon are covered.
## 10. Testing & acceptance checklist
Since I can't run it, here's what to verify on the laptop:
**Performance (the point of all this):**
- [ ] While recording, Task Manager shows the app near-idle on CPU (you're only buffering).
- [ ] After Stop, a ~10s utterance transcribes in a few seconds and the window stays responsive throughout.
- [ ] Latency does **not** grow with longer recordings (the old unbounded-backlog bug is gone).
- [ ] First recording after launch is instant (model preloaded) — no "Loading model…" stall.
**Workflow:**
- [ ] `Ctrl+Shift+Space` shows the mini window and starts recording; pressing it again stops and produces text.
- [ ] Text is on the clipboard automatically; with auto-paste on, it lands in the app you were in before the hotkey.
- [ ] `Ctrl+Shift+H` hides to tray; double-clicking the tray icon restores.
- [ ] Launching a second copy focuses the existing one instead of starting a rival (single-instance + hotkey ownership).
- [ ] Pin toggle keeps it above other windows; window is draggable and compact.
**Robustness:**
- [ ] Recording <0.3s or pure silence → "No speech detected", no crash.
- [ ] Mic selection change takes effect on the next recording.
- [ ] Paste into Notepad, a browser field, and your editor all work; note any app where Ctrl+V fails (use TypeUnicode fallback there).
- [ ] Unicode / punctuation comes through intact (UTF-8↔UTF-16 path).
## 11. Suggested implementation order
Build it incrementally so you can feel the win early and isolate any breakage:
1. **Batch core first (biggest payoff).** Rewrite `transcriber.h/.cpp` per §4–§5. Temporarily wire your *existing* big window's Record button to `start_recording()` / `stop_and_transcribe()` and dump the result into the text box. At this point the performance problem should already be gone. Verify §10 "Performance".
2. **Clipboard + auto-paste** (§6). Add `SetClipboardTextUtf8` and capture `g_prevForeground` on the button press; confirm copy works, then add `PasteIntoWindow`.
3. **Global hotkeys + focus capture** (§7). Switch to driving everything from `Ctrl+Shift+Space`; make sure `g_prevForeground` is grabbed *before* showing the window.
4. **Compact always-on-top UI + pin** (§7). Shrink the window, add `WS_EX_TOPMOST`, trim the layout, drop the buffer bar.
5. **Single-instance + start-to-tray + background preload** (§7).
6. **Tuning pass** (§8): set threads = 2, try `tiny.en` vs `tiny.en-q8_0` vs `base.en-q5_1`, add the model dropdown, apply the `exe_dir()` path fix.
7. **Desktop shortcut** (§9) and optional hold-to-talk.
Each step compiles and runs on its own. If you want, I can generate the **complete** rewritten `main.cpp`, `transcriber.cpp`, and `transcriber.h` (not just snippets) for step 1 so you have a drop-in starting point.