Files
win-dictate/old_text_files/UI-and-progress-bar-rebuild.md
T

37 KiB
Raw Blame History

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
  2. Part 2 — The progress system
  3. Part 3 — Cleanup checklist
  4. Part 4 — Suggested build order

Part 1 — The interface

1.1 Why the lines are really there

The hairlines aren't one bug; they're an emergent property of how the window is built. Today the UI is roughly nine separate child windows living on top of the main window:

  • BUTTON (owner-draw): Record, Pin, Copy, Paste, Clear
  • BUTTON (owner-draw) used as selects: mic, model
  • EDIT (multiline): the transcript
  • STATIC: status + placeholder

Each child is its own HWND with its own device context, its own paint timing, and — because the parent uses WS_CLIPCHILDREN — its own hard-clipped rectangle punched out of the parent's paint. That single fact is the source of the lines:

  1. Seams at every child boundary. The parent paints its background/panel, then Windows clips out each child rectangle and the child paints itself. The boundary between "parent pixels" and "child pixels" is a 1px hard edge. Any difference in rounding, antialiasing, or color across that edge reads as a hairline — even when both sides intend to be the same dark color.
  2. Theme chrome you didn't ask for. The EDIT control draws its own themed 1px border and a light-mode scrollbar (the pale bar on the right of your screenshot). SetWindowTheme(h, L"", L"") on the buttons disables visual styles but doesn't make seams go away.
  3. Square corners around round shapes. Your chips are drawn rounded, but the child window is rectangular, so the artifact rectangle has sharp corners that don't follow the chip — which is exactly what's visible around the Record pill and the Copy/Paste/Clear buttons.

So removing StrokeRound(..., C_BORDER, ...) only removes the intentional borders. The structural hairlines (items 13) remain. That's why it feels like a band-aid: you can't fully remove seams while compositing many themed child windows.

Root cause, one sentence: the window is assembled from many separate themed/owner-draw child HWNDs, and the boundaries between them can never be made perfectly seamless. The fix is to stop having those boundaries.

1.2 The architectural fix: one surface

Render the entire window as a single double-buffered surface, immediate-mode:

  • The parent's WM_PAINT draws everything — background, the card, every button, the selects, the status line, the VU/progress strip — onto one off-screen bitmap, then blits it once. (You already do this for the background and panel; we extend it to cover all chrome.)
  • There are no child windows for chrome. "Buttons" become painted regions described by a small data model (a rect + a kind + interaction state). There is exactly one surface, so there are zero inter-window seams. Antialiasing, radii, spacing, shadows, and animation are all under your control.
  • Interaction is handled in the parent: WM_MOUSEMOVE / WM_LBUTTONDOWN / WM_LBUTTONUP hit-test against the widget rects; you track hover/pressed/focus yourself and invalidate. (The window is tiny — invalidating the whole client area each frame is cheap.)
  • The one exception is the transcript, which stays a real EDIT child because you genuinely want selection, caret, scrolling, and IME. We make it visually chrome-less and inset it inside the painted card so the card is the only visible frame (see 1.7).

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.

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

// ---- 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_DIMT_TEXT on hover
Status caption 12.5 Regular T_DIM
Micro (threads, %, ETA) 11.5 Regular T_FAINT

Elevation & radius

  • Card radius 16; inner controls radius 1011; progress/level pill radius = half-height.
  • Optional soft shadow under the card (Tier 1: a pre-rendered blurred rounded-rect bitmap at ~22% alpha, offset y+6, blur ~18; Tier 2: a D2D shadow effect). Subtle — it should read as depth, not drama.

