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