Commit Graph
8 Commits
Author SHA1 Message Date
Frank 25df9d05a2 feat(frank-tts-engine): synthesize on the gfx1151 iGPU via Vulkan
The NPU path is a dead end for this payload (verdict:
report/tts-npu-spike-verdict-2026-10-19.md); the iGPU is the accelerator that
exists on this box today, and it is already shared with frank-dragoon.

- Containerfile.engine: -DGGML_VULKAN=ON, plus the three packages the door
  needs to configure — libvulkan-dev, spirv-headers, and glslc, which Ubuntu's
  glslang-tools does NOT ship (find_package(Vulkan COMPONENTS glslc REQUIRED)
  fails without it) — and libvulkan1 + mesa-vulkan-drivers (the RADV ICD) at
  runtime. No NVIDIA ICD on purpose: one Vulkan device in the enumeration is
  what makes the NAME Vulkan0 safe to write down.
- tts-engine-start.sh: QWENTTS_DEVICE -> --device, default Vulkan0. A value
  that is set but EMPTY passes no flag at all, which is the CPU rollback; that
  is why the default is written as an -v test and not with :=, because := also
  fires on an empty value and would silently undo the rollback.
- quadlet: AddDevice=/dev/dri + GroupAdd=keep-groups (a rootless container that
  drops its supplementary groups enumerates no render device). The node is not
  named: this host is hybrid, renderD128 is the NVIDIA card and renderD129 the
  amdgpu iGPU.
- env: QWENTTS_DEVICE=Vulkan0 plus the device law in the header, including the
  rollback line.
- tests: the two CPU-only assertions now assert the Vulkan shape, and a new one
  pins the rollback (empty device => no --device in argv). 451/451 green.

Measured before writing any of it: the configure in the digest-pinned base
answers "Found Vulkan: ... found components: glslc" and "Including Vulkan
backend"; gate 1 (RADV GFX1151 visible to a rootless container off this base)
is recorded in the verdict report.
2026-10-19 14:26:58 -07:00
Frank 5768680542 fix(homepage): the frank-tts card is a real probe now
The endpoint deployed on 2026-10-18, so the admin card must carry its state rather than a placeholder: wip becomes false (the registry schema wants the key on every service; false is what the live cards use) and the description drops the "Hidden until deployed" line. showLink stays false and the card stays admin-only: an HTTP API has no page to open, and homepage-status.py counts ANY HTTP response as up, so the card is honest with no link and no /health path in the probe. The voice seam that pinned wip == down is re-keyed to the deployed contract and now also refuses a link or a user-category placement.
2026-10-18 19:11:41 -07:00
Frank 2dc31ff09c docs(frank-tts): retire the 5.5 s cold-voice claim everywhere it still lived
PR #15 corrected the engine's page, the research brief's law bullet and the
warmer's header, but the retired ladder had six more homes: both quadlet files
("6.2-8.0 s… 0.5 ms", "~6-8 s each", "paying the 5.5 s cold decode"),
`scripts/frank-tts-engine.env` (the whole law paragraph and the WARM_TEXT note),
`scripts/frank-tts.env`, the [[Frank TTS]] troubleshooting row that told an
operator to expect ~5.5 s, the research brief's consequences paragraph, and one
test comment.

All re-keyed to what was measured on the deployed container 2026-10-18: the
first synthesis of a freshly registered voice costs 1,883 ms against 1,226-1,283
ms afterwards, so the warm pass buys about 0.6 s — and the reason it exists at
all, which the pages never led with, is that the registry is memory-only: without
the hook every voice name is a 400 until somebody synthesizes. The trial's dated
worklog entry stays as written; the correction points at it.

No behavior change: comments, unit lore and docs only, so no cfhash move and no
image rebuild.
2026-10-18 18:52:50 -07:00
Frank 4235c1d4cb fix(frank-tts): send the registration body through a file, not an argument
The deploy of the newly built engine image came back `0 voice(s) warm` with
`/app/warm-voices.sh: line 152: /usr/bin/curl: Argument list too long` above
every line, and the unit was `active` with a green `/health` beside it. The hook
built `{"name":…,"wav_b64":…}` in a shell variable and passed it as ONE curl
argument; the kernel caps a single argument at MAX_ARG_STRLEN = 131,072 bytes,
and the shipped reference clips base64 to about 1.27 MB, so curl was never
executed and the hook reported the absence as "HTTP no response".

The body now goes to a mktemp file and curl reads `@file`; the audio enters
through a redirection instead of a command substitution, so the script never
holds a 1.3 MB string at all. Every pack in the fleet is ~1 MB, i.e. ~13x the
cliff, so this was always going to happen — it took the first real packs to show
it because no fixture was ever that big.

