Skip to content

Voice provider abstraction: swappable engines + fully local STT/TTS (#36, #37) - #47

Merged
noisy merged 10 commits into
v3-desktopfrom
feature/voice-provider-abstraction
Aug 31, 2026
Merged

Voice provider abstraction: swappable engines + fully local STT/TTS (#36, #37)#47
noisy merged 10 commits into
v3-desktopfrom
feature/voice-provider-abstraction

Conversation

@noisy

@noisy noisy commented Aug 31, 2026

Copy link
Copy Markdown
Owner

What

A provider seam between the daemon and its voice engines (#36), and the first alternative provider: fully local, offline STT + TTS (#37).

  • providers/ package: TTSProvider/STTProvider protocols with capability flags; the registry re-reads providers.json (config dir) on every call, so switching engines is a file write - live immediately, no restart. Unknown names fall back to Grok.
  • Grok becomes a thin adapter over the existing clients; its exceptions subclass the new provider errors. Voice identity stays in the daemon - providers only receive a voice_id.
  • Local provider: STT via faster-whisper (model size configurable), TTS via Kokoro (kokoro-onnx) with natural offline voices; macOS say remains a zero-dependency fallback (local.tts_engine: "say"). Both report $0 cost. Batch-only providers automatically fall back to the daemon's batch paths.
  • Switching UI: GET/POST /providers (purely additive; /status, static serving and /shutdown untouched) + ProviderSettings.vue in SETTINGS -> SYSTEM, generated from per-provider catalog metadata (providers/manifest.py) - a future provider (OpenAI, Provider abstraction: support OpenAI voice APIs (STT + TTS) alongside Grok #36) needs no new frontend code, just fields in its manifest entry.
  • Model downloads: picking a local engine prefetches weights in a background thread; the UI polls during a download and draws byte-accurate progress for Kokoro, coarse state for whisper (huggingface_hub exposes no byte callback), checkmarks with sizes for what's on disk, and keeps failures visible.
  • Storybook: ProviderSettings.stories.ts with nine states (grok/local/mixed/not-ready, downloading, downloaded, failed, old daemon without the endpoint).

Heads-up for reviewers

  • First local use downloads ~340 MB (Kokoro model + voices) into CONFIG_DIR/models/kokoro/, plus the whisper model into the HF cache. The prefetch + progress bar make this visible, never dead air on the first utterance.
  • uv.lock was regenerated with --no-config (a machine-global uv index required credentials); the diff only adds the optional local extra: faster-whisper, kokoro-onnx.
  • SynthesizedAudio moved to providers/base.py; tts.py re-exports it, so existing imports keep working.
  • New deps are behind the optional extra: uv sync --extra local.

Testing

  • 179 Python unit tests (18 new) and 179 dashboard tests green; ruff clean.
  • Live-verified against a scratch daemon: runtime switching, repeated GET /providers (regression test for a catalog-shadowing bug the live check caught), and a spoken utterance rendered by Kokoro through /speak.
  • Offline round-trip: Kokoro synthesis -> faster-whisper transcription reproduces the sentence exactly.

Closes #37, groundwork for #36 (OpenAI provider intentionally not registered yet - no keys).

🤖 Generated with Claude Code

noisy and others added 7 commits August 31, 2026 11:16
- a splash window while the bundled daemon boots (a one-file build
  unpacks itself; cold start exceeds 20s, which read as a hang)
- daemon wait raised from 20s to 60s for the same reason
- the widget seeds its avatar from localStorage, so a restart shows
  the proper agent's face instead of the default while the first
  /character round-trip is in flight
- an unknown voice renders neutral cyan, not hue("")=0 pure red with
  a question-mark monogram
- npm run dist now rebuilds the dashboard bundle first - it shipped
  whatever happened to be in dashboard/dist, silently stale

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
At 36px, absolutely positioned to the logo's right, it collided with
the mute-all button on narrower windows. Now a small pill inside the
logo block, under the tagline, where nothing can overlap it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Introduce a provider abstraction between the daemon and its voice
engines. The speech pipeline (speech.py) and the listener loop
(daemon.py) no longer name Grok: they ask the registry for the active
TTS/STT provider at the moment of use, so switching engines is a write
to providers.json in the config dir - no restart, same contract as the
API key file.

- providers/base.py: TTSProvider/STTProvider protocols, capability
  flags (batch-only providers fall back to the batch paths), provider
  errors that Grok's existing exceptions now subclass.
- providers/grok.py: thin adapter over the existing xAI clients.
- providers/local.py: offline STT via faster-whisper (optional extra
  'local', model picked in providers.json) and offline TTS via the
  macOS say engine; both cost $0. Verified round-trip: say -> whisper
  reproduces the sentence.
- providers/catalog.py: per-provider setup metadata (kind, readiness,
  config fields) so a future dashboard chooser can ask 'which engine?'
  first and render each provider's own requirements generically.
- Voice identity stays in the daemon; providers only receive voice_id.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The macOS say engine sounds robotic; Kokoro (kokoro-onnx) gives
natural offline voices on CPU. Model files (~340 MB) download once
into the config dir; local.tts_engine = "say" keeps the
zero-dependency fallback. Verified aloud and via a whisper round-trip.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…chooser)

- http_api: GET /providers (catalog + active per direction) and POST
  /providers (switch engines / store local options) - both additive,
  /status untouched. Selection is live immediately: the pipeline
  re-reads providers.json on every call.
- dashboard: ProviderSettings.vue, a self-contained section in
  SETTINGS -> SYSTEM generated from the catalog (unknown fields render
  generically, so a new provider needs no frontend work). Mounted with
  one import + one tag in SettingsView.vue; api/client.ts gains the two
  typed calls.
- providers: rename catalog.py -> manifest.py; the submodule import was
  shadowing the package-level catalog() function, so the second GET
  /providers 500'd (found in the live check, regression-tested now).

Verified against a real daemon on a scratch port: repeated GETs stable,
switching stt+tts to local, and a spoken utterance rendered by Kokoro
through the daemon's own /speak.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…coverage

- downloads.py: a registry every weight fetch reports into; Kokoro
  streams byte-accurate progress, whisper reports coarsely (hf_hub has
  no byte callback). GET /providers now carries the list; switching an
  engine to local prefetches all weights in a background thread so the
  first utterance finds them on disk instead of waiting.
- ProviderSettings.vue: progress bars while weights arrive (polls only
  during a download), green checkmarks with sizes for what's on disk,
  and failures stay visible.
- ProviderSettings.stories.ts: nine states - grok/local/mixed/not-ready,
  downloading, downloaded, failed, old daemon without the endpoint.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@noisy

noisy commented Aug 31, 2026

Copy link
Copy Markdown
Owner Author

User-perspective review, as requested (flow: first contact -> switching -> downloads -> failure states).

HIGH - first contact still hardwires the xAI key. The full-screen FIRST CONTACT gate (App.vue, unconfigured = !api_key_set) demands a key before anything works, so the local/offline promise of this PR is unreachable for exactly the user it targets. manifest.py's own docstring names the fix: ask "which engine?" first. Suggested shape: the gate shows two cards driven by the catalog - "Cloud (Grok)": paste key, as today; "Local (offline)": no key, kicks the prefetch and shows the existing progress bars. unconfigured then becomes "no ready provider selected" rather than "no key". Can be a follow-up PR, but v3.0-beta first-run should not dead-end local users.

MED - NOT READY is a label, not a path. The chooser badges an engine "NOT READY" but never says why or how to fix it, and still lets you select it. The catalog already knows the reason (missing secret vs missing install). When the picked engine is not ready, render its fields/hint inline: for grok the key field, for local the install one-liner (uv sync --extra local).

MED - _local_ready() doesn't check what the default engine needs. It requires faster_whisper + say, but the default TTS engine is kokoro, which needs kokoro-onnx. Result: catalog says ready, user switches, and the failure surfaces only at first speak, in daemon logs. Either include find_spec("kokoro_onnx") when tts_engine != "say", or report per-direction readiness.

LOW - failed downloads have no retry. The FAILED row shows the detail (good), but recovery is re-picking the engine, which nothing suggests. A small RETRY on the error row, or even a hint in the text block, closes the loop.

LOW - stale field label. "System voice (empty = default)" only applies to the say engine, while the default is kokoro - as-is it reads like it configures the voice you actually hear. Label it "say voice" and show it only when `tts_engine = say", or add a kokoro voice choice instead.

Everything else held up nicely in reading: the additive routes, the self-contained component, download visibility rules ("never hide an error"), and the catalog-driven form. Nice work.

…d 1)

HIGH - first contact now asks 'which engine?' before any key:
EngineChoice.vue renders one card per engine kind inside the gate.
CLOUD keeps the existing pitch/key/verification flow untouched; LOCAL
is one click - selects local both ways, kicks the weight prefetch, and
shows the download bars right in the gate. Unconfigured now means 'no
ready+selected provider': /status gains an additive voice_ready key
(local-only with no xAI key at all passes the gate; verified live).

MED - NOT READY is actionable: the catalog carries ready_detail (why +
remedy), rendered inline under the choosers and in the gate.
MED - readiness reflects the configured engine: kokoro_onnx is checked
while Kokoro is the chosen engine, say needs no package.
LOW - failed download rows get a RETRY button (POST /providers accepts
{prefetch:true} to re-kick the fetches).
LOW - the voice field matches the engine: macOS free-text for say, a
Kokoro voice choice otherwise (static id list - no model load).

Storybook: EngineChoice.stories.ts with the five gate states
(cloud-picked, local-downloading, local-ready, not-ready-with-remedy,
download-failed).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@noisy

noisy commented Aug 31, 2026

Copy link
Copy Markdown
Owner Author

Round 2. The engine-first gate is the right shape - cloud path untouched, local is genuinely one click, remedies render inline, RETRY and the engine-conditional voice field are in. Three findings, then I expect to be done:

MED - the gate closes while the weights are still downloading. voice_ready() is true the moment the selected providers' deps are in place - it ignores whether the models are on disk. Picking LOCAL flips it true, the overlay unmounts on the next status poll, and the in-gate progress bars the card promises ("download while you watch") vanish mid-flight. Worse, a user who speaks right away gets minutes of silence while _ensure_downloaded blocks the first utterance. Fix: for the local provider, voice_ready should also require the weights to be present (downloads registry shows no missing/downloading/error for the selected engine) - that matches its own docstring, "can the daemon hear AND speak RIGHT NOW". Then the ok-line's "this gate closes by itself" becomes true at the right moment: when the last bar completes.

LOW - the poll fights the user's card choice. refresh() runs every second while downloading and calls setChosen("local") whenever localActive - so a user who picked LOCAL, saw 340 MB, and clicked CLOUD to paste a key instead is yanked back to the local card on the next tick. Force the choice once (first load), not on every refresh.

LOW - raw String(error) in user-facing notes (both EngineChoice and ProviderSettings). An axios failure prints as Error: Request failed with status code 500. One tiny helper that prefers the daemon's error body and falls back to a generic "the daemon refused - check its log" would read much better.

Nothing else - the catalog-driven fields, per-engine readiness with kokoro_onnx, story coverage and the additive /status key all look right.

- voice_ready() now requires the local WEIGHTS on disk, not just the
  installs: the gate stays open (bars visible) until the last download
  completes, and the first utterance never blocks on _ensure_downloaded.
  Whisper presence is checked in the HF cache on disk, so a fresh daemon
  recognizes weights a previous run fetched.
- EngineChoice: syncs chosen=local from daemon state once on first load
  (polls no longer fight a user who clicked back to CLOUD mid-download),
  and error notes surface the daemon's own error wording with a human
  fallback instead of raw String(error).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@noisy

noisy commented Aug 31, 2026

Copy link
Copy Markdown
Owner Author

Round 3. All three round-2 fixes verified in the diff and by running the provider tests here (14 passed). One genuine edge left, then I'm done:

MED (last one) - models_present() is not direction-aware. voice_ready() calls it when EITHER direction is local, but it always demands BOTH the kokoro weights (unless say) AND the whisper cache. A mixed setup - tts=local + stt=grok with a valid key, kokoro weights on disk - is fully able to hear and speak, yet voice_ready returns false because whisper isn't cached, and the first-contact gate locks the user out of a working configuration. Mirror the split: require kokoro weights only when TTS is local, the whisper cache only when STT is local.

The friendly() regex over the error text is mildly fragile, but it has a human fallback - not blocking, just noting it.

Fix the direction split and, from my side, this is merge-ready.

tts=local + stt=grok(with key) is a working setup: require kokoro
weights only when TTS is local and the whisper cache only when STT is
local, so a mixed configuration is never locked behind the gate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@noisy

noisy commented Aug 31, 2026

Copy link
Copy Markdown
Owner Author

Round 4: verified the direction-aware fix in the diff and ran the full unit suite here - 181 passed. No findings left. Merge-ready from my side. Nice iteration - five findings to zero in three rounds, with each fix pinned by a test.

@noisy
noisy merged commit 0c3fe31 into v3-desktop Aug 31, 2026
2 checks passed
noisy added a commit that referenced this pull request Aug 31, 2026
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@noisy

noisy commented Aug 31, 2026

Copy link
Copy Markdown
Owner Author

Post-approval notes from review (non-blocking, for a future pass — recording them so they survive the stream session):

  1. downloadsFor() in SignalPath.vue routes weights to pills by name prefix (whisper/kokoro/voices); a third provider's weights would silently route nowhere. A direction field on the download record is the sturdier shape.
  2. flip() hardcodes grok as the only cloud engine — needs to consult the catalog once OpenAI lands (Provider abstraction: support OpenAI voice APIs (STT + TTS) alongside Grok #36).
  3. One timing-sensitive Python test flaked once under heavy machine load (204 s run) and passed on every normal run since; if it flakes again, capture the name and pin it.

🤖 Generated with Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant