Compare commits

..
Author SHA1 Message Date
Frank 9a17b93e8a docs(frank-tts): crosslink the browser-Kokoro rejector both ways
A consumer that lists the 29-name af_*/am_*/bf_*/bm_* pack and refuses
narrator1 without a request reaching the façade is synthesizing in the
browser. Frank TTS's troubleshooting table gains that symptom; OpenWebUI's
See Also gains the service page.
2026-10-18 20:17:19 -07:00
Frank 3e779823e0 Merge remote-tracking branch 'origin/main' into wt/20261017-181502
# Conflicts:
#	serverAppConfigs-docs/log.md
2026-10-18 20:17:19 -07:00
Frank 30e3d36b1e docs(openwebui): the TTS voice rejector is in-browser Kokoro, not frank-tts
The `Voice "narrator1" not found. Should be one of: af_heart, …, bm_fable`
toast is OpenWebUI 0.11.4's bundled Kokoro.js validator: the enum is
Kokoro's fixed 29-voice pack, not frank-tts's registry. Frank-tts answers
200 audio/mpeg for the exact OpenWebUI body from inside the container, and
the failing click leaves no `audio/speech` line in the container log — that
contrast is the instrument. Admin config DB is correct
(engine=openai, base URL :7772/v1, model frank-tts, voice narrator1); no
container env and no user row carries an audio override, so the
`browser-kokoro` choice is per-browser storage. Fix: Settings → Audio →
TTS Engine → Default.
2026-10-18 20:15:28 -07:00
dank 5fbbea2c2a Merge pull request 'docs(frank-tts): the contract table gains its Default voice row' (#19) from docs/frank-tts-voice-lore into main 2026-10-18 19:33:06 -07:00
Frank 96b7687b34 docs(frank-tts): the contract table gains its Default voice row
#18 shipped DEFAULT_VOICE and #17 explained why the harness cannot name a voice from a setting, but the endpoint page still described voice resolution as if every request carried a name. One contract row, one paragraph in Naming a voice (a call that names nothing is the harness default shape, not a client bug), the env-file row now lists the default voice among its knobs, and the worklog records the rollout: both merges, the one-dispatch deploy with the 33/33 gate, the card flipping to up on the next checker tick, and the previously failing call returning 430124 bytes with the journal reading "speech voice=narrator1 (default)".
2026-10-18 19:32:03 -07:00
dank 7889a68497 Merge pull request 'fix(frank-tts): a voice-less request now speaks the declared default instead of 400ing' (#18) from fix/frank-tts-default-voice into main 2026-10-18 19:26:08 -07:00
dank b590695e2a Merge pull request 'docs(frank-tts): the voice stack is deployed — fix the six pages and the card that still deny it' (#17) from docs/frank-tts-live into main 2026-10-18 19:25:38 -07:00
Frank b91873578b fix(frank-tts): answer a voice-less request with the declared default
The omp speech device forwards a voice only when the call passes voice_id - there is no setting that feeds one (speech.voice is the TUI speak-aloud Kokoro enum, tts.localVoice belongs to the local backend) - so every harness voiceover arrives with no voice key and the shim answered 400 "unknown voice None". Measured on the deployed endpoint 2026-10-18, from a restarted session with the speech role pointed here.

DEFAULT_VOICE makes the absent case a spoken operator choice instead of a code surprise: the pack named there answers a voice-less request and the journal line is marked (default), so an unrequested voice never reads later like a requested one. An UNKNOWN name still 400s with the list that exists - the no-silent-fallback law that this endpoint was built on is untouched, and the strict shape stays the code default (empty DEFAULT_VOICE), which the second new seam test pins. Ships narrator1 in scripts/frank-tts.env.
2026-10-18 19:21:28 -07:00
Frank 0d70f115d1 docs(frank-tts): the harness sends a voice only in voice_id
The section this PR adds claimed speech.voice was the forwarded voice key. It is not: the openai-speech transport builds {model, input, response_format, voice?} and sets voice only when the CALL passed voice_id, tts.localVoice is the local Kokoro backend key, and speech.voice is the TUI speak-aloud voice whose enum is Kokoro-only. A restarted session proved it the honest way - the tts device arrived at 7772 with no voice key and got "unknown voice None; available voices: narrator1, narrator2, narrator3" - so the fresh-process success earlier that day was the run that was told to pass voice_id, not evidence of a setting.

The law now reads: voice comes from voice_id alone, so a voice-less call is the harness default shape (the endpoint grew DEFAULT_VOICE in its own PR to answer it), and speech.voice must stay a Kokoro name - a pack name there breaks the vocalizer and does nothing for synthesis. config.yml reverted to am_fenrir on the operator box, and the worklog entry that recorded narrator1 as wiring carries the correction.
2026-10-18 19:21:22 -07:00
6 changed files with 186 additions and 21 deletions

No files matched your search

+17 -6
View File
@@ -7,7 +7,10 @@ Consumers (the omp harness, OpenWebUI, open-notebook) speak the OpenAI
directory under ``$VOICES_DIR`` and ``reference.wav`` inside it is what makes
the name real. A name that has no reference is not a voice — the shim answers
400 with the list that is, instead of silently narrating in a fallback voice
(silent fallback is how a wrong-voice endpoint stays wrong for weeks).
(silent fallback is how a wrong-voice endpoint stays wrong for weeks). A request
that names NO voice at all takes ``$DEFAULT_VOICE``: that is a spoken choice in
the env file rather than a surprise in the code, and the journal marks it
``(default)`` so a name nobody asked for is never mistaken for one that was.
Two backends sit behind the one surface, chosen by ``TTS_BACKEND``:
@@ -70,7 +73,7 @@ class Config:
def __init__(self, voices_dir, backend, upstream, upstream_model, llama_bin,
llama_model, llama_mmproj, llama_lang, llama_threads, port,
max_chars, timeout):
max_chars, timeout, default_voice=""):
self.voices_dir = Path(voices_dir)
self.backend = backend
self.upstream = upstream
@@ -83,6 +86,7 @@ class Config:
self.port = port
self.max_chars = max_chars
self.timeout = timeout
self.default_voice = default_voice
@classmethod
def from_env(cls, environ=None):
@@ -100,6 +104,7 @@ class Config:
port=_int(env, "SHIM_PORT", 7772),
max_chars=_int(env, "SHIM_MAX_CHARS", 15000),
timeout=float(_int(env, "SHIM_TIMEOUT", 600)),
default_voice=(env.get("DEFAULT_VOICE") or "").strip(),
)
@@ -271,8 +276,13 @@ class SpeechHandler(BaseHTTPRequestHandler):
"endpoint serves wav and mp3" % fmt))
return
# `voice_id` is the alias the omp harness forwards for `voice`.
voice = body.get("voice") or body.get("voice_id")
# `voice_id` is the alias the omp harness forwards for `voice`. A request
# that names nothing takes the declared default — the harness has no
# setting that feeds a voice, so voice-less calls are the normal shape
# there, not a client bug.
requested = body.get("voice") or body.get("voice_id")
defaulted = not requested and bool(cfg.default_voice)
voice = requested or cfg.default_voice
reference = resolve_voice(cfg.voices_dir, voice)
if reference is None:
available = list_voices(cfg.voices_dir)
@@ -297,8 +307,9 @@ class SpeechHandler(BaseHTTPRequestHandler):
content_type = MP3_CONTENT_TYPE if fmt == "mp3" else WAV_CONTENT_TYPE
self._bytes(200, audio, content_type)
self.log_message("speech voice=%s format=%s chars=%d -> %d bytes %s",
voice, fmt, len(text), len(audio), content_type)
self.log_message("speech voice=%s%s format=%s chars=%d -> %d bytes %s",
voice, " (default)" if defaulted else "", fmt,
len(text), len(audio), content_type)
# -- backends ----------------------------------------------------------
def _speech_http(self, cfg, model, text, voice, reference):
+10
View File
@@ -57,6 +57,16 @@ TTS_BACKEND=omni-http
TTS_UPSTREAM=http://frank-tts-engine:8880
TTS_UPSTREAM_MODEL=voxcpm2
# THE DEFAULT VOICE — what a request that names NO voice speaks. The omp harness
# has no setting that feeds a voice to its speech device (`voice_id` is a
# per-call argument; `speech.voice` belongs to the TUI's Kokoro speak-aloud and
# never reaches an HTTP request), so a harness voiceover arrives voice-less and
# would otherwise be a 400 for every caller that does not name one. An UNKNOWN
# name still 400s with the list that exists — this covers the absent case only —
# and the journal marks such a line `(default)` so an unrequested voice never
# reads as a requested one. Any pack directory in /home/dank/voices is legal.
DEFAULT_VOICE=narrator1
# CLI-backend knobs, inert while TTS_BACKEND=omni-http. Empty means "not
# configured": the endpoint answers a 500 that says so instead of running
# `nice env -m ...` with an empty binary name.
+23 -12
View File
@@ -35,6 +35,7 @@ render needs the app installed, not this endpoint.
| Networks | `dankclaw_default` first (it owns the default route and the consumers), then `frank-tts-engine.network` (10.89.5.0/24, the bridge to the engine) |
| Voice packs | `/home/dank/voices` mounted read-only at `/voices` — host state, never repo state |
| Upstream | `http://frank-tts-engine:8880` (`scripts/frank-tts.env`, `TTS_UPSTREAM`) |
| Default voice | `DEFAULT_VOICE` (`scripts/frank-tts.env`) answers a request that names none; the journal marks that line `(default)`, and an **unknown** name is still a 400 |
| Rollback | `TTS_BACKEND=llama-tts-cli` runs a CLI binary per request and needs no upstream at all |
## Naming a voice
@@ -42,7 +43,9 @@ render needs the app installed, not this endpoint.
A voice **is** a directory name. `voice=narrator1` resolves to
`/voices/narrator1/reference.wav`, and that file — not a manifest, not a config
entry — is what makes the name real. omp's TTS device may send `voice_id`; the
shim accepts either.
shim accepts either. A call that sends **neither** — the harness's normal shape,
because no harness setting carries a voice for this transport — speaks
`$DEFAULT_VOICE`; empty default makes it a 400 again.
The usable shape is `^[a-z0-9][a-z0-9._-]*$`: lowercase, starting with a letter
or digit, then letters, digits, `.`, `_`, `-`, at most 64 characters. No slash,
@@ -114,7 +117,7 @@ shows up as a failed synthesis, never as a down probe.
| `dankclaw/voiceservice/Containerfile` | python 3.12-alpine pinned by digest + ffmpeg, uid 10000 |
| `dankclaw/voiceservice/quadlet/frank-tts.container` | the unit: two networks, `Restart=always`, `HealthCmd`, `[Install]` |
| `dankclaw/voiceservice/quadlet/frank-tts-engine.network` | the shared bridge, named after the engine unit and attached by both |
| `scripts/frank-tts.env` | backend, upstream, port, limits — paths and numbers, no credentials |
| `scripts/frank-tts.env` | backend, upstream, port, limits, and the default voice — paths and numbers, no credentials |
| `tests/frank_tts_unit.bats` | the shim against stub engines over real sockets |
| `tests/frank_tts_engine.bats` | the pair's wiring: generator output, planner, homepage, deployer |
@@ -154,17 +157,24 @@ The operator harness reaches this endpoint through
`baseUrl: http://192.168.0.143:7772/v1`, `auth: none`, and the runner api
`openai-speech` on the **model** row (naming a runner api at *provider* level
fails the whole file's validation and disables every custom provider, which is
how the fleet's chat endpoints disappear) — plus `config.yml`:
`modelRoles.speech: frank-tts (dankclaw)/frank-tts` and `speech.voice:
narrator1`. Three laws, read out of the harness bundle and measured the day of
the flip:
how the fleet's chat endpoints disappear) — plus `config.yml`
`modelRoles.speech: frank-tts (dankclaw)/frank-tts`. Four laws, read out of the
harness bundle and measured the day of the flip:
- **The forwarded voice key is `speech.voice`, not `tts.localVoice`.** The
latter belongs to the local Kokoro backend and still carries `bm_george`; it
is what answered when the role pointed at `local/kokoro`. `speech.voice`
becomes the request's `voice`, and this endpoint treats an unknown name as an
error rather than a default — so a Kokoro-style revert has to move **both**
keys together.
- **The only voice the harness can send is the `voice_id` argument of the call.**
The transport builds `{model, input, response_format, voice?}` and adds `voice`
**only** when the caller passed `voice_id`; no setting feeds it.
`tts.localVoice` is the *local Kokoro backend's* voice (it is what answered
`voice=kokoro/bm_george, backend=local` in the control test), and
`speech.voice` is the TUI's **speak-aloud** voice — its enum is Kokoro's list,
and it never reaches an HTTP request. So a harness call arrives voice-less by
default, which is exactly why the endpoint grew `DEFAULT_VOICE`: the declared
pack answers a voice-less request and the journal marks that line `(default)`,
while an **unknown** name still 400s with the list that exists.
- **`speech.voice` must stay a Kokoro name.** Setting it to a pack name does not
select a pack; it hands the vocalizer a value outside its enum. (The first
draft of this section said the opposite and was reverted the same day, after a
fresh session's call came back `unknown voice None`.)
- **There is no silent fallback.** An explicitly configured `modelRoles.speech`
yields exactly one candidate, so a dead 7772 fails the speech device with
*"No available speech model matches the speech role"* instead of quietly
@@ -191,3 +201,4 @@ the flip:
| Endpoint answers, synthesis fails | the engine: `podman healthcheck run frank-tts-engine`, then `systemctl --user status frank-tts-engine.service` |
| First request of the day is ~0.6 s slower than the rest — or every name returns 400 | the warm pass did not run; it carries `-`, so it cannot fail the unit. `journalctl --user -u frank-tts-engine` for the warmer's lines, then `podman exec frank-tts-engine /app/warm-voices.sh` — the registry is memory-only (law 1 of [[Frank TTS Engine]]) |
| The unit never appears after a deploy | the generator dropped it: `frank-tts-engine.network` is missing from `~/.config/containers/systemd/` |
| A consumer rejects `narrator1` by listing 29 names (`af_heart … bm_fable`) and no request ever reaches this endpoint | that consumer is synthesizing **in the browser** with Kokoro.js, whose voice pack is fixed — OpenWebUI 0.11.4 ships it as the `browser-kokoro` TTS engine; see [[OpenWebUI]] |
@@ -292,6 +292,42 @@ carries base64 image data or a URL.
Failure modes: connection refused ⇒ OpenWebUI or ComfyUI is down; timeouts
on large sizes ⇒ check ComfyUI resources; 401 ⇒ the token is bad.
## Text-to-speech (frank-tts)
TTS is the fleet's one OpenAI-compatible speech endpoint —
`http://host.containers.internal:7772/v1` (`audio.tts.openai.api_base_url`
in the config DB, 2026-10-18), model `frank-tts`, voice `narrator1`. That
port is the rootless quadlet `frank-tts` (this repo:
`dankclaw/voiceservice/quadlet/frank-tts.container`,
`scripts/frank-tts.env`), and its shim resolves `voice=<name>` against
`/home/dank/voices/<name>/reference.wav` — a name without a reference is a
400 listing the voices that exist, never a silent fallback.
**The 29-voice `af_* … bm_fable` list is NOT frank-tts.** That enum belongs
to **Kokoro.js running in the browser**: the deployed front end (0.11.4)
ships an in-page Kokoro implementation (chunk carrying a frozen voice table
`{af_heart:{name:"Heart",…}}` and `_validate_voice`, model
`onnx-community/Kokoro-82M-v1.0-ONNX`, plus a personal *Kokoro.js dtype*
setting). Its engine select takes `""` (Default) or `browser-kokoro`; when
`browser-kokoro` is chosen the page synthesizes locally and rejects any
voice outside its fixed pack — so `narrator1` fails with
`Voice: "narrator1" not found. Should be one of: af_heart, …` **without a
single request reaching the server**. Personal audio settings are per
browser: no `user.settings` row and no container env carries an audio
override, yet the toast still appears ⇒ the choice lives in that browser's
own storage. Fix in the UI: Settings → Audio → TTS Engine → **Default**,
which hands synthesis to the admin config (frank-tts); picking `af_heart`
instead keeps Kokoro, and Kokoro cannot clone — fixed pack only.
Verified 2026-10-18 from inside the container with the exact OpenWebUI body
(`{"model":"frank-tts","input":…,"voice":"narrator1",
"response_format":"mp3","speed":1.0}`) → **200, `audio/mpeg`, 7,629 B**,
while `podman logs open-webui` recorded **zero** `audio/speech` requests for
the failing click — that contrast is the instrument: a server-path failure
leaves a log line and a frank-tts error, a `browser-kokoro` failure leaves
neither.
## Retired instances
- The compose `open-webui` service (port 3000) was never run: 3000 is Gitea's HTTP port, so it could not bind.
@@ -349,6 +385,7 @@ on large sizes ⇒ check ComfyUI resources; 401 ⇒ the token is bad.
## See Also
- [[Dankclaw Stack]]
- [[Frank TTS]] — the speech endpoint behind *Text-to-speech (frank-tts)*
- [[Hermes]] — the agent OpenWebUI can talk to
- [[SearXNG]] — the search engine on this host
- [LLM knowledge base](../LLM/index.md) — the vLLM and llama.cpp endpoints
+68 -3
View File
@@ -2706,9 +2706,10 @@ every restart since the cutover.
actually selects a voice. Engine-side ledger for the harness request:
`TTFA 67.5 ms`, `Total 2158.8 ms`, `audio 3.76 s, RTF 0.574`.
- **The harness `speech` role moved off Kokoro**: `config.yml`
`modelRoles.speech: frank-tts (dankclaw)/frank-tts` and `speech.voice:
narrator1`. `tts.localVoice` stays `bm_george` — that key belongs to the local
backend, which is what answered in the control test.
`modelRoles.speech: frank-tts (dankclaw)/frank-tts`. No voice key moves with it.
The bundle is unambiguous: the speech device forwards the call's `voice_id` and
nothing else, `tts.localVoice` belongs to the local backend (it is what answered
in the control test), and `speech.voice` is the TUI's speak-aloud enum.
- Two harness laws learned by breaking it first (both now on [[Frank TTS]]): an
explicit speech role has **no fallback candidate**, so a dead endpoint errors
instead of degrading; and `models.yml` loads at process start while
@@ -2722,3 +2723,67 @@ every restart since the cutover.
voice leg works; open-notebook itself is what is missing), the engine page's
"Deploy order — nothing below has been run" is now a **Deploy record**, and the
homepage card lost `wip: down` so the admin panel probes 7772 for real.
- Corrected the same evening, by breaking it in a restarted session: a harness
call that names no voice reaches 7772 with **no `voice` key at all** — `400
unknown voice None; available voices: narrator1, narrator2, narrator3` —
because no setting feeds one. The earlier `Saved 180524 bytes
(voice=narrator1)` succeeded only because that run was *told* to pass
`voice_id`. The endpoint therefore grew `DEFAULT_VOICE` (its own PR): a
voice-less request speaks the declared pack and the journal line carries
`(default)`, while an unknown name still 400s with the list. Reverting the role
to Kokoro is one key, not two, and `speech.voice: narrator1` — briefly recorded
above as wiring — went back to `am_fenrir`: that key is the vocalizer's Kokoro
enum, so a pack name there breaks speak-aloud and does nothing for synthesis.
## 2026-10-18 — the default voice shipped: a harness voiceover needs no voice
- Merged **#17** (`b590695`) and **#18** (`7889a68`), then deployed both affected
services in one dispatch — `--service frank-tts --service homepage`, 159 s,
Playwright gate 33/33. The shim image rebuilt with the new handler; the
homepage checker took the card on its next timer tick: `frank-tts → up`,
probed for real rather than gated.
- The call that had failed is the one that proves the fix. Same device, same
restarted session, **no** `voice_id`: `Saved 430124 bytes (model=frank-tts
(dankclaw)/frank-tts, voice=default, codec=wav)`. The façade journal says what
actually spoke — `speech voice=narrator1 (default) format=wav chars=148 ->
430124 bytes` — with the engine's ledger behind it (`audio 8.96 s, RTF 0.551`).
The harness prints `voice=default` because it never had a name to print; the
voice that answered is the operator's line in `scripts/frank-tts.env`, and the
journal keeps them distinguishable forever after.
- The page gained a **Default voice** row in its contract table and
[[#Naming a voice]] now states that a call naming nothing is the harness's
normal shape, not a client bug — the misconception that produced the 400 in
the first place.
## 2026-10-18 — OpenWebUI "voice narrator1 not found": the rejector is in the browser
- Frank's toast — `Error: Voice "narrator1" not found. Should be one of:
af_heart, …, bm_fable` — looked like frank-tts refusing the name. It is
not: that 29-name enum is the voice pack of **Kokoro.js running in the
page**, and OpenWebUI 0.11.4 ships that implementation in its own front
end (frozen table `{af_heart:{name:"Heart",…}}`, `_validate_voice`, model
`onnx-community/Kokoro-82M-v1.0-ONNX`, a personal *Kokoro.js dtype*
setting, engine values `""`/`browser-kokoro`).
- Instruments that separated the two paths, in order: the admin config DB is
correct (`audio.tts.engine=openai`,
`audio.tts.openai.api_base_url=http://host.containers.internal:7772/v1`,
`model=frank-tts`, `voice=narrator1`, `split_on=punctuation`); from inside
the container the exact OpenWebUI body
(`{"model":"frank-tts","input":…,"voice":"narrator1","response_format":"mp3","speed":1.0}`)
returns **200 `audio/mpeg` 7,629 B**; and `podman logs open-webui` holds
**zero** `audio/speech` requests for the failing click. A server-path
failure leaves a log line and a frank-tts error — a `browser-kokoro`
failure leaves neither. No container env carries an `AUDIO_*`/`TTS`
variable and no `user.settings` row (all four users) carries an audio
override, so the engine choice is per-browser storage, not fleet state.
- Fix is a UI click, not a deploy: Settings → Audio → TTS Engine →
**Default** hands synthesis back to frank-tts and the cloned voices;
choosing `af_heart` keeps in-browser Kokoro, which cannot clone (fixed
pack). Recorded on [[OpenWebUI]] under *Text-to-speech (frank-tts)*;
suite green 384/384 (wall 65.9 s).
- Also in the window: **PR #22 merged** (`0c7fe8b`, `dank/command-center`) so
`agent/extensions/dankvoice.ts` landed on the auto-load path — proven in a
plain `omp` session with no flags: `/say merged build check` → 1.20 s WAV
to the player, temp dir cleaned; the audible pass through the default
`pw-play` detection confirmed by Frank. Sessions open before the merge need
a restart to pick the command up.
+31
View File
@@ -183,6 +183,37 @@ assert 'voice_id' not in body, body
PY
}
@test "a voice-less request speaks the declared default and marks the log" {
# The omp harness has no setting that feeds a voice to its speech device, so
# a voice-less call is its NORMAL shape rather than a client bug. With the
# operator's default armed the call must produce that pack's audio, and the
# journal line must say (default) — otherwise an unrequested voice reads,
# weeks later, exactly like one somebody asked for.
arm_omni
export DEFAULT_VOICE=narrator1
start_app
speech '{"model":"tts-1","input":"hello"}'
[ "$output" = "200" ] || { cat "$TEST_TMP/app.log"; false; }
cmp "$TEST_TMP/out.bin" "$TEST_TMP/upstream.wav"
python3 - "$TEST_TMP/capture.json" <<'PY'
import json, sys
body = json.load(open(sys.argv[1]))
assert body['voice'] == 'narrator1', 'the declared default never reached the engine'
PY
grep -q 'speech voice=narrator1 (default) format=wav' "$TEST_TMP/app.log" \
|| { cat "$TEST_TMP/app.log"; false; }
}
@test "with no default declared a voice-less request stays a 400" {
# Strictness is still the code default: an endpoint that invents a voice
# nobody configured is exactly how a wrong-voice deployment stays wrong.
arm_omni
start_app
speech '{"model":"tts-1","input":"hello"}'
[ "$output" = "400" ] || { cat "$TEST_TMP/app.log"; false; }
grep -q 'narrator1' "$TEST_TMP/out.bin"
}
@test "an unknown voice is a 400 naming the voices that exist" {
arm_omni
start_app