Audio version — Estimated duration: 7 min 43 sec
Threading
Whisper Dictate uses multiple threads to do different things at the same time — listening for key events, recording audio, showing previews, and running AI transcription. This page explains why each thread exists, how they coordinate, and what problems were encountered along the way.
Why Multiple Threads?
A single-threaded program can only do one thing at a time. If we ran everything sequentially:
- You press the hotkey
- We start recording… and can’t detect the key release while recording
- We detect the key release… but can’t process audio while detecting more keys
- We process audio… and the program freezes during transcription
That’s unusable. We need to:
- Detect keyboard events instantly (low latency)
- Capture audio continuously (it must not drop samples)
- Show previews while still recording
- Transcribe and type without blocking anything else
Threads solve this — each independent task gets its own thread, and they coordinate through shared memory protected by a lock.
Thread Model Diagram
This diagram shows all the threads in the system. Boxes are threads, arrows show spawning relationships, and the colored boxes show shared data that multiple threads access.
graph TB subgraph "Thread 1: Main (asyncio)" A[asyncio event loop] -->|monitors| KB1[Keyboard Device 1] A -->|monitors| KB2[Keyboard Device 2] KB1 -->|key_down| D[on_key_down] KB2 -->|key_down| D KB1 -->|key_up| E[on_key_up] KB2 -->|key_up| E end subgraph "Thread 2: Recording" D -.->|spawns| F[_record_loop] F -->|callback| G[sd.InputStream<br/>audio capture] G -->|appends chunks| BUF[Shared: _audio_chunks<br/>_stream_idx] end subgraph "Threads 3-5: Stream Previews" F -.->|spawns every 2s| SP1[Stream Preview Thread] F -.->|spawns| SP2[Another Preview Thread] SP1 -->|reads new chunks| BUF SP2 -->|reads new chunks| BUF end subgraph "Thread 6: Transcription" E -.->|spawns| TT[Transcription Worker] TT -->|uses snapshot| BUF TT -->|check flag| HELD[Shared: _hotkey_held] end
Thread Details
Thread 1: Main (asyncio event loop)
| Property | Detail |
|---|---|
| Created by | asyncio.run(main_async()) in __main__ |
| Lifetime | Entire program execution |
| Purpose | Listen for keyboard events from all devices |
| Contains | One coroutine per keyboard (monitor_device()) |
This thread runs a Python asyncio event loop. It has one coroutine per keyboard device, each running an async for loop that reads events. When a key event matches our hotkey, it calls on_key_down() or on_key_up() on the main thread — these run synchronously (fast) and spawn threads for slow work.
Critical: The event loop must never block. That’s why on_key_down() and on_key_up() return immediately after spawning threads. If they waited for transcription to finish, you’d miss key events in the meantime.
Thread 2: Recording Loop
| Property | Detail |
|---|---|
| Created by | on_key_down() |
| Lifetime | Duration of hotkey press (spawned fresh every press) |
| Purpose | Open microphone, capture audio until key is released |
This thread runs _record_loop() which opens a sounddevice.InputStream and sleeps in a loop checking _recording. The audio capture happens in a callback (which runs on a separate sounddevice thread internally) and appends chunks to _audio_chunks.
When the key is released, _recording becomes False, the loop exits, and the thread terminates automatically.
Threads 3-5: Stream Preview Threads
| Property | Detail |
|---|---|
| Created by | Recording loop (every _stream_interval seconds) |
| Lifetime | One-shot, terminates after transcription |
| Purpose | Transcribe partial audio for terminal preview |
These are daemon threads — they can be terminated abruptly when the program exits. Each one:
- Acquires the lock
- Determines which chunks haven’t been previewed yet (via
_stream_idx) - Copies those chunks and updates
_stream_idx - Releases the lock
- Transcribes the copied audio and prints to terminal
Multiple preview threads can exist concurrently if a transcription takes longer than 2 seconds. This is fine because they each copy the data they need under the lock and work independently afterward.
Thread 6: Transcription Worker
| Property | Detail |
|---|---|
| Created by | on_key_up() |
| Lifetime | One-shot, terminates after typing |
| Purpose | Transcribe full audio and inject text |
This is the only thread that calls _type_text(). It receives a snapshot of the audio buffer as a parameter (not reading from the global), so it’s immune to the audio buffer being reset by a new recording.
After transcription, it checks _hotkey_held to decide whether to type or copy to clipboard.
Shared State
Multiple threads access these global variables. The threading lock (_lock) prevents race conditions.
_audio_chunks = [] # List of numpy arrays — grows during recording
_recording = False # Tells recording loop whether to keep running
_hotkey_held = False # Tells everyone whether the hotkey is currently pressed
_stream_idx = 0 # Marks which chunks have been stream-previewedLock Usage
The lock is acquired whenever a thread reads or writes the shared state:
| Thread | What It Accesses | When |
|---|---|---|
Main (on_key_down) | _recording, _audio_chunks, _stream_idx | On key press — reset buffer |
Main (on_key_up) | _recording, _audio_chunks | On key release — snapshot buffer |
| Stream preview | _audio_chunks, _stream_idx | Every 2s — read and advance |
| Recording callback | _audio_chunks | Every audio chunk (appends) |
The recording callback (_audio_callback) appends to _audio_chunks without acquiring the lock. This is intentional — the callback must run in real time (it’s called from PortAudio’s audio thread) and cannot block. The list .append() operation is atomic in CPython (GIL-protected), so it’s safe. All other accesses use the lock.
Race Conditions Fixed
Race conditions are bugs that occur when the timing of thread execution causes unexpected behavior. Here are the two that were discovered and fixed in this project.
Race Condition 1: Empty Audio Buffer
Scenario: User presses hotkey, speaks, releases, then immediately presses hotkey again.
Before the fix, the following could happen in this order:
- User releases hotkey →
on_key_up()spawned transcription worker - User presses hotkey →
on_key_down()set_audio_chunks = [](cleared for new recording) - Transcription worker reads
_audio_chunks→ gets empty list → nothing transcribed
Result: The first recording is lost. The user hears nothing typed and has to dictate again.
The fix: on_key_up() saves a snapshot of _audio_chunks before spawning the worker:
def on_key_up():
global _recording, _hotkey_held
if not _hotkey_held:
return
_hotkey_held = False
with _lock:
_recording = False
chunks = list(_audio_chunks) # Copy BEFORE anything else can modify it
threading.Thread(target=_transcribe_and_type, args=(chunks,), daemon=True).start()Now even if on_key_down() resets the global buffer, the worker already has its own copy. The two operations are decoupled.
Race Condition 2: AltGr / Screenshot Interference
Scenario: User presses hotkey (Right Alt), speaks for several seconds with stream previews, then releases.
Before the fix, the stream preview thread called _type_text() to show text in real time. The problem was that Right Alt was still physically held down when ydotool was typing. Since ydotool injects keystrokes at the kernel level, those keystrokes had the active Alt modifier:
- Typing “screenshot” → Alt+PrtSc combination → OS triggers screenshot
- AltGr characters (like
@,#,[) → wrong characters typed - Application keyboard shortcuts → unexpected behavior
The fix: Two changes were made:
-
Stream previews never type —
_stream_transcribe()only prints to terminal. It doesn’t call_type_text()at all. The terminal output is not affected by active modifiers. -
Transcription worker checks hotkey state — Before calling
_type_text(), the worker checks_hotkey_held:if not _hotkey_held: _type_text(text) # Safe — Alt is released else: # Hotkey pressed again — don't type while Alt is active wl-copy to clipboard # Safe alternative
This ensures that text is only injected via ydotool when the hotkey is confirmed to be released.
Timeline of the Race Condition Fix
The sequence diagram below shows the race condition scenario and how the fix prevents both bugs:
sequenceDiagram participant User participant Main as Main Thread<br/>(asyncio) participant Recorder participant Worker as Transcription<br/>Worker Note over User,Worker: Bug 1 scenario: Quick double-press User->>Main: Press hotkey (Recording 1 starts) Main->>Recorder: Start recording Main->>Main: _audio_chunks = [c1, c2, c3, c4, c5] User->>Main: Release hotkey Main->>Main: SNAPSHOT chunks = [c1, c2, c3, c4, c5] Main->>Worker: Start transcription with snapshot User->>Main: Press hotkey again (Recording 2 starts!) Main->>Main: Reset _audio_chunks = [] ← Does NOT affect Worker's snapshot Worker->>Worker: Transcribe snapshot [c1..c5] Worker->>Worker: Check _hotkey_held → True (user is recording again) Worker->>Worker: Copy to clipboard (not type) ← Bug 2 avoided Note over User,Worker: Both bugs prevented: Note over User,Worker: ✓ Recording 1 transcribed from snapshot Note over User,Worker: ✓ Text copied to clipboard (Alt not active during injection)
Thread Safety Summary
| Variable | Protected By | Notes |
|---|---|---|
_audio_chunks | _lock (read), GIL (append) | Append in callback is lock-free, all other access uses lock |
_recording | _lock | Written by both key handlers, read by recording loop |
_hotkey_held | Not locked | Written only by main thread (sequential), read by worker |
_stream_idx | _lock | Written by stream preview and on_key_down(), read by stream preview |
The lock-free _hotkey_held is safe because it’s only ever modified by the main asyncio thread (which runs on_key_down() and on_key_up() sequentially) and only read by the worker thread. The worst case is a slightly stale read — which is acceptable because we only need a “good enough” check before typing.
Now that you understand threading, see components for implementation details of each subsystem, or variables for the settings you can adjust.