Merge remote-tracking branch 'origin/main' into wt/20261017-181502

# Conflicts:
#	serverAppConfigs-docs/log.md
This commit is contained in:
Frank
2026-10-18 20:17:19 -07:00
37 changed files with 5170 additions and 54 deletions
+443
View File
@@ -2311,6 +2311,449 @@ every restart since the cutover.
Kokoro-enum-only — so "the agent talks in my voice" needs that provider row or
a `pi.registerProvider` extension, decided in phase 2.
## 2026-10-18 — TTS engine picked: arm A `qwentts.cpp`, on the live-output criterion
- Frank listened to the four audition pairs (`qwentts-*.mp3` against their
`armB-*.mp3` mainline partners) and **passed every clone**. That deleted the
only axis the A/B could not measure, and the question was reframed: **which
engine is better for live output**. On that criterion RTF only has to stay
under 1.0 with margin — the discriminators are time-to-first-audio and
run-to-run spread, because an arm whose RTF can cross 1.0 stalls a live
stream mid-sentence.
- **Chosen: arm A `qwentts.cpp` @ `5916bf7`, the resident `tts-server`, 1.7 B
talker Q4_K_M + Q8_0 tokenizer, in a `frank-tts-engine` container on 8880.**
The `TTS_BACKEND=llama-tts-cli` path in `scripts/frank-tts.env` stays in the
repo as the rollback. The numbers that carried it: first audio byte 86–451 ms
against a per-request model load, warm RTF 0.55–0.67 against 0.93–0.99, spread
±0.01 against ±0.25, and a native `/v1/audio/speech` against no HTTP path at
all. Three consequences are configuration, not preference: boot-warm every
voice, do not pin a cpuset (threads are `hardware_concurrency()/2`), register
every pack with its WAV (a lone embedding is rejected).
- Two follow-ups deliberately **not** decided: the **0.6 B talker** (0.44 RTF,
3.4 GiB, never auditioned — every clip is 1.7 B) and **ICL vs
embedding-only** conditioning. Embedding-only is the shipped default because
it is cheaper and needs no `ref_text`, which this fleet cannot transcribe
honestly; `.spk` + `.rvq` still ride in every pack, so ICL remains a per-voice
opt-in for batch work where total time beats first byte.
- **Addressing law corrected** in
[[Local TTS and Voice Cloning]]: the pair may not meet at a host-loopback
publish. netavark DNATs published ports only and a bridge container cannot
reach a host `127.0.0.1` bind — the lesson that 502'd hermes — so
`host.containers.internal:8880`, which `scripts/frank-tts.env` documents, is
correct only for a routable bind. The deployed pair shares one quadlet network
and the façade addresses the engine by container name.
- **PR #12** (`feat/open-notebook-podcast` @ `114dd7a`, 8 commits, 403 tests) is
**merged** as `1eb8b21` (Frank's OK, given in session): open-notebook +
SurrealDB quadlets on the private bridge `10.89.4.0/24`, the API published on
**5056** because seerr holds `127.0.0.1:5055`, and `ESPERANTO_TTS_TIMEOUT`
moved 600 → **660** to sit one round-trip outside frank-tts's own
`SHIM_TIMEOUT`. Merged is not runnable: nothing listens on 7772 until the
voice stack lands, and the episode destination (`/home/dank/media/podcasts`)
stays interim pending Gitea #7.
- Still in flight, not in this branch: `feat/frank-tts-engine` — the arm A image
build, the quadlet pair on one network, the boot-warm step, deployer /
planner / `test-changed.sh` routing, homepage and docs. This branch is also
where the two-branch overlap got resolved: merging `main` here conflicted on
`log.md` alone (both branches appended an entry) and took `index.md`,
`deploy-remote.sh`, `deploy-plan.py` and `test-changed.sh` clean.
## 2026-10-18 — open-notebook: the podcast generator's config lands (nothing deployed)
- Goal: **open-notebook** as the fleet's podcast generator on Dankclaw — notes
and sources in, a multi-speaker episode out, script written by
**frank-zealot**, voices rendered by **frank-tts**, no cloud provider
anywhere in the path. Repo-side only: no host touched, no container pulled or
started. Runbook: [[Open Notebook]].
- **Two containers, not one.** `lfnovo/open_notebook:v1-latest` plus
`surrealdb/surrealdb:v2`, both **digest-pinned** with `Pull=never` and a
documented one-time `podman pull` — frank-dragoon's pinning style, because
`:latest` moving underneath a rootless service is how you get an unbootable
pod after an unrelated `systemctl-daemon-reload`. The registry round-trip is
what proved `v2` is SurrealDB **2.7.0** and that the app image is Debian
trixie with **curl and ffmpeg already inside** — which is how both health
probes can be argument-only: `curl -fsS …/health` (route is upstream's,
`api/main.py:239`, and it sits on the auth exclude list) and
`/surreal is-ready`, which I ran against the digest here: green in 2 s, and
it refuses to pass without `SURREAL_PASS` in the environment — a credential
that must never ride the command line, so `Exec=` carries flags only.
- **Host 5055 was already spoken for.** seerr has held `127.0.0.1:5055` since
2026-10-14 (live `ss -ltn` agrees today), so the API publishes
`0.0.0.0:5056:5055`. open-notebook's frontend derives its API URL from the
window origin when `API_URL` is unset — the upstream env reference spells the
reverse-proxy symptom — so a silent remap would hang the UI on a port nobody
listens on, and `API_URL=http://192.168.0.143:5056` is set accordingly. 8502
publishes on `0.0.0.0` because this service carries its own password (the
episode-audio route is unauthenticated), unlike the loopback-and-proxied
observability stack; **8000 is published nowhere**, where upstream exposes it
on loopback with root credentials, and the seam forbids it.
- **The CPU guard, pre-armed.** `frank-tts` spawns one ~8 GiB, 8-thread engine
per request on a box that also serves two LLMs, while open-notebook defaults
to `TTS_BATCH_SIZE=5` × `OPEN_NOTEBOOK_WORKER_MAX_TASKS=5` — a five-way
self-DoS. Both are pinned to `1` in `scripts/open-notebook.env`, and the
worker knob is only real because the image's own supervisord expands
`${OPEN_NOTEBOOK_WORKER_MAX_TASKS:-5}` (`supervisord.conf:25`). The TTS clock
goes to **660 — one round-trip outside** frank-tts's own `SHIM_TIMEOUT=600`, so
a shim give-up reads as the shim's error rather than the worker's; the seam
asserts the direction (`>`) against the frank-tts env instead of restating a
number. `ESPERANTO_LLM_TIMEOUT` stays at the app's 180 s default: frank-zealot
answers an outline or a transcript well inside it, and upstream says to keep it
under 600 so a slow call fails with a name on it.
- **What a credential needs in order to exist:** `OPEN_NOTEBOOK_ENCRYPTION_KEY`
must be set before the UI can save *any* provider credential, and
`OPEN_NOTEBOOK_PASSWORD` must stay **absent** to remain auth-off — the app
reads `os.environ.get(…) is not None`, so `PASSWORD=` boots a login form
asking for the empty string. The repo env file carries both keys empty; the
deployer seeds `/home/dank/Containers/open-notebook/env` (600) from the host
vault and **refuses to start the pair while either secret is blank**, because
a half-configured open-notebook stores no credentials and fails quietly.
- **Episode destination is interim, by decision.** The destination is **Gitea
issue #7, `human-only`** — agents must not work it. Until Frank settles it,
the app keeps its own named volume and `/home/dank/media/podcasts` is
bind-mounted over `/app/data/podcasts` (`DATA_FOLDER` is hard-coded `./data`),
so finished MP3s are ordinary files in his home directory instead of volume
contents needing `podman unshare` — the database volume demonstrably lands on
a mapped subuid, measured with the pinned image. One mount line; one deletion
undoes it.
- **Two UI buttons will fail, and that is cosmetic.** Test Connection does
`GET <base>/models` and the TTS probe synthesises voice `alloy`; frank-tts
serves `/health` and `/v1/audio/speech` only. Model rows are created *by
name* and the voice field is free text, so `narrator1/2/3` is typed in and
never discovered. Documented, not worked around.
- **Blocked on frank-tts, stated plainly:** phase 1 landed without deployer or
homepage registration and nothing listens on 7772 today, so a generation run
writes its script and then fails on the first voice. That is upstream's
creation-podcasts warning in fleet clothing.
- Suite green: **403/403** (`./tests/run.sh`, wall 67 s) — 19 tests in the new
`tests/open_notebook.bats`, structure only: pins, boot persistence, the
unpublishable database port, the 5055→5056 remap plus the `API_URL` that has
to match it, the env-file read order, the guard knobs, the health gate naming
`/health`, planner routing, the homepage card hidden while undeployed.
- Three deployer seams cost time and are now law-shaped: a service must be
registered in **five** places — `SERVICE_ORDER`, `DANKCLAW_SERVICES`, the
`deploy-remote.sh` dispatch case, its pre-deploy requirement, and
`deploy.sh`'s dispatch regex, where an omission silently plans an empty set
for `--only`; a `dankclaw/<svc>/*` case must precede the `dankclaw/*.container`
glob because that `*` crosses `/`; and the changed-path routing lives *inside*
the per-path loop, after the `scripts/*.env)` case, not at the top of the file.
- Deliberately NOT done: the deploy (pulling 1.27 GiB to Dankclaw was not
asked for), any Caddy vhost or public exposure, audiobook/EPUB (issue #5), any
`frank-tts` code change, and the harbor templates on Dankserver —
`~/.harbor/compose.opennotebook.yml` pins the deprecated `-single` image,
carries no encryption key and injects the whole harbor env — left untouched,
read-only, as the record of what not to copy.
## 2026-10-18 — frank-tts-engine: the arm A engine becomes deployable (nothing deployed)
- Goal: turn the engine the day's A/B picked into a **repo-owned, boot-persistent,
self-healing container** — `frank-tts-engine`, `qwentts.cpp` commit
`5916bf7c79dbb4e66fc059cdc507a79a23a28bb4`, resident `tts-server`, 1.7 B base
talker Q4_K_M + Q8_0 tokenizer, container port 8880, and **not** a fleet
endpoint: the endpoint stays `frank-tts` on 7772. Runbook: [[Frank TTS Engine]].
- **No `PublishPort` at all, which is the shape the merged A/B states as law.**
netavark DNATs published ports only, so a `127.0.0.1:8880` publish is not a path
to a peer container — the façade could never have used it (the trap that 502'd
hermes) — while being the one LAN-reachable surface of an unauthenticated
synthesiser. Both voice containers therefore sit on one committed quadlet
network, `frank-tts-engine.network` at **10.89.5.0/24** (the next free /24 after
open-notebook's 10.89.4.0/24), and the façade addresses the engine by container
name. Diagnostics moved to `podman exec`, including the deployer's health gate
(`wait_container_healthy`, not `wait_for_health`: there is no URL to dial).
- **The boot warmer is a unit hook, not an operator habit.** Cold-voice law: the
first request for a registered voice decodes the whole reference to snapshot
codec state (6.2–8.0 s, first byte 5 530 ms), later ones restore in 0.5 ms; the
registry is memory-only and re-registering does not warm. So
`ExecStartPost=-/usr/bin/podman exec frank-tts-engine /app/warm-voices.sh` with
`TimeoutStartSec=900`, and the `-` prefix is what stops a failed warm from
crash-looping the endpoint under `Restart=always`. It logs `cold=` and `warm=`
per voice, so the under-a-second second request is proven on every boot.
- **Thread law recorded before anyone tunes it away:** no `--threads` exists;
`src/backend.h` uses `hardware_concurrency()/2`, so a cpuset pin silently
changes the thread count. Nothing pins this container.
- Image builds from the pinned commit with the **proven** line, verbatim —
`GGML_BLAS=OFF` is forced (this host has `libopenblas.so.0`, no dev headers),
every GPU backend off, `-j 8`, and no `-DGGML_NATIVE` because ggml's default is
`-march=native` for the building host: the image is built where it runs. Base
`ubuntu:24.04` pinned by index digest; two stages, because ggml builds shared
and the RPATH names the build tree.
- Weights fetched by `scripts/download-frank-tts-engine-qwentts.sh` with byte and
LFS-oid checks (talker 1 219 245 248 B, tokenizer 291 150 624 B); the deployer
gates on `--verify-only` rather than pulling 1.4 GiB inside a deploy.
- Registered in all five places the open-notebook entry just named as law, plus
the routing case above the `dankclaw/*.container` glob. New seam
`tests/frank_tts_engine.bats` (18 tests): the Quadlet **generator's own output**
for the network/publish/boot/warm shape, the entrypoint's argv driven from the
tracked env file, the warmer against a stub `tts-server` over a real socket
(registration keys, `ref_text` absent, late engine, hung synthesis, name
refusal), the fetcher's verifier, and both routing tables.
- Deliberately NOT done: the deploy (no host touched, no container started, no
image built), any homepage card or Prometheus job for the engine — it publishes
nothing and exposes no metrics, so there is nothing to probe, which is said out
loud in the page rather than papered over. No ICL/`ref_text` work: the packs
keep `reference.spk`/`reference.rvq` so it stays a per-voice opt-in.
## 2026-10-18 — frank-tts: the 7772 endpoint gets its deploy wiring, and voice naming is written down
- Stage 2 of the voice stack: the **façade** in front of the engine is now a
deployable service too. Nothing deployed, no host touched. Runbook:
[[Frank TTS]].
- **Two attached networks, in a meaningful order.** `dankclaw_default` first — it
owns the default route and is where consumers live (open-notebook dials
`http://frank-tts:7772/v1` by container name) — then the voice pair's private
bridge. `scripts/frank-tts.env`'s `TTS_UPSTREAM` moves from
`host.containers.internal:8880` to `http://frank-tts-engine:8880`, which is the
actual fix for a shape that could never have worked: a bridge container cannot
reach a host-loopback bind, and the engine publishes nothing.
- **`Wants=` + `After=` on the engine, never `Requires=`:** the façade still
carries the `llama-tts-cli` rollback backend, which needs no upstream, so an
engine that will not boot must delay this endpoint, not take it down.
- The façade handler installs the **engine's** quadlet pair as well. Not tidiness:
with a referenced `Network=` file absent, the v1 Quadlet generator produces no
container unit at all, and `--only frank-tts` has no other way to obtain it. It
also `mkdir -p /home/dank/voices` as the deploy user, because rootless podman
auto-creates a missing bind source **root-owned** and the endpoint then reports
"no voices" instead of an error.
- **A voice is a lowercase directory name.** `^[a-z0-9][a-z0-9._-]*$`, ≤ 64 chars,
no slash/`..`/space. The shape is the intersection of two rules that do not
agree: `app.py` would accept mixed case, `tts-server` lowercases names — so a
pack named `Narrator1` resolves through the façade and not through the engine,
and the boot warmer refuses such a directory loudly. `narrator1/2/3` are
load-bearing for open-notebook, and `frank-tts-add-cloned-voice` is the skill
that walks the pack → register → warm → prove-a-second-request flow.
- Registered everywhere the open-notebook entry named as law (planner order puts
the engine before the façade, so a warm registry precedes the first synthesis),
plus the homepage card — `wip: down` and hidden, because nothing listens on
7772 yet.
- **No Prometheus job, on purpose.** The façade exports no `/metrics`, Prometheus 3
refuses HTML/JSON content types (the `comfyui` and `services` probes are
documented down for exactly that), and `LlmEndpointDown` is scoped
`up{job=~"frank-.*"}` — so a `/health` scrape would page on Signal every few
minutes for a healthy endpoint. That is the frank-archon never-green lesson
pointed the other way, and it is written in the page instead of being quietly
ignored.
- Tests added to the voice seam: the generator's own rendering of the pair (both
attachments, first-network order, one publish, Wants-not-Requires), the
env-file upstream checked as a **network join** rather than a string
comparison, planner separation and order, the façade handler's steps, the
homepage card's gating, and routing that selects both seams for either
deployable's paths.
## 2026-10-18 — voice-pack builder: the 10-second source-material gate becomes code
- Third slice of the voice stack (after the façade and the engine decision):
`scripts/build-voice-pack.sh` turns a source recording plus a crop offset into
a deployable pack — `reference.wav` (24 kHz mono `pcm_s16le`, 18–25 s), the
engine's own `reference.spk` (8,192 B) + `reference.rvq` (5,456–6,864 B) made
from it, and `manifest.json` provenance. Tracked tunables in
`scripts/voice-packs.env`: pack list, per-voice source/offset/duration/pinned
source sha256, extractor path and engine commit, artifact arithmetic. New runbook
[[Voice Packs]].
- **The gate is the product.** The project-wide invariant — no voice material
from the first 10 s of a sample file — had only ever been a sentence in
`scripts/frank-tts.env`. An offset inside it now exits 1 before a directory is
created, and the same assertion runs again against the manifest that was just
written, next to the recorded sha256, so a pack cannot carry a forbidden crop
even if the env file is edited into nonsense. `10` stays a constant in the
builder; the env file carries offsets, never the law.
- **The extractor's contract was read, not guessed:** `qwen-codec` in qwentts.cpp
`5916bf7c` takes `--model <codec gguf> --talker <base talker gguf> -i <wav>`
with an optional `--device`, has **no `-o`** and names both artifacts next to
the input with the extension swapped. So the WAV is staged under its final stem
and `reference.wav` — the file the façade gates on — is moved into place
**last**, after the artifacts and the manifest: a failed extractor leaves a
directory that is not a voice. `--force` retires an existing pack whole to
`.AGENTTRASH/` instead of touching bytes; nothing in this script deletes.
- Sizes are arithmetic, so they are checked: 16 codebooks × 11 bits = 22 B per
12.5 Hz frame (`22 × floor(samples/1920)`) and `enc_dim 2048 × f32` = 8,192 B.
That makes the **effective crop window 19.84–25 s**, narrower than the WAV
law's 18–25 s, and a too-short crop is refused before the extractor runs. An
engine bump that changes either number fails the build by design; the bump
procedure is in [[Voice Packs]].
- Verified against reality, not against the model: the pinned `narrator1` crop
(98.739–118.636 s of `audiosample.mp3`, sha256 `2299510…`) comes out of a
`--dry-run` at 955,378 B / 19.897 s with 5,456 B of `.rvq` planned — byte-for-byte
the trial's hand-cut numbers — and all three pinned source digests match the
files on disk.
- Seam: `tests/voice_pack_unit.bats` (13 tests) — refusal inside the forbidden
region creates nothing, a legal crop builds the whole pack, `ffprobe` on the
generated reference, manifest round-trip through `jq` with a sha that matches
the source, re-run-without-`--force` leaves every byte alone, `--force` retires
rather than deletes, `--dry-run` needs no extractor, a wrong-sized artifact
from a bumped engine cannot publish a voice, and every crop in the tracked pack
list clears the invariant. `qwen-codec` is stood in by a stub with the real
tool's I/O shape, so no engine, no GPU, no network, no fleet host. Suite green:
**416/416** (`./tests/run.sh`, wall 77 s) — 403 before this branch.
- Deliberately NOT done: no transcript in the manifest (ICL wants a curated
`ref_text`; the trial's machine transcripts were wrong, and the engine
registers WAV-only voices anyway), no 16 kHz variant (arm A is native 24 kHz),
no loudness pass (not in the pack law), and no pack built on the host — the
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
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.
## 2026-10-18 — frank-tts-engine deployed: it builds, it serves, and the warm hook died on the kernel's argument cap
- Merged as **#14** (`747c665`) and deployed `--service frank-tts-engine`. The
payload took 18 s to fetch for the 1.4 GiB pair (byte count + Hub LFS sha256,
`--verify-only` OK), and the image built clean — the first real proof of the
`useradd` fix: `STEP 3/13: RUN useradd --system --uid 10000 …` passed with only
the documented cosmetic `SYS_UID_MAX` warning, and the unit was `healthy` after
31 s.
- Then the deploy reported `0 voice(s) warm`:
`/app/warm-voices.sh: line 152: /usr/bin/curl: Argument list too long`. The hook
handed the registration body to curl as ONE argument; the kernel caps a single
argument at 131,072 bytes (`MAX_ARG_STRLEN`) and a shipped 955 KB
`reference.wav` base64's to about 1.27 MB, so curl never executed and the hook
logged that as "HTTP no response". Fourth silent fault of the voice stack, same
species as the other three: `/health` stayed green throughout.
- Fix: the body goes to a `mktemp` file and curl gets `@file`. The regression test
is the honest shape — the seam's stub engine is a real listener, `curl` is the
real binary, the fixture pack is 300 KB — so it fails with the same kernel error
the host produced (proved red first, green after). Every shipped pack sits well
over the ~96 KB cliff, so no small fixture could ever have seen this.
- Live proof on the deployed container with the three real packs:
`narrator1 cold=1175ms warm=1874ms`, `narrator2 1373/1892`, `narrator3
1816/1487`, exit 0, 15.2 s for the whole pass.
- **A trial law did not survive deployment.** Against an empty registry —
register only, then time — the FIRST synthesis of a freshly registered voice
cost **1 883 ms**, the next two **1 283** and **1 226 ms**; a ~110-word request
cost **9.4 s** then **9.8 s**. The trial's 6.2–8.0 s cold / 0.5 ms restore
ladder, which the warmer's own header repeated, is retired: warming buys
registration — mandatory, because the registry is memory-only — plus about
**0.6 s**. Corrected in [[Frank TTS Engine]] law 1 and its troubleshooting
table, in [[Local TTS and Voice Cloning]], and in [[Voice Packs]].
- Host state, honestly: the engine is `active` with three voices warm, but the hook
fix is not in the image yet, so the next restart re-breaks it until this branch
is merged and re-deployed. `frank-tts` (the 7772 façade) is still undeployed.
- Deliberately NOT done in this window: the `frank-tts` deploy, any Prometheus
probe for the voice pair (neither container exposes `/metrics`; the blackbox
question is still open), and the podcast render — open-notebook is on `main` but
not installed.
- Sweep afterwards: the retired ladder still lived in six places the first pass
missed — both quadlet files, `scripts/frank-tts-engine.env`,
`scripts/frank-tts.env`, the [[Frank TTS]] troubleshooting row, the research
brief's consequences paragraph, and one test comment. All re-keyed to the
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
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.
## 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