# Win Dictation — Architecture Analysis & Dev Guide (Build 4)

Two parts:

- **Part A — Architecture analysis & recommendations.** Where the app stands now (post-fixes) and what's worth hardening.
- **Part B — Dev guide** for the two things you asked for: (1) removing the leftover Windows chrome (combo dropdown buttons, button hairlines, the line around *Pinned*), and (2) showing real transcription progress + a time estimate instead of a static "Transcribing…".

Context: native C++ / Win32 + SDL2 + whisper.cpp, CPU-only (i5-7th-gen, 2c/4t). Companion docs: `MODERN-UI-AND-FIXES.md`, `FINDINGS-FIXES-TESTS.md`.

---

# Part A — Architecture Analysis & Recommendations

## A1. Current architecture (it's in good shape)

```
┌── UI thread (message loop) ───────────────┐     ┌── Audio thread (SDL callback) ──┐
│  • window, hotkeys, tray, GDI+ paint      │     │  • append f32 samples to        │
│  • owns Transcriber                       │◀────│    m_capture (mutex)            │
│  • marshals worker results via PostMessage│     │  • update VU energy (atomic)    │
└──────────────┬────────────────────────────┘     └─────────────────────────────────┘
               │ stop_and_transcribe()
               ▼
┌── Worker thread (one per utterance) ──────┐
│  • run_inference(): whisper_full() ONCE   │
│  • PostMessage(WM_APP_RESULT, text)       │
└────────────────────────────────────────────┘
```

State machine: **Idle → Recording → Transcribing → Idle**, with the model preloaded on a detached thread at startup.

**What's genuinely good now:**
- **Clean thread boundaries.** Audio capture, inference, and UI never block each other; all cross-thread hand-offs are atomics or `PostMessage`. This is the right shape.
- **Batch (record-then-transcribe).** The correct model for dictation on a slow CPU — no real-time deadline, latency independent of clip length.
- **Core is decoupled and unit-testable** (`Transcriber` + `run_inference`/`transcribe_sync`, `text_util.h`). The UI is a thin shell over it.
- **Robust startup** (single-instance, honest `g_modelOk`, `g_initializing` guard, `m_cfg_mtx`) — the earlier crashes are designed out, not patched over.

The remaining issues are **polish and hardening**, not structural. No rewrite needed.

## A2. Recommendations (ranked by impact ÷ effort)

| # | Recommendation | Why | Effort |
|---|---|---|---|
| 1 | **Transcription progress + ETA** (Part B2) | The #1 UX gap: a 1:40 clip shows a static "Transcribing…" with no sign of life. whisper.cpp already exposes a progress callback — wire it up. | S |
| 2 | **De-theme the stock controls** (Part B1.1) | The hairlines around the buttons / *Pinned* are themed-edge leftovers. `SetWindowTheme(h, L"", L"")` + `WS_CLIPCHILDREN` + hide-focus removes them. | S |
| 3 | **Replace the comboboxes with custom dropdowns** (Part B1.2) | A `CBS_OWNERDRAWFIXED` combo *cannot* hide its native dropdown button — that's the "second arrow" you see. The only clean fix is to not use a combobox. | M |
| 4 | **Persist settings** | Mic, model, pin, auto-paste, window position all reset every launch. A tiny INI (`WritePrivateProfileStringW`) or registry blob fixes it. | S |
| 5 | **Cancel/abort during transcription** | Pairs with #1 — if a long clip is wrong, let the user abort via `whisper_full_params.abort_callback`. | S |
| 6 | **Delete the stale `src/CMakeLists.txt`** | It references removed APIs (`init`, `is_using_gpu`) and a `test-audio` target the root build ignores. It's a trap for the next contributor. | XS |
| 7 | **Bound the capture buffer** | `m_capture` grows ~1.9 MB/30 s with no cap. Add a soft limit (e.g. auto-stop at 10 min) so a forgotten recording can't grow unbounded. | XS |
| 8 | **Configurable hotkeys + conflict check** | `Ctrl+Shift+Space`/`H` are hard-coded; `RegisterHotKey` failures are silently ignored. Surface a warning and allow remap. | M |
| 9 | **Lightweight logging** | A single rotating log line per session (model, threads, last error) makes field issues diagnosable without a debugger. | S |

**Architectural note for the future:** the app leans on owner-drawing *stock* controls (buttons, combos). That's fine at this size, but every stock control fights you on chrome (edges, focus cues, dropdown buttons). If the UI grows, the higher-leverage move is a small set of **fully custom controls** (a `Button`, a `Select`, a `ProgressBar` you paint entirely in `WM_PAINT` and hit-test yourself) rather than more owner-draw patches. Part B1.2's custom dropdown is the first step down that path.

---

# Part B — Dev Guide

## B1. Remove the leftover Windows chrome

There are **three** separate sources of "Windows-y" pixels, each with a different cause:

| What you see | Cause |
|---|---|
| Thin light line around *Pinned*; vertical line left + horizontal line top of Copy/Paste/Clear | The visual-style **themed edge** drawn behind owner-draw buttons, plus **focus rectangles** |
| Two arrows / a boxy button on the mic + model selectors | A `CBS_OWNERDRAWFIXED` combo still lets the **system draw the dropdown button** and field border — owner-draw only covers the text/items |

### B1.1 Kill the button hairlines + focus rectangles (quick, high-impact)

Three changes, all small:

**(a) De-theme the owner-draw controls.** `SetWindowTheme(h, L"", L"")` strips the UxTheme styling from a control so Windows stops painting its themed border/edge behind your `WM_DRAWITEM`. Add the header/lib and call it on every owner-draw control right after you create it:

```cpp
#include <uxtheme.h>
#pragma comment(lib, "uxtheme.lib")

// after creating each owner-draw button (Record, Pin, Copy, Paste, Clear):
SetWindowTheme(hBtn, L"", L"");      // remove themed edges
SetWindowSubclass(hBtn, BtnProc, 1, 0);
```

**(b) Stop the parent painting under the children.** Add `WS_CLIPCHILDREN` to the main window so its double-buffered `WM_PAINT` never bleeds a pixel into a child's rectangle:

```cpp
hMainWnd = CreateWindowExW(
    WS_EX_TOPMOST,
    L"WhisperDictationClass", L"Dictation",
    WS_OVERLAPPEDWINDOW | WS_CLIPCHILDREN,   // <- add WS_CLIPCHILDREN
    CW_USEDEFAULT, CW_USEDEFAULT, 400, 340,
    nullptr, nullptr, hInstance, nullptr);
```

**(c) Hide focus rectangles app-wide.** The dotted/thin rect that appears on whichever control has keyboard focus (often *Pinned* after you click it). Tell the UI-state machine to keep focus cues hidden — once, after the controls exist:

```cpp
SendMessageW(hMainWnd, WM_CHANGEUISTATE, MAKEWPARAM(UIS_SET, UISF_HIDEFOCUS), 0);
```

After (a)+(b)+(c), the only remaining stock chrome is the combo dropdown button, handled next.

### B1.2 Replace the comboboxes with a custom dropdown (removes the native button)

A combobox always owns its dropdown button — you can't draw it away. Replace each combo with a **flat owner-draw "select" button** (you already draw exactly this look in `DrawCombo`) that opens a **custom dark popup window**. No native button, no field edge, fully on-theme.

**Data model** (replace `g_modelComboPaths` usage as needed; keep a label list + selection per selector):

```cpp
std::vector<std::wstring> g_audioItems;  int g_audioSel = 0;
std::vector<std::wstring> g_modelItems;  int g_modelSel = 0;   // parallel to g_modelComboPaths
```
Populate these in `RefreshAudioDevices` / `RefreshModelList` instead of `CB_ADDSTRING`. Create `ID_SEL_AUDIO` / `ID_SEL_MODEL` as `BS_OWNERDRAW` buttons (not comboboxes) and draw them with a field renderer:

