Files
command-center/agent/managed-skills/frank-tts-consumer-voice-triage/SKILL.md
T

4.8 KiB

name, description
name description
frank-tts-consumer-voice-triage 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=<name> against /home/dank/voices/<name>/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 <consumer>:/tmp/p.json
    podman exec <consumer> 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 <consumer> | 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 <consumer> sh -c 'grep -ho ".\{200\}Should be one of.\{80\}" /app/build/_app/immutable/chunks/*.js | head'
    podman exec <consumer> 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 <c> 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 <file> (expect stream drained + exit 0), and target a device explicitly with pw-play --target <node-id> to identify which hardware is live. A one-word clip (~0.7 s) is easy to miss — probe with a multi-second sentence.