# Win Dictation 2.0 — Rebuild Spec (fast, compact, push-to-talk) ## 1. Summary & Assumptions This spec rebuilds the existing `whisper.cpp/examples/win-dictation` C++/Win32 app into a **fast, compact, push-to-talk dictation tool**. The headline change is architectural, not cosmetic: stop doing live streaming transcription and instead **record audio cheaply, then run Whisper exactly once when you stop**. That single change is what makes it usable on a 2-core CPU. **Assumptions made** (you didn't pick on the two questions — flip any of these freely): - **Transcription model: batch / record-then-transcribe.** You hit record, speak, hit stop; ~1–2s later the text appears. No live word-by-word feed. This is the big performance win and is also more accurate. - **Output: copy to clipboard + auto-paste into the app you were last in.** A toggle lets you fall back to copy-only. - **English-only**, CPU-only, model `ggml-tiny.en.bin` by default (with an easy switch to `base.en` / quantized). - **Toggle hotkey** (press once to start, again to stop) rather than hold-to-talk. Hold-to-talk is included as an optional add-on in §7. **What changes, at a glance:** | Area | Today | 2.0 | |---|---|---| | Inference | Rolling 5–6s window every ~0.4s (needs many cores) | One `whisper_full` call per utterance | | CPU while speaking | Pegged (continuous inference) | ~0% (just buffering audio) | | Threads | 4 on 2 physical cores (UI starves) | = physical cores (default 2), UI stays responsive | | Model load | On first record (blocks UI) | Preloaded in background at startup | | Window | 720×600, not on top | Compact ~360×180, always-on-top, pin toggle | | Get text out | Manually select + Ctrl+C | Auto-copied; optional auto-paste into last app | | Launch | exe | exe + desktop shortcut, single-instance, start-to-tray | **I cannot build or test Windows binaries in my environment** — every snippet below is written against the whisper.cpp API your code already uses (`whisper_init_from_file_with_params`, `whisper_full`, …) and standard Win32/SDL2. Build on your machine and send me any compiler errors; I'll fix them. ## 2. Root Cause — why it's slow today Your `transcriber.cpp` `worker_loop` implements the classic whisper.cpp *stream* pattern: - `length_ms` ≈ 5000–6000 → every inference transcribes a **5–6 second** window. - `step_ms` ≈ 400–1000 → it tries to do that **every 0.4–1s**, keeping a 200ms overlap. For this to keep up, your CPU must transcribe 6s of audio in well under 1s — i.e. **>6× real-time**. The README's reference numbers (10–15× real-time) were measured on a **24-thread** machine. Your i5-7th-gen is **2 cores / 4 threads** and will do tiny.en at roughly **2–4× real-time** at best. Consequences: 1. **Unbounded backlog.** Each 6s window takes ~1.5–2.5s to process, but a new one is requested every 0.4s. The ring buffer fills, latency grows the longer you talk, and `get_buffer_fullness()` climbs toward 100%. 2. **Wasted re-work.** The sliding window + overlap re-transcribes much of the same audio repeatedly, and chunk boundaries split words → duplicated/garbled output. 3. **UI starvation.** `n_threads = hardware_concurrency()` = 4 Whisper threads on 2 physical cores. Whisper's matmuls are memory-bandwidth bound, so the hyperthreads add little throughput but do steal cycles from the UI/audio threads → janky window, laggy VU meter. **Key insight:** for *dictation* (as opposed to live captioning) you never needed streaming. Record the whole utterance, transcribe once. Whisper then runs at its own pace with **no deadline**, processes each second of audio exactly once, and produces cleaner text. A 10s utterance at 3× real-time = ~3.3s of processing **after** you stop talking — predictable and fine. While you're *speaking*, CPU is near-idle because you're only copying samples into a buffer. ## 3. Target architecture Three threads, a simple state machine, and one-shot inference. ``` ┌─ UI thread (Win32 message loop) ──────────────┐ │ • owns the window, hotkeys, tray, buttons │ │ • owns Transcriber │ │ • receives result via PostMessage │ └───────────────────────────────────────────────┘ │ start_recording() ▲ WM_APP_RESULT (text) ▼ │ ┌─ Audio thread (SDL callback) ─┐ │ │ • appends f32 samples to │ │ │ m_capture (mutex) │ │ │ • updates VU energy (atomic) │ │ └───────────────────────────────┘ │ │ stop_and_transcribe() │ ▼ │ ┌─ Worker thread (spawned on stop) ─────────────┐ │ • optional silence trim │ │ • whisper_full(...) ONCE │ │ • clean text → PostMessage to UI ────────────┘ └──────────────────────────────────────────────── ``` **State machine:** ``` Idle ──(hotkey/Record)──▶ Recording ──(hotkey/Stop)──▶ Transcribing ──(result)──▶ Idle ▲ │ └────────────────────────────(cancel / Esc)─────────────────────────────────────┘ ``` Guards: ignore Start while `Transcribing`; `stop_and_transcribe` swaps the capture buffer out under the mutex and hands it to the worker by value, so the audio thread can't race the reader. The whole ring-buffer / overlap machinery from the current `transcriber.cpp` is **deleted**. ## 4. transcriber.h (new) Drop the ring buffer, `step_ms`/`length_ms`, buffer-fullness, etc. New surface: ```cpp #pragma once #include #include #include #include #include #include struct whisper_context; struct WhisperConfig { std::string model_path = "models/ggml-tiny.en.bin"; std::string language = "en"; int n_threads = 0; // 0 = auto (physical cores) bool use_gpu = false; // CPU on this machine int capture_id = 0; // SDL capture device index bool trim_silence = true; // cheap VAD on the captured clip }; class Transcriber { public: using ResultCb = std::function; Transcriber() = default; ~Transcriber(); bool preload(const WhisperConfig& cfg); // load model off the UI thread bool is_loaded() const { return m_ctx != nullptr; } bool start_recording(); // open mic, begin capture (cheap) void stop_and_transcribe(); // stop mic, kick ONE transcription void cancel(); // abort recording, no transcription bool is_recording() const { return m_recording.load(); } bool is_busy() const { return m_busy.load(); } // transcribing float get_audio_energy() const { return m_energy.load(); }// 0..1 VU void set_result_callback(ResultCb cb) { m_on_result = std::move(cb); } void on_audio(const float* samples, int n); // called by SDL C shim static std::vector get_audio_devices(); private: void transcribe_worker(std::vector audio); static int default_threads(); WhisperConfig m_cfg; whisper_context* m_ctx = nullptr; unsigned int m_dev = 0; std::vector m_capture; // grows while recording std::mutex m_capture_mtx; std::atomic m_recording{false}; std::atomic m_busy{false}; std::atomic m_energy{0.0f}; std::thread m_worker; ResultCb m_on_result; }; ``` Memory note: 16kHz × 4 bytes = 64 KB/s, so a 2-minute clip ≈ 7.6 MB. Reserve ~30s up front; optionally cap recording length (e.g. 5 min) to bound memory. ## 5. transcriber.cpp — capture & one-shot transcription ```cpp #include "transcriber.h" #include "whisper.h" #include #include #include #include #include Transcriber::~Transcriber() { cancel(); if (m_worker.joinable()) m_worker.join(); if (m_ctx) whisper_free(m_ctx); } int Transcriber::default_threads() { unsigned hc = std::thread::hardware_concurrency(); // 4 on 2c/4t if (hc <= 2) return (int)std::max(1u, hc); return (int)(hc / 2); // 4 logical -> 2 physical } bool Transcriber::preload(const WhisperConfig& cfg) { m_cfg = cfg; if (m_cfg.n_threads <= 0) m_cfg.n_threads = default_threads(); if (m_ctx) return true; whisper_context_params cp = whisper_context_default_params(); cp.use_gpu = m_cfg.use_gpu; m_ctx = whisper_init_from_file_with_params(m_cfg.model_path.c_str(), cp); return m_ctx != nullptr; } static void sdl_capture_cb(void* user, Uint8* stream, int len) { auto* self = static_cast(user); self->on_audio(reinterpret_cast(stream), len / (int)sizeof(float)); } bool Transcriber::start_recording() { if (m_recording.load() || m_busy.load()) return false; { std::lock_guard lk(m_capture_mtx); m_capture.clear(); m_capture.reserve(WHISPER_SAMPLE_RATE * 30); } SDL_AudioSpec want{}, have{}; want.freq = WHISPER_SAMPLE_RATE; // 16000 want.format = AUDIO_F32SYS; want.channels = 1; want.samples = 1024; want.callback = sdl_capture_cb; want.userdata = this; const char* dev = SDL_GetAudioDeviceName(m_cfg.capture_id, SDL_TRUE); m_dev = SDL_OpenAudioDevice(dev, SDL_TRUE, &want, &have, 0); if (!m_dev) return false; m_energy = 0.0f; m_recording = true; SDL_PauseAudioDevice(m_dev, 0); // start capturing return true; } void Transcriber::on_audio(const float* s, int n) { if (n <= 0 || !m_recording.load()) return; double sq = 0.0; for (int i = 0; i < n; ++i) sq += (double)s[i] * s[i]; float rms = (float)std::sqrt(sq / n); float e = m_energy.load(); m_energy = std::min(1.0f, e * 0.6f + (rms * 4.0f) * 0.4f); // smoothed std::lock_guard lk(m_capture_mtx); m_capture.insert(m_capture.end(), s, s + n); } void Transcriber::cancel() { if (!m_recording.load()) return; m_recording = false; if (m_dev) { SDL_PauseAudioDevice(m_dev, 1); SDL_CloseAudioDevice(m_dev); m_dev = 0; } std::lock_guard lk(m_capture_mtx); m_capture.clear(); m_energy = 0.0f; } void Transcriber::stop_and_transcribe() { if (!m_recording.load()) return; m_recording = false; if (m_dev) { SDL_PauseAudioDevice(m_dev, 1); SDL_CloseAudioDevice(m_dev); m_dev = 0; } m_energy = 0.0f; std::vector audio; { std::lock_guard lk(m_capture_mtx); audio.swap(m_capture); } if (audio.size() < (size_t)(WHISPER_SAMPLE_RATE * 0.3)) { // <300ms if (m_on_result) m_on_result(""); return; } if (m_worker.joinable()) m_worker.join(); m_busy = true; m_worker = std::thread(&Transcriber::transcribe_worker, this, std::move(audio)); } ``` **Silence trim + text cleanup helpers** (file-local statics): ```cpp static void trim_silence(std::vector& a, float thresh = 0.01f) { const size_t win = 1600; // 100ms auto loud = [&](size_t i){ float m = 0.f; for (size_t k=i; k thresh; }; size_t s = 0, e = a.size(); while (s + win < a.size() && !loud(s)) s += win; while (e > win && !loud(e - win)) e -= win; if (s + win <= e) a.assign(a.begin()+ (s>win? s-win:0), a.begin()+e); // keep 100ms pad } static std::string clean_text(std::string s) { const char* junk[] = {"[BLANK_AUDIO]","[NOISE]","(blank)","(noise)","[ Silence ]"}; for (auto j : junk) { size_t p; while ((p=s.find(j))!=std::string::npos) s.erase(p, strlen(j)); } size_t b = s.find_first_not_of(" \t\r\n"); size_t e = s.find_last_not_of(" \t\r\n"); return (b==std::string::npos) ? "" : s.substr(b, e-b+1); } ``` **The one-shot worker:** ```cpp void Transcriber::transcribe_worker(std::vector audio) { if (m_cfg.trim_silence) trim_silence(audio); whisper_full_params wp = whisper_full_default_params(WHISPER_SAMPLING_GREEDY); wp.print_progress = false; wp.print_realtime = false; wp.print_timestamps = false; wp.no_timestamps = true; wp.translate = false; wp.language = m_cfg.language.c_str(); wp.n_threads = m_cfg.n_threads; wp.no_context = true; wp.suppress_blank = true; wp.temperature = 0.0f; // greedy + temperature 0 = fastest, deterministic. (Optionally set // wp.suppress_nst = true on newer whisper.cpp to drop non-speech tokens.) std::string out; if (m_ctx && whisper_full(m_ctx, wp, audio.data(), (int)audio.size()) == 0) { int n = whisper_full_n_segments(m_ctx); for (int i = 0; i < n; ++i) { const char* t = whisper_full_get_segment_text(m_ctx, i); if (t) out += t; } out = clean_text(out); } m_busy = false; if (m_on_result) m_on_result(out); // runs on worker thread -> PostMessage in UI } ``` `get_audio_devices()` stays as-is from your current file (it already enumerates SDL capture devices). Call `SDL_Init(SDL_INIT_AUDIO)` once at app startup (and `SDL_Quit()` at exit) rather than per-call. ## 6. Clipboard & auto-paste This is the feature that makes it actually useful: text lands on the clipboard automatically, and (optionally) gets pasted straight into whatever app you were in before the popup. **UTF-8 → UTF-16 + set clipboard:** ```cpp static std::wstring to_w(const std::string& s) { if (s.empty()) return L""; int n = MultiByteToWideChar(CP_UTF8, 0, s.c_str(), -1, nullptr, 0); std::wstring w(n ? n-1 : 0, L'\0'); if (n) MultiByteToWideChar(CP_UTF8, 0, s.c_str(), -1, &w[0], n); return w; } bool SetClipboardTextUtf8(HWND owner, const std::string& utf8) { std::wstring w = to_w(utf8); if (!OpenClipboard(owner)) return false; EmptyClipboard(); size_t bytes = (w.size() + 1) * sizeof(wchar_t); HGLOBAL h = GlobalAlloc(GMEM_MOVEABLE, bytes); if (h) { void* p = GlobalLock(h); memcpy(p, w.c_str(), bytes); GlobalUnlock(h); SetClipboardData(CF_UNICODETEXT, h); // clipboard now owns h; don't free } CloseClipboard(); return h != nullptr; } ``` **Auto-paste into the previously focused window.** Capture the target HWND at the moment your hotkey fires (before you steal focus — see §7), then: ```cpp static void send_ctrl_v() { INPUT in[4] = {}; in[0].type = INPUT_KEYBOARD; in[0].ki.wVk = VK_CONTROL; in[1].type = INPUT_KEYBOARD; in[1].ki.wVk = 'V'; in[2].type = INPUT_KEYBOARD; in[2].ki.wVk = 'V'; in[2].ki.dwFlags = KEYEVENTF_KEYUP; in[3].type = INPUT_KEYBOARD; in[3].ki.wVk = VK_CONTROL; in[3].ki.dwFlags = KEYEVENTF_KEYUP; SendInput(4, in, sizeof(INPUT)); } void PasteIntoWindow(HWND target) { if (!target || !IsWindow(target)) return; DWORD me = GetCurrentThreadId(); DWORD other = GetWindowThreadProcessId(target, nullptr); AttachThreadInput(me, other, TRUE); // bypass foreground-lock SetForegroundWindow(target); SetFocus(target); AttachThreadInput(me, other, FALSE); Sleep(40); // let focus settle send_ctrl_v(); } ``` **Alternative — type the text directly** (no clipboard touched; works in apps with quirky paste handling). Surrogate pairs are handled because each UTF-16 code unit is sent as its own scan code: ```cpp void TypeUnicode(const std::wstring& text) { std::vector in; in.reserve(text.size()*2); for (wchar_t c : text) { INPUT d{}; d.type = INPUT_KEYBOARD; d.ki.wScan = c; d.ki.dwFlags = KEYEVENTF_UNICODE; INPUT u = d; u.ki.dwFlags |= KEYEVENTF_KEYUP; in.push_back(d); in.push_back(u); } if (!in.empty()) SendInput((UINT)in.size(), in.data(), sizeof(INPUT)); } ``` **Caveats to bake in:** - **Elevation/UIPI:** a non-elevated app cannot `SendInput` into an elevated (Run-as-Admin) window. If you dictate into elevated apps, ship an elevation manifest — otherwise leave it un-elevated (recommended) and it'll just work for normal apps. - Recommend **clipboard + Ctrl+V** as the default (fast, preserves formatting-free text); offer **TypeUnicode** as a fallback toggle for stubborn targets. - Consider saving/restoring the user's previous clipboard contents if you want to be polite (optional). ## 7. main.cpp — window, hotkeys, tray, single-instance Targeted changes to your existing `main.cpp`. New message IDs: ```cpp #define WM_APP_RESULT (WM_APP + 1) // worker -> UI: transcription text #define WM_APP_SHOW (WM_APP + 2) // 2nd instance -> existing window #define HK_TOGGLE 1 #define HK_HIDE 2 static Transcriber g_tx; static WhisperConfig g_config; static HWND g_prevForeground = nullptr; // app to paste back into static bool g_autoPaste = true; ``` **Single instance** (very top of `wWinMain`, before creating the window): ```cpp HANDLE hMutex = CreateMutexW(nullptr, TRUE, L"WhisperDictation_SingleInstance"); if (GetLastError() == ERROR_ALREADY_EXISTS) { HWND existing = FindWindowW(L"WhisperDictationClass", nullptr); if (existing) PostMessage(existing, WM_APP_SHOW, 0, 0); return 0; // a copy is already running (and owns the hotkeys) } ``` **Compact, always-on-top window** (replace the big `CreateWindowEx`): ```cpp hMainWnd = CreateWindowExW( WS_EX_TOPMOST | WS_EX_TOOLWINDOW, // on top, no taskbar button L"WhisperDictationClass", L"Dictation", WS_POPUP | WS_CAPTION | WS_SYSMENU, // small, draggable by caption CW_USEDEFAULT, CW_USEDEFAULT, 360, 180, nullptr, nullptr, hInstance, nullptr); void SetAlwaysOnTop(HWND h, bool on) { SetWindowPos(h, on ? HWND_TOPMOST : HWND_NOTOPMOST, 0,0,0,0, SWP_NOMOVE|SWP_NOSIZE); } ``` Suggested compact layout (3 rows): **[ ● Record / ■ Stop ] [ 📌 pin ]** · status line ("Ready • tiny.en • 2 threads" / "Recording 0:04" / "Transcribing…" / "Copied ✓") · a small read-only multiline EDIT showing the last result + a **Copy** and **Paste** button. Keep the VU meter — it's cheap and reassures you the mic is live. **Preload the model in the background** (after the window exists, so first record is instant): ```cpp std::thread([]{ g_tx.preload(g_config); }).detach(); g_tx.set_result_callback([](const std::string& t){ PostMessage(hMainWnd, WM_APP_RESULT, (WPARAM)new std::string(t), 0); }); ``` **Global hotkeys** (after window creation): ```cpp RegisterHotKey(hMainWnd, HK_TOGGLE, MOD_CONTROL | MOD_SHIFT, VK_SPACE); // show + record/stop RegisterHotKey(hMainWnd, HK_HIDE, MOD_CONTROL | MOD_SHIFT, 'H'); // hide to tray ``` **Message handling:** ```cpp case WM_HOTKEY: if (wParam == HK_TOGGLE) { if (!g_tx.is_recording() && !g_tx.is_busy()) { g_prevForeground = GetForegroundWindow(); // capture BEFORE we steal focus ShowWindow(hWnd, SW_SHOWNA); // show without stealing focus if (g_tx.start_recording()) SetStatus(hWnd, L"Recording…"); } else if (g_tx.is_recording()) { g_tx.stop_and_transcribe(); SetStatus(hWnd, L"Transcribing…"); } } else if (wParam == HK_HIDE) { if (g_tx.is_recording()) g_tx.cancel(); ShowWindow(hWnd, SW_HIDE); } break; case WM_APP_SHOW: ShowWindow(hWnd, SW_SHOW); SetForegroundWindow(hWnd); break; case WM_APP_RESULT: { std::string* res = (std::string*)wParam; if (res && !res->empty()) { SetDlgItemTextW(hWnd, ID_EDIT_TEXT, to_w(*res).c_str()); SetClipboardTextUtf8(hWnd, *res); if (g_autoPaste && g_prevForeground) { ShowWindow(hWnd, SW_HIDE); // get out of the way first PasteIntoWindow(g_prevForeground); } SetStatus(hWnd, g_autoPaste ? L"Pasted ✓" : L"Copied ✓"); } else { SetStatus(hWnd, L"No speech detected"); } delete res; } break; ``` **Recording timer / VU:** keep a lightweight `WM_TIMER` (e.g. 50ms) that, while `is_recording()`, updates the VU meter from `get_audio_energy()` and shows elapsed seconds. Drop the buffer-fullness bar (no longer meaningful). **Tray:** keep your existing tray setup; `WM_CLOSE` hides to tray (as today). Add a "Start in tray" option: `ShowWindow(hMainWnd, startHidden ? SW_HIDE : nCmdShow);`. On `WM_DESTROY`, also `ReleaseMutex(hMutex)`. **Optional — hold-to-talk** (instead of toggle): `RegisterHotKey` only fires on key-down, so true push-and-hold needs a low-level keyboard hook: ```cpp // SetWindowsHookEx(WH_KEYBOARD_LL, LowLevelKbProc, hInst, 0); // In the proc: on your chosen key WM_KEYDOWN -> start_recording (once), // on WM_KEYUP -> stop_and_transcribe. Debounce auto-repeat with a flag. ``` Keep it behind a setting; the toggle hotkey matches your "hit record" description and is simpler/robust. ## 8. WhisperConfig tuning for the i5-7th-gen **Threads.** Default to physical cores (2). Whisper's matmuls are memory-bandwidth bound, so 4 threads on 2 cores buys little throughput and steals from the UI/audio threads. Try 2 (default) vs 3 and keep whichever feels best — expose it in settings. **Model choice (CPU, English).** All from `download-ggml-model`: | Model | Size | Rel. speed on 2c | Accuracy | Use when | |---|---|---|---|---| | `tiny.en` | 75 MB | ★★★★★ fastest | ok | **default** — snappy dictation | | `tiny.en-q8_0` | ~42 MB | ★★★★★ | ≈ tiny | low RAM / similar speed | | `base.en-q5_1` | ~57 MB | ★★★☆ | better | want more accuracy, can wait ~2× | | `base.en` | 142 MB | ★★★ | better | accuracy over latency | | `small.en` | 466 MB | ★★ slow | best | only for short clips / patience | Quantized (`q5_1`/`q8_0`) models are smaller and can be a touch faster on a bandwidth-limited CPU for a small accuracy cost — worth A/B testing `tiny.en` vs `tiny.en-q8_0` and `base.en-q5_1`. Add a model dropdown in the UI that re-runs `preload()` on a background thread. **Whisper params** (already in §5): greedy sampling, `temperature = 0`, `no_context = true`, `no_timestamps = true`. These are the fastest, most deterministic settings. Avoid beam search. **Build flags.** Your CMake already enables AVX2/FMA/F16C (the `WHISPER_NO_*` options default OFF) and MSVC `/O2 /GL` + `/LTCG`. That's correct for Kaby Lake — keep it. Confirm you're building **Release**, not Debug (Debug whisper is multiples slower). Optional extras, in rough order of effort/value: - **OpenBLAS** (`-DGGML_BLAS=ON` with a BLAS vendor) — sometimes helps CPU matmul; measure, it's not always a win for tiny. - **Vulkan on the iGPU** (`-DGGML_VULKAN=ON`) — your HD/UHD 620 *can* run it, but for tiny.en it's often no faster than CPU and adds driver/DLL complexity. Low priority; the batch redesign already solves the felt problem. **Path robustness.** `model_path` is relative (`models/...`), so the app only works when the working directory is the exe folder. Resolve it from the exe location so a desktop shortcut always works: ```cpp std::string exe_dir() { char buf[MAX_PATH]; GetModuleFileNameA(nullptr, buf, MAX_PATH); std::string p(buf); return p.substr(0, p.find_last_of("\\/")); } // g_config.model_path = exe_dir() + "\\models\\ggml-tiny.en.bin"; ``` ## 9. Build, paths & desktop shortcut **Files touched:** `src/transcriber.h`, `src/transcriber.cpp`, `src/main.cpp`. The clipboard/paste helpers can live inside `main.cpp` (no new translation unit needed). If you split them into `src/clipboard.cpp`, add it to both `add_executable(...)` lists in `CMakeLists.txt` and `src/CMakeLists.txt`. No new third-party dependencies. **Build (unchanged):** ```powershell cmake -B build -DWHISPER_SDL2=ON cmake --build build --config Release # build\bin\Release\win-dictation.exe ``` Your `build.ps1` already downloads SDL2 + models and deploys DLLs; keep using it. **Desktop shortcut** (double-click to launch). The `WorkingDirectory` must be the exe folder so `models/` resolves — unless you adopt the `exe_dir()` fix in §8, in which case it doesn't matter: ```powershell $exe = "C:\code\whisper.cpp\examples\win-dictation\build\bin\Release\win-dictation.exe" $ws = New-Object -ComObject WScript.Shell $sc = $ws.CreateShortcut("$env:USERPROFILE\Desktop\Dictation.lnk") $sc.TargetPath = $exe $sc.WorkingDirectory = Split-Path $exe $sc.IconLocation = "$exe,0" $sc.Save() ``` **Start with Windows** (optional): drop that same `.lnk` into `shell:startup`, or add a `Run` registry value. Combined with start-to-tray, it's always one hotkey away. **Icon:** you already have `win-dictation.rc` / `IDI_ICON1`, so the exe and tray icon are covered. ## 10. Testing & acceptance checklist Since I can't run it, here's what to verify on the laptop: **Performance (the point of all this):** - [ ] While recording, Task Manager shows the app near-idle on CPU (you're only buffering). - [ ] After Stop, a ~10s utterance transcribes in a few seconds and the window stays responsive throughout. - [ ] Latency does **not** grow with longer recordings (the old unbounded-backlog bug is gone). - [ ] First recording after launch is instant (model preloaded) — no "Loading model…" stall. **Workflow:** - [ ] `Ctrl+Shift+Space` shows the mini window and starts recording; pressing it again stops and produces text. - [ ] Text is on the clipboard automatically; with auto-paste on, it lands in the app you were in before the hotkey. - [ ] `Ctrl+Shift+H` hides to tray; double-clicking the tray icon restores. - [ ] Launching a second copy focuses the existing one instead of starting a rival (single-instance + hotkey ownership). - [ ] Pin toggle keeps it above other windows; window is draggable and compact. **Robustness:** - [ ] Recording <0.3s or pure silence → "No speech detected", no crash. - [ ] Mic selection change takes effect on the next recording. - [ ] Paste into Notepad, a browser field, and your editor all work; note any app where Ctrl+V fails (use TypeUnicode fallback there). - [ ] Unicode / punctuation comes through intact (UTF-8↔UTF-16 path). ## 11. Suggested implementation order Build it incrementally so you can feel the win early and isolate any breakage: 1. **Batch core first (biggest payoff).** Rewrite `transcriber.h/.cpp` per §4–§5. Temporarily wire your *existing* big window's Record button to `start_recording()` / `stop_and_transcribe()` and dump the result into the text box. At this point the performance problem should already be gone. Verify §10 "Performance". 2. **Clipboard + auto-paste** (§6). Add `SetClipboardTextUtf8` and capture `g_prevForeground` on the button press; confirm copy works, then add `PasteIntoWindow`. 3. **Global hotkeys + focus capture** (§7). Switch to driving everything from `Ctrl+Shift+Space`; make sure `g_prevForeground` is grabbed *before* showing the window. 4. **Compact always-on-top UI + pin** (§7). Shrink the window, add `WS_EX_TOPMOST`, trim the layout, drop the buffer bar. 5. **Single-instance + start-to-tray + background preload** (§7). 6. **Tuning pass** (§8): set threads = 2, try `tiny.en` vs `tiny.en-q8_0` vs `base.en-q5_1`, add the model dropdown, apply the `exe_dir()` path fix. 7. **Desktop shortcut** (§9) and optional hold-to-talk. Each step compiles and runs on its own. If you want, I can generate the **complete** rewritten `main.cpp`, `transcriber.cpp`, and `transcriber.h` (not just snippets) for step 1 so you have a drop-in starting point.