```cpp
void DrawSelect(LPDRAWITEMSTRUCT d, const std::wstring& text) {
    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);
    Rect field = rc; field.Inflate(-1,-1);
    bool hover = GetWindowLongPtr(d->hwndItem, GWLP_USERDATA) != 0;
    FillRound(g, hover ? C_SURFACEHI : C_SURFACE, field, 9);
    StrokeRound(g, C_BORDER, field, 9, 1.0f);
    Font f(d->hDC, g_fUI);
    RectF tb((REAL)field.X+10, (REAL)field.Y, (REAL)(field.Width-28), (REAL)field.Height);
    DrawTextC(g, text.c_str(), f, C_TEXT, tb, StringAlignmentNear, StringAlignmentCenter);
    int cx = field.GetRight()-16, cy = field.Y + field.Height/2;     // our chevron, the only one now
    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);
}
// in WM_DRAWITEM:
//   case ID_SEL_AUDIO: DrawSelect(d, g_audioItems.empty()?L"No devices":g_audioItems[g_audioSel]); return TRUE;
//   case ID_SEL_MODEL: DrawSelect(d, g_modelItems.empty()?L"—":g_modelItems[g_modelSel]); return TRUE;
```

**The popup window** — a small borderless top-level you paint yourself; closes on pick or focus loss:

```cpp
struct PopupState { std::vector<std::wstring> items; int sel; int hot; HWND owner; int ctrlId; };
static PopupState g_pop;

LRESULT CALLBACK PopupProc(HWND h, UINT m, WPARAM w, LPARAM l) {
    switch (m) {
    case WM_MOUSEMOVE: {
        int row = GET_Y_LPARAM(l) / 30;
        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_LBUTTONUP: {
        int row = GET_Y_LPARAM(l) / 30;
        if (row >= 0 && row < (int)g_pop.items.size())
            PostMessageW(g_pop.owner, WM_APP_SELECT, (WPARAM)g_pop.ctrlId, (LPARAM)row);
        DestroyWindow(h); return 0;
    }
    case WM_KILLFOCUS: 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);
            Rect all(0,0,rc.right,rc.bottom);
            FillRound(g, C_SURFACE, all, 10); StrokeRound(g, C_BORDER, all, 10, 1.0f);
            Font f(mem, g_fUI);
            for (int i = 0; i < (int)g_pop.items.size(); ++i) {
                Rect row(3, i*30+3, rc.right-6, 28);
                if (i == g_pop.hot) FillRound(g, C_SURFACEHI, 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(), f, (i==g_pop.sel)?C_ACCENT:C_TEXT,
                          tb, StringAlignmentNear, StringAlignmentCenter);
            }
        }
        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);
}

void ShowSelectPopup(HWND owner, int ctrlId, const std::vector<std::wstring>& items, int sel) {
    static bool reg = false;
    if (!reg) { WNDCLASSEXW wc{ sizeof(wc) }; wc.lpfnWndProc = PopupProc; wc.hInstance = hInst;
        wc.hCursor = LoadCursor(nullptr, IDC_ARROW); wc.lpszClassName = L"DictPopup";
        RegisterClassExW(&wc); reg = true; }
    g_pop = { items, sel, -1, owner, ctrlId };
    RECT rc; GetWindowRect(GetDlgItem(owner, ctrlId), &rc);
    int h = (int)items.size()*30 + 6, wdt = rc.right - rc.left;
    HWND p = CreateWindowExW(WS_EX_TOOLWINDOW | WS_EX_TOPMOST, L"DictPopup", L"",
        WS_POPUP, rc.left, rc.bottom+2, wdt, h, owner, nullptr, hInst, nullptr);
    int corner = DWMWCP_ROUND; DwmSetWindowAttribute(p, DWMWA_WINDOW_CORNER_PREFERENCE, &corner, sizeof(corner));
    ShowWindow(p, SW_SHOWNA); SetForegroundWindow(p); SetFocus(p);
}
```

Wire it up: clicking a selector opens the popup; the popup posts the chosen row back:

```cpp
// WM_COMMAND:
case ID_SEL_AUDIO: ShowSelectPopup(hWnd, ID_SEL_AUDIO, g_audioItems, g_audioSel); break;
case ID_SEL_MODEL: ShowSelectPopup(hWnd, ID_SEL_MODEL, g_modelItems, g_modelSel); break;

// new message handler:
case WM_APP_SELECT: {                      // #define WM_APP_SELECT (WM_USER + 5)
    int ctrlId = (int)wParam, idx = (int)lParam;
    if (ctrlId == ID_SEL_AUDIO) { g_audioSel = idx; g_config.capture_id = idx; }
    else if (ctrlId == ID_SEL_MODEL && idx < (int)g_modelComboPaths.size()) {
        g_modelSel = idx;
        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); g_modelOk=ok; g_modelLoaded=true; }).detach();
    }
    InvalidateRect(GetDlgItem(hWnd, ctrlId), nullptr, FALSE);
} break;
```

