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

631 lines
37 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Win Dictation — UI & Progress Rebuild Guide
A design-led plan to (1) kill the "thin lines" problem at its architectural root rather than patching it, and (2) replace the broken progress bar with a self-calibrating, smoothly-animated estimator that learns this machine's transcription speed and fuses whisper's own progress signal.
This is implementation guidance with concrete code. You build on Windows; nothing here is compiled or tested in place.
---
## Table of contents
1. [Part 1 — The interface](#part-1--the-interface)
- [1.1 Why the lines are really there](#11-why-the-lines-are-really-there)
- [1.2 The architectural fix: one surface](#12-the-architectural-fix-one-surface)
- [1.3 Two tiers: GDI+ vs Direct2D](#13-two-tiers-gdi-vs-direct2d)
- [1.4 A real design language](#14-a-real-design-language)
- [1.5 Component specs](#15-component-specs)
- [1.6 Rendering scaffold + hit-testing (code)](#16-rendering-scaffold--hit-testing-code)
- [1.7 The transcript field & DPI](#17-the-transcript-field--dpi)
- [1.8 Migration order from today's main.cpp](#18-migration-order-from-todays-maincpp)
2. [Part 2 — The progress system](#part-2--the-progress-system)
- [2.1 Why it's broken today](#21-why-its-broken-today)
- [2.2 The plan: predict, then correct](#22-the-plan-predict-then-correct)
- [2.3 Persistent per-model timing history](#23-persistent-per-model-timing-history)
- [2.4 The live estimator (smooth countdown + fusion)](#24-the-live-estimator-smooth-countdown--fusion)
- [2.5 `timing.h` — full code](#25-timingh--full-code)
- [2.6 Wiring into main.cpp](#26-wiring-into-maincpp)
- [2.7 Tuning & edge cases](#27-tuning--edge-cases)
3. [Part 3 — Cleanup checklist](#part-3--cleanup-checklist)
4. [Part 4 — Suggested build order](#part-4--suggested-build-order)
---
# Part 1 — The interface
## 1.1 Why the lines are really there
The hairlines aren't one bug; they're an emergent property of how the window is built. Today the UI is roughly **nine separate child windows** living on top of the main window:
- `BUTTON` (owner-draw): Record, Pin, Copy, Paste, Clear
- `BUTTON` (owner-draw) used as selects: mic, model
- `EDIT` (multiline): the transcript
- `STATIC`: status + placeholder
Each child is its own HWND with its own device context, its own paint timing, and — because the parent uses `WS_CLIPCHILDREN` — its own **hard-clipped rectangle punched out of the parent's paint**. That single fact is the source of the lines:
1. **Seams at every child boundary.** The parent paints its background/panel, then Windows clips out each child rectangle and the child paints itself. The boundary between "parent pixels" and "child pixels" is a 1px hard edge. Any difference in rounding, antialiasing, or color across that edge reads as a hairline — even when both sides *intend* to be the same dark color.
2. **Theme chrome you didn't ask for.** The `EDIT` control draws its own themed 1px border and a **light-mode scrollbar** (the pale bar on the right of your screenshot). `SetWindowTheme(h, L"", L"")` on the buttons disables visual styles but doesn't make seams go away.
3. **Square corners around round shapes.** Your chips are drawn rounded, but the *child window* is rectangular, so the artifact rectangle has sharp corners that don't follow the chip — which is exactly what's visible around the Record pill and the Copy/Paste/Clear buttons.
So removing `StrokeRound(..., C_BORDER, ...)` only removes the *intentional* borders. The *structural* hairlines (items 13) remain. That's why it feels like a band-aid: **you can't fully remove seams while compositing many themed child windows.**
> **Root cause, one sentence:** the window is assembled from many separate themed/owner-draw child HWNDs, and the boundaries between them can never be made perfectly seamless. The fix is to stop having those boundaries.
## 1.2 The architectural fix: one surface
Render the **entire window as a single double-buffered surface**, immediate-mode:
- The parent's `WM_PAINT` draws *everything* — background, the card, every button, the selects, the status line, the VU/progress strip — onto **one off-screen bitmap**, then blits it once. (You already do this for the background and panel; we extend it to cover all chrome.)
- **There are no child windows for chrome.** "Buttons" become **painted regions** described by a small data model (a rect + a kind + interaction state). There is exactly one surface, so there are zero inter-window seams. Antialiasing, radii, spacing, shadows, and animation are all under your control.
- **Interaction** is handled in the parent: `WM_MOUSEMOVE` / `WM_LBUTTONDOWN` / `WM_LBUTTONUP` hit-test against the widget rects; you track hover/pressed/focus yourself and invalidate. (The window is tiny — invalidating the whole client area each frame is cheap.)
- **The one exception is the transcript**, which stays a real `EDIT` child because you genuinely want selection, caret, scrolling, and IME. We make it *visually chrome-less* and inset it inside the painted card so the card is the only visible frame (see [1.7](#17-the-transcript-field--dpi)).
This is the same "retained data model + immediate-mode paint" approach used by every good custom-drawn desktop UI. Separation between elements comes from **fills, spacing, and elevation — not outlines.** Once outlines stop being load-bearing, the hairline problem is gone by construction.
## 1.3 Two tiers: GDI+ vs Direct2D
You said you'll happily take more effort for a result that looks genuinely good. Here are the two honest options.
### Tier 1 — GDI+ single-surface (recommended baseline)
- Keep GDI+ (already in the project). Move all drawing into one parent paint routine that renders to a 32-bit DIB back-buffer, then `BitBlt`.
- Reuse your existing helpers (`FillRound`, `StrokeRound`, `DrawTextC`) — they're good. You're changing *what hosts them*, not the primitives.
- Add an animation clock + hover/press state.
- **Effort:** moderate. **Payoff:** the seams disappear, you get full control of spacing/elevation/motion, and it will look clean and modern. This removes 100% of the reported problem.
- **Limitations:** GDI+ has no true GPU compositing; soft drop-shadows must be faked (pre-blurred bitmap or layered alpha), and very large blurs are slow. For a 400×340 utility this is a non-issue.
### Tier 2 — Direct2D + DirectWrite (premium path)
- GPU-accelerated geometry with flawless antialiasing, real `ID2D1Effect` drop shadows / Gaussian blur, per-primitive opacity layers, and **DirectWrite** text with subpixel positioning (noticeably crisper labels, especially at fractional DPI).
- Pairs naturally with a swap-chain or a DC render target; integrates with DWM for tear-free animation at the monitor refresh rate.
- Optionally add **Windows.UI.Composition / DirectComposition** for soft shadows and an acrylic/mica backdrop — a true Windows 11 feel.
- **Effort:** higher (COM lifetimes, device-lost handling, more setup). **Payoff:** the highest visual ceiling and the best foundation if this app grows.
- You can still keep the `EDIT` child for the transcript layered above the D2D surface.
**Recommendation:** Build **Tier 1 now** — it eliminates the actual defect and looks great, and almost all of the work (the design language, the widget model, the interaction layer, the progress system in Part 2) is *identical* regardless of renderer. If you later want the extra polish, swapping the draw calls to Direct2D is a contained change because the data model and layout stay the same. The rest of this guide is written renderer-agnostic with GDI+ code samples.
## 1.4 A real design language
The current look is "many bordered boxes." The target look is **one calm, elevated card** where hierarchy comes from type, spacing, and a single light source — not lines.
### Tokens (define once)
```cpp
// ---- color tokens (ARGB) ----
const Color T_BG (255, 0x0E, 0x10, 0x14); // app backdrop (near-black)
const Color T_CARD (255, 0x16, 0x19, 0x20); // elevated card
const Color T_CARD_HI (255, 0x1E, 0x22, 0x2B); // hovered surface
const Color T_CARD_LO (255, 0x12, 0x15, 0x1B); // pressed surface / wells
const Color T_TEXT (255, 0xEC, 0xEE, 0xF2); // primary text
const Color T_DIM (255, 0x8A, 0x90, 0x9C); // secondary text
const Color T_FAINT (255, 0x5A, 0x60, 0x6C); // tertiary / icons at rest
const Color T_ACCENT (255, 0x6E, 0x8B, 0xFF); // primary action
const Color T_ACCENT_HI (255, 0x83, 0x9C, 0xFF); // accent hover
const Color T_DANGER (255, 0xFF, 0x5C, 0x5C); // recording
const Color T_GOOD (255, 0x46, 0xD3, 0x9A); // level / success
// The ONLY "edge" allowed: a low-alpha top highlight on the card,
// to read as "lit from above." Never a full gray rectangle.
const Color T_TOPLIGHT (26, 0xFF, 0xFF, 0xFF); // ~10% white
```
**Principle:** elements are distinguished by *fill* (`T_CARD` vs `T_CARD_HI`), by *space* (generous padding), and by *elevation* (the card sits on the backdrop, optionally with a soft shadow). Outlines are reserved for nothing, or at most one hairline-as-toplight on the card itself.
### Type scale (Segoe UI Variable, which you already load)
| Role | Size (logical px) | Weight | Color |
|------|------|--------|-------|
| Primary state ("Record" / "Stop" / "Transcribing") | 16 | SemiBold | white on accent / `T_TEXT` |
| Body / transcript | 16 | Regular | `T_TEXT` |
| Buttons (ghost) | 14 | Medium | `T_DIM``T_TEXT` on hover |
| Status caption | 12.5 | Regular | `T_DIM` |
| Micro (threads, %, ETA) | 11.5 | Regular | `T_FAINT` |
### Elevation & radius
- Card radius **16**; inner controls radius **1011**; progress/level pill radius = half-height.
- Optional soft shadow under the card (Tier 1: a pre-rendered blurred rounded-rect bitmap at ~22% alpha, offset y+6, blur ~18; Tier 2: a D2D shadow effect). Subtle — it should read as depth, not drama.
### Motion (this is what makes it feel "good", not just look good)
- Hover/press fills cross-fade over **120160ms**, ease-out-cubic.
- Recording state: a **1.2s sine "breathing"** on the record pill + a live waveform (see below).
- Progress: bar width and the % label are **eased**, never snapped (except the final 100%).
- Drive all of it from one animation clock (Section 1.6). Run the timer at ~16ms **only while something is animating**, and idle otherwise (don't burn CPU on a 2-core machine when nothing moves).
## 1.5 Component specs
**Record (hero).** Full-width pill, `T_ACCENT` fill, white glyph + label. States:
- *Idle:* circle glyph + "Record". Hover → `T_ACCENT_HI`. Press → ×0.9 brightness.
- *Recording:* `T_DANGER`, breathing alpha, square "stop" glyph, label "Stop", and a **live waveform** drawn across the pill or in the strip below.
- Keep it the visual anchor; everything else is quieter.
**Ghost actions (Copy / Paste / Clear).** No resting fill, no border — just a Medium-weight label in `T_DIM`. On hover, a `T_CARD_HI` rounded fill fades in and text lifts to `T_TEXT`; on press, `T_CARD_LO`. Because there's no resting border, there are no hairlines; separation is purely spacing. (Add small 16px line icons before labels for a more finished feel.)
**Pin.** An icon toggle (pin glyph), `T_ACCENT` when active, `T_FAINT` when not. No label needed.
**Selects (mic / model).** Quiet rows: small dim label on top ("Microphone"), value below in `T_TEXT`, a small chevron at the right; hover = `T_CARD_HI` fill. **Consider relocating both behind a small gear/settings affordance** — a dictation utility doesn't need model internals on the main face. If you keep them visible, give them the same fill-on-hover, no-border treatment.
**Status + progress strip (unified).** One horizontal zone under the hero that changes by state:
- *Idle:* `"Ready · 2 threads"` in `T_DIM`.
- *Recording:* live waveform + `mm:ss` timer.
- *Transcribing:* the progress bar (Part 2) with smooth % and a **counting-down** ETA.
**Level / waveform.** Replace the 14-segment VU (reads as "old") with either a smooth antialiased waveform (ring buffer of recent RMS samples drawn as a filled path) or a single breathing level pill. Color `T_GOOD`, riding on `T_CARD_LO`.
**Empty state.** Centered mic glyph + "Your transcription will appear here" in `T_DIM`, drawn *inside* the card (not as a separate STATIC) so it shares the surface.
## 1.6 Rendering scaffold + hit-testing (code)
The whole UI becomes a small list of widgets plus one paint routine and one interaction handler. Skeleton (GDI+, Tier 1):
```cpp
enum class WK { RecordHero, Pin, Copy, Paste, Clear, SelAudio, SelModel, Transcript };
struct Widget {
WK kind;
RectF r; // logical rect, filled by Layout()
bool hover = false;
bool pressed = false;
float anim = 0.0f; // 0..1 eased hover/press amount
};
static Widget g_w[ (int)WK::Transcript + 1 ];
static int g_hot = -1; // index under cursor
static int g_active = -1; // index pressed
// --- one animation clock ---
static DWORD g_lastFrame = 0;
static bool AnyAnimating(); // true if any widget anim is mid-transition, or recording, or busy
// Advance eased states; call from the render timer.
void StepAnimations(float dt) {
for (auto& w : g_w) {
float target = (g_active == (&w - g_w) ) ? 1.0f : (w.hover ? 0.6f : 0.0f);
// ease toward target
w.anim += (target - w.anim) * std::min(1.0f, dt * 12.0f);
}
}
// --- layout: compute rects from client size & DPI scale ---
void Layout(int W, int H, float s /*dpi scale*/);
// --- paint: ONE surface ---
void Paint(HWND hwnd) {
PAINTSTRUCT ps; HDC hdc = BeginPaint(hwnd, &ps);
RECT rc; GetClientRect(hwnd, &rc);
int W = rc.right, H = rc.bottom;
HDC mem = CreateCompatibleDC(hdc);
HBITMAP bmp = CreateCompatibleBitmap(hdc, W, H);
HBITMAP old = (HBITMAP)SelectObject(mem, bmp);
{
Graphics g(mem);
g.SetSmoothingMode(SmoothingModeAntiAlias);
g.SetTextRenderingHint(TextRenderingHintClearTypeGridFit);
SolidBrush bg(T_BG); g.FillRectangle(&bg, 0, 0, W, H);
DrawCardWithShadow(g, g_cardRect, 16); // optional soft shadow + T_CARD fill + T_TOPLIGHT edge
for (auto& w : g_w) {
switch (w.kind) {
case WK::RecordHero: DrawHero(g, w); break;
case WK::Copy: DrawGhost(g, w, L"Copy"); break;
case WK::Paste: DrawGhost(g, w, L"Paste"); break;
case WK::Clear: DrawGhost(g, w, L"Clear"); break;
case WK::Pin: DrawPin(g, w); break;
case WK::SelAudio: DrawSelect(g, w, g_audioVal); break;
case WK::SelModel: DrawSelect(g, w, g_modelVal); break;
case WK::Transcript: /* the EDIT child paints itself; we just leave its inset */ break;
}
}
DrawStatusStrip(g, g_stripRect); // idle / recording waveform / progress
}
BitBlt(hdc, 0, 0, W, H, mem, 0, 0, SRCCOPY);
SelectObject(mem, old); DeleteObject(bmp); DeleteDC(mem);
EndPaint(hwnd, &ps);
}
// --- interaction: hit-test in the parent ---
int HitTest(POINT p) {
for (int i = 0; i < (int)std::size(g_w); ++i)
if (g_w[i].kind != WK::Transcript && g_w[i].r.Contains((REAL)p.x, (REAL)p.y)) return i;
return -1;
}
LRESULT CALLBACK WndProc(HWND h, UINT m, WPARAM w, LPARAM l) {
switch (m) {
case WM_MOUSEMOVE: {
POINT p{ GET_X_LPARAM(l), GET_Y_LPARAM(l) };
int hot = HitTest(p);
if (hot != g_hot) {
if (g_hot >= 0) g_w[g_hot].hover = false;
g_hot = hot;
if (g_hot >= 0) g_w[g_hot].hover = true;
TRACKMOUSEEVENT t{ sizeof(t), TME_LEAVE, h, 0 }; TrackMouseEvent(&t);
EnsureAnimating(h);
}
return 0;
}
case WM_MOUSELEAVE:
if (g_hot >= 0) { g_w[g_hot].hover = false; g_hot = -1; EnsureAnimating(h); }
return 0;
case WM_LBUTTONDOWN:
g_active = g_hot;
if (g_active >= 0) { g_w[g_active].pressed = true; SetCapture(h); EnsureAnimating(h); }
return 0;
case WM_LBUTTONUP: {
ReleaseCapture();
POINT p{ GET_X_LPARAM(l), GET_Y_LPARAM(l) };
if (g_active >= 0 && HitTest(p) == g_active) OnClick(h, g_w[g_active].kind);
if (g_active >= 0) g_w[g_active].pressed = false;
g_active = -1; EnsureAnimating(h);
return 0;
}
case WM_ERASEBKGND: return 1; // we paint everything
case WM_PAINT: Paint(h); return 0;
case WM_SIZE: Layout(LOWORD(l), HIWORD(l), g_dpiScale); InvalidateRect(h, nullptr, FALSE); return 0;
// ... WM_TIMER drives StepAnimations + InvalidateRect while AnyAnimating()
}
return DefWindowProc(h, m, w, l);
}
```
Notes:
- `DrawGhost` simply lerps its fill alpha by `w.anim` between transparent → `T_CARD_HI`, and text color between `T_DIM``T_TEXT`. No `StrokeRound`. That's the whole trick.
- `EnsureAnimating(h)` starts the 16ms timer if it isn't running; the timer stops itself when `AnyAnimating()` returns false to spare the CPU.
- Keyboard focus (for accessibility / Tab) can be added later by tracking a `g_focus` index and painting a soft focus ring on the focused widget only — still no native chrome.
## 1.7 The transcript field & DPI
**Transcript = the one real child window.** Keep `EDIT` (multiline, read-only) for free selection/caret/scroll/IME, but strip its chrome:
1. **No border:** create without `WS_BORDER`/`WS_EX_CLIENTEDGE` (already the case). To suppress the *themed* edit border entirely, either `SetWindowTheme(hEdit, L"", L"")` (kills the theme, gives a classic flat look) or subclass and handle `WM_NCPAINT` to no-op. Prefer the dark-mode route below so the scrollbar also matches.
2. **Dark background:** you already return `g_brSurface` from `WM_CTLCOLOREDIT`; set it to `T_CARD`/`T_CARD_LO` so the field is invisible against the card.
3. **Dark (or custom) scrollbar — this removes the pale bar in your screenshot:**
- Easiest: enable app dark mode then theme the control:
```cpp
// once, after the process starts (uxtheme, undocumented but widely used):
// AllowDarkModeForApp(true); SetPreferredAppMode(AllowDark);
SetWindowTheme(hEdit, L"DarkMode_Explorer", nullptr); // dark scrollbar
```
- Most control: hide the native scrollbar (`ShowScrollBar(hEdit, SB_VERT, FALSE)` or `WM_NCCALCSIZE`) and **paint a slim custom scrollbar on the parent surface**, driven by `EM_GETFIRSTVISIBLELINE` / line count. Best looking, more work.
4. **Inset it inside the card** by ~1416px so the card's rounded surface is the visible frame and the EDIT contributes no edges of its own.
**DPI awareness (do this — it's part of "looks good").** Today metrics are fixed pixels; on a HiDPI panel they blur/misalign.
- Declare **Per-Monitor-V2** via the app manifest (preferred) or `SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2)` at startup.
- Compute `g_dpiScale = GetDpiForWindow(hwnd) / 96.0f`; multiply every metric (padding, radii, font sizes, widget sizes) by it.
- Recreate fonts and re-`Layout()` on `WM_DPICHANGED`, and use the suggested rect it passes for repositioning.
## 1.8 Migration order from today's main.cpp
You can do this incrementally without a rewrite:
1. **Stop creating chrome child windows.** Delete the `CreateWindow(L"BUTTON", …)` calls for Record/Pin/Copy/Paste/Clear and the two selects, and the two `STATIC`s. Keep only the `EDIT`.
2. **Add the `Widget` array + `Layout()`** computing the same rectangles your `LayoutControls` used (reuse the math; just store `RectF`s instead of `MoveWindow`-ing HWNDs).
3. **Move your existing draw functions** (`DrawRecordButton`→`DrawHero`, `DrawFlatButton`→`DrawGhost`, `DrawSelect`) to take a `Widget&` and draw into the shared `Graphics&` — and **delete every `StrokeRound(..., T_BORDER/C_BORDER, …)`**. Replace the panel border with the card + optional shadow + toplight.
4. **Route interaction** through `WndProc` hit-testing (Section 1.6). `OnClick(kind)` calls your existing handlers (toggle record, copy, paste, clear, open the popup for selects).
5. **Add the animation clock**; convert hover from per-HWND `GWLP_USERDATA` to `w.anim`.
6. **Theme the EDIT + scrollbar**, inset it, and add DPI scaling.
7. The popup list (`PopupProc`) can stay as-is — it's already a single custom-painted surface and looks consistent.
Result: one surface, zero seams, full control. The "thin lines" cannot come back because nothing draws them and there are no child boundaries to leak them.
---
# Part 2 — The progress system
## 2.1 Why it's broken today
Three separate problems, all visible in your 1:27 example.
**(a) The ETA counts *up*.** In `UpdateStatus`:
```cpp
float elapsed = (GetTickCount() - g_busyStart) / 1000.0f;
float est = elapsed * 100.0f / (float)p; // total, derived from stale p
float remain = est - elapsed; // = elapsed * (100 - p) / p
```
`remain` is recomputed every 50ms, but `p` (whisper's progress) only changes at chunk boundaries. With `p` held constant and `elapsed` rising, `remain = elapsed·(100p)/p` **increases over time** — the ETA climbs until the next `p` update, then snaps down when `p` jumps. That's precisely "counts up, then jumps to 20s, then counts up again."
**(b) The percentage jumps in big steps.** whisper.cpp calls its progress callback at most **once per 30-second audio chunk**. 1:27 = 87s ≈ **three chunks**, so `p` arrives roughly as `0 → 33 → 67 → 100`. The 34% and 72% you saw are those chunk boundaries (off slightly due to seek rounding). The bar can't be smooth if its only input updates 3 times.
**(c) Dead air at the start.** For 87s of audio the first callback only fires after the *first* 30s chunk finishes decoding — several seconds on a 2-core CPU — so nothing moves at first (you read it as "model loading"). The model is actually already preloaded; it's first-chunk latency with no fallback signal.
**Conclusion:** whisper's callback is a *coarse, occasional measurement*, not a progress source. We need our own continuous prediction, corrected by that measurement.
## 2.2 The plan: predict, then correct
Exactly your idea, formalized:
1. **Predict** total processing time the instant recording stops, from a **history of how long this machine took** for clips of various lengths (per model). This drives a smooth bar from frame 1 — even for sub-30s clips that get *zero* whisper updates.
2. **Correct** that prediction as whisper reports progress: each callback implies a *measured* total time; we fuse it into our estimate with exponential smoothing so accuracy improves **without jumps**.
3. **Display** a strictly **counting-down** remaining time and a **smoothly rising** percent derived from the same model, ease to **95%**, and **snap to 100%** when the real result arrives.
4. **Learn:** on completion, record `(audio_seconds, actual_processing_seconds)` and persist it, so the next prediction is better.
## 2.3 Persistent per-model timing history
Processing time vs audio length is, to first order, **linear**: `proc ≈ a + b·audio`, where `b` is roughly the inverse real-time factor and `a` is fixed overhead. We fit `a, b` per model (tiny.en and base.en behave very differently) with an **online least-squares** accumulator, with a gentle decay so the model adapts to thermal throttling / machine load.
- **Key by model filename** (e.g. `ggml-tiny.en.bin`), since speed is model-dependent.
- **Cold start:** before we have ≥2 samples, use baked-in defaults (rough seeds for a 2-core i5-7th-gen; they self-correct after a run or two):
- tiny.en: `a ≈ 0.3s`, `b ≈ 0.45` (≈2.2× real-time)
- base.en: `a ≈ 0.5s`, `b ≈ 1.1` (≈0.9× real-time)
- (These are only seeds; the regression takes over quickly.)
- **Persist** alongside the existing `win-dictation.ini` using the same `WritePrivateProfileString` style you already use in `settings.h`, one section per model holding the five accumulators.
## 2.4 The live estimator (smooth countdown + fusion)
State: `T_hat` (current best total-time estimate), `disp_rem` (displayed remaining, monotonic), `t` (seconds since start).
- **begin(T_pred):** `T_hat = disp_rem = max(0.4, T_pred)`, `t = 0`.
- **on_whisper(t_now, p):** ignore `p < 5` (noisy). Else measured total `T_meas = 100·t_now / p`; fuse: `T_hat = (1−α)·T_hat + α·T_meas` with `α ≈ 0.5`. This is where whisper "adjusts our countdown" — it moves the estimate, not the displayed number directly, so there's never a visible jump.
- **tick(dt):** the smoothing rules that make it feel solid:
1. Always count down in real time: `disp_rem -= dt`.
2. Pull toward the model's `raw_rem = max(0, T_hat t)`, but **only ever downward**, and **rate-limited**:
- `err = raw_rem disp_rem`
- if `err < 0` (we're behind → need to speed up): `disp_rem += max(err, maxCatchUp·dt)` (bounded extra shrink, no snap)
- if `err ≥ 0` (we have more headroom than shown): **do nothing** — never push remaining up. The bar simply keeps easing and parks near 95% if we under-predicted.
3. Clamp `disp_rem ≥ 0`.
4. Derive fraction from the same numbers: `frac = t / (t + disp_rem)`, clamp to **0.95**. Because `t` only rises and `disp_rem` only falls, `frac` only rises — smooth, monotonic, no jumps.
- **on_result:** snap `frac → 1.0`; record `(audio_seconds, t)` into the timing model and persist.
This guarantees: **ETA only counts down** (bug fixed), **% only rises smoothly** (no 34→72 jumps), whisper's coarse measurements **gently re-aim** the countdown, and there's **motion from frame 1** (no dead start). On a sub-30s clip with no whisper updates, it runs purely on the learned prediction — exactly what you asked for.
## 2.5 `timing.h` — full code
```cpp
#pragma once
#include <windows.h>
#include <string>
#include <cmath>
#include <algorithm>
// ---------------------------------------------------------------------------
// Online linear model: proc_sec ~= a + b * audio_sec, fitted per whisper model.
// Decayed least squares so it adapts to thermal / load drift over time.
// ---------------------------------------------------------------------------
struct TimingModel {
double n=0, sx=0, sy=0, sxx=0, sxy=0; // decayed accumulators
double a=0, b=0; // fitted intercept / slope
bool fitted=false;
double def_a=0.4, def_b=0.6; // cold-start seeds (set per model)
void recompute() {
if (n >= 2.0) {
double denom = n*sxx - sx*sx;
if (std::fabs(denom) > 1e-9) {
double bb = (n*sxy - sx*sy) / denom;
double aa = (sy - bb*sx) / n;
if (bb < 0.02) bb = def_b; // guard against degenerate fits
if (aa < 0.0) aa = 0.0;
a=aa; b=bb; fitted=true; return;
}
}
a=def_a; b=def_b; fitted=false;
}
double predict(double audio_sec) const {
double t = (fitted ? a : def_a) + (fitted ? b : def_b) * audio_sec;
return std::max(0.4, t);
}
void add_sample(double audio_sec, double proc_sec) {
const double decay = 0.97; // ~30-sample memory
n*=decay; sx*=decay; sy*=decay; sxx*=decay; sxy*=decay;
n+=1; sx+=audio_sec; sy+=proc_sec;
sxx+=audio_sec*audio_sec; sxy+=audio_sec*proc_sec;
recompute();
}
};
// ---------------------------------------------------------------------------
// Live estimator: smooth, monotonic countdown fused with whisper's progress.
// ---------------------------------------------------------------------------
struct ProgressEstimator {
double T_hat=1.0, disp_rem=1.0, t=0.0;
bool done=false;
void begin(double T_pred) {
T_hat = std::max(0.4, T_pred);
disp_rem = T_hat; t = 0.0; done=false;
}
void on_whisper(double t_now, int p) { // p in (0,100]
if (done || p < 5) return;
double T_meas = 100.0 * t_now / (double)p;
const double alpha = 0.5; // how much we trust the measurement
T_hat = (1.0-alpha)*T_hat + alpha*T_meas;
if (T_hat < t_now) T_hat = t_now; // never imply we're already done
}
// dt seconds since last tick. Outputs eased fraction [0,1] and remaining secs.
void tick(double dt, float& out_frac, float& out_remaining) {
if (done) { out_frac=1.0f; out_remaining=0.0f; return; }
t += dt;
disp_rem -= dt; // (1) real-time countdown
double raw_rem = std::max(0.0, T_hat - t);
const double maxCatchUp = 2.5; // cap speed-up (×realtime)
double err = raw_rem - disp_rem;
if (err < 0) disp_rem += std::max(err, -maxCatchUp*dt); // (2) shrink only
if (disp_rem < 0) disp_rem = 0; // (3)
double frac = (t + disp_rem > 1e-6) ? t/(t+disp_rem) : 0.0;
if (frac > 0.95) frac = 0.95; // (4) hold until result
out_frac = (float)frac;
out_remaining = (float)disp_rem;
}
void finish(float& out_frac, float& out_remaining) {
done=true; out_frac=1.0f; out_remaining=0.0f;
}
};
// ---------------------------------------------------------------------------
// Persistence (same ini style as settings.h). Section = model base filename.
// ---------------------------------------------------------------------------
inline std::wstring TimingIniPath() {
wchar_t buf[MAX_PATH]; GetModuleFileNameW(nullptr, buf, MAX_PATH);
std::wstring p(buf); p = p.substr(0, p.find_last_of(L"\\/"));
return p + L"\\win-dictation.ini";
}
inline std::wstring SectionFor(const std::string& modelPath) {
std::string base = modelPath.substr(modelPath.find_last_of("\\/")+1);
return L"timing-" + std::wstring(base.begin(), base.end());
}
inline void PutD(const std::wstring& sec, const wchar_t* k, double v) {
wchar_t b[64]; swprintf_s(b, L"%.6f", v);
WritePrivateProfileStringW(sec.c_str(), k, b, TimingIniPath().c_str());
}
inline double GetD(const std::wstring& sec, const wchar_t* k, double d) {
wchar_t b[64]; swprintf_s(b, L"%.6f", d);
wchar_t out[64];
GetPrivateProfileStringW(sec.c_str(), k, b, out, 64, TimingIniPath().c_str());
return wcstod(out, nullptr);
}
inline void LoadTiming(TimingModel& m, const std::string& modelPath) {
auto s = SectionFor(modelPath);
m.n=GetD(s,L"n",0); m.sx=GetD(s,L"sx",0); m.sy=GetD(s,L"sy",0);
m.sxx=GetD(s,L"sxx",0); m.sxy=GetD(s,L"sxy",0);
m.recompute();
}
inline void SaveTiming(const TimingModel& m, const std::string& modelPath) {
auto s = SectionFor(modelPath);
PutD(s,L"n",m.n); PutD(s,L"sx",m.sx); PutD(s,L"sy",m.sy);
PutD(s,L"sxx",m.sxx); PutD(s,L"sxy",m.sxy);
}
// Set per-model cold-start seeds when (re)loading a model.
inline void SeedDefaults(TimingModel& m, const std::string& modelPath) {
std::string p = modelPath;
auto has = [&](const char* s){ return p.find(s)!=std::string::npos; };
if (has("tiny")) { m.def_a=0.3; m.def_b=0.45; }
else if (has("base")) { m.def_a=0.5; m.def_b=1.10; }
else if (has("small")){ m.def_a=0.8; m.def_b=3.00; }
else { m.def_a=0.5; m.def_b=1.00; }
m.recompute();
}
```
## 2.6 Wiring into main.cpp
Add globals and capture the audio length **before** `stop_and_transcribe()` swaps the buffer away:
```cpp
TimingModel g_timing;
ProgressEstimator g_est;
double g_lastAudioLen = 0.0; // seconds of the clip being transcribed
DWORD g_lastTick = 0;
```
**At model load / model switch** (where you set `g_config.model_path`), seed + load history:
```cpp
SeedDefaults(g_timing, g_config.model_path);
LoadTiming(g_timing, g_config.model_path);
```
**On STOP → transcribe** (the `HK_TOGGLE` stop branch and the max-length branch):
```cpp
g_lastAudioLen = g_tx.recorded_seconds(); // BEFORE stop swaps the buffer
g_busyStart = GetTickCount();
g_lastTick = g_busyStart;
g_est.begin(g_timing.predict(g_lastAudioLen)); // bar moves from frame 1
g_progress = 0;
g_cancelRequested = false;
g_tx.stop_and_transcribe();
```
**whisper progress** (`WM_APP_PROGRESS`) becomes a *correction*, not the display source:
```cpp
case WM_APP_PROGRESS: {
double t_now = (GetTickCount() - g_busyStart) / 1000.0;
g_est.on_whisper(t_now, (int)wParam);
InvalidateRect(hWnd, &g_vuRect, FALSE);
return 0;
}
```
**The UI timer** (`WM_TIMER`, while `g_tx.is_busy()`) advances the estimator and paints:
```cpp
DWORD now = GetTickCount();
float dt = (now - g_lastTick) / 1000.0f; g_lastTick = now;
float frac, remain;
g_est.tick(dt, frac, remain);
g_progressFrac = frac; // float 0..1 used by DrawProgress
g_progressRemain = remain; // seconds, for the ETA label
InvalidateRect(hWnd, &g_vuRect, FALSE);
```
**Status text** (replaces the counts-up math entirely):
```cpp
int mm = (int)g_lastAudioLen/60, ss=(int)g_lastAudioLen%60;
int pct = (int)(g_progressFrac*100.0f + 0.5f);
int rem = (int)(g_progressRemain + 0.5f);
swprintf_s(buf, L"Transcribing %d:%02d · %d%% · %ds left", mm, ss, pct, rem);
```
**On result** (`WM_APP_RESULT`): snap, learn, persist:
```cpp
float frac, remain; g_est.finish(frac, remain);
g_progressFrac = 1.0f; g_progressRemain = 0.0f;
double actual = (GetTickCount() - g_busyStart) / 1000.0;
if (g_lastAudioLen > 0.5 && actual > 0.2 && !g_cancelRequested) {
g_timing.add_sample(g_lastAudioLen, actual);
SaveTiming(g_timing, g_config.model_path);
}
```
**`DrawProgress`** already takes a fraction — just feed `g_progressFrac` instead of `g_progress/100.0f`, and optionally add a subtle animated shimmer on the fill for life.
## 2.7 Tuning & edge cases
- **`alpha` (whisper trust)** 0.5 is a good start. Lower (0.3) = smoother but slower to correct; higher (0.7) = snappier, slightly jumpier.
- **`maxCatchUp`** (2.5× real-time) caps how fast the countdown may accelerate when we over-predicted, so a correction never looks like a snap. Raise for faster catch-up, lower for calmer motion.
- **Under-prediction** (transcription takes longer than estimated): `frac` parks at 95% and the ETA sits at a small floor until the result lands — which is the honest, expected behavior.
- **Decay `0.97`** ≈ last ~30 runs dominate. Increase toward 0.99 for steadier long-term averages, decrease for faster adaptation to a throttling machine.
- **Cancel / no-speech:** call `g_est.finish(...)`, reset `g_progressFrac=0`, and **don't** record a sample.
- **Model switch mid-history:** because history is keyed per model, switching tiny.en↔base.en uses the right curve automatically.
- **Sanity clamp:** keep `predict()`'s floor (0.4s) so ultra-short clips still show a brief, graceful sweep rather than instant 100%.
- **Optional richer model:** if you ever want better fits on very short vs long clips, swap the linear `a+b·x` for a two-segment fit (sub-30s vs ≥30s) — the accumulators and API stay the same; just keep two `TimingModel`s.
---
# Part 3 — Cleanup checklist
Smaller items that make the project cleaner and the app feel finished:
- [ ] **Kill the pale scrollbar** (dark-mode theme or custom slim scrollbar) — Section 1.7.
- [ ] **Delete all owner-draw chrome child windows**; keep only the transcript `EDIT` — Section 1.8.
- [ ] **Remove every `StrokeRound(..., C_BORDER, …)`**; rely on fills + elevation.
- [ ] **One animation clock** that idles when nothing moves (protect the 2-core CPU).
- [ ] **DPI Per-Monitor-V2** + scaled metrics + font reload on `WM_DPICHANGED`.
- [ ] **Reconcile the docs with reality.** `README.md`, `src/README.md`, and `src/CHANGES.md` still describe the *old* streaming architecture — "ring buffer", "24 threads", "VAD", "step_ms/length_ms", "<1s real-time GPU". The app is now **push-to-talk batch, CPU, physical-core threads, `whisper_full` once on stop**. Update or archive those docs so future-you isn't misled. (The `CUDA-SETUP.md` / `QUICK-REBUILD-GPU.md` RTX-3090 guides don't apply to the target Dell i5 either.)
- [ ] **Status copy:** "Ready · N threads" is good; make the idle/recording/transcribing strings come from one place.
- [ ] **Remove dead members** once streaming is gone (any leftover `step_ms`/`length_ms`/VAD config that no longer feeds `whisper_full`).
---
# Part 4 — Suggested build order
Do them in this sequence so each step is verifiable on its own:
1. **Progress system first** (Part 2). It's self-contained, low-risk, and immediately fixes the most visible "is it even working?" problem. You'll see a smooth countdown the same day.
2. **DPI + tokens** (1.4 / 1.7). Small, mechanical, and everything after looks better for it.
3. **Single-surface conversion** (1.6 / 1.8): convert one widget at a time — start with the ghost buttons (highest hairline payoff), then the hero, then the selects, then retire the STATICs.
4. **Transcript chrome + scrollbar** (1.7).
5. **Motion polish** (1.4): hover cross-fades, recording breathing, waveform, progress shimmer.
6. **Docs reconciliation** (Part 3).
7. *(Optional later)* **Direct2D/DirectWrite** (1.3) if you want the premium ceiling — the data model and Part 2 carry over unchanged.
---
*Build target reminder: native Win32 C++, MSVC Release, CPU-only on a 2-core / 4-thread i5-7th-gen. Keep the idle CPU near zero — animate only when something is actually moving.*