37 KiB
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
- Part 1 — The interface
- Part 2 — The progress system
- Part 3 — Cleanup checklist
- 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, ClearBUTTON(owner-draw) used as selects: mic, modelEDIT(multiline): the transcriptSTATIC: 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:
- 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.
- Theme chrome you didn't ask for. The
EDITcontrol 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. - Square corners around round shapes. Your chips are drawn rounded, but the child window is rectangular, so the artifact rectangle has sharp corners that don't follow the chip — which is exactly what's visible around the Record pill and the Copy/Paste/Clear buttons.
So removing StrokeRound(..., C_BORDER, ...) only removes the intentional borders. The structural hairlines (items 1–3) remain. That's why it feels like a band-aid: you can't fully remove seams while compositing many themed child windows.
Root cause, one sentence: the window is assembled from many separate themed/owner-draw child HWNDs, and the boundaries between them can never be made perfectly seamless. The fix is to stop having those boundaries.
1.2 The architectural fix: one surface
Render the entire window as a single double-buffered surface, immediate-mode:
- The parent's
WM_PAINTdraws 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_LBUTTONUPhit-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
EDITchild 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.
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
ID2D1Effectdrop 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
EDITchild 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_DIM → T_TEXT on hover |
| Status caption | 12.5 | Regular | T_DIM |
| Micro (threads, %, ETA) | 11.5 | Regular | T_FAINT |
Elevation & radius
- Card radius 16; inner controls radius 10–11; progress/level pill radius = half-height.
- Optional soft shadow under the card (Tier 1: a pre-rendered blurred rounded-rect bitmap at ~22% alpha, offset y+6, blur ~18; Tier 2: a D2D shadow effect). Subtle — it should read as depth, not drama.
Motion (this is what makes it feel "good", not just look good)
- Hover/press fills cross-fade over 120–160ms, ease-out-cubic.
- Recording state: a 1.2s sine "breathing" on the record pill + a live waveform (see below).
- Progress: bar width and the % label are eased, never snapped (except the final 100%).
- Drive all of it from one animation clock (Section 1.6). Run the timer at ~16ms only while something is animating, and idle otherwise (don't burn CPU on a 2-core machine when nothing moves).
1.5 Component specs
Record (hero). Full-width pill, T_ACCENT fill, white glyph + label. States:
- Idle: circle glyph + "Record". Hover →
T_ACCENT_HI. Press → ×0.9 brightness. - Recording:
T_DANGER, breathing alpha, square "stop" glyph, label "Stop", and a live waveform drawn across the pill or in the strip below. - Keep it the visual anchor; everything else is quieter.
Ghost actions (Copy / Paste / Clear). No resting fill, no border — just a Medium-weight label in T_DIM. On hover, a T_CARD_HI rounded fill fades in and text lifts to T_TEXT; on press, T_CARD_LO. Because there's no resting border, there are no hairlines; separation is purely spacing. (Add small 16px line icons before labels for a more finished feel.)
Pin. An icon toggle (pin glyph), T_ACCENT when active, T_FAINT when not. No label needed.
Selects (mic / model). Quiet rows: small dim label on top ("Microphone"), value below in T_TEXT, a small chevron at the right; hover = T_CARD_HI fill. Consider relocating both behind a small gear/settings affordance — a dictation utility doesn't need model internals on the main face. If you keep them visible, give them the same fill-on-hover, no-border treatment.
Status + progress strip (unified). One horizontal zone under the hero that changes by state:
- Idle:
"Ready · 2 threads"inT_DIM. - Recording: live waveform +
mm:sstimer. - 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:
DrawGhostsimply lerps its fill alpha byw.animbetween transparent →T_CARD_HI, and text color betweenT_DIM→T_TEXT. NoStrokeRound. That's the whole trick.EnsureAnimating(h)starts the 16ms timer if it isn't running; the timer stops itself whenAnyAnimating()returns false to spare the CPU.- Keyboard focus (for accessibility / Tab) can be added later by tracking a
g_focusindex 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:
- No border: create without
WS_BORDER/WS_EX_CLIENTEDGE(already the case). To suppress the themed edit border entirely, eitherSetWindowTheme(hEdit, L"", L"")(kills the theme, gives a classic flat look) or subclass and handleWM_NCPAINTto no-op. Prefer the dark-mode route below so the scrollbar also matches. - Dark background: you already return
g_brSurfacefromWM_CTLCOLOREDIT; set it toT_CARD/T_CARD_LOso the field is invisible against the card. - 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)orWM_NCCALCSIZE) and paint a slim custom scrollbar on the parent surface, driven byEM_GETFIRSTVISIBLELINE/ line count. Best looking, more work.
- Easiest: enable app dark mode then theme the control:
- Inset it inside the card by ~14–16px so the card's rounded surface is the visible frame and the EDIT contributes no edges of its own.
DPI awareness (do this — it's part of "looks good"). Today metrics are fixed pixels; on a HiDPI panel they blur/misalign.
- Declare Per-Monitor-V2 via the app manifest (preferred) or
SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2)at startup. - Compute
g_dpiScale = GetDpiForWindow(hwnd) / 96.0f; multiply every metric (padding, radii, font sizes, widget sizes) by it. - Recreate fonts and re-
Layout()onWM_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:
- Stop creating chrome child windows. Delete the
CreateWindow(L"BUTTON", …)calls for Record/Pin/Copy/Paste/Clear and the two selects, and the twoSTATICs. Keep only theEDIT. - Add the
Widgetarray +Layout()computing the same rectangles yourLayoutControlsused (reuse the math; just storeRectFs instead ofMoveWindow-ing HWNDs). - Move your existing draw functions (
DrawRecordButton→DrawHero,DrawFlatButton→DrawGhost,DrawSelect) to take aWidget&and draw into the sharedGraphics&— and delete everyStrokeRound(..., T_BORDER/C_BORDER, …). Replace the panel border with the card + optional shadow + toplight. - Route interaction through
WndProchit-testing (Section 1.6).OnClick(kind)calls your existing handlers (toggle record, copy, paste, clear, open the popup for selects). - Add the animation clock; convert hover from per-HWND
GWLP_USERDATAtow.anim. - Theme the EDIT + scrollbar, inset it, and add DPI scaling.
- 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·(100−p)/p increases over time — the ETA climbs until the next p update, then snaps down when p jumps. That's precisely "counts up, then jumps to 20s, then counts up again."
(b) The percentage jumps in big steps. whisper.cpp calls its progress callback at most once per 30-second audio chunk. 1:27 = 87s ≈ three chunks, so p arrives roughly as 0 → 33 → 67 → 100. The 34% and 72% you saw are those chunk boundaries (off slightly due to seek rounding). The bar can't be smooth if its only input updates 3 times.
(c) Dead air at the start. For 87s of audio the first callback only fires after the first 30s chunk finishes decoding — several seconds on a 2-core CPU — so nothing moves at first (you read it as "model loading"). The model is actually already preloaded; it's first-chunk latency with no fallback signal.
Conclusion: whisper's callback is a coarse, occasional measurement, not a progress source. We need our own continuous prediction, corrected by that measurement.
2.2 The plan: predict, then correct
Exactly your idea, formalized:
- 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.
- 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.
- 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.
- 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.)
- tiny.en:
- Persist alongside the existing
win-dictation.iniusing the sameWritePrivateProfileStringstyle you already use insettings.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 totalT_meas = 100·t_now / p; fuse:T_hat = (1−α)·T_hat + α·T_measwithα ≈ 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:
- Always count down in real time:
disp_rem -= dt. - 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.
- Clamp
disp_rem ≥ 0. - Derive fraction from the same numbers:
frac = t / (t + disp_rem), clamp to 0.95. Becausetonly rises anddisp_remonly falls,fraconly rises — smooth, monotonic, no jumps.
- Always count down in real time:
- 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):
fracparks 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(...), resetg_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·xfor a two-segment fit (sub-30s vs ≥30s) — the accumulators and API stay the same; just keep twoTimingModels.
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, andsrc/CHANGES.mdstill 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_fullonce on stop. Update or archive those docs so future-you isn't misled. (TheCUDA-SETUP.md/QUICK-REBUILD-GPU.mdRTX-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 feedswhisper_full).
Part 4 — Suggested build order
Do them in this sequence so each step is verifiable on its own:
- 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.
- DPI + tokens (1.4 / 1.7). Small, mechanical, and everything after looks better for it.
- 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.
- Transcript chrome + scrollbar (1.7).
- Motion polish (1.4): hover cross-fades, recording breathing, waveform, progress shimmer.
- Docs reconciliation (Part 3).
- (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.