feat(frank-tts-engine): Qwen-TTS as a boot-persistent endpoint, plus the three silent faults auditing it found #14

Merged
dank merged 10 commits from feat/frank-tts-engine into main 2026-10-18 18:30:22 -07:00
Showing only changes of commit 3380b7da10 - Show all commits
+54
View File
@@ -2589,3 +2589,57 @@ every restart since the cutover.
three sources still live on the workstation and the engine GGUFs have not been
fetched to `/home/dank/models/Qwen3-TTS-GGUF` yet, so the first real run is a
host step.
## 2026-10-18 — voice stack audit: three faults that no gate could see
- The voice stack had never been run end to end — no image built, nothing on
7772 — so the whole path was audited as static code against the tools it
calls, and three faults came out of it. All three are the same species: a
step that **reports success while doing nothing**.
- **The build would not have happened.** `Containerfile.engine` created its
unprivileged user with `adduser`; the pinned ubuntu:24.04 base has no
`adduser` at all. Proven in the base on Dankclaw rather than argued from
memory: `command -v adduser` prints nothing, and the replacement line
`useradd --system --uid 10000 --home-dir /app --shell /usr/sbin/nologin tts`
answers `uid=10000(tts) gid=999(tts)`. Its `SYS_UID_MAX` warning is cosmetic
and is the price of matching the façade's uid, so the comment says that
before someone "fixes" it by dropping `--system`.
- **The warm pass would not have warmed.** The unit hands its env file to podman
via `--env-file`, which performs no shell parsing and takes the literal
remainder of the line, while `source` strips one quoting layer. `WARM_TEXT`
therefore reached the container with its quotes, the hook interpolated them
into the synthesis body, the JSON was invalid, and every voice would log
"registered, but the warm synthesis failed" beside an `active` unit. Fixed in
the hook — strip exactly one matched pair, only when the inner content also
carries a quote or backslash, then JSON-escape — and the seam now reads
`scripts/frank-tts-engine.env` **both** ways and asserts the engine gets the
same sentence in both shapes. The bug survived the first deploy precisely
because every existing test used `source`.
- **A rebuilt image would not have been rebuilt.** `build_compose_image` skips
`podman build` on a matching `cfhash` label, and that hash covered the
`Containerfile` alone — so editing `app.py` or `tts-engine-warm.sh` (both
`COPY`'d) skipped the build, the deploy reported success, `/health` answered,
and the container served old code. Each case arm now names the files its
`Containerfile` copies and hashes them with it; comfyui/loki/alloy have no
extras, so their labels are byte-unchanged and they do not rebuild here.
- Tests as behaviour, not as greps: the new seam extracts the real
`build_compose_image` from the deployer and drives it against a stub `podman`
that replays per-image label state, asserting the observable question — did
the deploy ask podman to build, or did it skip? — through build, skip,
engine-hook-edit-rebuilds-engine-only, and `app.py`-rebuilds-the-façade.
Reverting `scripts/deploy-remote.sh` turns it red at the warm-hook step, which
is the fault being prevented. Suite green: **443/443** (`./tests/run.sh`,
wall 110 s); each fix commit was left green on its own so the branch bisects.
- Docs carry the lessons where an operator will hit them: a **Locally built
images** section in [[deployment]], two new troubleshooting lines in [[Frank
TTS Engine]] (all-voices-cold = quoted `WARM_TEXT`; façade 502 beside engine
200 = model loaded ≠ voices registered), and `Voice Packs.md` step 4
corrected — the warm hook re-registers **every** pack on every run, because
the engine replaces a name it already holds (`tools/tts-server.cpp:148` at
the pinned commit: *"Re-registering a name replaces the previous entry."*),
so a restart is what an engine bump needs and not what a rebuilt pack needs.
- Opened as PR **#14** against `main`. Deliberately NOT done: no deploy (the
first `--service frank-tts-engine` run is a ~10-minute recursive source clone
and a new unit on a host that serves people — Frank's call), no voice pack
built on the host, no Playwright run (this branch publishes no host port and
touches no vhost), and Gitea **#2** phase 1 stays open until 7772 answers.