You can now delete the `CBS_*`/`WM_MEASUREITEM`/`DrawCombo` combo code and the `WM_CTLCOLORLISTBOX` handler. The selectors are now pixel-identical to your other controls with exactly one (your) chevron.

> Lighter alternative if you don't want a popup window yet: keep the combos but call `SetWindowTheme(hCombo, L"", L"")` — it flattens the dropdown button to a plain square. It's *less* boxy but the button is still there, so the popup-window route above is the real fix.

---

## B2. Transcription progress + time estimate

You don't have to guess the timing — **whisper.cpp reports real progress.** `whisper_full_params` has a `progress_callback` that fires repeatedly during inference with an `int` 0–100 (fraction of the audio processed). Feed that to a determinate progress bar + a live ETA, so "Transcribing…" becomes "Transcribing 1:40 of audio · 45% · ~9s left".

### B2.1 transcriber — expose progress + audio length

`transcriber.h` (public):

```cpp
using ProgressCb = std::function<void(int)>;            // 0..100
void  set_progress_callback(ProgressCb cb) { m_on_progress = std::move(cb); }
float audio_seconds() const { return m_audio_seconds.load(); }
void  request_cancel() { m_abort = true; }              // optional (B2.4)
```
`transcriber.h` (private):

```cpp
ProgressCb m_on_progress;
std::atomic<float> m_audio_seconds{0.0f};
std::atomic<bool>  m_abort{false};
static void s_progress(struct whisper_context*, struct whisper_state*, int p, void* ud);
static bool s_abort(void* ud);
```

`transcriber.cpp` — the static trampolines and the `run_inference` hook:

```cpp
void Transcriber::s_progress(whisper_context*, whisper_state*, int p, void* ud) {
    auto* self = static_cast<Transcriber*>(ud);
    if (self && self->m_on_progress) self->m_on_progress(p);
}
bool Transcriber::s_abort(void* ud) {
    auto* self = static_cast<Transcriber*>(ud);
    return self && self->m_abort.load();
}

std::string Transcriber::run_inference(std::vector<float>& audio) {
    if (!m_ctx) return "";
    m_abort = false;
    if (m_cfg.trim_silence) trim_silence(audio);
    m_audio_seconds = (float)(audio.size() / (double)WHISPER_SAMPLE_RATE);   // for the UI

    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;
    wp.progress_callback           = &Transcriber::s_progress;   // <- live progress
    wp.progress_callback_user_data = this;
    wp.abort_callback              = &Transcriber::s_abort;      // <- optional cancel
    wp.abort_callback_user_data    = this;

    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;
}
```

> If your `whisper.h` predates `abort_callback`, drop those two lines — `progress_callback` has been in whisper.cpp far longer and is what matters here.

### B2.2 main.cpp — progress state + ETA

```cpp
#define WM_APP_PROGRESS (WM_USER + 6)
std::atomic<int> g_progress{0};
DWORD g_busyStart = 0;

// at startup, next to set_result_callback:
g_tx.set_progress_callback([](int p){ PostMessage(hMainWnd, WM_APP_PROGRESS, (WPARAM)p, 0); });
```

Start the clock when transcription begins (HK_TOGGLE stop branch):

```cpp
} else {                       // was recording -> stop
    g_busyStart = GetTickCount();
    g_progress  = 0;
    g_tx.stop_and_transcribe();
    SetStatus(hWnd, L"Transcribing…");
}
```

Receive progress and repaint the bar:

```cpp
case WM_APP_PROGRESS:
    g_progress = (int)wParam;
    InvalidateRect(hWnd, &g_vuRect, FALSE);
    return 0;
```

ETA text (replace the `is_busy()` branch in `UpdateStatus`):

