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 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 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 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``: 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, def __init__(self, voices_dir, backend, upstream, upstream_model, llama_bin,
llama_model, llama_mmproj, llama_lang, llama_threads, port, llama_model, llama_mmproj, llama_lang, llama_threads, port,
max_chars, timeout): max_chars, timeout, default_voice=""):
self.voices_dir = Path(voices_dir) self.voices_dir = Path(voices_dir)
self.backend = backend self.backend = backend
self.upstream = upstream self.upstream = upstream
@@ -83,6 +86,7 @@ class Config:
self.port = port self.port = port
self.max_chars = max_chars self.max_chars = max_chars
self.timeout = timeout self.timeout = timeout
self.default_voice = default_voice
@classmethod @classmethod
def from_env(cls, environ=None): def from_env(cls, environ=None):
@@ -100,6 +104,7 @@ class Config:
port=_int(env, "SHIM_PORT", 7772), port=_int(env, "SHIM_PORT", 7772),
max_chars=_int(env, "SHIM_MAX_CHARS", 15000), max_chars=_int(env, "SHIM_MAX_CHARS", 15000),
timeout=float(_int(env, "SHIM_TIMEOUT", 600)), 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)) "endpoint serves wav and mp3" % fmt))
return return
# `voice_id` is the alias the omp harness forwards for `voice`. # `voice_id` is the alias the omp harness forwards for `voice`. A request
voice = body.get("voice") or body.get("voice_id") # 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) reference = resolve_voice(cfg.voices_dir, voice)
if reference is None: if reference is None:
available = list_voices(cfg.voices_dir) 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 content_type = MP3_CONTENT_TYPE if fmt == "mp3" else WAV_CONTENT_TYPE
self._bytes(200, audio, content_type) self._bytes(200, audio, content_type)
self.log_message("speech voice=%s format=%s chars=%d -> %d bytes %s", self.log_message("speech voice=%s%s format=%s chars=%d -> %d bytes %s",
voice, fmt, len(text), len(audio), content_type) voice, " (default)" if defaulted else "", fmt,
len(text), len(audio), content_type)
# -- backends ---------------------------------------------------------- # -- backends ----------------------------------------------------------
def _speech_http(self, cfg, model, text, voice, reference): 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=http://frank-tts-engine:8880
TTS_UPSTREAM_MODEL=voxcpm2 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 # CLI-backend knobs, inert while TTS_BACKEND=omni-http. Empty means "not
# configured": the endpoint answers a 500 that says so instead of running # configured": the endpoint answers a 500 that says so instead of running
# `nice env -m ...` with an empty binary name. # `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) | | 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 | | 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`) | | 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 | | Rollback | `TTS_BACKEND=llama-tts-cli` runs a CLI binary per request and needs no upstream at all |
## Naming a voice ## 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 A voice **is** a directory name. `voice=narrator1` resolves to
`/voices/narrator1/reference.wav`, and that file — not a manifest, not a config `/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 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 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, 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/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.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 | | `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_unit.bats` | the shim against stub engines over real sockets |
| `tests/frank_tts_engine.bats` | the pair's wiring: generator output, planner, homepage, deployer | | `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 `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 `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 fails the whole file's validation and disables every custom provider, which is
how the fleet's chat endpoints disappear) — plus `config.yml`: how the fleet's chat endpoints disappear) — plus `config.yml`
`modelRoles.speech: frank-tts (dankclaw)/frank-tts` and `speech.voice: `modelRoles.speech: frank-tts (dankclaw)/frank-tts`. Four laws, read out of the
narrator1`. Three laws, read out of the harness bundle and measured the day of harness bundle and measured the day of the flip:
the flip:
- **The forwarded voice key is `speech.voice`, not `tts.localVoice`.** The - **The only voice the harness can send is the `voice_id` argument of the call.**
latter belongs to the local Kokoro backend and still carries `bm_george`; it The transport builds `{model, input, response_format, voice?}` and adds `voice`
is what answered when the role pointed at `local/kokoro`. `speech.voice` **only** when the caller passed `voice_id`; no setting feeds it.
becomes the request's `voice`, and this endpoint treats an unknown name as an `tts.localVoice` is the *local Kokoro backend's* voice (it is what answered
error rather than a default — so a Kokoro-style revert has to move **both** `voice=kokoro/bm_george, backend=local` in the control test), and
keys together. `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` - **There is no silent fallback.** An explicitly configured `modelRoles.speech`
yields exactly one candidate, so a dead 7772 fails the speech device with yields exactly one candidate, so a dead 7772 fails the speech device with
*"No available speech model matches the speech role"* instead of quietly *"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` | | 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]]) | | 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/` | | 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 Failure modes: connection refused ⇒ OpenWebUI or ComfyUI is down; timeouts
on large sizes ⇒ check ComfyUI resources; 401 ⇒ the token is bad. 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 ## Retired instances
- The compose `open-webui` service (port 3000) was never run: 3000 is Gitea's HTTP port, so it could not bind. - 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 ## See Also
- [[Dankclaw Stack]] - [[Dankclaw Stack]]
- [[Frank TTS]] — the speech endpoint behind *Text-to-speech (frank-tts)*
- [[Hermes]] — the agent OpenWebUI can talk to - [[Hermes]] — the agent OpenWebUI can talk to
- [[SearXNG]] — the search engine on this host - [[SearXNG]] — the search engine on this host
- [LLM knowledge base](../LLM/index.md) — the vLLM and llama.cpp endpoints - [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: 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`. `TTFA 67.5 ms`, `Total 2158.8 ms`, `audio 3.76 s, RTF 0.574`.
- **The harness `speech` role moved off Kokoro**: `config.yml` - **The harness `speech` role moved off Kokoro**: `config.yml`
`modelRoles.speech: frank-tts (dankclaw)/frank-tts` and `speech.voice: `modelRoles.speech: frank-tts (dankclaw)/frank-tts`. No voice key moves with it.
narrator1`. `tts.localVoice` stays `bm_george` — that key belongs to the local The bundle is unambiguous: the speech device forwards the call's `voice_id` and
backend, which is what answered in the control test. 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 - 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 explicit speech role has **no fallback candidate**, so a dead endpoint errors
instead of degrading; and `models.yml` loads at process start while 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 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 "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. 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 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" { @test "an unknown voice is a 400 naming the voices that exist" {
arm_omni arm_omni
start_app start_app