--- name: frank-tts-consumer-voice-triage description: "Triage a consumer (OpenWebUI, omp, open-notebook) rejecting or ignoring a frank-tts voice name: prove whether the server or the browser-side Kokoro.js engine produced the error." --- # frank-tts consumer voice triage A consumer says a voice name is invalid, speaks in the wrong voice, or is silent. `frank-tts` (`192.168.0.143:7772`, shim `dankclaw/voiceservice/app.py`) resolves `voice=` against `/home/dank/voices//reference.wav` and answers **400 + the list of voices that exist** for a name it cannot resolve — never a silent fallback. Any error naming a *different* voice list did not come from it. **Signature that decides the case immediately:** an enum of ~29 names `af_heart, af_alloy, … am_*, bf_*, bm_* … bm_fable` is **Kokoro's fixed pack**, the voice list of **Kokoro.js running in the browser** (OpenWebUI ships it in its own front end as TTS engine value `browser-kokoro`, with a personal "Kokoro.js dtype" setting; model `onnx-community/Kokoro-82M-v1.0-ONNX`). `narrator1/2/3` are frank-tts cloned voices. Kokoro cannot clone. ## Instrument ladder (stop at the first that answers) 1. **What the endpoint actually accepts** (never infer from a consumer's text): `curl -s http://127.0.0.1:7772/v1/audio/voices` → JSON array of pack names. 2. **Speak with the consumer's exact body, from inside the consumer's container** — same network, same shape. Write the JSON to a file and `--data-binary @file` (nested `sh -c` quoting silently sends an empty body and yields a bogus `400 malformed request body`): ``` printf '%s' '{"model":"frank-tts","input":"probe","voice":"narrator1","response_format":"mp3","speed":1.0}' > /tmp/p.json podman cp /tmp/p.json :/tmp/p.json podman exec curl -sS -o /tmp/o.bin -w 'http=%{http_code} type=%{content_type} bytes=%{size_download}\n' \ -X POST http://host.containers.internal:7772/v1/audio/speech \ -H 'Content-Type: application/json' --data-binary @/tmp/p.json ``` `200 audio/mpeg|audio/wav` ⇒ the server path is healthy; the failure is elsewhere. 3. **Did the request arrive at all?** `podman logs --tail 400 | grep -c audio/speech`. A server-path failure leaves a log line **and** a frank-tts error body; a client-side engine leaves **neither**. This contrast is the discriminator. 4. **Where the rejection string lives** (proves the emitter without guessing): ``` podman exec sh -c 'grep -ho ".\{200\}Should be one of.\{80\}" /app/build/_app/immutable/chunks/*.js | head' podman exec sh -c 'grep -ho ".\{60\}af_heart.\{200\}" /app/build/_app/immutable/chunks/*.js | head -2' ``` Matched inside the front-end bundle ⇒ browser-side validator, not the API. 5. **Config authority sweep** (OpenWebUI): admin config is the `config` table (one key per row, JSON `value`; expected `audio.tts.engine=openai`, `audio.tts.model`, `audio.tts.voice`, `audio.tts.openai.api_base_url`); per-user overrides are the `settings` JSON column of table `user` (singular, no `is_active`; some rows hold the string `null` → guard it). `audio.stt.*` and `audio.tts.*` are separate. No `sqlite3` CLI in the image or on the host: `podman exec -i python -` with the stdlib module. **`podman exec` needs `-i` to accept a heredoc script** — without it the script is silently dropped and you get no output. ## Known traps - OpenWebUI is a **rootless** quadlet: `sudo podman …` reports *no such container*. Read it rootless. - `podman ps` in this podman build has no `.ImageName` field — use `{{.Image}}`. - The in-browser engine choice is **per-browser storage**: empty `user.settings` + correct admin config + zero backend hits is the normal shape of this bug. Fix is UI: Settings → Audio → TTS Engine → **Default**. - `GET /api/config` publishes no `audio` block, and nonexistent `/api/...` paths return the SPA HTML with `200` — never conclude "config missing" from an HTML body. - TTS is memory-registry sensitive: the engine's voice registry is memory-only, so "every name returns 400" after a restart points at the warm pass, not at the caller (see [[frank-tts-add-cloned-voice]]). ## Local playback side (omp `/say`, manual probes) If synthesis succeeds (`200`, sane WAV bytes) but nothing is audible, the fault is the PipeWire graph, not the endpoint: `wpctl status` (default sink marked `*`, and *Default Configured Devices* — a remembered-but-offline `bluez_output.*` there is the classic silent route), `wpctl get-volume @DEFAULT_AUDIO_SINK@`, `pw-play -v ` (expect `stream drained` + exit 0), and target a device explicitly with `pw-play --target ` to identify which hardware is live. A one-word clip (~0.7 s) is easy to miss — probe with a multi-second sentence.