Compare commits

..
Author SHA1 Message Date
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
10 changed files with 85 additions and 135 deletions
+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.
@@ -1,6 +1,6 @@
--- ---
type: reference type: reference
description: "frank-tts-engine is the arm A TTS engine on Dankclaw: qwentts.cpp's resident tts-server with the 1.7 B base talker, on a private podman bridge behind frank-tts — deployed 2026-10-18, and it publishes no port on purpose." description: "frank-tts-engine is the arm A TTS engine on Dankclaw: qwentts.cpp's resident tts-server with the 1.7 B base talker, on a private podman bridge behind frank-tts — repo-owned, not yet deployed."
tags: tags:
- dankclaw - dankclaw
- tts - tts
@@ -14,7 +14,7 @@ timestamp: 2026-10-18
`frank-tts-engine` is the **arm A text-to-speech engine** on Dankclaw: [qwentts.cpp](https://github.com/ServeurpersoCom/qwentts.cpp)'s resident `tts-server` HTTP process, holding the Qwen3-TTS **1.7 B base talker (Q4_K_M)** and the **12 Hz tokenizer (Q8_0)** in RAM once and synthesizing from there. It listens on container port **8880** and is **deliberately not a fleet endpoint** — the endpoint consumers address is `frank-tts` on **7772**, which resolves voice names, re-encodes to mp3 and owns the timeout budget. This page is the engine runbook; the endpoint's contract lives with its own page. `frank-tts-engine` is the **arm A text-to-speech engine** on Dankclaw: [qwentts.cpp](https://github.com/ServeurpersoCom/qwentts.cpp)'s resident `tts-server` HTTP process, holding the Qwen3-TTS **1.7 B base talker (Q4_K_M)** and the **12 Hz tokenizer (Q8_0)** in RAM once and synthesizing from there. It listens on container port **8880** and is **deliberately not a fleet endpoint** — the endpoint consumers address is `frank-tts` on **7772**, which resolves voice names, re-encodes to mp3 and owns the timeout budget. This page is the engine runbook; the endpoint's contract lives with its own page.
**Deployed 2026-10-18.** The chain ran in the order at the bottom of this page — fetch (1.4 GiB, byte + LFS oid verified, 18 s), image build against the pinned commit, unit + network install, container-health gate, warm pass — and the live state still reads the way this page describes it: unit `active` with no drop-ins, container healthy 31 s after the build, **4.0 GiB** resident on a host that kept 17 GiB available, `3 voice(s) registered and warm` in the journal, and no published port anywhere. Two things the first real boot taught: the warm hook's registration body must reach curl through a **file**, not an argument (Troubleshooting), and `STEP 3/13 RUN useradd` is the first honest proof of the image's non-root user. **Nothing here is deployed.** This page documents repo-owned configuration — the Quadlet unit and network, the image build, the weight fetcher, the boot warmer, the deployer path — and the measured facts those files were written against. No container, image, weight file or network exists on Dankclaw yet, and the engine publishes no port at all, so there is nothing for the homepage checker or a browser to reach. Deploying it is a deliberate next step, in the order at the bottom of this page.
**Host:** Dankclaw (`192.168.0.143`), rootless Podman, user `dank` — **Port:** 8880, **never published** — **Network:** the private `systemd-frank-tts-engine` bridge (10.89.5.0/24) and no other — **Deployer:** `frank-tts-engine` — **Consumers:** `frank-tts` (7772) and, through it, every voice request in the fleet including [[Open Notebook]]'s podcast renders. **Host:** Dankclaw (`192.168.0.143`), rootless Podman, user `dank` — **Port:** 8880, **never published** — **Network:** the private `systemd-frank-tts-engine` bridge (10.89.5.0/24) and no other — **Deployer:** `frank-tts-engine` — **Consumers:** `frank-tts` (7772) and, through it, every voice request in the fleet including [[Open Notebook]]'s podcast renders.
@@ -96,27 +96,25 @@ The **façade** on 7772 is the pair's public surface and carries the probe. Note
|---|---| |---|---|
| `dankclaw/voiceservice/Containerfile.engine` | two-stage build of the pinned commit, proven cmake line, digest-pinned base, ships both scripts | | `dankclaw/voiceservice/Containerfile.engine` | two-stage build of the pinned commit, proven cmake line, digest-pinned base, ships both scripts |
| `dankclaw/voiceservice/tts-engine-start.sh` | image entrypoint (`/app/start.sh`): env → flags, refuses to boot without weights | | `dankclaw/voiceservice/tts-engine-start.sh` | image entrypoint (`/app/start.sh`): env → flags, refuses to boot without weights |
| `dankclaw/voiceservice/tts-engine-warm.sh` | boot warmer (`/app/warm-voices.sh`): register from `reference.wav` (body via a temp file), speak a fixed sentence twice, report cold/warm | | `dankclaw/voiceservice/tts-engine-warm.sh` | boot warmer (`/app/warm-voices.sh`): register from `reference.wav`, speak one sentence, report cold/warm |
| `dankclaw/voiceservice/quadlet/frank-tts-engine.container` | the unit: bridge only, no publish, env file, two read-only mounts, health probe, warm hook, `Restart=always` | | `dankclaw/voiceservice/quadlet/frank-tts-engine.container` | the unit: bridge only, no publish, env file, two read-only mounts, health probe, warm hook, `Restart=always` |
| `dankclaw/voiceservice/quadlet/frank-tts-engine.network` | the private 10.89.5.0/24 bridge, with the subnet registry | | `dankclaw/voiceservice/quadlet/frank-tts-engine.network` | the private 10.89.5.0/24 bridge, with the subnet registry |
| `scripts/frank-tts-engine.env` | paths and numbers only, with the thread law and the registry law in its header | | `scripts/frank-tts-engine.env` | paths and numbers only, with the thread law and the cold-voice law in its header |
| `scripts/download-frank-tts-engine-qwentts.sh` | weight fetcher: no resume, byte count + LFS oid check, `--verify-only` | | `scripts/download-frank-tts-engine-qwentts.sh` | weight fetcher: no resume, byte count + LFS oid check, `--verify-only` |
| `scripts/deploy-remote.sh` → `deploy_frank_tts_engine` | weights gate → build → quadlet → restart → container health → warm | | `scripts/deploy-remote.sh` → `deploy_frank_tts_engine` | weights gate → build → quadlet → restart → container health → warm |
| `tests/frank_tts_engine.bats` | the seam: generator output, entrypoint argv, warmer against a stub engine, planner and routing maps | | `tests/frank_tts_engine.bats` | the seam: generator output, entrypoint argv, warmer against a stub engine, planner and routing maps |
## Deploy record (host steps, in the order they ran) ## Deploy order (host steps, in order)
Run 2026-10-18 as `./scripts/deploy.sh dankclaw --service frank-tts-engine`, then Nothing below has been run. Steps 1–5 are this service; 6–7 are what unblocks the podcast pipeline.
`--service frank-tts`. Steps 1–6 are this service and the façade; only step 7 is
still open.
1. **Fetch the weights** — `scripts/download-frank-tts-engine-qwentts.sh` (1.4 GiB, byte + sha256 verified, idempotent; `--verify-only` is what the deployer calls). 1. **Fetch the weights** — `scripts/download-frank-tts-engine-qwentts.sh` (1.4 GiB, byte + sha256 verified, idempotent; `--verify-only` is what the deployer calls).
2. **Deploy the engine** — `./scripts/deploy.sh dankclaw --service frank-tts-engine` (`--service` is the only way to name one service; `deploy-remote.sh` is the remote half and takes `<target> <git-revision> [service]...`, so never call it by hand with a bare service name). It builds the image (needs github.com once), installs the unit and the network file, gates on the container's health status, then warms. 2. **Deploy the engine** — `./scripts/deploy.sh dankclaw --service frank-tts-engine` (`--service` is the only way to name one service; `deploy-remote.sh` is the remote half and takes `<target> <git-revision> [service]...`, so never call it by hand with a bare service name). Builds the image (needs github.com once), installs the unit and the network file, restarts, gates on the container's health status, then warms.
3. **Confirm the endpoint is alive** — `podman exec frank-tts-engine curl -fsS http://127.0.0.1:8880/health` → `{"status":"ok"}`, and `systemctl --user status frank-tts-engine` showing the unit `active` with the warm pass behind it. 3. **Confirm the endpoint is alive** — `podman exec frank-tts-engine curl -fsS http://127.0.0.1:8880/health` → `{"status":"ok"}`, and `systemctl --user status frank-tts-engine` showing the unit `active` with the warm pass behind it.
4. **Confirm the voices are known** — the warmer's journal lines name every pack, and once the façade is up `curl http://127.0.0.1:7772/v1/audio/voices` is the authoritative list (the façade has exactly three routes: `/health`, `/v1/audio/voices`, `/v1/audio/speech`). 4. **Confirm the voices are known** — the warmer's journal lines must name every pack; once the façade is up, `curl http://127.0.0.1:7772/v1/audio/voices` is the authoritative list (the façade has exactly three routes: `/health`, `/v1/audio/voices`, `/v1/audio/speech`).
5. **Measure the ladder instead of trusting the trial** — two identical requests for one voice back to back. The design expected 6.2-8.0 s then 0.5 ms; the deployed container gave **1,883 ms then 1,226-1,283 ms**, so the hook buys ~0.6 s and stays only for the registry reason (law 1). The retired numbers must not be quoted. 5. **Prove the cold-voice law is paid at boot, not by a listener** — two identical synthesis requests for one voice, back to back, from inside the engine's namespace; the **second** must answer well under a second (`warm=` in the journal is already that number; the manual pair is the confirmation on the real artifact).
6. **Then the façade on 7772** (its own page), after the engine answered — that order is the whole point of step 6: publishing the façade in front of a dead engine would make every consumer fail at the first voice line. 6. Then the façade on **7772** (its own page): nothing may be published there until the engine answers, because open-notebook's render fails the moment the façade is up and the engine is not.
7. **Open:** [[Open Notebook]] is still not installed, so nothing renders a podcast yet; the voice leg it waits on now exists. 7. Nothing on 7772 exists yet — that is why open-notebook's podcast render cannot run. See [[Open Notebook]].
## Troubleshooting ## Troubleshooting
+7 -55
View File
@@ -1,6 +1,6 @@
--- ---
type: reference type: reference
description: "frank-tts is the fleet's cloned-voice speech endpoint on Dankclaw :7772 — the one voice surface consumers address, in front of frank-tts-engine; deployed 2026-10-18 and wired as the harness speech role." description: "frank-tts is the fleet's cloned-voice speech endpoint on Dankclaw :7772 — the one voice surface consumers address, in front of frank-tts-engine; repo-owned, not yet deployed."
tags: tags:
- dankclaw - dankclaw
- tts - tts
@@ -16,15 +16,10 @@ audio in that cloned voice. Everything else in the voice stack sits behind it
the engine ([[Frank TTS Engine]]) publishes no port on purpose, and consumers the engine ([[Frank TTS Engine]]) publishes no port on purpose, and consumers
never learn a path. never learn a path.
**Deployed 2026-10-18** — `./scripts/deploy.sh dankclaw --service frank-tts`, **Nothing here is deployed.** No host was touched while this was written: no
both units `active`, no drop-ins, and the homepage card's `wip` gate came off in image built, no container started, nothing listening on 7772. That is also why
the same change. Probes on the deployed endpoint: `/health` answers [[Open Notebook]] cannot render a podcast episode yet — its pipeline calls this
`{"status":"ok","backend":"omni-http","voices":["narrator1","narrator2","narrator3"]}`, endpoint.
and two different voices rendered the same sentence into **different** bytes
(24 kHz mono PCM16, 4.24 s, healthy RMS) — so name resolution is real, not a
pass-through of one default. The operator harness routes its `speech` role here;
see [[#The harness speech role]]. What still waits is [[Open Notebook]]: its
render needs the app installed, not this endpoint.
| | | | | |
| --- | --- | | --- | --- |
@@ -143,51 +138,8 @@ A `/health` probe job would therefore be structurally never-green, and because
`frank-tts` target would page on Signal every few minutes for an endpoint that `frank-tts` target would page on Signal every few minutes for an endpoint that
is fine — the frank-archon lesson pointed the other way. The engine cannot be is fine — the frank-archon lesson pointed the other way. The engine cannot be
scraped at all: it publishes nothing. Status lives in `systemctl --user status`, scraped at all: it publishes nothing. Status lives in `systemctl --user status`,
`podman healthcheck run`, the two unit journals, and the homepage card, which `podman healthcheck run`, the two unit journals, and the homepage card, which is
probes `127.0.0.1:7772` for real now — the `wip: down` gate came off with the gated `wip: down` until the deploy and probes `127.0.0.1:7772`.
2026-10-18 deploy.
## The harness speech role
The operator harness reaches this endpoint through
`~/.omp/agent/models.yml` — provider `frank-tts (dankclaw)`,
`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`. Four laws, read out of the
harness bundle and measured the day of the flip:
- **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
degrading to Kokoro. That is the price of `auth: none` (which makes a dead
endpoint read as available) and it is the wanted shape: a narration job should
fail loudly rather than ship a voice Frank never asked for.
- **A session older than the models.yml row cannot see it.** `models.yml` loads
at process start while `config.yml` is read live, so a long-lived session
carrying the flipped role fails exactly like a dead endpoint while a fresh
process succeeds in the same minute. Measured 2026-10-18: the open session
failed twice (provider/id form, then the bare id), `local/kokoro` succeeded in
that same session, and a fresh `omp -p --tools tts` returned `Saved 180524
bytes (model=frank-tts (dankclaw)/frank-tts, voice=narrator1, codec=wav)`
while the façade logged the matching `speech voice=narrator1 format=wav
chars=51 -> 180524 bytes` from the podman bridge. Rule the stale registry out
before blaming the service.
## Troubleshooting ## Troubleshooting
@@ -59,7 +59,7 @@ podman exec open-notebook curl -sS http://frank-tts:7772/health
podman exec open-notebook curl -sS http://host.containers.internal:7771/v1/models podman exec open-notebook curl -sS http://host.containers.internal:7771/v1/models
``` ```
**The blocker moved; it did not vanish.** frank-tts and frank-tts-engine are **deployed** (2026-10-18) and answer on 7772, so the voice leg works end to end — `podman exec open-notebook curl -sS http://frank-tts:7772/health` is the check to run the day the app exists. What still blocks a render is open-notebook itself: not installed on this host yet. When it is, the two provider rows above are the only wiring, and the seeded speaker profiles carrying OpenAI voice names are the first trap. **The blocker to state plainly:** frank-tts is built, named and tested but **not deployed** — nothing answers on 7772 (`ss -ltn`, Dankclaw, 2026-10-18; [[Local TTS and Voice Cloning]] records the same census). open-notebook deployed today writes scripts and fails at the first voice line. frank-tts ships first, or podcast generation waits.
**Why frank-zealot and not frank-dragoon:** podcast scriptwriting asks the model for a JSON-schema response (with a plain-JSON fallback). llama.cpp's OpenAI-compatible endpoint is a known-good target for that; frank-dragoon's halogen runtime has **unverified** structured-output behaviour, so it is the unproven alternative, not the fallback. `ESPERANTO_LLM_TIMEOUT=180` (the app's own default) covers an outline or transcript call on zealot comfortably; upstream's note is to keep it under 600 so a slow call fails with a clear error instead of a UI timeout. **Why frank-zealot and not frank-dragoon:** podcast scriptwriting asks the model for a JSON-schema response (with a plain-JSON fallback). llama.cpp's OpenAI-compatible endpoint is a known-good target for that; frank-dragoon's halogen runtime has **unverified** structured-output behaviour, so it is the unproven alternative, not the fallback. `ESPERANTO_LLM_TIMEOUT=180` (the app's own default) covers an outline or transcript call on zealot comfortably; upstream's note is to keep it under 600 so a slow call fails with a clear error instead of a UI timeout.
+2 -2
View File
@@ -80,8 +80,8 @@ macOS mini) as quadlets inside its podman machine — not on Dankserver.
- [[Seerr]] — unified *arr request manager (guard-chained; fronts FrankBook-Pro's stack) - [[Seerr]] — unified *arr request manager (guard-chained; fronts FrankBook-Pro's stack)
- [[Marreta]] — content-cleaning proxy (guard-chained; shares SearXNG's Gluetun egress) - [[Marreta]] — content-cleaning proxy (guard-chained; shares SearXNG's Gluetun egress)
- [[Open Notebook]] — podcast generator (scripts by frank-zealot, voices by frank-tts); repo-owned config, not deployed - [[Open Notebook]] — podcast generator (scripts by frank-zealot, voices by frank-tts); repo-owned config, not deployed
- [[Frank TTS Engine]] — the arm A TTS engine (`qwentts.cpp` resident `tts-server`, 1.7 B talker) behind frank-tts on a private bridge; deployed on Dankclaw, CPU-only, publishes no port by design - [[Frank TTS Engine]] — the arm A TTS engine (`qwentts.cpp` resident `tts-server`, 1.7 B talker) behind frank-tts on a private bridge; repo-owned config, not deployed
- [[Frank TTS]] — the 7772 cloned-voice endpoint consumers address: voice naming, pack layout, why the upstream is a container name; deployed, and it is the harness `speech` role - [[Frank TTS]] — the 7772 cloned-voice endpoint consumers address: voice naming, pack layout, why the upstream is a container name; repo-owned config, not deployed
- [[Voice Packs]] — how a frank-tts voice is built: reference.wav + the pinned engine's speaker artifacts, cut past the 10-second source-material gate - [[Voice Packs]] — how a frank-tts voice is built: reference.wav + the pinned engine's speaker artifacts, cut past the 10-second source-material gate
## Infrastructure ## Infrastructure
-44
View File
@@ -2690,47 +2690,3 @@ every restart since the cutover.
measured claim: about **0.6 s**, and the 400-for-every-name reason the pages measured claim: about **0.6 s**, and the 400-for-every-name reason the pages
should have led with. The trial entry above is left as written — a dated worklog should have led with. The trial entry above is left as written — a dated worklog
entry is a record — and this line is the correction that points at it. entry is a record — and this line is the correction that points at it.
## 2026-10-18 — 7772 is live and the harness speaks through it
- Merged **#15** (`0315a62`, the warmer's argument-cap fix) and **#16**
(`d2ce669`, the comment sweep), then deployed both voice services in order:
`--service frank-tts-engine` (image rebuilt from the fixed warmer, unit
`active`, zero drop-ins, warm pass in the unit's own start — `narrator1
cold=1542ms warm=1709ms`, `narrator2 1329/1405`, `narrator3 1451/1394`, `3
voice(s) registered and warm`) and `--service frank-tts`. The Playwright gate
ran at the end of each deploy: 33/33 twice.
- Deployed-state proof from a LAN client: `/health` → `omni-http` with all three
voices; the same sentence in `narrator1` and `narrator2` came back with
**different sha256** and different RMS (4.24 s, 24 kHz mono PCM16), so the name
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`. 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
`config.yml` is read live, so a session older than the `frank-tts` row fails
exactly like a dead endpoint. Fresh-process proof in the same minute:
`Saved 180524 bytes (model=frank-tts (dankclaw)/frank-tts, voice=narrator1)`
with the matching façade log line `speech voice=narrator1 format=wav chars=51
-> 180524 bytes`. This window's own session is that stale case and knows it.
- Status sweep, because six pages still said the endpoint did not exist: both
vault descriptions, the two `index.md` bullets, the Open Notebook blocker (the
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.
+2 -2
View File
@@ -541,12 +541,12 @@
"name": "frank-tts", "name": "frank-tts",
"category": "admin", "category": "admin",
"section": "AI", "section": "AI",
"description": "The fleet's cloned-voice speech endpoint: POST /v1/audio/speech with voice=<name>, audio back in that voice. Published on 7772; the engine behind it (frank-tts-engine) is unpublished on purpose. It is also the harness speech role.", "description": "The fleet's cloned-voice speech endpoint: POST /v1/audio/speech with voice=<name>, audio back in that voice. Public on 7772; the engine behind it (frank-tts-engine) is unpublished on purpose. Hidden until deployed.",
"url": "", "url": "",
"docs": "", "docs": "",
"probe": "127.0.0.1:7772", "probe": "127.0.0.1:7772",
"method": "http", "method": "http",
"wip": false, "wip": "down",
"showLink": false, "showLink": false,
"art": "/img/apps/mono-ft.svg" "art": "/img/apps/mono-ft.svg"
} }
+4 -12
View File
@@ -943,14 +943,10 @@ DRV
|| { echo "editing app.py must rebuild the façade image"; false; } || { echo "editing app.py must rebuild the façade image"; false; }
} }
@test "the voice stack's homepage state: one probed card for the pair, and no link" { @test "the voice stack's homepage state: one card for the pair, and it is hidden while undeployed" {
# The engine is not on the homepage and must not be: it publishes no port, so # The engine is not on the homepage and must not be: it publishes no port, so
# there is no address the checker could dial, and a card that can never be # there is no address the checker could dial, and a card that can never be
# green is worse than no card. The façade carries the pair's signal — and # green is worse than no card. The façade carries the pair's signal.
# since the 2026-10-18 deploy that signal is a real probe (`wip: false`), not
# a placeholder. It stays `showLink: false` and admin-only: an HTTP API has no
# page to open, and the checker counts ANY HTTP response as up, so the card is
# honest without a link and without a `/health` path in the probe.
python3 - "$REPO_ROOT" <<'PY' python3 - "$REPO_ROOT" <<'PY'
import json, os, sys import json, os, sys
root = sys.argv[1] root = sys.argv[1]
@@ -959,12 +955,8 @@ assert "frank-tts-engine" not in cards, \
"the engine publishes no port: a card for it could never be probed green" "the engine publishes no port: a card for it could never be probed green"
fts = cards["frank-tts"] fts = cards["frank-tts"]
assert fts["probe"] == "127.0.0.1:7772", fts["probe"] assert fts["probe"] == "127.0.0.1:7772", fts["probe"]
assert fts["wip"] is False, \ assert fts["wip"] == "down" and fts["showLink"] is False, \
"the endpoint is deployed; a wip card would hide its real state: " + repr(fts.get("wip")) "nothing listens on 7772 until the deploy, so a visible card reads as down forever"
assert fts["showLink"] is False and fts["url"] == "", \
"an API endpoint has no UI to link to — it is a status card, not a tile"
assert fts["category"] == "admin", \
"consumers dial this; it is not an app the user homepage should advertise"
assert os.path.exists(f"{root}/shared/homepage{fts['art']}"), f"missing art {fts['art']}" assert os.path.exists(f"{root}/shared/homepage{fts['art']}"), f"missing art {fts['art']}"
PY PY
} }
+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