Motion (this is what makes it feel "good", not just look good)

  • Hover/press fills cross-fade over 120160ms, ease-out-cubic.
  • Recording state: a 1.2s sine "breathing" on the record pill + a live waveform (see below).
  • Progress: bar width and the % label are eased, never snapped (except the final 100%).
  • Drive all of it from one animation clock (Section 1.6). Run the timer at ~16ms only while something is animating, and idle otherwise (don't burn CPU on a 2-core machine when nothing moves).

1.5 Component specs

Record (hero). Full-width pill, T_ACCENT fill, white glyph + label. States:

  • Idle: circle glyph + "Record". Hover → T_ACCENT_HI. Press → ×0.9 brightness.
  • Recording: T_DANGER, breathing alpha, square "stop" glyph, label "Stop", and a live waveform drawn across the pill or in the strip below.
  • Keep it the visual anchor; everything else is quieter.

Ghost actions (Copy / Paste / Clear). No resting fill, no border — just a Medium-weight label in T_DIM. On hover, a T_CARD_HI rounded fill fades in and text lifts to T_TEXT; on press, T_CARD_LO. Because there's no resting border, there are no hairlines; separation is purely spacing. (Add small 16px line icons before labels for a more finished feel.)

Pin. An icon toggle (pin glyph), T_ACCENT when active, T_FAINT when not. No label needed.

Selects (mic / model). Quiet rows: small dim label on top ("Microphone"), value below in T_TEXT, a small chevron at the right; hover = T_CARD_HI fill. Consider relocating both behind a small gear/settings affordance — a dictation utility doesn't need model internals on the main face. If you keep them visible, give them the same fill-on-hover, no-border treatment.

Status + progress strip (unified). One horizontal zone under the hero that changes by state:

  • Idle: "Ready · 2 threads" in T_DIM.
  • Recording: live waveform + mm:ss timer.
  • Transcribing: the progress bar (Part 2) with smooth % and a counting-down ETA.

Level / waveform. Replace the 14-segment VU (reads as "old") with either a smooth antialiased waveform (ring buffer of recent RMS samples drawn as a filled path) or a single breathing level pill. Color T_GOOD, riding on T_CARD_LO.

Empty state. Centered mic glyph + "Your transcription will appear here" in T_DIM, drawn inside the card (not as a separate STATIC) so it shares the surface.

1.6 Rendering scaffold + hit-testing (code)

The whole UI becomes a small list of widgets plus one paint routine and one interaction handler. Skeleton (GDI+, Tier 1):

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_DIMT_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:
      // once, after the process starts (uxtheme, undocumented but widely used):
      // AllowDarkModeForApp(true); SetPreferredAppMode(AllowDark);
      SetWindowTheme(hEdit, L"DarkMode_Explorer", nullptr); // dark scrollbar
      
    • Most control: hide the native scrollbar (ShowScrollBar(hEdit, SB_VERT, FALSE) or WM_NCCALCSIZE) and paint a slim custom scrollbar on the parent surface, driven by EM_GETFIRSTVISIBLELINE / line count. Best looking, more work.
  4. Inset it inside the card by ~1416px so the card's rounded surface is the visible frame and the EDIT contributes no edges of its own.

DPI awareness (do this — it's part of "looks good"). Today metrics are fixed pixels; on a HiDPI panel they blur/misalign.

  • Declare Per-Monitor-V2 via the app manifest (preferred) or SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2) at startup.
  • Compute g_dpiScale = GetDpiForWindow(hwnd) / 96.0f; multiply every metric (padding, radii, font sizes, widget sizes) by it.
  • Recreate fonts and re-Layout() on WM_DPICHANGED, and use the suggested rect it passes for repositioning.

1.8 Migration order from today's main.cpp

You can do this incrementally without a rewrite:

  1. Stop creating chrome child windows. Delete the CreateWindow(L"BUTTON", …) calls for Record/Pin/Copy/Paste/Clear and the two selects, and the two STATICs. Keep only the EDIT.
  2. Add the Widget array + Layout() computing the same rectangles your LayoutControls used (reuse the math; just store RectFs instead of MoveWindow-ing HWNDs).
  3. Move your existing draw functions (DrawRecordButtonDrawHero, DrawFlatButtonDrawGhost, 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:

float elapsed = (GetTickCount() - g_busyStart) / 1000.0f;
float est     = elapsed * 100.0f / (float)p;   // total, derived from stale p
float remain  = est - elapsed;                  // = elapsed * (100 - p) / p

remain is recomputed every 50ms, but p (whisper's progress) only changes at chunk boundaries. With p held constant and elapsed rising, remain = elapsed·(100p)/p increases over time — the ETA climbs until the next p update, then snaps down when p jumps. That's precisely "counts up, then jumps to 20s, then counts up again."

(b) The percentage jumps in big steps. whisper.cpp calls its progress callback at most once per 30-second audio chunk. 1:27 = 87s ≈ three chunks, so p arrives roughly as 0 → 33 → 67 → 100. The 34% and 72% you saw are those chunk boundaries (off slightly due to seek rounding). The bar can't be smooth if its only input updates 3 times.

(c) Dead air at the start. For 87s of audio the first callback only fires after the first 30s chunk finishes decoding — several seconds on a 2-core CPU — so nothing moves at first (you read it as "model loading"). The model is actually already preloaded; it's first-chunk latency with no fallback signal.

Conclusion: whisper's callback is a coarse, occasional measurement, not a progress source. We need our own continuous prediction, corrected by that measurement.

2.2 The plan: predict, then correct

Exactly your idea, formalized:

  1. Predict total processing time the instant recording stops, from a history of how long this machine took for clips of various lengths (per model). This drives a smooth bar from frame 1 — even for sub-30s clips that get zero whisper updates.
  2. Correct that prediction as whisper reports progress: each callback implies a measured total time; we fuse it into our estimate with exponential smoothing so accuracy improves without jumps.
  3. Display a strictly counting-down remaining time and a smoothly rising percent derived from the same model, ease to 95%, and snap to 100% when the real result arrives.
  4. Learn: on completion, record (audio_seconds, actual_processing_seconds) and persist it, so the next prediction is better.

2.3 Persistent per-model timing history

Processing time vs audio length is, to first order, linear: proc ≈ a + b·audio, where b is roughly the inverse real-time factor and a is fixed overhead. We fit a, b per model (tiny.en and base.en behave very differently) with an online least-squares accumulator, with a gentle decay so the model adapts to thermal throttling / machine load.

  • Key by model filename (e.g. ggml-tiny.en.bin), since speed is model-dependent.
  • Cold start: before we have ≥2 samples, use baked-in defaults (rough seeds for a 2-core i5-7th-gen; they self-correct after a run or two):
    • tiny.en: a ≈ 0.3s, b ≈ 0.45 (≈2.2× real-time)
    • base.en: a ≈ 0.5s, b ≈ 1.1 (≈0.9× real-time)
    • (These are only seeds; the regression takes over quickly.)
  • Persist alongside the existing win-dictation.ini using the same WritePrivateProfileString style you already use in settings.h, one section per model holding the five accumulators.

2.4 The live estimator (smooth countdown + fusion)

State: T_hat (current best total-time estimate), disp_rem (displayed remaining, monotonic), t (seconds since start).

  • begin(T_pred): T_hat = disp_rem = max(0.4, T_pred), t = 0.
  • on_whisper(t_now, p): ignore p < 5 (noisy). Else measured total T_meas = 100·t_now / p; fuse: T_hat = (1−α)·T_hat + α·T_meas with α ≈ 0.5. This is where whisper "adjusts our countdown" — it moves the estimate, not the displayed number directly, so there's never a visible jump.
  • tick(dt): the smoothing rules that make it feel solid:
    1. Always count down in real time: disp_rem -= dt.
    2. Pull toward the model's raw_rem = max(0, T_hat t), but only ever downward, and rate-limited:
      • err = raw_rem disp_rem
      • if err < 0 (we're behind → need to speed up): disp_rem += max(err, maxCatchUp·dt) (bounded extra shrink, no snap)
      • if err ≥ 0 (we have more headroom than shown): do nothing — never push remaining up. The bar simply keeps easing and parks near 95% if we under-predicted.
    3. Clamp disp_rem ≥ 0.
    4. Derive fraction from the same numbers: frac = t / (t + disp_rem), clamp to 0.95. Because t only rises and disp_rem only falls, frac only rises — smooth, monotonic, no jumps.
  • on_result: snap frac → 1.0; record (audio_seconds, t) into the timing model and persist.

This guarantees: ETA only counts down (bug fixed), % only rises smoothly (no 34→72 jumps), whisper's coarse measurements gently re-aim the countdown, and there's motion from frame 1 (no dead start). On a sub-30s clip with no whisper updates, it runs purely on the learned prediction — exactly what you asked for.

2.5 timing.h — full code

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

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:

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

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:

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:

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

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:

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

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.