Voice provider abstraction: swappable engines + fully local STT/TTS (#36, #37) - #47
Conversation
- 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>
|
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, 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 ( MED - 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 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>
|
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. LOW - the poll fights the user's card choice. LOW - raw 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>
|
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) - The 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>
|
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. |
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Post-approval notes from review (non-blocking, for a future pass — recording them so they survive the stream session):
🤖 Generated with Claude Code |
What
A provider seam between the daemon and its voice engines (#36), and the first alternative provider: fully local, offline STT + TTS (#37).
TTSProvider/STTProviderprotocols with capability flags; the registry re-readsproviders.json(config dir) on every call, so switching engines is a file write - live immediately, no restart. Unknown names fall back to Grok.voice_id.kokoro-onnx) with natural offline voices; macOSsayremains a zero-dependency fallback (local.tts_engine: "say"). Both report $0 cost. Batch-only providers automatically fall back to the daemon's batch paths.GET/POST /providers(purely additive;/status, static serving and/shutdownuntouched) +ProviderSettings.vuein 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.ProviderSettings.stories.tswith nine states (grok/local/mixed/not-ready, downloading, downloaded, failed, old daemon without the endpoint).Heads-up for reviewers
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.--no-config(a machine-global uv index required credentials); the diff only adds the optionallocalextra: faster-whisper, kokoro-onnx.SynthesizedAudiomoved toproviders/base.py;tts.pyre-exports it, so existing imports keep working.uv sync --extra local.Testing
GET /providers(regression test for a catalog-shadowing bug the live check caught), and a spoken utterance rendered by Kokoro through/speak.Closes #37, groundwork for #36 (OpenAI provider intentionally not registered yet - no keys).
🤖 Generated with Claude Code