--- name: openwebui-tts-chain-audit description: Audit/repair OpenWebUI TTS on Dankclaw when cloned voices get rejected by a 29-name Kokoro enum or synthesis is silent; rootless DB + endpoint probes --- Audit OpenWebUI's TTS/STT wiring on Dankclaw when a voice is rejected, synthesis is silent, or someone asks "is the cloned voice wired correctly". Not for model-picker/ACL issues (openwebui-picker-acl-repair owns those). ## Shape of the stack - Container `open-webui` runs **rootless as `dank`** on Dankclaw: `ssh dankclaw 'podman exec open-webui ...'` (NO sudo: `sudo podman` sees an empty world). Host port 3005. - Server-side config = `webui.db` `config` table (admin), per-user overrides = `user.settings` JSON column. Settings changed via UI persist per-user; admin page persists to `config`. - Cloned voices live behind the frank-tts façade `:7772` (`/v1/audio/speech`, `/v1/audio/voices`); voices resolve to `/home/dank/voices//reference.wav`. ## The trap (2026-10-18 law) `Error: Voice "narrator1" not found. Should be one of: af_heart, ...` (29 names) is **OpenWebUI's bundled in-browser Kokoro** (`browser-kokoro`) rejecting client-side — frank-tts never sees the request. Signature: container logs show zero `audio/speech` hits. Fix = the personal dropdown Settings -> Audio -> TTS Engine -> Default (empty string), not any deploy. ## Probe sequence (read-only, safe) 1. Admin truth: `podman exec open-webui python -c "import sqlite3; c=sqlite3.connect('/app/backend/data/webui.db'); [print(k,'=',v) for k,v in sorted(dict(c.execute('SELECT key,value FROM config')).items()) if 'audio' in k or 'tts' in k.lower()]"` Expect: `audio.tts.engine=openai`, `audio.tts.openai.api_base_url=http://host.containers.internal:7772/v1`, `audio.tts.model=frank-tts`, `audio.tts.voice=`. 2. User overrides — a user pinned to `browser-kokoro` beats admin config. CAUTION: `user.settings` can be SQL NULL or the JSON string `"null"`: `... for uid,s in c.execute('SELECT id,settings FROM user'): d=json.loads(s) if s else None; d=d if isinstance(d,dict) else {}; hits={k:v for k,v in d.items() if 'audio' in k or 'tts' in k.lower()} ...` 3. Endpoint truth: `curl -s http://127.0.0.1:7772/v1/audio/voices` (roster), then POST the exact OpenWebUI body `{"model":"frank-tts","input":"probe","voice":"narrator1","response_format":"mp3","speed":1.0}` to `/v1/audio/speech` — expect `200 audio/mpeg`. 4. Contrast logic: server-path failure leaves a container log line AND a façade error; browser-kokoro failure leaves neither. ## Law - Full contract + diagnosis live in serverAppConfigs docs (`Dankclaw/OpenWebUI.md` § Text-to-speech, `Dankclaw/Frank TTS.md` troubleshooting). Docs are the source; this skill is just the probe route. - Rootless gotcha generalizes: any Dankclaw service probe through podman goes WITHOUT sudo unless the unit is a system quadlet.