Docs refresh: add user manual & architecture review; update README with accurate details
This commit is contained in:
@@ -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 5–6 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 "10–15× real-time" numbers were measured on a **24-thread** box.
|
||||
- A 2-core i5-7th-gen does tiny.en at roughly **2–4× real-time**.
|
||||
|
||||
So each 6 s window took ~1.5–2.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,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 5–6 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 "10–15× real-time" numbers were measured on a **24-thread** box.
|
||||
- A 2-core i5-7th-gen does tiny.en at roughly **2–4× real-time**.
|
||||
|
||||
So each 6 s window took ~1.5–2.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 1–3) 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 **10–11**; 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 **120–160ms**, 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 ~14–16px 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·(100−p)/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, 10–12px 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.
|
||||
|
||||
|
||||
|
||||
@@ -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 (1–2 days). Do phases in order; tasks within a phase are mostly independent unless "Depends on" says otherwise. After every task: `cmake --build build --config Release` must succeed with **zero new warnings**, and the app must still launch.
|
||||
|
||||
**Color tokens (already in `main.cpp`, reuse — never hard-code hex elsewhere):** `C_BG #0F1115`, `C_SURFACE #181B22`, `C_SURFACEHI #20242D`, `C_BORDER #262B36`, `C_TEXT #E7E9EE`, `C_TEXTDIM #9AA0AB`, `C_ACCENT #6E8BFF`, `C_DANGER #FF5C5C`, `C_GOOD #46D39A`.
|
||||
|
||||
---
|
||||
|
||||
## Phase 0 — Repo hygiene (do first; clears traps)
|
||||
|
||||
### Task 0.1 — Delete the stale `src/CMakeLists.txt`
|
||||
- **Goal:** Remove a build file that references **removed** APIs (`init()`, `is_using_gpu()`) and 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 1–2 min clip shows a filling bar + percentage + shrinking ETA and completes; short clips still feel instant.
|
||||
- **Effort:** M
|
||||
|
||||
### Task 2.2 — Cancel a running transcription (B2.4)
|
||||
- **Goal:** Let the user abort a long/incorrect transcription instead of waiting it out.
|
||||
- **Files:** `src/transcriber.h/.cpp`, `src/main.cpp`.
|
||||
- **Steps:**
|
||||
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.1–0.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 |
|
||||
|
||||
|
||||
@@ -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:** 1–2 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 1–2 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:** 45–60 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 10–50 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 3–4 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:** 30–45 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.
|
||||
@@ -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; ~1–2s 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 5–6s 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` ≈ 5000–6000 → every inference transcribes a **5–6 second** window.
|
||||
- `step_ms` ≈ 400–1000 → it tries to do that **every 0.4–1s**, 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 (10–15× 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 **2–4× real-time** at best. Consequences:
|
||||
|
||||
1. **Unbounded backlog.** Each 6s window takes ~1.5–2.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.
|
||||
Reference in New Issue
Block a user