Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
96b7687b34 | ||
|
|
7889a68497 | ||
|
|
b590695e2a | ||
|
|
b91873578b | ||
|
|
0d70f115d1 |
No files matched your search
@@ -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):
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,34 @@ 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.
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user