594 lines
27 KiB
Markdown
594 lines
27 KiB
Markdown
# 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.
|