Merge remote-tracking branch 'origin/main' into wt/20261017-181502
# Conflicts: # serverAppConfigs-docs/log.md
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user