Compare commits

..
Author SHA1 Message Date
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
5 changed files with 115 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.
+22 -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
+35 -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,34 @@ 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.
+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