Design Spec

harp — Real-time streaming & back-patch dictation

approved 2026-05-18 Source: voice vision note (Recording 20260518135728)

Goal

Turn harp into a local-first, real-time dictation engine: as you speak, transcribed text appears in the focused application with a few-seconds delay — typed key-by-key as if you were typing it — and earlier text is silently corrected (delete + retype) when later transcription passes revise it. This is the "Whisper Flow" effect. Real-time matters because a 2-hour dictation (e.g. a meeting) cannot wait until the end for its transcript. This spec covers slice A only: the streaming core + back-patch typing inside the existing keyboard daemon.

Locked decisions

  1. Stable-prefix + volatile tail. Only the unstable tail (last few seconds) is ever rewritten. Once a segment is old enough and agrees across passes it is committed and never touched again — bounds the blast radius of the dangerous backspace path over a 2-hour session.
  2. Approach 1 — re-decode rolling window + LocalAgreement-2. Re-transcribe a sliding window each tick; commit the longest prefix that agrees across two successive hypotheses. De-facto standard for local streaming Whisper; zero new dependencies.
  3. Drop command mode + cloud LLM entirely. Remove api.py, LLMClient, Ctrl+Shift+Space, the openai dependency and the llm_* / command_prompt / send_clipboard config. harp becomes a pure local dictation engine. (Folds the old "slice C" cleanup into this work since the streaming rewrite touches the same stop path.)
  4. Streaming is the only mode. No batch fallback — the commit policy degenerates correctly for short utterances (everything commits on stop). One activation: Ctrl+Space toggles a streaming session; hold-to-talk variant analogous.
  5. Streaming core is I/O-free. StreamingTranscriber depends only on an injected transcribe() callable — so the future REST server (slice B, Magpie substrate) feeds it identically to the daemon. That reuse boundary is the reason it is a standalone module now.
  6. Cadence defaults are empirical. Slide interval / window / model defaults will be tuned by measurement per model size and CUDA availability, not guessed up front.

Components

UnitResponsibilityDepends on
streaming.py
StreamingTranscriber
Holds the float32/16k audio buffer. feed(pcm), step() → TranscriptState(committed, tail). Implements LocalAgreement-2 + buffer trim at the commit point. Pure logic, no I/O, no audio device, no uinput. injected transcribe(np.ndarray, prompt, lang) → str
input.py
IncrementalTyper
Tracks rendered = chars currently on screen. On update(text): longest-common-prefix vs rendered, backspace the divergent suffix, type the remainder. Honors pause_typing, never backspaces past session start, hard per-step backspace cap. WaylandTyper (existing backspace()/type_text())
daemon.py
(record path rewrite)
While RECORDING, every slides: pull new samples → feedstep (in executor, coalesced) → IncrementalTyper.update. On stop: final flush, commit tail, end session. AudioStreamer, StreamingTranscriber, IncrementalTyper, LocalWhisperEngine

Data flow

  1. mic → AudioStreamer callback appends PCM to buffer (existing).
  2. Daemon slide loop (every slides while RECORDING) pulls newly-arrived samples and calls transcriber.feed(pcm).
  3. transcriber.step() re-decodes the rolling window via LocalWhisperEngine.transcribe wrapped in run_in_executor (keeps the event loop responsive); returns TranscriptState(committed, tail).
  4. IncrementalTyper.update(committed + tail) diffs against what is already on screen and emits the minimal backspace + type sequence into the focused window.
  5. Ctrl+Space again → one final feed/step, commit the remaining tail, end the session.
def step(self) -> TranscriptState:
    # re-decode window [commit_point - overlap : now]
    hyp = self._transcribe(self._window(), self._committed_text, self.lang)
    agreed = longest_common_prefix(hyp, self._prev_hyp)   # LocalAgreement-2
    self._commit(agreed)            # append to committed, trim audio buffer
    self._prev_hyp = hyp
    return TranscriptState(self._committed_text, hyp[len(agreed):])

Commit policy (LocalAgreement-2)

Removal scope

Delete: src/harp/api.py, LLMClient usage in daemon.py, the Ctrl+Shift+Space command-mode branch, _get_clipboard_context, the openai dependency in pyproject.toml, and the llm_api_key / llm_base_url / llm_model / command_prompt / send_clipboard fields in config.py + their CLI options in __main__.py.

Keep: copy_result (clipboard copy of the final text — independent of the cloud), all local_* Whisper config, model-management CLI, the evdev grab / uinput passthrough machinery.

Replace: the half-built continuous mode + _background_transcription_loop + the batch finalize block in _stop_recording are superseded by the streaming loop and removed.

Error handling

Testing

Tests are written and validated inline (not delegated — they are the verification layer).

Non-goals

Open questions & deferred items
  • Exact default values for slide_interval / window / overlap per model & CUDA — set during the live-tuning step, then recorded in config.py defaults and the CLI reference.
  • Whether to surface a CLI flag to fully disable back-patch (append-only mode) as a safety valve — decide after live testing.
  • Uncertain segments in the source voice note ([?e-inbox?], the garbled final sentence) relate to slice D, not this slice.