```cpp
} else if (g_tx.is_busy()) {
    int   p     = g_progress.load();
    float total = g_tx.audio_seconds();
    float elapsed = (GetTickCount() - g_busyStart) / 1000.0f;
    int   mm = (int)total / 60, ss = (int)total % 60;
    if (p >= 3) {
        float est    = elapsed * 100.0f / p;          // projected total
        float remain = est - elapsed; if (remain < 0) remain = 0;
        swprintf_s(buf, L"Transcribing %d:%02d  •  %d%%  •  ~%ds left", mm, ss, p, (int)(remain + 0.5f));
    } else {
        swprintf_s(buf, L"Transcribing %d:%02d of audio…", mm, ss);
    }
    SetStatus(hwnd, buf);
}
```

### B2.3 Reuse the VU strip as a determinate progress bar

While recording the strip shows the VU; while transcribing it shows progress. Add a renderer and branch in `WM_PAINT`:

```cpp
void DrawProgress(Graphics& g, const RECT& r, float frac) {
    Rect track(r.left, r.top, r.right - r.left, r.bottom - r.top);
    FillRound(g, C_SURFACEHI, track, 4);
    frac = frac < 0 ? 0 : (frac > 1 ? 1 : frac);
    int w = (int)((r.right - r.left) * frac);
    if (w > 0) { Rect fill(r.left, r.top, w, r.bottom - r.top); FillRound(g, C_ACCENT, fill, 4); }
}
```
```cpp
// in WM_PAINT, where you currently call DrawVU:
if (g_tx.is_busy())      DrawProgress(g, g_vuRect, g_progress.load() / 100.0f);
else                     DrawVU(g, g_vuRect, g_energy);
```

Keep it ticking between callbacks so the ETA counts down smoothly — in `WM_TIMER`, add:

```cpp
else if (g_tx.is_busy()) {
    InvalidateRect(hWnd, &g_vuRect, FALSE);   // (UpdateStatus already runs each tick below)
}
```

That's it: a moving bar, a percentage, and a shrinking "~Ns left" — the user can see it's alive and roughly how long is left. The estimate self-corrects as real progress arrives (the first few percent are rougher; it tightens quickly).

### B2.4 (Optional) Cancel a long transcription

You added `request_cancel()` + the abort callback in B2.1. Hook it so that pressing the button **while busy** aborts instead of being ignored, and treat the empty result as "Cancelled":

```cpp
// top of the HK_TOGGLE handler:
if (g_tx.is_busy()) { g_tx.request_cancel(); SetStatus(hWnd, L"Cancelling…"); break; }
```
`whisper_full` returns promptly with no/aborted segments → your existing `WM_APP_RESULT` empty-string path shows "No speech detected"; change that label to "Cancelled" when a cancel was requested if you want to distinguish them.

---

## B3. Build & test

- **New link deps:** `uxtheme.lib` (added via `#pragma comment` in B1.1). GDI+ is already linked.
- **New files:** none required for B2; B1.2 adds the popup window proc inside `main.cpp` (no new translation unit).
- **Rebuild:** `cmake --build build --config Release` as usual.

**Verify:**
- [ ] No hairline around *Pinned*; no left/top lines on Copy/Paste/Clear; focus no longer draws a dotted rect.
- [ ] Mic + model selectors show a single (your) chevron, open a dark rounded popup, and selecting reloads the model / switches device.
- [ ] Record a ~1–2 min clip → the strip fills as a progress bar, the status shows `%` and a shrinking `~Ns left`, and it completes (no static "Transcribing…").
- [ ] (If added) pressing the button mid-transcription cancels promptly.

**Headless regression (extends `tests/test_core.cpp` from `FINDINGS-FIXES-TESTS.md`):**
- Set a progress callback that records the max value seen; assert it reaches ~100 for `samples/jfk.wav`, and that callbacks arrive in non-decreasing order. This locks in that progress reporting keeps working across whisper.cpp upgrades.

```cpp
int last = -1, maxp = 0; bool monotonic = true;
t.set_progress_callback([&](int p){ if (p < last) monotonic = false; last = p; if (p > maxp) maxp = p; });
t.transcribe_sync(audio);
CHECK(monotonic, "progress is non-decreasing");
CHECK(maxp >= 95, "progress reaches ~100%");
```

---

*Apply order: B1.1 (5 min, instant visual win) → B2 (progress, the big UX gain) → B1.2 (custom dropdowns) → optional B2.4 cancel. Each is independent and safe to ship on its own.*


