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:

  1. You press the hotkey
  2. We start recording… and can’t detect the key release while recording
  3. We detect the key release… but can’t process audio while detecting more keys
  4. 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)

PropertyDetail
Created byasyncio.run(main_async()) in __main__
LifetimeEntire program execution
PurposeListen for keyboard events from all devices
ContainsOne 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

PropertyDetail
Created byon_key_down()
LifetimeDuration of hotkey press (spawned fresh every press)
PurposeOpen 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

PropertyDetail
Created byRecording loop (every _stream_interval seconds)
LifetimeOne-shot, terminates after transcription
PurposeTranscribe partial audio for terminal preview

These are daemon threads — they can be terminated abruptly when the program exits. Each one:

  1. Acquires the lock
  2. Determines which chunks haven’t been previewed yet (via _stream_idx)
  3. Copies those chunks and updates _stream_idx
  4. Releases the lock
  5. 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

PropertyDetail
Created byon_key_up()
LifetimeOne-shot, terminates after typing
PurposeTranscribe 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-previewed

Lock Usage

The lock is acquired whenever a thread reads or writes the shared state:

ThreadWhat It AccessesWhen
Main (on_key_down)_recording, _audio_chunks, _stream_idxOn key press — reset buffer
Main (on_key_up)_recording, _audio_chunksOn key release — snapshot buffer
Stream preview_audio_chunks, _stream_idxEvery 2s — read and advance
Recording callback_audio_chunksEvery 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:

  1. User releases hotkey → on_key_up() spawned transcription worker
  2. User presses hotkey → on_key_down() set _audio_chunks = [] (cleared for new recording)
  3. 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:

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

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

VariableProtected ByNotes
_audio_chunks_lock (read), GIL (append)Append in callback is lock-free, all other access uses lock
_recording_lockWritten by both key handlers, read by recording loop
_hotkey_heldNot lockedWritten only by main thread (sequential), read by worker
_stream_idx_lockWritten 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.