LogNotes is a local speech-to-text dictation app: a push-to-talk hotkey records audio, Whisper transcribes it, and the result is pasted at the cursor. This document describes how the app is built.
LogNotes has an Electron front end for the UI and a Python back end (the "sidecar") for the ML pipeline and OS integration. They run as separate processes and communicate over a loopback WebSocket.
The split exists because the valuable part of the app — Whisper inference (ctranslate2 / faster-whisper), global hotkeys (pynput), and paste-at-cursor — has no production-quality JavaScript equivalent, so it stays in Python. Electron owns only the UI (windows, tabs, overlay, tray). Electron does not change transcription latency; the pipeline is identical to what a pure-Python build would run.
┌──────────────────────────────────────────────────────────────┐
│ Electron (Node) │
│ main process (electron/main.js): │
│ - spawns + supervises the Python back end │
│ - reads its `PORT <n>` stdout handshake, hands it to the │
│ renderers over IPC │
│ - owns Tray, app lifecycle, single-instance lock │
│ renderers (contextIsolation on, nodeIntegration off): │
│ - main window: Settings / Activity / Logs tabs │
│ - overlay: separate frameless always-on-top status pill │
│ each renderer opens its OWN WebSocket to the back end │
└──────────────────────────────────────────────────────────────┘
│ loopback WebSocket (127.0.0.1, ephemeral port) ▲ events
▼ │
┌──────────────────────────────────────────────────────────────┐
│ Python back end (sidecar.py) │
│ SidecarServer + LogNotesController (src/controller.py) │
│ - hotkey (pynput), recorder, Whisper ASR, paste │
│ - ConfigStore, ActivityStore, log ring buffer │
└──────────────────────────────────────────────────────────────┘
The controller (src/controller.py) orchestrates the same streaming pipeline regardless of which front end is attached:
- Capture — the hotkey press starts the recorder; release (hold mode) or a second press (toggle mode) stops it.
- Transcribe — Whisper (
faster-whisper, with its internal VAD) yields text segments as they decode. - Checkpoint paste — segments are accumulated until a sentence boundary (
.?!), then that chunk is pasted immediately. This means partial output survives if processing fails mid-stream. - Clipboard hygiene — mid-stream chunks paste without clearing the clipboard (a 150 ms guard prevents the next chunk overwriting before the target app reads it); the final chunk clears the clipboard 5 s after paste.
- Record activity — the session audio + final text are stored in memory for the Activity tab (retryable with a different model).
Transport. A loopback-only WebSocket on 127.0.0.1, ephemeral port. The back end binds port 0 (OS-assigned), prints PORT <n> on stdout; the Electron main process reads that line and hands the port to each renderer via the preload bridge. WebSocket (not request/response) is used because the back end pushes status, activity, config, and log events to the UI.
Protocol. JSON messages, WebSocket-framed:
Request: {"id": <int>, "method": <str>, "params": {...}}
Response: {"id": <int>, "result": <any>} | {"id": <int>, "error": <str>}
Event: {"event": <str>, "data": {...}} (server -> client)
Request methods (handled in sidecar.py): getConfig, setConfig (whitelist-validated, persisted, side effects applied), getModels, getActivity, retryTranscription, deleteActivity, clearActivity, toggleRecording, isBusy, getLogs, clearLogs.
Pushed events: ready (per-client snapshot on connect), status,
configChanged, activityChanged, audioError, logLine.
Privacy. Activity audio stays in RAM in the back end and is never serialized over the wire — only metadata + text. Nothing is written to disk; the session clears on quit.
The controller is front-end-agnostic: it talks to the UI only through an injected UIBridge / ConfigStore / ActivityStore, so it has no Tk or Electron coupling and the back end imports no UI framework.
| Area | Files | Role |
|---|---|---|
| Orchestrator | src/controller.py | LogNotesController — the pipeline; talks to the UI via injected interfaces. |
| UI contract | src/ui_bridge.py | UIBridge protocol (set_status, show_audio_error). |
| Config | src/config.py | Schema, whitelist validation, 0o600 save, ConfigStore, single-key validate_value(). |
| Activity | src/activity.py | In-memory ActivityStore + ActivityEntry (audio held in RAM only). |
| Transcription | src/transcription/ | whisper.py (faster-whisper), registry.py (model registry), device.py (CUDA detection). |
| Audio | src/audio/ | recorder.py (sounddevice). |
| Input | src/input/ | hotkey.py (pynput global hotkey), paster.py (paste-at-cursor + clipboard). |
| Paths | src/paths.py | dev-vs-frozen asset resolution + user data/cache dirs. |
| Back-end server | sidecar.py | SidecarServer (WebSocket + RPC) and HeadlessBridge (UIBridge → events). |
| Electron main | electron/main.js | Process spawn/supervision, windows, tray, lifecycle. |
| Preload | electron/preload.js | Hardened contextBridge surface. |
| Renderer | electron/renderer/ | index.html/renderer.js (tabs), overlay.html/overlay.js (pill). |
A legacy Tkinter UI (src/ui/, driven by main.py) is kept in the tree for reference. It shares
LogNotesControllerwith the Electron back end. The shipped product is the Electron app.
- Two independent renderers. The main window and the overlay each open their own WebSocket. The overlay shows only the short state label (Ready / Recording / Processing); verbose detail stays in the main window's status box.
- Responsive startup. The main window is shown immediately on launch (before the back end finishes starting), so a slow first launch doesn't look frozen; the renderer polls for the back-end port until it's up.
- Single instance.
requestSingleInstanceLock()+ asecond-instancehandler surface the existing window instead of spawning a duplicate.setAppUserModelIdties the window + pinned taskbar shortcut to the app identity on Windows. - Close Quits, Minimize Backgrounds. Closing the main window quits the whole app — window, overlay, and the Python back end — so nothing is orphaned (the sidecar's tree is killed on every exit path and a parent-watchdog covers a hard kill). To keep the global hotkey working in the background, minimize instead of closing; the tray's Show/Quit still work.
- Hardening.
contextIsolation: true,nodeIntegration: false, a typed preload bridge, and a permission handler that denies all web permissions except clipboard.
Models live in a registry (src/transcription/registry.py) mapping a stable id to a display name, backend, and load argument:
- Whisper:
whisper-base,whisper-smallvia faster-whisper. Auto-selects CUDA float16 when available, else CPU int8.
All Whisper sizes are warmed in the background at startup so Activity-tab retries with a different model are instant.
- Config:
%APPDATA%\LogNotes\config.json, whitelist-validated on load, written with0o600permissions. See documentation/configuration.md for the schema. - Model caches:
%LOCALAPPDATA%\LogNotes\cache\{hf,torch}.HF_HOME/TORCH_HOMEare set in the entry point (sidecar.py/main.py) before torch is imported.
A single NSIS installer that bundles the Python back end inside it.
- The back end is frozen with PyInstaller (build/LogNotes.spec, entry point
sidecar.py) intodist/LogNotes/LogNotes.exe. - electron-builder (electron/package.json) bundles that as an
extraResourceand produces the installer indist-electron/. - build/build-electron.ps1 orchestrates the build.
- User config/cache paths are identical to any prior install, so settings and downloaded models carry over.
See documentation/troubleshooting.md for build and runtime troubleshooting.
- Audio is session-only — held in RAM, never written to disk, cleared on exit.
- Config validation — all values whitelist-validated; config file created
0o600. - Clipboard hygiene — clipboard cleared after paste; clearing is guaranteed even if paste raises.
- Electron hardening — context isolation, no node integration, all web permissions denied except clipboard.
- Local only — no network traffic except local model downloads; no telemetry; the app and installer run without admin.