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.
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.)Ctrl+Space toggles a
streaming session; hold-to-talk variant analogous.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.| Unit | Responsibility | Depends on |
|---|---|---|
streaming.pyStreamingTranscriber |
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.pyIncrementalTyper |
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
→ feed → step (in executor,
coalesced) → IncrementalTyper.update. On stop:
final flush, commit tail, end session. |
AudioStreamer, StreamingTranscriber,
IncrementalTyper, LocalWhisperEngine |
AudioStreamer callback appends PCM to buffer
(existing).slides while RECORDING) pulls
newly-arrived samples and calls transcriber.feed(pcm).transcriber.step() re-decodes the rolling window via
LocalWhisperEngine.transcribe wrapped in
run_in_executor (keeps the event loop responsive); returns
TranscriptState(committed, tail).IncrementalTyper.update(committed + tail) diffs against
what is already on screen and emits the minimal backspace + type
sequence into the focused window.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_point − overlap)
is dropped so decode cost stays bounded regardless of session length.initial_prompt for
continuity across the trim boundary.slide_interval,
window, overlap, agreement passes (default 2).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.
pause_typing defers emission; reconcile in one batch when
quiet.Tests are written and validated inline (not delegated — they are the verification layer).
StreamingTranscriber units — scripted
fake transcribe() returning a controlled hypothesis
sequence; assert commit/tail evolution, LocalAgreement-2 correctness,
buffer trim, prompt continuity. Pure, fast, no audio.IncrementalTyper units — recording
fake typer capturing (backspace_count, typed_str) ops;
assert minimal diff, committed-prefix preservation, pause deferral,
cap-exceeded fallback.integration); assert
final text close to expected.openai absent
from pyproject.toml, no LLMClient import, full
suite green, ruff clean.slide_interval /
window / overlap per model & CUDA — set
during the live-tuning step, then recorded in config.py
defaults and the CLI reference.[?e-inbox?],
the garbled final sentence) relate to slice D, not this slice.