Files
win-dictate/old_text_files/win-dictation-2-0-rebuild-spec-fast-compact-push-to-talk.md

594 lines
27 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 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; ~12s 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 56s 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` ≈ 50006000 → every inference transcribes a **56 second** window.
- `step_ms` ≈ 4001000 → it tries to do that **every 0.41s**, 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 (1015× 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 **24× real-time** at best. Consequences:
1. **Unbounded backlog.** Each 6s window takes ~1.52.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 <string>
#include <vector>
#include <thread>
#include <mutex>
#include <atomic>
#include <functional>
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<void(const std::string&)>;
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<std::string> get_audio_devices();
private:
void transcribe_worker(std::vector<float> audio);
static int default_threads();
WhisperConfig m_cfg;
whisper_context* m_ctx = nullptr;
unsigned int m_dev = 0;
std::vector<float> m_capture; // grows while recording
std::mutex m_capture_mtx;
std::atomic<bool> m_recording{false};
std::atomic<bool> m_busy{false};
std::atomic<float> 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 <SDL.h>
#include <SDL_audio.h>
#include <algorithm>
#include <cmath>
#include <cstring>
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<Transcriber*>(user);
self->on_audio(reinterpret_cast<float*>(stream), len / (int)sizeof(float));
}
bool Transcriber::start_recording() {
if (m_recording.load() || m_busy.load()) return false;
{ std::lock_guard<std::mutex> 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<std::mutex> 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<std::mutex> 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<float> audio;
{ std::lock_guard<std::mutex> 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<float>& 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<std::min(a.size(), i+win); ++k) m = std::max(m, std::fabs(a[k]));
return m > 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<float> 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<INPUT> 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.