27 KiB
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.binby default (with an easy switch tobase.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:
- 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%. - Wasted re-work. The sliding window + overlap re-transcribes much of the same audio repeatedly, and chunk boundaries split words → duplicated/garbled output.
- 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:
#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
#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):
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:
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:
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:
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:
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
SendInputinto 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:
#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):
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):
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):
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):
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:
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:
// 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=ONwith 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:
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):
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:
$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+Spaceshows 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+Hhides 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:
- Batch core first (biggest payoff). Rewrite
transcriber.h/.cppper §4–§5. Temporarily wire your existing big window's Record button tostart_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". - Clipboard + auto-paste (§6). Add
SetClipboardTextUtf8and captureg_prevForegroundon the button press; confirm copy works, then addPasteIntoWindow. - Global hotkeys + focus capture (§7). Switch to driving everything from
Ctrl+Shift+Space; make sureg_prevForegroundis grabbed before showing the window. - Compact always-on-top UI + pin (§7). Shrink the window, add
WS_EX_TOPMOST, trim the layout, drop the buffer bar. - Single-instance + start-to-tray + background preload (§7).
- Tuning pass (§8): set threads = 2, try
tiny.envstiny.en-q8_0vsbase.en-q5_1, add the model dropdown, apply theexe_dir()path fix. - 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.