# 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](../CLAUDE.md) accurate. [src/whisper-host.ts](../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](../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](../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`, resolves the configured port via `resolvePort` (falls back to `8080` when the setting is missing, non-finite, `<= 0`, or `> 65535`; the upper bound used to be unchecked and a too-large port fell through to a confusing "did not become ready" timeout instead of a clear validation), probes it 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 (see the ownership-specific detail below for the adopted-PID case, which re-verifies the port first). **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 any user-supplied `--host` is non-loopback (`getHostArgs` + `isLoopbackHost` in [src/whisper-host.ts](../src/whisper-host.ts); loopback = `127.0.0.1` / `localhost` / `::1`). `getHostArgs` collects EVERY `--host` (or `--host=value`) occurrence in `extraArgs`, not just the first: whisper-server (like most CLI parsers) honors the LAST `--host` it's given, so checking only the first let `--host 127.0.0.1 --host 0.0.0.0` slip past the guard and bind an unauthenticated server to the LAN. 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](../src/transcription/whisper-local.ts)) is a thin shim that POSTs to `http://127.0.0.1:/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](../src/audio-transcode.ts) (Web Audio API: `AudioContext.decodeAudioData` → `OfflineAudioContext` 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](../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](../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](../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](../src/settings/tab.ts) and [src/ui/setup-card.ts](../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 `getHostArgs` so every non-loopback `--host` occurrence 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](../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 `/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; the 3 s SIGKILL fallback `setTimeout` is cleared as soon as the child's `exit` event fires, so it does not leak or fire spuriously after a prompt SIGTERM exit. 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. - **Stopping an adopted server re-verifies the port immediately before killing by PID.** The sidecar's PID could have been recycled by the OS for an unrelated process since we adopted it (e.g. after an OS crash between sessions); `probe()`'s `isPidAlive` check only confirms SOME process has that PID, not that it's actually bound to the tracked port or is whisper-server at all. `stop()`'s adopted-PID branch re-checks `isPortReachable` on the tracked port right before sending a signal: if nothing is listening there anymore, whatever we adopted is already gone, so it resets to `'stopped'` and clears the sidecar WITHOUT sending a signal to the (possibly reassigned) PID. This narrows, but does not eliminate, the PID-reuse window: a full port-plus-PID collision is far less likely than a bare PID collision, but not impossible. - **`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](../src/settings/tab.ts) (re-render on Start/Stop click) and [src/ui/whisper-status-bar.ts](../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.