wiseguru_ReWrite-Voice-Notes/docs/WHISPER_HOST.md
2026-06-18 08:13:53 -07:00

8.6 KiB

Local whisper.cpp host (desktop)

Extracted from CLAUDE.md. Subject to the same maintenance rule: when you change whisper-host behavior, update this file in the same change, and keep the summary in CLAUDE.md accurate.

src/whisper-host.ts exposes the WhisperHost class. The plugin instantiates one in onload and stops it in onunload. Configuration lives at GlobalSettings.localWhisper = { binaryPath, modelPath, port, extraArgs } — all user-supplied. No automatic discovery, no PATH lookup at runtime, no auto-download. The one (user-initiated) convenience is the Auto-detect button beside the Binary path field in settings: detectWhisperBinary() in src/settings/tab.ts lazy-requires fs/os/path (same window.require + Platform.isDesktop guard as whisper-host.ts) and returns the first existing candidate from the build script's install locations (~/.local/bin/whisper-server, then ~/.local/share/whisper.cpp/build/bin/whisper-server) followed by common system/Homebrew paths (/usr/local/bin, /opt/homebrew/bin, /usr/bin); .exe suffix on Windows. It only checks existence when clicked and never runs the binary. The binaryPath default stays '' (the setup-card gating depends on it), so this is a fill-in helper, not a baked default.

The companion CLI installer is scripts/build-whisper-linux.sh: it clones/builds whisper.cpp into ~/.local/share/whisper.cpp (override --source-dir), symlinks the binary to ~/.local/bin/whisper-server (override --prefix, skip with --no-symlink), and prints the final Binary path to paste into settings. Keep the Auto-detect candidate list in sync with this script's default paths.

start() validates the binary and model paths exist via fs.existsSync, probes the port via net.createServer().listen(port) to detect conflicts, spawns the user's whisper-server with child_process.spawn, captures stdout/stderr into a 1 MB ring buffer, then polls net.createConnection against the port every 250 ms for up to 5 s before declaring 'running'. Any failure transitions status to 'crashed' with the log tail surfaced in the error message. stop() sends SIGTERM, waits up to 3 s, then SIGKILL.

Loopback binding is enforced. whisper-server has no auth/TLS, so start() pins --host 127.0.0.1 when the user did not supply a --host in extraArgs (so we don't depend on upstream's default binding), and throws before spawning if a user-supplied --host is non-loopback (getHostArg + isLoopbackHost in src/whisper-host.ts; loopback = 127.0.0.1 / localhost / ::1). This prevents a stray --host 0.0.0.0 from silently exposing an unauthenticated transcription server to the LAN. The README's "Network exposure" disclosure and the Extra args field description both state this.

The whisper-local transcription provider (src/transcription/whisper-local.ts) is a thin shim that POSTs to http://127.0.0.1:<port>/inference (whisper.cpp's native server route, not OpenAI's /v1/audio/transcriptions). Before sending, the recorded blob is transcoded to 16 kHz mono 16-bit PCM WAV via transcodeToWavPcm in src/audio-transcode.ts (Web Audio API: AudioContext.decodeAudioDataOfflineAudioContext for resample/downmix → hand-written RIFF header). whisper.cpp's server cannot decode WebM/Opus (the MediaRecorder default) without a custom ffmpeg-enabled build, so the transcode is mandatory. The same helper is reused by src/transcription/mistral-voxtral.ts (Voxtral also rejects WebM). If you add a third audio-uploading provider that needs WAV, import from audio-transcode.ts rather than duplicating the helpers. The shim grabs the host reference via bindWhisperHost(host) called from src/main.ts on load. If the host status is anything other than 'running', the adapter throws a ProviderError with a "start it from settings" message; the pipeline surfaces that as a Notice. No API key is collected for this provider (no auth, no settings field).

Mobile compatibility: WhisperHost and the whisper-local option are guarded everywhere by Platform.isDesktop. Node modules (child_process, net, fs) are lazy-required inside the Platform.isDesktop branch, mirroring the src/secrets.ts getSafeStorage pattern; on mobile the host is inert and the provider option is filtered out of dropdowns in src/settings/tab.ts and src/ui/setup-card.ts.

Gotchas

  • WhisperHost lazy-requires Node modules inside a Platform.isDesktop guard. Importing child_process / net / fs at module top would crash on mobile load. The cached nodeApiCache is null on mobile, so any host method that needs it bails with a clear "desktop only" error.
  • splitArgs is a naive whitespace split. The local-whisper extraArgs value is tokenized on whitespace only, so a single argument containing spaces (e.g. a quoted path) is not supported. Not a security issue (argv array, no shell). The Extra args settings field description states the limitation; add quote-aware parsing only if users actually hit it. The tokenized list is also scanned by getHostArg so a non-loopback --host is rejected before spawn (see Loopback binding above).
  • WhisperHost has three ownership states. 'spawned' (this session's child handle is live; log capture works), 'adopted' (port bound, PID sidecar matches a live PID — we started it in a previous session, no log capture), 'external' (port bound but no sidecar match — someone else started it). Status enum gains 'external' alongside stopped/starting/running/crashed; running means we own it (spawned OR adopted) and Stop works, external means we don't and Stop is disabled. WhisperHost.snapshot() returns { status, baseUrl, ownership, pid } for UI consumers; formatWhisperStatus(snap) produces labels like "Running on http://... (adopted from previous session, pid 12345)." or "External whisper-server on http://... (not started by ReWrite).".
  • Transcription never checks WhisperHost.status(). src/transcription/whisper-local.ts just asks for host.baseUrl() and POSTs. The HTTP API doesn't care who owns the process — if the port is reachable, transcription works. baseUrl() returns the URL for both 'running' and 'external' states.
  • PID sidecar at <plugin folder>/whisper-host.pid.json records { pid, port, binaryPath, startedAt } once the server is ready. stop() and the child exit handler clear it. WhisperHost.probe(config) (called from onload and at the top of start()) reads the sidecar: if the port is reachable AND the sidecar PID is still alive AND the sidecar port matches the configured port, the host adopts it as 'running' with ownership 'adopted'. Otherwise (port bound, no sidecar match) the host transitions to 'external'. Probe never disturbs state when we already hold a live spawned child. Uses process.kill(pid, 0) for liveness probing (added to the lazy NodeAPI cache alongside cp/net/fs).
  • Stop semantics depend on ownership. Spawned: child.kill() via the live ChildProcess handle. Adopted: process.kill(pid, signal) since we only have the PID, then clear the sidecar. External: stop() throws "not started by ReWrite — stop it via OS tools." (the settings-tab button is disabled with a tooltip, the status-bar click shows a Notice, the stop-whisper-host command is hidden via checkCallback). This preserves the "never kill a process we didn't start" invariant — the sidecar is proof we started it.
  • onunload stops the whisper-host fire-and-forget. void this.whisperHost?.stop() — Obsidian's Plugin.onunload signature is () => void, so we can't await. Stop() sends SIGTERM with a 3 s SIGKILL fallback, but the unload sequence may complete before that. Child processes do NOT auto-die with the parent on Linux (they reparent to init), on Windows (orphaned but still running), or reliably on macOS (SIGTERM may not land before Obsidian exits). The probe/adopt flow above is what closes this hole on next launch; don't try to make unload async.
  • Whisper status bar polls whisperHost.status() every 1 s via registerInterval. The host has no event emitter; both src/settings/tab.ts (re-render on Start/Stop click) and src/ui/whisper-status-bar.ts (interval poll) re-read the status synchronously. If you add a third consumer, poll the same way; do not bolt on an event system.