The regression test therefore ships a 300 KB pack against the seam's REAL stub
listener with the REAL curl: it fails with the same kernel error the host did
(`Argument list too long`, then the 400-for-every-name summary), and passes once
the body is a file. Proved red before the fix, green after.
2026-10-18 18:45:31 -07:00
Frank 59e021da2c fix(deploy): hash every file a locally built image COPYs, not just its Containerfile
build_compose_image skips `podman build` when the installed image's `cfhash`
label matches a hash taken from the checkout now — the skip is what keeps
frank-tts-engine's ten-minute recursive source clone off every re-deploy. The
hash covered the Containerfile ALONE, so any change to a file the image COPYs
was invisible: edit dankclaw/voiceservice/app.py or tts-engine-warm.sh, re-run
the deploy, and podman reports the existing image is fine. The redeployed
container runs the previous code, /health answers 200, and the e2e gate has no
way to see it. It bit the boot warmer twice this week.

Each case arm now names its own extra=(…) — the paths its Containerfile COPYs —
and the label hashes the Containerfile plus those. The two arms are kept apart
deliberately: the façade bakes app.py and tts-start.sh, the engine bakes
tts-engine-start.sh and tts-engine-warm.sh, and comfyui/loki/alloy have no extra
files, so their hash is unchanged and they do not rebuild on this commit.
Hashing rather than word-splitting because these are repo-relative paths under
a fixed checkout root.

The seam drives the real function against a stub podman and asserts observable
output — did the deploy ask podman to build, or did it skip? — in five steps:
first deploy builds, unchanged tree does not, editing the engine's COPY'd warm
hook rebuilds the ENGINE image and not the façade, editing app.py rebuilds the
façade. Reverted scripts/deploy-remote.sh to prove it red on the old hash: the
warm-hook step is exactly where it fails.
2026-10-18 18:06:43 -07:00
Frank d336bdaa66 fix(frank-tts): make the boot warm pass survive both readers of its env
`WARM_TEXT` reaches the container **with its quotes**. The unit reads the env
file through podman's `--env-file`, which takes the literal remainder of the
line — no shell parsing — while bash `source` strips one layer. The hook
interpolated the raw value into the synthesis body, so with the quotes intact
the JSON was invalid and every voice logged "registered, but the warm synthesis
failed" while a hand-run hook (bash shape) succeeded. Reproduced both ways
against a real `podman run --env-file`, not by reading the man page.

The hook now strips exactly one matched pair — only when both ends are the same
quote AND the inner content carries another quote or a backslash, so a value
that legitimately contains quotes is never mangled — and then JSON-escapes what
is left, which is what makes a sentence like `He said "hi\" once"` arrive as
itself instead of corrupting the body.

The seam gains a test that reads `scripts/frank-tts-engine.env` BOTH ways —
`source` and the way `podman --env-file` does — and asserts the engine receives
the same sentence either way, in both shapes. A test that only sources the file
cannot see this bug, which is how it survived the first deploy.
2026-10-18 18:06:06 -07:00
Frank 8fefe6e1a9 feat(frank-tts): deploy wiring for the 7772 voice endpoint, and voice naming written down
Stage 2 of the voice stack. Repo state only: nothing deployed, no host touched,
no container started.

- `frank-tts` is now registered everywhere a dankclaw service has to be:
  `SERVICE_ORDER` (after `frank-tts-engine`, before the consumers), the planner's
  dankclaw set, `deploy-remote.sh`'s allowlist + dispatch + a
  `deploy_frank_tts` handler, and `deploy.sh`'s dispatch regex — plus the
  `build_compose_image` arm naming `dankclaw/voiceservice/Containerfile`, without
  which the build silently returns 0 and the unit's `Pull=never` tag never
  exists.
- The unit joins TWO networks, in order: `dankclaw_default` first (default route,
  where consumers and open-notebook live), then the voice bridge. `TTS_UPSTREAM`
  moves from `host.containers.internal:8880` to `http://frank-tts-engine:8880` —
  the actual fix for a shape that could never work, since a bridge container
  cannot reach a host-loopback bind and the engine publishes nothing at all.
- `Wants=`/`After=` on the engine, never `Requires=`: the `llama-tts-cli` rollback
  backend needs no upstream, so a broken engine may delay this endpoint but must
  not take its unit down.
- The handler installs the engine's quadlet pair too, because a referenced
  `Network=` file the generator cannot find means no façade unit is produced at
  all, and `--only frank-tts` has no other way to get it. It creates
  `/home/dank/voices` as the deploy user (rootless podman would auto-create a
  missing bind source root-owned, which reads as "no voices"), and gates on
  podman's recorded health.
