Files
win-dictate/bug-fixes-and-visual-upgrade.md
T

31 KiB
Raw Blame History

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):

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:

std::atomic<bool> g_modelLoaded{false};
std::atomic<bool> g_modelOk{false};

Fix the preload lambda so failure is recorded:

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:

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.

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)

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:

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

bool reload(const WhisperConfig& cfg);          // free + re-init with a new model
int  threads() const { return m_cfg.n_threads; }

transcriber.cpp — implement reload

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:

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

} else if (g_modelLoaded.load()) {
    if (!g_modelOk.load()) { SetStatus(hwnd, L"Model not found — check models folder"); return; }
    swprintf_s(buf, L"Ready  •  %d threads", g_tx.threads());
    SetStatus(hwnd, buf);
}

Part 2 — Modern UI (flat, rounded, dark)

2.1 Design language

The "boxy" feeling comes from three things: the gray 3-D system buttons, the chunky title bar, and zero breathing room. We fix all three.

  • Palette — near-black canvas, one elevated surface tone, a single indigo accent, hairline borders. (Linear/Raycast register.)
  • Shape — everything rounded: 10px buttons/fields, 14px panels, rounded window corners.
  • Type — Segoe UI Variable / Segoe UI, a clear size hierarchy, no monospace for the transcript (sans reads more "app").
  • Space — 16px outer margin, 1012px gaps, the transcript is the hero and grows with the window.
  • Motion — a soft pulse on the record button while recording; subtle hover/press on buttons.
Token Hex Use
Bg #0F1115 window canvas
Surface #181B22 fields, transcript panel
SurfaceHi #20242D hover
Border #262B36 1px hairlines
Text #E7E9EE primary text
TextDim #9AA0AB status, placeholders
Accent #6E8BFF record idle, focus
AccentHi #5B7BFF accent hover
Danger #FF5C5C recording / stop
Good #46D39A VU meter

2.2 How it's built

  • GDI+ does the drawing — it anti-aliases rounded rectangles and supports alpha, so we get smooth corners without Direct2D.
  • Owner-drawn child buttons for Record / Copy / Paste / Pin (we paint them; Windows still gives us click + focus).
  • Parent-painted background, transcript panel frame, and the VU meter (in WM_PAINT).
  • Native EDIT stays for the transcript (so selection/scroll work) but flat-themed with padding; a STATIC shows placeholder text when empty.
  • Owner-drawn comboboxes for mic/model so they match the dark theme.
  • A tiny button subclass gives reliable hover.

This keeps real controls (accessibility, IME, scrolling) while looking custom.

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:

#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:

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):

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)

// 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

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.

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).

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:

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.

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:

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:

// 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.

case WM_MEASUREITEM: {
    auto* mi = (LPMEASUREITEMSTRUCT)lParam;
    if (mi->CtlType == ODT_COMBOBOX) mi->itemHeight = 26;
    return TRUE;
}
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:

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):

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:

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.

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:

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.

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):

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:

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:

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.