- Voice naming is now written down where it can be found: a voice is a lowercase
  directory name matching `^[a-z0-9][a-z0-9._-]*$`, at most 64 chars. That shape
  is the intersection of two rules that disagree — the shim would accept mixed
  case, the engine lowercases names — so a mixed-case pack resolves on one side
  only, and the boot warmer refuses it loudly. `narrator1/2/3` are load-bearing
  for open-notebook.
- Homepage card for the pair, `wip: down` and hidden, probing `127.0.0.1:7772`;
  the engine gets no card because it publishes nothing and could never be probed.
- **No Prometheus job, deliberately.** No `/metrics` exists on the shim, this
  Prometheus 3 refuses HTML/JSON bodies (the existing `comfyui` and `services`
  probes are documented down for exactly that), and `LlmEndpointDown` is scoped
  `up{job=~"frank-.*"}` — so a `/health` job would page on Signal for a healthy
  endpoint. Recorded in the page instead of quietly skipped.
- Vault: new [[Frank TTS]] page linked from the index; [[Frank TTS Engine]] now
  points at it for the pair's public signal; log entry for the day.
- Seam grows to 25 tests: the generator's rendering of the pair (both
  attachments, first-network order, one publish, Wants-not-Requires, bounded
  per-unit blocks), the env file's upstream checked as a network JOIN rather than
  a string, planner separation and order, the handler's steps, the card's gating,
  routing that selects both seams for either deployable.
2026-10-18 17:43:08 -07:00
Frank 8d76854a1e feat(frank-tts-engine): the arm A engine as a boot-persistent quadlet unit
Repo state only: nothing deployed, no host touched, no container started.

- `frank-tts-engine` on Dankclaw: `qwentts.cpp` commit
  5916bf7c79dbb4e66fc059cdc507a79a23a28bb4, resident `tts-server`, 1.7 B base
  talker Q4_K_M + Q8_0 tokenizer, container port 8880. It is deliberately NOT a
  fleet endpoint — `frank-tts` on 7772 is the endpoint consumers address.
- Publishes NOTHING. A host-loopback publish would have been useless to the
  façade (netavark DNATs published ports only, so a bridge container cannot
  reach a host loopback bind — the shape that 502'd hermes) and would have been
  the one LAN-reachable surface of an unauthenticated synthesiser. Both voice
  containers sit on one committed quadlet network, `frank-tts-engine.network`
  pinned to 10.89.5.0/24 (next free /24 after open-notebook's 10.89.4.0/24), and
  peers address the engine by container name. Diagnostics and the deployer's
  health gate run inside the netns (`podman exec`, `wait_container_healthy`).
- Cold-voice law honoured in the unit: the first request for a registered voice
  decodes its whole reference to snapshot codec state (6.2-8.0 s, first audio
  byte 5 530 ms), later ones restore in 0.5 ms, and the registry is memory-only.
  `ExecStartPost=-/usr/bin/podman exec frank-tts-engine /app/warm-voices.sh`
  with `TimeoutStartSec=900`; the `-` prefix keeps a failed warm from
  crash-looping the endpoint under `Restart=always`. It logs cold=/warm= per
  voice, so the under-a-second second request is proven on every boot.
- Thread law recorded: `tts-server` has no `--threads`; src/backend.h uses
  hardware_concurrency()/2, so a cpuset pin silently changes the thread count.
- Image builds from the pinned commit with the proven line (GGML_BLAS=OFF
  forced by this host, every GPU backend off, -j 8, no -DGGML_NATIVE because
  ggml defaults to -march=native for the building host). Two stages; the runtime
  stage COPYs both the entrypoint and the warmer, because a container that
  publishes nothing is reached with `podman exec`, not a port.
- `scripts/download-frank-tts-engine-qwentts.sh` fetches the two GGUFs with byte
  and LFS-oid checks (talker 1 219 245 248 B, tokenizer 291 150 624 B); the
  deployer gates on --verify-only instead of pulling 1.4 GiB mid-deploy.
- Registered in SERVICE_ORDER, DANKCLAW_SERVICES, the deploy-remote.sh dispatch,
  its pre-deploy requirement, deploy.sh's regex, the planner's path map (the
  whole `dankclaw/voiceservice/` tree mapped to nothing before this), and
  test-changed.sh ABOVE the `dankclaw/*.container` case whose `*` crosses `/`.
- New seam tests/frank_tts_engine.bats (19): the Quadlet generator's own output
  for network/publish/boot/warm shape, entrypoint 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, the pinned cmake line, and both routing
  tables. Suite: 422 passed.
- Vault: [[Frank TTS Engine]] linked from the index; log.md entry for the day.
2026-10-18 17:26:50 -07:00