Compare commits

...
Author SHA1 Message Date
Frank 0d70f115d1 docs(frank-tts): the harness sends a voice only in voice_id
The section this PR adds claimed speech.voice was the forwarded voice key. It is not: the openai-speech transport builds {model, input, response_format, voice?} and sets voice only when the CALL passed voice_id, tts.localVoice is the local Kokoro backend key, and speech.voice is the TUI speak-aloud voice whose enum is Kokoro-only. A restarted session proved it the honest way - the tts device arrived at 7772 with no voice key and got "unknown voice None; available voices: narrator1, narrator2, narrator3" - so the fresh-process success earlier that day was the run that was told to pass voice_id, not evidence of a setting.

The law now reads: voice comes from voice_id alone, so a voice-less call is the harness default shape (the endpoint grew DEFAULT_VOICE in its own PR to answer it), and speech.voice must stay a Kokoro name - a pack name there breaks the vocalizer and does nothing for synthesis. config.yml reverted to am_fenrir on the operator box, and the worklog entry that recorded narrator1 as wiring carries the correction.
2026-10-18 19:21:22 -07:00
Frank 7e391d4325 docs(frank-tts): the voice stack is deployed, and the harness speaks through it
Six statements still claimed the endpoint did not exist: both vault descriptions, the two index.md bullets, the Open Notebook blocker, and the engine page deploy order that opened with "Nothing below has been run". All re-keyed to the deployed state, with the ledger the deploys printed: engine healthy 31 s after the build at 4.0 GiB resident, three voices warm, the ladder measured at 1883 ms then 1226-1283 ms (not the retired 6.2-8.0 s), and two voices rendering the same sentence into different bytes.

Adds a The harness speech role section to the endpoint page with the three wiring laws measured the day of the flip: speech.voice is the forwarded key (tts.localVoice belongs to Kokoro), an explicit speech role has no fallback candidate, 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: omp -p --tools tts saved 180524 bytes with voice=narrator1 while the facade logged the matching speech line.
2026-10-18 19:11:41 -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
dank d2ce6693da Merge pull request 'docs(frank-tts): the retired cold-voice claim still lived in six more places' (#16) from docs/retire-cold-voice-ladder into main 2026-10-18 18:59:13 -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
dank 0315a62d84 Merge pull request 'fix(frank-tts): the warm hook died on the kernel argument cap; the cold-voice law is retired by measurement' (#15) from fix/frank-tts-warm-argv into main
none
2026-10-18 18:50:00 -07:00
Frank 0773b26559 docs(frank-tts): the cold-voice ladder did not survive the deployment
The first real boot gave the chance to measure what warming actually buys, and
the trial's numbers were wrong:

- 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 and then 9.8 s.
- So the published 6.2–8.0 s cold / first-audio-byte 5 530 ms / later-requests
  0.5 ms ladder does not reproduce, and request time tracks the audio generated
  rather than snapshot state. The 0.5 ms figure is an internal state-restore
  cost, never an end-to-end request time.
- Warming still earns its place for a different reason than the page claimed:
  the registry is memory-only and re-registering does not warm, so a boot without
  the hook leaves the façade answering 400 for every name. The saving is ~0.6 s.

Law 1 of [[Frank TTS Engine]] is re-titled the registry law and carries the
measured numbers; its example journal line is a real one from today; the
troubleshooting row for "first request of the day is slow" — the retired law's
symptom — is replaced by the argument-cap fault that actually happened. The same
ladder in the research brief is annotated rather than erased, and [[Voice Packs]]
step 6 stops claiming the hook registers only packs it has not seen (the engine
replaces a name it already holds).
2026-10-18 18:45:31 -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
dank 747c6656d5 Merge pull request 'feat(frank-tts-engine): Qwen-TTS as a boot-persistent endpoint, plus the three silent faults auditing it found' (#14) from feat/frank-tts-engine into main
none
2026-10-18 18:30:22 -07:00
Frank 6956189bb6 docs(frank-tts): the worklog wikilink must not straddle a line break
The vault link check reads [[Frank TTS Engine]] literally, so wrapping it made
the target "Frank\nTTS Engine" and the unresolved-link seam failed. Rewrapped,
not reworded.
2026-10-18 18:10:25 -07:00
Frank 3380b7da10 docs(frank-tts): worklog entry for the voice-stack audit
Three faults, all of the reporting-success-while-doing-nothing species: the
build that could not start (no adduser in the base), the warm pass that could
not warm (podman's --env-file keeps the quotes), and the image that would never
rebuild (cfhash over the Containerfile alone). Records the proofs, the two
behaviour seams, and what stays undone: nothing is deployed, #2 phase 1 stays
open until 7772 answers.
2026-10-18 18:10:02 -07:00
Frank 7178e1c789 docs(frank-tts): what makes a locally built voice image stale, and what the warm text must survive
Three pages, all from faults found this session:

- `LLM/deployment.md` gains "Locally built images": which images have no
  registry digest, what the `cfhash` skip is for, why it must cover every COPY'd
  file (the silent shape: build skipped, /health green, old code serving), and
  the base-image half of the law — the engine's `useradd`, because the pinned
  ubuntu:24.04 has no `adduser`.
- `Dankclaw/Frank TTS Engine.md` troubleshooting gains the two lines an operator
  actually needs: every voice cold on a fresh boot with a hand-run hook
  succeeding (= `WARM_TEXT` arrived quoted, `podman exec … env | grep WARM_TEXT`
  proves it), and a 502 from the façade beside a 200 `/health` from the engine
  (model loaded ≠ voices registered). The stale-warmer row is re-keyed to the
  new rebuild rule.
- `Dankclaw/Voice Packs.md` step 4 was wrong: it told the next agent to restart
  the engine *instead of* the warm hook because the hook "only registers packs it
  has not seen". It registers every pack on every run and 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." A restart is what an
  engine bump needs (the registry is memory-only), never worse, only slower.
- `Dankclaw/Frank TTS.md` deploy command re-keyed to `./scripts/deploy.sh
  dankclaw --service frank-tts`, the way it is actually deployed; the raw
  `deploy-remote.sh` form is plumbing.
2026-10-18 18:07:19 -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 d135ae7f02 fix(frank-tts): create the engine user with useradd — the base has no adduser
The runtime stage of Containerfile.engine ran `adduser --system …`. The pinned
ubuntu:24.04 base ships no adduser, so the build died at that RUN line with
crun's "executable file not found in $PATH".

Verified in the base itself on Dankclaw, 2026-10-18: `command -v adduser`
prints nothing, and the exact replacement line — `useradd --system --uid 10000
--home-dir /app --shell /usr/sbin/nologin tts` — answers `uid=10000(tts)
gid=999(tts)`. Same account as before: numeric uid/gid 10000, non-login shell,
no password entry. Its one warning ("uid 10000 is greater than SYS_UID_MAX
999") is cosmetic and is what keeps the uid equal to the façade's, so the
comment says so before someone "fixes" it by dropping --system.
2026-10-18 18:05:07 -07:00
Frank c1087f214e docs(frank-tts): plain wikilink to the merged voice-pack page 2026-10-18 17:44:43 -07:00
Frank 1312c29fe2 Merge remote-tracking branch 'origin/main' into feat/frank-tts-engine
# Conflicts:
#	serverAppConfigs-docs/index.md
#	serverAppConfigs-docs/log.md
2026-10-18 17:44:31 -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
dank d421cb69cf Merge pull request 'feat(voice-packs): the builder that turns a crop into a deployable voice pack' (#13) from feat/voice-pack-builder into main 2026-10-18 17:41:11 -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
Frank 2427fc448f docs(voice-packs): runbook, index link, and the log entry
The page a new operator needs: what the three required files are and who
reads each one, how a pack is built (with the extractor's usage text
verbatim, because the flags are not guessable and there is no -o), the
10-second gate and where it fires twice, how to add a fourth voice
including the warm hook that registers a pack without restarting the
engine, and the engine-bump procedure for when the artifact sizes move.
Plus the host census as of 2026-10-18 — the trial packs carry no
artifacts and their manifest is the trial's own schema, so the builder's
first real run is a --force migration — and the three gaps left open on
purpose: no curated ICL transcript, no 16 kHz variant, no loudness pass.

Linked from the Dankclaw services list; log entry appended oldest-first.
2026-10-18 17:25:34 -07:00
Frank 3824d03ac5 Merge remote-tracking branch 'origin/main' into feat/voice-pack-builder 2026-10-18 17:18:31 -07:00
Frank 042003a969 feat(voice-packs): tracked tunables, the bats seam, and its wiring
scripts/voice-packs.env carries the decisions worth a diff and nothing
else: the pack list, each voice's source file, crop offset, crop length
and pinned source digest, the extractor path with its engine repo and
commit, and the artifact arithmetic. Paths and numbers only — the pack
directories themselves stay host state under /home/dank/voices.

tests/voice_pack_unit.bats pins behaviour, from inputs to observable
output: a crop inside the first ten seconds exits nonzero naming the
invariant and creates nothing, a legal one builds the whole pack, the
reference comes out 24 kHz mono inside 18-25 s (ffprobe, on a locally
generated tone), the manifest round-trips through jq with offsets and a
sha256 that matches the source bytes, a re-run without --force leaves
every byte untouched, --force retires rather than deletes, --dry-run
needs no extractor at all, and a stub extractor that changed its artifact
size cannot publish a voice. No engine binary, no GPU, no network, no
fleet host: qwen-codec is stood in by a stub with the real tool's I/O
shape, the way frank_tts_unit.bats drives its backends.

test-changed.sh routes both new paths to that seam instead of letting
them land on the generic scripts/* syntax fallback.
2026-10-18 17:18:30 -07:00
Frank 2242fcfec0 feat(voice-packs): builder — crop, pinned artifacts, manifest, 10 s gate
Turns a source recording plus a crop offset into the pack frank-tts
resolves by name: reference.wav (24 kHz mono pcm_s16le, 18-25 s), the
engine's own reference.spk / reference.rvq made from it, and manifest.json
provenance (source path + sha256, crop start/end, sample rate, duration,
tool, engine commit, voice name).

The extractor is qwen-codec from the pinned qwentts.cpp tree, and the
flags are its own usage text, not a guess: --model <codec gguf>, --talker
<base talker gguf>, -i <wav>, optional --device; there is no -o, so it
names both artifacts next to the input with the extension swapped, which
is why the pack's WAV is staged under its final stem.

The 10-second source-material invariant is enforced, not annotated: an
offset inside the forbidden region exits 1 before a directory is created,
and the gate is asserted a second time from the manifest that was just
written, together with the recorded sha256. Artifact sizes are checked
against the codec's own arithmetic (8,192 B x-vector; 22 B per 12.5 Hz
frame), so an engine bump fails the build instead of shipping a pack the
engine rejects at synthesis time — and the crop is refused up front when
it is too short to fill the .rvq window.

reference.wav is the file the façade gates on, so it lands last, after
the artifacts and the manifest: a voice name never exists on disk without
its artifacts behind it. Nothing is ever deleted — an existing pack is
refused unless --force, which retires it whole to .AGENTTRASH/ — and
--dry-run runs every step except the extractor, so the seam can test the
gate without the engine.
2026-10-18 17:18:21 -07:00
dank e13f875c94 Merge pull request 'docs(tts): record the measured CPU engine A/B behind frank-tts' (#10) from docs/tts-engine-trials into main 2026-10-18 17:15:00 -07:00
Frank 1d082a7f1d Merge remote-tracking branch 'origin/main' into docs/tts-engine-trials
# Conflicts:
#	serverAppConfigs-docs/log.md
2026-10-18 16:54:13 -07:00
Frank 5c8ac208a0 Merge remote-tracking branch 'origin/main' into docs/tts-engine-trials 2026-10-18 16:51:01 -07:00
Frank dbc2c67bcb docs(tts): record the arm A engine decision and the loopback-publish law
Frank heard every audition pair and passed it, so likeness stopped being the
tiebreak and the engine was picked on the live-output criterion: arm A
qwentts.cpp 5916bf7, resident tts-server, 1.7 B talker, llama-tts-cli kept as
the rollback. Records the discriminators (first audio byte, warm RTF, spread),
the three consequences that are configuration (boot-warm every voice, no cpuset
pin, WAV-only registration), the two follow-ups left open on purpose, and the
corrected addressing law: a host-loopback publish is unreachable from a bridge
container, so the pair shares one quadlet network.
2026-10-18 16:51:01 -07:00
dank 1eb8b2199a Merge pull request 'feat(open-notebook): podcast pipeline on Dankclaw (blocked on frank-tts)' (#12) from feat/open-notebook-podcast into main 2026-10-18 16:50:47 -07:00
dank 0e4bb327da Merge pull request 'docs: attribution footer for machine-opened issues and PRs' (#11) from chore/pr-attribution-trailer into main 2026-10-18 16:46:00 -07:00
Frank 114dd7a93e fix(open-notebook): size the TTS clock outside frank-tts's own ceiling
The env file first landed with ESPERANTO_TTS_TIMEOUT equal to SHIM_TIMEOUT=600.
Equality is a race: both budgets expire at the same instant, so the case the
number existed to prevent — the worker abandoning a synthesis frank-tts is still
finishing — is exactly what happens on the tail of a long line. Nested budgets
go inside-out, which is the rule scripts/frank-tts.env states for its own
LLAMA_TIMEOUT; the client waits one round-trip longer than the server, so a
frank-tts give-up arrives labelled as frank-tts's error. 660.

The seam's assertion moves from equality to `>` for the same reason, and now
also holds ESPERANTO_LLM_TIMEOUT under upstream's 600 s web-UI patience — above
it the failure surfaces as a UI timeout with no name on it. The scriptwriting
number itself stays at the app's 180 s default: frank-zealot answers an outline
or a transcript well inside it, which corrects the earlier commit message's
claim that both timeouts were raised past upstream's 300 s.
2026-10-18 16:31:49 -07:00
Frank e2393bacad docs(open-notebook): runbook, episode-destination interim, and the log entry
Page registered in the vault index (the docs-completeness seam fails an
unregistered page) and logged in the bundle change log.

What the page owes the reader: the provider contract (frank-zealot for scripts
with a /v1 base URL, frank-tts for voices with 1-4 speakers mapped to the
narrator1/2/3 voice packs typed as free text), why Test Connection and Discover
Models fail cosmetically against frank-tts rather than being a fault, the
encryption key as the precondition for saving any credential, and the honest
blocker — frank-tts is not deployed and nothing listens on 7772, so a generation
run writes its script and fails on the first voice.

Episode destination is stated as interim and cites Gitea issue #7 (human-only,
agents must not work it): the app keeps its named volume, one bind mount puts
finished MP3s under /home/dank/media/podcasts, and deleting that one line is the
whole rollback. Rollback section also covers the full undo, including that the
podman network only disappears once the units are gone.
2026-10-18 16:27:03 -07:00
Frank ff11c37ee0 test(open-notebook): seam for the units, the env guards, and the wiring
Structure only: what the units declare and how the scripts route them, never how
the app behaves inside a container. 19 tests.

The ones carrying real weight: the app unit has no publishable 8000 anywhere and
the database unit publishes nothing at all; the API remap off host 5055 is only
correct if API_URL names the same host port, so the seam parses the PublishPort
and compares; each unit reads the repo tunable file before the host secret file,
because podman's last-match-wins makes the order load-bearing; both health
probes are argument-only (a shell string there is a unit that starts dead); the
database password appears only as an env reference, never in Exec=; and the
episode host bind appears exactly once, since a second mount would silently
shadow the first.

The deployer assertions read the functions themselves: the gate URL path is
/health on the remapped port and must not name 5055, and the seed function
contains the blank-secret refusal plus the no-clobber test. No test asserts a
made-up endpoint or a cloud URL, and nothing touches the network.
2026-10-18 16:27:03 -07:00
Frank 0fe23c5233 build(scripts): route open-notebook paths to its own seam
The case has to sit where it does: the dankclaw/*.container glob's `*` crosses
`/`, so placed below it, dankclaw/open-notebook/quadlet/open-notebook.container
matched that generic case and ran the open-webui/pihole/searxng seams instead of
its own. Same reason the env path is grouped here — scripts/*.env would otherwise
send it to the generic env seams.
2026-10-18 16:27:03 -07:00
Frank 716eefef5d feat(homepage): open-notebook card, hidden until the service is deployed
Internal-only: port 8502 direct, no vhost field, no Caddy change — exposure
stays behind an explicit decision, and the app carries its own password.

urlStatus=hidden is deliberate: the homepage probe runs on the gateway and the
pair is not deployed, so a live card would render dead on day one. Flipping it
to live is the only homepage change the deploy needs; the seam asserts the
hidden state while the service is repo-only and requires the required card
fields either way.

mono-on.svg follows the mono-* icon set the sibling cards use, so the card does
not fall back to the generic dot.
2026-10-18 16:26:52 -07:00
Frank 8816a9d498 feat(deploy): wire open-notebook through both deployers, gated on secrets
Registration is load-bearing, not cosmetic: deploy-plan.py refuses any service
it does not know, so SERVICE_ORDER + DANKCLAW_SERVICES are what let `--only` and
`--auto` address the pair at all; the dispatch case in deploy-remote.sh runs it;
the requirement in check_prerequisites gates a manual deploy; and the dispatch
regex in deploy.sh's host-mode branch decides whether a changed path dispatches
a service or falls through to "no service mapped" — an omission there is silent,
which is exactly how the AGENTS.md deployer-registration law was earned.

The deploy seeds /home/dank/Containers/open-notebook/env (0600) from the host
env and refuses to start the pair while OPEN_NOTEBOOK_ENCRYPTION_KEY or
OPEN_NOTEBOOK_PASSWORD is blank: without the key no provider credential saves
and without the password the API — including the episode-audio route — is open
to the LAN with no auth. A seeded file is never rewritten, so re-deploying
cannot clobber a rotated key, which would silently make every stored credential
undecryptable. SURREAL_PASS is derived from SURREAL_PASSWORD in the same write.

The gate names paths that answer, not invented ones: the database is judged by
its own container health status (/surreal is-ready, verified against the pinned
digest) and the app by wait_for_health on /health at the published 5056 mapping —
the route is upstream's and is on the auth middleware's exclude list, so it
stays 200 after the password is set. 180 s covers Next.js build + startup and a
degraded-but-listening first boot, matching the sibling LLM deploys.

Both digest pins are checked against local storage before restart, because
Pull=never means a missing image would otherwise surface as a unit stuck in
"Start request queued" (the 203/EXEC trap's sibling). The interim episode
directory is created before the app starts: a missing bind source makes podman
create it as root-owned and the app's mkdir would then fail.
2026-10-18 16:26:52 -07:00
Frank 032229c1e2 feat(open-notebook): env contract — CPU guard, credential store, auth posture
The two knobs that keep the box alive: TTS_BATCH_SIZE=1 and
OPEN_NOTEBOOK_WORKER_MAX_TASKS=1. Upstream ships 5 x 5; frank-tts spawns one
~8 GiB, 8-thread engine process per synthesis request on a machine that also
serves two LLMs, so a five-way fan-out is a self-inflicted shutdown. The worker
knob is real, not decorative: the image's supervisord expands
${OPEN_NOTEBOOK_WORKER_MAX_TASKS:-5} into the worker command
(supervisord.conf:25).

Both ESPERANTO_* timeouts go above upstream's 300 s defaults, sized for CPU
synthesis (SHIM_TIMEOUT=600, LLAMA_TTS_THREADS=8) — one episode is dozens of
per-line /audio/speech calls and a timeout kills the episode, not the line.

API_URL is forced for the 5055 -> 5056 remap; SURREAL_URL addresses the database
by container name; CORS_ORIGINS is origin-form (scheme://host:port), the form
the app compares against. OPEN_NOTEBOOK_PASSWORD stays absent here on purpose:
the app treats a set-but-empty value as auth-on.

Secrets stay placeholders in the tracked file and in dankclaw/.env.example, which
lists SURREAL_PASSWORD rather than SURREAL_PASS — the deployer derives the
surreal CLI's spelling from it when it seeds, so the app and the database cannot
be handed different values.
2026-10-18 16:26:43 -07:00
Frank 09b0badaf5 feat(open-notebook): Quadlet pair — app + SurrealDB on a private bridge
Two containers, because that is what upstream runs (docker-compose.yml:6,13):
the app image under supervisord, plus SurrealDB. The `v1-latest-single` tag is
not in play — it bundles its own database, is deprecated upstream, and would
give the host a second hidden DB.

Both images are digest-pinned with Pull=never and a documented one-time pull,
the frank-dragoon posture: a Quadlet on :latest re-resolves on the first restart
after an unrelated daemon-reload, and the failure is silent.

Port map paid for by what is already on the host: seerr holds 127.0.0.1:5055
(since 2026-10-14, live `ss -ltn` agrees), so the API leaves on 5056 and the env
file has to set API_URL to match, because the frontend derives the API URL from
the window origin when it is unset. 8502 publishes on 0.0.0.0 — the service
carries its own password and the episode-audio route is unauthenticated. 8000 is
published nowhere, where upstream exposes it on loopback with root credentials.

The app joins dankclaw_default first and the private systemd-open-notebook
second: it reaches the published fleet endpoints through the former
(host.containers.internal, the arrangement Prometheus is proven against) and
finds its database by container name through the latter. Generated --network
flags keep file order, so the tunable file is listed before the host secret file
in both units — podman applies last-match-wins.

No UserMapping: the app runs as root in its namespace (no USER in the Dockerfile)
and writes only paths owned by that root. Exec= carries no database credential —
`exec start --user --pass` puts it in world-readable argv; the image's own
entrypoint passes it on stdin, so only the container env holds it.
2026-10-18 16:26:37 -07:00
Frank 0e7936f94e docs(tts): record the measured CPU engine A/B behind frank-tts
Both candidate :8880 engines were measured on Dankclaw's CPU (no GPU,
no container, nothing deployed). arm A qwentts.cpp 5916bf7 resident at
RTF 0.55-0.57 warm / 0.67 ICL / 1.65 cold; arm B mainline llama-tts at
0.93-0.99 with a +-0.25 spread. Records the four configuration laws the
trial produced (boot-warm every voice, .spk+.rvq replay is byte-identical,
streaming != buffered, /v1/audio/voices rejects a lone spk_b64), the
likeness measurement's resolution limit, and re-keys the stale
8890/voiceshim shape on that page to frank-tts on 7772. The engine pick
stays open: the likeness metric cannot rank clones, so the audition clips
decide.
2026-10-18 16:19:51 -07:00
34 changed files with 5080 additions and 48 deletions
+20
View File
@@ -79,3 +79,23 @@ MARRETA_APP_KEY=changeme
# account on first boot from these values. # account on first boot from these values.
MARRETA_ADMIN_EMAIL=admin@franksdank.cloud MARRETA_ADMIN_EMAIL=admin@franksdank.cloud
MARRETA_ADMIN_PASSWORD=changeme MARRETA_ADMIN_PASSWORD=changeme
# open-notebook (podcast generator; internal-only, no vhost).
# deploy-remote.sh seeds /home/dank/Containers/open-notebook/env from these
# values (only if that file does not already exist) and refuses to start the
# units while any of them is blank: a blank OPEN_NOTEBOOK_PASSWORD means
# authentication OFF, and a blank encryption key means no provider credential
# can be saved. The seed writes SURREAL_PASS too — the spelling the `surreal`
# CLI reads — from SURREAL_PASSWORD, so the app and the database cannot be
# given different values.
# OPEN_NOTEBOOK_ENCRYPTION_KEY: encrypts the stored AI provider keys at rest.
# Generate: openssl rand -hex 32
# Changing it after credentials are saved makes them undecryptable.
OPEN_NOTEBOOK_ENCRYPTION_KEY=changeme
# UI/API password. Every API route — including the episode-audio download the
# app serves without a session — is open to the LAN while this is unset.
OPEN_NOTEBOOK_PASSWORD=changeme
# SurrealDB root password (the root user NAME comes from
# scripts/open-notebook.env, so this file holds only the secret).
# Generate: openssl rand -base64 32
SURREAL_PASSWORD=changeme
@@ -0,0 +1,84 @@
# Quadlet unit: open-notebook's SurrealDB (rootless, boot-persistent).
#
# Install path on dankclaw: ~/.config/containers/systemd/open-notebook-surrealdb.container
# Unit name: open-notebook-surrealdb.service (container name: open-notebook-surrealdb)
#
# WHAT IT IS. open-notebook's only datastore: notebooks, notes, sources,
# provider credentials and podcast episodes all live here. The app talks to it
# over the WebSocket RPC on the private bridge (SURREAL_URL in
# scripts/open-notebook.env), which is why the container NAME is part of the
# contract — renaming it breaks the app's database URL, not just this file.
#
# IMAGE. `surrealdb/surrealdb:v2` pinned by manifest-list digest, resolved from
# the registry on 2026-10-18 (that digest is SurrealDB 2.7.0, image built
# 2026-09-23; `v2` itself is a MOVING tag, so the digest — not the tag — is what
# makes this reproducible). Upstream open-notebook's own compose file pins the
# same `v2` tag, and its Dockerfile copies /surreal out of the same image for
# the retired single-container variant, so v2 is the version this app is built
# against. Pull=never + the documented pull step (docs: [[Open Notebook]]):
#
# podman pull docker.io/surrealdb/surrealdb:v2@sha256:e5d7387e3727bbe2f7cd6d869e48cd34777272258572ead37c9656b25f745723
#
# NO PUBLISHED PORT. 8000 is deliberately not published: the app reaches it by
# name on systemd-open-notebook, and a published 8000 would put the RPC with
# the root credentials on every interface (upstream publishes 127.0.0.1:8000
# "purely for local debugging" — a `podman exec` probe is enough here). The
# binary binds 0.0.0.0:8000 INSIDE its own namespace via the image's
# SURREAL_BIND env; without that image default it would bind loopback only and
# the app could never connect, so no --bind flag belongs here.
#
# CREDENTIALS, AND WHY THEY ARE NOT IN THE COMMAND LINE. The `surreal start`
# CLI reads its options from the environment (`--username` is [env:
# SURREAL_USER=], `--password` is [env: SURREAL_PASS=] — verified against the
# pinned image's own `start --help`), so the credentials arrive through the two
# EnvironmentFile lines and the argv stays clean. Passing `--pass` instead —
# what upstream's compose does — would put the database password in
# /proc/<pid>/cmdline, readable by any `ps` on the host. Note the two
# spellings: the DATABASE reads SURREAL_PASS, the APP reads SURREAL_PASSWORD,
# and scripts/open-notebook.env declares both because they must hold the same
# value; the deployer writes that pair when it seeds the host secret file.
#
# NO USER= OVERRIDE. The image runs as uid 65532 (Chainguard glibc-dynamic
# base). With a NAMED volume that works unmodified: podman creates the volume's
# backing directory owned by the container's user (measured with this digest —
# the host directory lands on the mapped subuid and the store starts clean),
# which is exactly why upstream's `user: root` line is not repeated here: that
# line is commented in upstream as being "required for bind mounts", and this
# unit has none. The consequence is that the DB files are NOT readable by dank
# without `podman unshare` — accepted for a datastore nobody edits by hand;
# episode AUDIO is a host directory for the opposite reason (see the app unit).
#
# HEALTH. The base image is distroless: no shell, no curl, no wget, so no
# CMD-SHELL probe and no wget/curl probe is possible here (same class of unit
# as loki, whose probe had to move into the image). SurrealDB ships its own
# probe instead — `surreal is-ready` connects to ws://localhost:8000 by default
# and needs no credentials — verified against this digest: it prints `OK` and
# exits 0 while the server is up. Argument-only HealthCmd, like every unit on
# this host, because the quadlet generator splits it on spaces.
[Unit]
Description=SurrealDB (open-notebook datastore)
After=network-online.target
Wants=network-online.target
[Container]
ContainerName=open-notebook-surrealdb
Image=docker.io/surrealdb/surrealdb:v2@sha256:e5d7387e3727bbe2f7cd6d869e48cd34777272258572ead37c9656b25f745723
Pull=never
Network=open-notebook.network
EnvironmentFile=/home/dank/serverAppConfigs/scripts/open-notebook.env
EnvironmentFile=/home/dank/Containers/open-notebook/env
Exec=start --log info rocksdb:/mydata/mydatabase.db
Volume=open-notebook-db:/mydata
HealthCmd=/surreal is-ready
HealthInterval=30s
HealthTimeout=10s
HealthRetries=3
HealthStartPeriod=20s
[Service]
Restart=always
RestartSec=5
[Install]
WantedBy=default.target
@@ -0,0 +1,101 @@
# Quadlet unit: open-notebook (rootless, boot-persistent).
#
# Install path on dankclaw: ~/.config/containers/systemd/open-notebook.container
# Unit name: open-notebook.service (container name: open-notebook)
#
# WHAT IT IS. The fleet's podcast generator: notes and sources go in, a
# two-voice (up to four) episode MP3 comes out. One image runs THREE processes
# under supervisord — the FastAPI API on 5055, the background worker that
# writes and speaks the episode, and the Next.js UI on 8502 — so this one unit
# owns the whole app, and the frontend refuses to start until the API answers
# (the image's own wait-for-api.sh). Runbook: [[Open Notebook]].
#
# IMAGE. `lfnovo/open_notebook:v1-latest` pinned by manifest-list digest,
# resolved from the registry on 2026-10-18; it is the tag upstream's own
# docker-compose.yml pins for the multi-container shape. Do NOT reach for the
# `-single` tags: that variant bundles SurrealDB inside the app image, is
# deprecated upstream, and would give this host two databases. Pull=never, so
# the pin is fetched by the documented one-time step (docs: [[Open Notebook]]),
# which the deployer also runs when the image is absent:
#
# podman pull docker.io/lfnovo/open_notebook:v1-latest@sha256:54b2025a4a4853c188f74b9b8246eca8c5480e5dd1babfa92db1038ff2f30212
#
# NETWORKS, in this order on purpose. The generated run line keeps file order,
# so the DEFAULT ROUTE rides dankclaw_default: that is the network where this
# host has measured that a bridge container can reach a host-namespace
# listener through the gateway name (the Prometheus scrape of frank-zealot is
# the proof — see [[Container Network Namespaces]]), and frank-tts lives there
# too, so the TTS credential can be `http://frank-tts:7772/v1`. The private
# systemd-open-notebook bridge is second and carries only the database; its
# traffic is on-link, so losing the default route costs it nothing.
#
# PUBLISHED PORTS, and the 5055 collision. Upstream publishes 8502 and 5055,
# and 5055 is exactly the port seerr already holds on this host
# (`PublishPort=127.0.0.1:5055:5055`; `ss -ltn` on Dankclaw 2026-10-18 shows
# 127.0.0.1:5055 taken). So the API leaves the container on host port 5056.
# That forces one setting: the frontend auto-detects the API as
# `<host-you-opened>:5055`, so API_URL in scripts/open-notebook.env must name
# the remapped port explicitly, or the UI loads and every call fails. Both
# ports are published on all interfaces because the consumer is a browser on
# the LAN — the fleet's browser-facing services (open-webui, comfyui, gitea)
# bind the same way, while loopback-only is reserved for services whose user
# path is the guard chain. This service has NO vhost and no Caddy route: it is
# internal-only until it is guarded, and its own password
# (OPEN_NOTEBOOK_PASSWORD) is what stands in front of it. That password is not
# decoration: with it unset the API answers everyone on the LAN, including the
# unauthenticated episode-audio download.
#
# VOLUMES. `/app/data` is a named volume: uploads, the LangGraph SQLite
# checkpoints and the extraction caches the image keeps under it
# (UV_CACHE_DIR, HF_HOME) are app state nobody browses. The ONE exception is
# finished audio, which is bind-mounted out of the container at
# /home/dank/media/podcasts. That mount is interim and reversible: episode
# destination is an open human-only decision (Gitea issue #7, label
# `human-only` — agents must not work it), and until it lands, episodes are
# ordinary files on the host. DATA_FOLDER is hard-coded `./data` in the app
# (WORKDIR /app) and the DB stores audio paths RELATIVE to data/podcasts, so
# covering that one subdirectory is enough and removing the line changes
# nothing else. The image declares no USER, so the app runs as container root,
# which in this host's rootless mapping (0 -> 1000, measured on this host —
# see [[Seerr]]) writes those episode files as dank.
#
# HEALTH. `GET /health` on the API returns {"status":"healthy"} and is in the
# password middleware's excluded-path list, so the probe stays honest after the
# password is set (both read from api/main.py upstream). The image installs
# curl and ffmpeg in the runtime layer, so the probe needs no shell — kept
# argument-only for the generator's space-splitting. Note what this probe does
# NOT prove: the route returns a static body, so it says the API serves, not
# that the database answers. The DB has its own probe in its unit, and the
# worker's failure to reach the database shows up as failed jobs, not as an
# unhealthy container.
[Unit]
Description=open-notebook (podcast generator: API, worker, web UI)
After=network-online.target open-notebook-surrealdb.service
Wants=network-online.target open-notebook-surrealdb.service
[Container]
ContainerName=open-notebook
Image=docker.io/lfnovo/open_notebook:v1-latest@sha256:54b2025a4a4853c188f74b9b8246eca8c5480e5dd1babfa92db1038ff2f30212
Pull=never
Network=dankclaw_default
Network=open-notebook.network
PublishPort=0.0.0.0:8502:8502
PublishPort=0.0.0.0:5056:5055
EnvironmentFile=/home/dank/serverAppConfigs/scripts/open-notebook.env
EnvironmentFile=/home/dank/Containers/open-notebook/env
Volume=open-notebook-data:/app/data
Volume=/home/dank/media/podcasts:/app/data/podcasts
HealthCmd=curl -fsS http://127.0.0.1:5055/health
HealthInterval=30s
HealthTimeout=10s
HealthRetries=3
HealthStartPeriod=60s
[Service]
Restart=always
RestartSec=10
TimeoutStartSec=900
[Install]
WantedBy=default.target
@@ -0,0 +1,34 @@
# Quadlet network: private bridge for the open-notebook pair (rootless).
#
# Install path on dankclaw: ~/.config/containers/systemd/open-notebook.network
# Unit name: open-notebook-network.service (podman network name:
# systemd-open-notebook — quadlet prefixes "systemd-", so probe the runtime
# name, never the filename; see [[Container Network Namespaces]]).
#
# WHY A PRIVATE BRIDGE. The app reaches its database by container name
# (SURREAL_URL=ws://open-notebook-surrealdb:8000/rpc), and container DNS names
# resolve only on the network that owns them. Putting SurrealDB here instead of
# on dankclaw_default keeps the datastore reachable ONLY to the container that
# owns it: 8000 is not published (an unpublished port is refused outright from
# every other namespace anyway), and nothing else on the fleet-wide network can
# dial it with the root credentials. Same reasoning as the gitea and authentik
# database pairs.
#
# Kernel bridge, not slirp4netns: the 2026-08-22 open-webui incident (a dead
# per-container userspace helper left a container that accepted TCP and reset
# every request for two days, logging nothing). A bridge has no such helper.
#
# Subnet registry on dankclaw (rootless):
# 10.88.0.0/16 default "podman" bridge
# 10.89.0.0/24 gitea (systemd-gitea)
# 10.89.1.0/24 compose stack (dankclaw_default)
# 10.89.2.0/24 searxng (systemd-searxng)
# 10.89.3.0/24 open-webui (systemd-open-webui)
# 10.89.4.0/24 open-notebook (systemd-open-notebook) <- this file
# Pinned so the container IPs stay deterministic across reboots, like the
# other pinned nets; 10.89.4.0/24 was the next unclaimed /24 in this registry.
[Network]
Driver=bridge
Subnet=10.89.4.0/24
Gateway=10.89.4.1
+140
View File
@@ -0,0 +1,140 @@
# frank-tts-engine — the arm A TTS engine: qwentts.cpp's resident `tts-server`
# (the CPU binary that frank-tts on :7772 forwards to). The runbook is
# [[Frank TTS Engine]]; the fleet-facing endpoint is [[Frank TTS]].
#
# WHAT THIS IS. The upstream source is NOT vendored here: the image clones
# ServeurpersoCom/qwentts.cpp at one pinned commit and builds it. Rebuilding is
# therefore a network operation (github.com plus objects.githubusercontent.com
# for the `ggml` submodule), and build_compose_image in scripts/deploy-remote.sh
# skips it whenever this Containerfile's sha256 label already matches the built
# image — the same offline-safe idiom as loki/alloy/comfyui, so a re-deploy of
# an unchanged engine needs no network.
#
# THE PINNED COMMIT IS THE CONTRACT. 5916bf7c79dbb4e66fc059cdc507a79a23a28bb4
# is the commit the 2026-10-18 CPU trial on Dankclaw measured (server-side TTFA
# 94-112 ms, warm RTF 0.55-0.67, run spread +-0.01 — the numbers that won the
# engine decision). Moving it means re-measuring, not just editing this line.
# `ggml` is a submodule of the repo (the only one; vendor/cpp-httplib and
# vendor/yyjson are ordinary directories in the tree), and the submodule commit
# is recorded in the parent tree, so cloning the parent commit recursively is
# reproducible without a second pin.
#
# THE BUILD LINE IS THE ONE PROVEN ON DANKCLAW, verbatim:
#
# cmake -B build -DCMAKE_BUILD_TYPE=Release -DGGML_CCACHE=OFF \
# -DGGML_BACKEND_DL=OFF -DGGML_CPU_ALL_VARIANTS=OFF -DGGML_OPENMP=ON \
# -DGGML_BLAS=OFF -DGGML_CUDA=OFF -DGGML_HIP=OFF -DGGML_VULKAN=OFF \
# -DGGML_METAL=OFF -DGGML_SYCL=OFF -DGGML_RPC=OFF \
# && cmake --build build -j 8
#
# Every flag is load-bearing, so do not "tidy" it:
# GGML_BLAS=OFF is FORCED, not a preference — Dankclaw has libopenblas.so.0
# but no BLAS dev headers, so the upstream default (BLAS on) fails to
# configure. Upstream's own Dockerfile and buildcpu.sh both pass BLAS on,
# which is why this file does not copy them.
# GGML_CCACHE=OFF because a container build layer has no persistent ccache
# directory to hit; on, it only writes a useless one.
# GGML_OPENMP=ON is what makes the runtime need libgomp1 (installed below).
# GGML_BACKEND_DL=OFF / GGML_CPU_ALL_VARIANTS=OFF: upstream's images build
# every x86 ISA as a loadable backend so one image runs anywhere. This one
# is built on the machine that runs it, so the single native CPU backend is
# what was measured. That is also why no -DGGML_NATIVE is passed: ggml
# defaults it ON outside wasm/cross-compiles, i.e. -march=native for THIS
# host — correct while build host == run host, wrong if anyone ever builds
# this image elsewhere, which is why the fleet builds it on Dankclaw.
# CUDA/HIP/VULKAN/METAL/SYCL/RPC all OFF: this is a CPU endpoint (the trial
# ran on CPU; the box's GPUs belong to the text endpoints).
#
# BUILD SHARED LIBS, RUNTIME SMALL. BUILD_SHARED_LIBS defaults ON on Linux, so
# tts-server links libggml*.so rather than swallowing them; the runtime stage
# copies the binary and its shared objects out of the build tree and points
# LD_LIBRARY_PATH at them, exactly as upstream's own Dockerfile does (the RPATH
# baked at link time names the build directory, which does not exist here).
#
# BASE PINNED BY DIGEST, like every other image this fleet builds: the digest
# is the multi-arch INDEX of ubuntu:24.04 as resolved from Docker Hub on
# 2026-10-18; Dankclaw takes the amd64 entry. One base for both stages so there
# is exactly one thing to re-resolve.
#
# NO HEALTHCHECK IN THIS FILE. The probe lives in the Quadlet unit
# (HealthCmd=), which is the fleet pattern for anything with a shell — see the
# alertmanager/prometheus units, and the opposite case in dankclaw/loki, whose
# distroless image has no /bin/sh for the generator's CMD-SHELL wrapping.
# `curl` is therefore installed in the runtime stage, for that probe only.
ARG BASE_IMAGE=docker.io/library/ubuntu:24.04@sha256:68ee22004a9109b44973313b02ff7b1de250f1de0f5ecb628d73f579aaff33ac
# ------------------------------------------------------------------ build
FROM ${BASE_IMAGE} AS build
RUN apt-get update -qq \
&& apt-get install -y -qq --no-install-recommends \
ca-certificates cmake g++ git make \
&& rm -rf /var/lib/apt/lists/*
# Checked out detached at a full SHA, then the recorded submodule. No branch
# name anywhere: a branch moves, this commit does not.
ARG QWENTTS_COMMIT=5916bf7c79dbb4e66fc059cdc507a79a23a28bb4
RUN git clone --no-checkout https://github.com/ServeurpersoCom/qwentts.cpp /src \
&& git -C /src checkout --detach "${QWENTTS_COMMIT}" \
&& git -C /src submodule update --init --depth 1 \
&& git -C /src rev-parse HEAD \
&& git -C /src submodule status
WORKDIR /src
RUN cmake -B build -DCMAKE_BUILD_TYPE=Release \
-DGGML_CCACHE=OFF \
-DGGML_BACKEND_DL=OFF \
-DGGML_CPU_ALL_VARIANTS=OFF \
-DGGML_OPENMP=ON \
-DGGML_BLAS=OFF \
-DGGML_CUDA=OFF \
-DGGML_HIP=OFF \
-DGGML_VULKAN=OFF \
-DGGML_METAL=OFF \
-DGGML_SYCL=OFF \
-DGGML_RPC=OFF \
&& cmake --build build -j 8
# ---------------------------------------------------------------- runtime
FROM ${BASE_IMAGE}
# libgomp1: the OpenMP runtime the build above links against. curl: the unit's
# HealthCmd. ca-certificates: so that curl can speak https if anyone ever
# points a probe at a URL with a scheme this host terminates elsewhere.
RUN apt-get update -qq \
&& apt-get install -y -qq --no-install-recommends \
ca-certificates curl libgomp1 \
&& rm -rf /var/lib/apt/lists/*
# Same unprivileged uid as the frank-tts façade (10000): the voice packs and the
# model directory are host state mounted read-only, and one uid across the pair
# means one thing to remember about what is world-readable.
#
# `useradd`, NOT `adduser`: this trimmed ubuntu:24.04 base ships no `adduser` at
# all (verified 2026-10-18 against this digest — `adduser` is absent from the
# base and is not pulled in by ca-certificates/curl/libgomp1 either), so an
# `adduser` line dies at image-build time with crun's "executable file `adduser`
# not found in $PATH". `useradd --system --uid 10000` succeeds and prints one
# cosmetic warning (`uid 10000 is greater than SYS_UID_MAX 999`); the uid is the
# point, and it is what matches the façade. The alpine façade Containerfile keeps
# its own busybox `adduser -D -u 10000` — different base, different tool, same uid.
RUN useradd --system --uid 10000 --home-dir /app --shell /usr/sbin/nologin tts
WORKDIR /app
COPY --from=build /src/build/tts-server /src/build/*.so* /app/
COPY dankclaw/voiceservice/tts-engine-start.sh /app/start.sh
# The boot warmer lives INSIDE the image because the engine publishes no port:
# the unit reaches it with `podman exec`, and the script needs the same curl,
# base64 and /voices the engine's own namespace has. It is a sibling of
# start.sh, not a second entrypoint — CMD stays the server.
COPY dankclaw/voiceservice/tts-engine-warm.sh /app/warm-voices.sh
RUN chmod 755 /app/start.sh /app/warm-voices.sh
# The binary's RPATH points inside the build tree; this is what makes it find
# the libggml*.so copied beside it.
ENV LD_LIBRARY_PATH=/app
USER tts
EXPOSE 8880
CMD ["/app/start.sh"]
@@ -0,0 +1,115 @@
# Quadlet unit: frank-tts-engine (rootless, boot-persistent).
#
# Install path on dankclaw: ~/.config/containers/systemd/frank-tts-engine.container
# Unit name: frank-tts-engine.service (container name: frank-tts-engine)
#
# WHAT IT IS. The arm A TTS engine: qwentts.cpp (pinned commit
# 5916bf7c79dbb4e66fc059cdc507a79a23a28bb4) built into an image by
# dankclaw/voiceservice/Containerfile.engine, running its resident `tts-server`
# with the 1.7 B base talker Q4_K_M and the Q8_0 12 Hz tokenizer loaded ONCE at
# startup. It is deliberately NOT a fleet endpoint: the endpoint consumers
# address is **frank-tts on 7772** ([[Frank TTS]]), which resolves voice names,
# re-encodes to mp3 and owns the timeout budget. Runbook: [[Frank TTS Engine]].
#
# WHY THIS ENGINE (Frank's call, 2026-10-18, criterion "whatever is better for
# live output"): server-side time-to-first-audio 94-112 ms (86-451 ms end to
# end through the façade), streaming that stays ahead of real time at warm RTF
# 0.55-0.67, and a run-to-run spread of +-0.01 — against the mainline CLI path's
# 0.93-0.99 at +-0.25, which crosses 1.0 often enough to stall a live stream.
# The A/B that decided it is "Decision: arm A for live output" in
# [[Local TTS and Voice Cloning]].
#
# IMAGE. Built locally by deploy-remote.sh (build_compose_image), like
# loki/alloy/frank-tts. `Pull=never` plus the `localhost/` prefix are required:
# this host's quadlet path cannot resolve a locally built tag from local storage
# otherwise. The build is a network operation (it clones the pinned commit) and
# is skipped when the Containerfile hash label already matches.
#
# NETWORK, AND WHY IT PUBLISHES NOTHING. The container sits on exactly one
# network: the private `frank-tts-engine.network` (10.89.5.0/24). The façade
# joins the same network and dials `http://frank-tts-engine:8880` — the
# container DNS name, on the network that owns it. There is no `PublishPort` of
# any kind, not even `127.0.0.1:8880`, and the reason is the lesson that 502'd
# hermes: netavark DNATs published ports only, so a host-loopback publish is not
# a path to a peer container — the façade could never have used it, and a bridge
# container cannot reach the host's own loopback bind either (see
# [[Container Network Namespaces]]). A publish would therefore buy exactly one
# thing: an extra reachable surface on an engine that has no authentication, on
# a box whose CPU is also carrying the text endpoints. Diagnostics do not need
# it either — `podman exec frank-tts-engine curl http://127.0.0.1:8880/health`
# speaks to the server from inside its own namespace, which is where the address
# is valid.
#
# MEMORY / NO GPU. Peak RSS measured 5.7 GiB with both GGUFs resident
# (2026-10-18 trial); no memory limit is set because the box has the room and a
# limit would convert a busy moment into an OOM kill mid-utterance. No
# /dev/kfd, no /dev/dri, no keep-groups: every GPU backend is compiled out of
# the image, the endpoint is CPU-only by decision, and the GPUs on this host
# belong to the text endpoints.
#
# THE VOICE MOUNT IS READ-ONLY AND IS FOR THE WARMER, NOT THE ENGINE. A voice
# becomes known to the engine only through POST /v1/audio/voices, and the
# registration is MEMORY ONLY — it does not survive a restart, which is exactly
# why the warm hook below runs on every start. That hook runs INSIDE this
# container (see ExecStartPost), so it reads the packs at /voices; `tts-server`
# itself never opens the directory, it receives the reference audio as base64 in
# the registration body. The façade mounts the same host directory read-only for
# its own name list, so one directory stays the single source of a voice name.
#
# REGISTRY LAW, THE REASON EXECSTARTPOST EXISTS. The voice registry is memory
# only: a restart forgets every voice, and re-registering does not warm one —
# only synthesizing does — so a boot without this hook leaves the façade
# answering 400 for every name. The per-voice cold penalty is real but small, and
# not the trial's 6.2-8.0 s: measured on the built container 2026-10-18 against an
# empty registry, the first synthesis of a freshly registered voice cost 1,883 ms
# against 1,226-1,283 ms for the requests after it. The warm pass runs serially
# (two short syntheses per voice, ~5 s each) and `TimeoutStartSec=900` keeps a growing library from
# tripping systemd's default 90 s start deadline. The leading `-` on
# ExecStartPost is load-bearing: with `Restart=always`, a warmer that exits
# non-zero (a pack missing its reference.wav, the engine still loading past the
# warmer's own patience) would restart the whole container and crash-loop the
# endpoint forever. A failed warm must be a journal line, not a restart.
#
# The hook is `podman exec`, not a host script, because there is no address the
# host could dial: this container publishes nothing (above). Exec-ing into it
# also means the warmer inherits this unit's EnvironmentFile, so every knob it
# has lives in scripts/frank-tts-engine.env beside the engine's own, and it sees
# the same /voices and 127.0.0.1:8880 the engine does.
#
# HEALTH. `GET /health` answers `{"status":"ok"}` from the HTTP thread, not the
# synthesis queue, so a busy engine still reads healthy and a dead one does not.
# HealthStartPeriod covers the first model load. The command is argument-only
# and needs /bin/sh — the generator wraps HealthCmd as CMD-SHELL — so the image
# installs curl (see the opposite case in dankclaw/loki, whose distroless base
# has no shell).
[Unit]
Description=frank-tts-engine (arm A qwentts.cpp TTS engine, internal :8880)
After=network-online.target
Wants=network-online.target
[Container]
ContainerName=frank-tts-engine
Image=localhost/franksdank/frank-tts-engine:latest
Pull=never
Network=frank-tts-engine.network
# No PublishPort, at all: the only reachable address this server has is the
# bridge address the façade resolves by name. Diagnostics use `podman exec`.
EnvironmentFile=/home/dank/serverAppConfigs/scripts/frank-tts-engine.env
Volume=/home/dank/models/Qwen3-TTS-GGUF:/models:ro
Volume=/home/dank/voices:/voices:ro
HealthCmd=curl -fsS http://127.0.0.1:8880/health
HealthInterval=30s
HealthTimeout=10s
HealthRetries=3
HealthStartPeriod=120s
[Service]
Restart=always
RestartSec=10
# Model load (tens of seconds) plus two short syntheses per registered voice.
TimeoutStartSec=900
ExecStartPost=-/usr/bin/podman exec frank-tts-engine /app/warm-voices.sh
[Install]
WantedBy=default.target
@@ -0,0 +1,52 @@
# Quadlet network: the private bridge of the voice pair (rootless).
#
# Install path on dankclaw: ~/.config/containers/systemd/frank-tts-engine.network
# Unit name: frank-tts-engine-network.service (podman network name:
# systemd-frank-tts-engine — quadlet prefixes "systemd-", so probe the runtime
# name, never the filename; see [[Container Network Namespaces]]).
#
# WHY A PRIVATE BRIDGE FOR A PAUSE THAT LOOKS PUBLIC-FACING. frank-tts (:7772,
# published, the one endpoint consumers address) and frank-tts-engine (:8880,
# never published, the arm A qwentts.cpp server behind it) could both have
# stayed on the fleet-wide dankclaw_default and resolved each other by name.
# They are split off anyway, for two reasons:
#
# 1. THE HOST-LOOPBACK TRAP. The phase-1 shape in `scripts/frank-tts.env` still
# names `host.containers.internal:8880` as the upstream, on the assumption the
# engine runs on the host and publishes `127.0.0.1:8880`. That cannot work:
# netavark DNATs published ports only, a published port is not on any podman
# bridge, and a container cannot reach the host's own loopback bind — the same
# shape that 502'd hermes, and the reason the signal-relay hop needs a host LAN
# bind instead ([[Container Network Namespaces]], [[Homepage Status Probes]]).
# Putting both containers on one committed network removes the guess: the
# façade dials the engine's container DNS name on the network that owns it, and
# the engine publishes nothing at all.
#
# 2. THE ENGINE HAS NO AUTH AND NO QUEUE LIMIT WORTH THE NAME. `tts-server`
# answers anyone who can open a socket, and at `--max-batch 1` a second
# caller simply waits behind the first. Left on dankclaw_default every
# container on that net — open-webui, hermes, the *arr proxies — could drive
# a CPU synthesiser that occupies every other core on a box that also serves
# two text endpoints. On this bridge the only dialer is frank-tts: the engine
# publishes no port, so not even the host has an address to reach it at, and
# diagnostics go through `podman exec` instead.
#
# Kernel bridge, not slirp4netns: the 2026-08-22 open-webui incident (a dead
# per-container userspace helper left a container that accepted TCP and reset
# every request for two days, logging nothing). A bridge has no such helper.
#
# Subnet registry on dankclaw (rootless):
# 10.88.0.0/16 default "podman" bridge
# 10.89.0.0/24 gitea (systemd-gitea)
# 10.89.1.0/24 compose stack (dankclaw_default)
# 10.89.2.0/24 searxng (systemd-searxng)
# 10.89.3.0/24 open-webui (systemd-open-webui)
# 10.89.4.0/24 open-notebook (systemd-open-notebook)
# 10.89.5.0/24 voice pair (systemd-frank-tts-engine) <- this file
# Pinned so the two container IPs stay deterministic across reboots, like the
# other pinned nets; 10.89.5.0/24 was the next unclaimed /24 in this registry.
[Network]
Driver=bridge
Subnet=10.89.5.0/24
Gateway=10.89.5.1
@@ -9,9 +9,9 @@
# /home/dank/voices holding reference.wav; the shim refuses any name without # /home/dank/voices holding reference.wav; the shim refuses any name without
# one (400 + the list that does exist) rather than narrating in a fallback # one (400 + the list that does exist) rather than narrating in a fallback
# voice nobody chose. The engine behind it is a separate decision — # voice nobody chose. The engine behind it is a separate decision —
# TTS_BACKEND in scripts/frank-tts.env picks between forwarding to an # TTS_BACKEND in scripts/frank-tts.env picks between forwarding to the
# OpenAI-shaped upstream on host :8880 and running a local CLI binary per # OpenAI-shaped engine (`frank-tts-engine`, [[Frank TTS Engine]]) and running a
# request. # local CLI binary per request.
# #
# IMAGE. Built locally from dankclaw/voiceservice/Containerfile by # IMAGE. Built locally from dankclaw/voiceservice/Containerfile by
# deploy-remote.sh (build_compose_image), like loki/alloy: python 3.12-alpine # deploy-remote.sh (build_compose_image), like loki/alloy: python 3.12-alpine
@@ -19,16 +19,27 @@
# Pull=never and the localhost/ prefix are required — this host's quadlet # Pull=never and the localhost/ prefix are required — this host's quadlet
# path cannot resolve a locally built tag from local storage otherwise. # path cannot resolve a locally built tag from local storage otherwise.
# #
# NETWORK. Bridge (dankclaw_default), NOT host: nothing here needs a shared # NETWORK. TWO attached networks, and the order is the one the open-notebook
# loopback namespace, and the comfyui/loki/authentik units are bridge too. # pair uses: `dankclaw_default` first (the default route, and where consumers
# Consequences both ways: consumers reach it on the published 7772, and the # live — they reach the published 7772, and open-notebook dials
# shim reaches the host-side TTS engine through host.containers.internal # `http://frank-tts:7772/v1` by container name), then the voice pair's private
# (never the LAN IP — a bridge netns cannot route to the host's own address # bridge, `frank-tts-engine.network`, where the arm A engine lives. The shim's
# for every bind shape, and the fleet's law is the gateway name). NOTE for # upstream is therefore the engine's container DNS name
# whoever starts the :8880 engine: host.containers.internal reaches the host # (`http://frank-tts-engine:8880`, scripts/frank-tts.env) and NOT
# network namespace, so the engine must bind an address that namespace # host.containers.internal: netavark DNATs published ports only, so a bridge
# accepts; a 127.0.0.1-only bind is not it (the 2026-09-17 signal-relay hop # container cannot reach a host-loopback bind — the shape that 502'd hermes —
# needed the host LAN bind for the same reason). # and the engine publishes nothing at all. That file MUST be installed beside
# this unit (the engine's handler does it; the façade handler re-installs it for
# `--only frank-tts`), because with a referenced Network= file missing the v1
# generator refuses to produce this container unit at all.
#
# ORDERING. Wants+After on frank-tts-engine.service, not Requires: the façade
# has a second backend (`TTS_BACKEND=llama-tts-cli`) that needs no upstream, and
# an engine that will not start must not take this endpoint's unit down with it.
# When both boot, this comes up after the engine, whose ExecStartPost warm pass
# is part of its start — so the first request of the day meets a REGISTERED voice
# (the registry is memory-only) rather than a 400, and skips the ~0.6 s penalty
# the first synthesis of an unwarmed voice costs.
# #
# VOICES. /home/dank/voices is host state, never repo state, and is mounted # VOICES. /home/dank/voices is host state, never repo state, and is mounted
# READ-ONLY: this endpoint has no business writing voice packs. Because the # READ-ONLY: this endpoint has no business writing voice packs. Because the
@@ -41,14 +52,16 @@
# carries them deliberately. # carries them deliberately.
[Unit] [Unit]
Description=frank-tts (cloned-voice OpenAI-compatible TTS facade) Description=frank-tts (cloned-voice OpenAI-compatible TTS facade)
After=network-online.target After=network-online.target frank-tts-engine.service
Wants=network-online.target Wants=network-online.target
Wants=frank-tts-engine.service
[Container] [Container]
ContainerName=frank-tts ContainerName=frank-tts
Image=localhost/franksdank/frank-tts:latest Image=localhost/franksdank/frank-tts:latest
Pull=never Pull=never
Network=dankclaw_default Network=dankclaw_default
Network=frank-tts-engine.network
PublishPort=7772:7772 PublishPort=7772:7772
EnvironmentFile=/home/dank/serverAppConfigs/scripts/frank-tts.env EnvironmentFile=/home/dank/serverAppConfigs/scripts/frank-tts.env
Volume=/home/dank/voices:/voices:ro Volume=/home/dank/voices:/voices:ro
+89
View File
@@ -0,0 +1,89 @@
#!/usr/bin/env bash
# tts-engine-start.sh — the entrypoint of the frank-tts-engine image
# (dankclaw/voiceservice/Containerfile.engine). It exists for one reason: the
# knobs of the arm A engine are command-line flags, while the fleet's knobs
# live in scripts/frank-tts-engine.env and reach the container through
# EnvironmentFile=. This is the translation layer, and nothing else — no
# supervision, no retries, no logging of its own. PID 1 stays the server, so
# `podman stop` and the unit's Restart=always drive it directly.
#
# THE FLAGS THAT ARE NOT HERE, and must stay absent:
#
# --threads / -t does not exist in this build. src/backend.h computes the
# CPU thread count as std::thread::hardware_concurrency()/2
# (the comment there: GEMM shares SIMD units across
# hyperthreads), and there is no flag to override it. So a
# CPU affinity pin (CPUQuota=, taskset, a cpuset) silently
# changes the engine's thread count instead of just its
# placement — pinning is deliberately not done anywhere in
# this stack. See the env file header for the same warning
# aimed at the operator.
# --device left at its default. Every GPU backend is compiled out of
# the image, so the default device pick can only land on
# CPU; naming a device would add a way to be wrong.
#
# Everything defaults to the shape the 2026-10-18 trial measured, so an empty
# environment still boots the decided configuration: 1.7 B talker Q4_K_M with
# the Q8_0 tokenizer, one request at a time, on the private bridge.
set -euo pipefail
# The binary path. Overridable for one reason: the bats seam runs a stub
# instead of a 1.2 GiB model. Operators never set it — the image ships exactly
# one binary at /app/tts-server.
: "${QWENTTS_BIN:=/app/tts-server}"
# Paths are CONTAINER paths (the host directories they come from are named in
# scripts/frank-tts-engine.env and mounted by the Quadlet unit).
: "${QWENTTS_MODEL:=/models/qwen-talker-1.7b-base-Q4_K_M.gguf}"
: "${QWENTTS_CODEC:=/models/qwen-tokenizer-12hz-Q8_0.gguf}"
# Reported by GET /v1/models, and what scripts/frank-tts.env's
# TTS_UPSTREAM_MODEL names. A model id, not a filename: the file name would
# leak a quantization choice into every consumer's config.
: "${QWENTTS_ALIAS:=frank-tts-engine}"
# 0.0.0.0 inside the container is not "public". tts-server defaults to
# 127.0.0.1 (its own usage prints `--host <ip> Listen address (default:
# 127.0.0.1)`), and inside a container netns a loopback bind is reachable by
# nothing outside that namespace — the façade dials this container by its bridge
# address on the private voice network, which is why the address has to be
# broad. The unit PUBLISHES NOTHING (see
# dankclaw/voiceservice/quadlet/frank-tts-engine.container: no PublishPort at
# all, not even 127.0.0.1:8880), so the only namespaces that can reach
# 0.0.0.0:8880 are that bridge and this container's own loopback — and its own
# loopback is exactly where the boot warmer and the health probe speak.
: "${QWENTTS_HOST:=0.0.0.0}"
: "${QWENTTS_PORT:=8880}"
# The language label used when a request omits one. `auto` is the upstream
# default and what the trial ran.
: "${QWENTTS_LANG:=auto}"
# One request at a time. The server-side queue is a serialised FIFO at this
# setting, which is what keeps the measured per-voice timings true; batching
# on a CPU build splits the same cores between two syntheses and the warm RTF
# margin (0.55-0.67 against real time 1.0) disappears. The engine's own
# default is also 1; it is written here because it is a decision, not an
# accident.
: "${QWENTTS_MAX_BATCH:=1}"
# Fail loudly BEFORE binding, so the journal says which artifact is missing
# instead of the unit reporting a container that starts and dies. This is the
# self-healing path: Restart=always re-runs it every RestartSec, and the same
# sentence repeats until the operator fetches the weights
# (scripts/download-frank-tts-engine-qwentts.sh).
for artifact in "$QWENTTS_MODEL" "$QWENTTS_CODEC"; do
if [[ ! -r "$artifact" ]]; then
echo "frank-tts-engine: cannot read ${artifact}; run scripts/download-frank-tts-engine-qwentts.sh on the host (the /models mount is read-only and holds the two GGUF files)" >&2
exit 1
fi
done
exec "$QWENTTS_BIN" \
--model "$QWENTTS_MODEL" \
--codec "$QWENTTS_CODEC" \
--alias "$QWENTTS_ALIAS" \
--host "$QWENTTS_HOST" \
--port "$QWENTTS_PORT" \
--lang "$QWENTTS_LANG" \
--max-batch "$QWENTTS_MAX_BATCH"
+222
View File
@@ -0,0 +1,222 @@
#!/usr/bin/env bash
# tts-engine-warm.sh — the boot warmer of the arm A TTS engine, shipped inside
# the frank-tts-engine image at /app/warm-voices.sh
# (dankclaw/voiceservice/Containerfile.engine).
#
# WHO RUNS IT. Two callers, same binary:
#
# 1. The Quadlet unit, as
# `ExecStartPost=-/usr/bin/podman exec frank-tts-engine /app/warm-voices.sh`
# — after every start of the container, including every boot.
# `podman exec` rather than a host script because the engine publishes no
# port at all, so there is no address the host could dial; and because an
# exec inherits the unit's EnvironmentFile, which is what puts every knob of
# this script in scripts/frank-tts-engine.env next to the engine's own.
# 2. scripts/deploy-remote.sh's deploy_frank_tts_engine, once per deploy, so a
# voice that did not warm becomes a deploy result instead of only a journal
# line (the unit's `-` prefix deliberately cannot fail the unit).
#
# WHY IT EXISTS AT ALL (the registry law). The voice registry is MEMORY ONLY: a
# restart forgets every voice, and re-registering a voice does not warm it — only
# synthesizing does. A boot that registers nothing therefore leaves frank-tts
# answering 400 for every name, so this script registers every pack it finds. The
# per-voice cold penalty is real but small, and smaller than the trial reported:
# measured on the deployed build 2026-10-18 against an empty registry, the FIRST
# synthesis of a freshly registered voice cost 1,883 ms against 1,226-1,283 ms for
# the requests after it — about 0.6 s, not the trial's 6.2-8.0 s. Each voice gets
# two identical requests so the journal carries both numbers, `voice <name>
# cold=…ms warm=…ms`; cold ≈ warm is the expected shape, and a large gap is what
# would mean the snapshot path is broken.
#
# THE VOICES. /voices — the host directory /home/dank/voices mounted read-only by
# the unit, the same packs frank-tts mounts for its own name list. A pack is a
# directory that contains reference.wav; everything else in it (manifest.json, the
# engine's reference.spk / reference.rvq extraction, a 16 kHz variant) is not read
# here. The registration payload is `{"name": <dir>, "wav_b64": <base64 of
# reference.wav>}` with NO ref_text, which selects the embedding-only (x-vector)
# voice — the shipped default, warm RTF ~0.57-0.63 against ICL's 0.67, and the one
# this fleet can produce honestly because it has no trustworthy transcript for the
# audition crops. Adding ref_text (with the codes) would flip that voice to
# in-context-learning mode: better fidelity on a good transcript, slower, and only
# worth it for batch work like a podcast render. That is a per-voice opt-in this
# script deliberately does NOT exercise, and the door stays open: a pack that
# wants ICL needs its text in the pack plus one key in the payload below.
#
# A lone `spk_b64` is a 400 (src/tts-server.h:480 — it is `wav_b64` or BOTH
# `spk_b64` and `rvq_b64`), which is why the WAV travels and the .spk stays home.
#
# NOTHING HERE IS A SECRET, and nothing is logged that could be: the audio bytes
# are discarded, the synthesized text comes from the env file, and the reference
# clip is base64'd into a request body that is written to a temporary file and
# handed to curl as `@file` — never echoed, and never placed on a command line,
# because of the size law explained at the mktemp below.
set -euo pipefail
# The engine's own address, from inside its namespace. Loopback is valid HERE:
# this script runs in the container, next to the server. There is deliberately no
# host-visible address to use instead — the unit publishes nothing, because a
# host-loopback publish would be unreachable from the façade anyway (netavark
# DNATs published ports only) while adding a surface on an unauthenticated
# server.
: "${ENGINE_URL:=http://127.0.0.1:8880}"
# Container path; the host directory behind it is named in the Quadlet unit.
: "${VOICES_DIR:=/voices}"
# How long to wait for the engine to answer /health. Model load dominates: the
# two GGUFs (1.2 GiB + 291 MB) are read and the context built before the HTTP
# thread binds, and a boot races every other unit on this box.
: "${HEALTH_WAIT:=300}"
# One short sentence, long enough to exercise the codec snapshot and short enough
# that warming a library of voices is seconds, not minutes. Never varied per run:
# the cold/warm numbers in the journal should mean the same thing every boot. The
# synthesis asks for `wav`, the buffered path — the streamed path (`pcm`) is
# different audio at the same seed, and `wav` is also what frank-tts asks for.
: "${WARM_TEXT:=Warm up, one two three.}"
# Per-request ceiling. A cold voice is 6-8 s by measurement; anything approaching
# this is a stalled engine and should be reported rather than waited on forever —
# systemd counts the whole warm pass against the unit's start deadline.
: "${REQUEST_TIMEOUT:=120}"
# The warm text goes into a JSON string literal by interpolation. Two steps make
# whatever the two readers of scripts/frank-tts-engine.env deliver arrive as the
# sentence itself:
#
# 1. Unwrap ONE shell-style quoting layer. bash (`set -a; source`) strips the
# quotes around a value with spaces; podman's `--env-file` does NOT — measured
# 2026-10-18 against the image's pinned base, `WARM_TEXT="…"` reaches the
# container WITH its quotes. Unwrapped only when the whole value is exactly one
# quoted string (starts AND ends with `"` and holds no quote inside), so a
# sentence that legitimately contains quotes is never mangled.
# 2. Escape the two characters that would corrupt or end a JSON string: a
# backslash, and a double quote.
#
# Without step 1 the body is `{"input":""Warm up…"",…}`, the engine's JSON parser
# rejects it, every voice stays cold, and the boot still reads healthy. Voice NAMES
# need neither step: the character class checked below admits no quote, no
# backslash and no control byte.
warm_text="$WARM_TEXT"
if (( ${#warm_text} >= 2 )) && [[ "$warm_text" == \"*\" ]]; then
inner="${warm_text:1:${#warm_text}-2}"
[[ "$inner" != *'"'* ]] && warm_text="$inner"
fi
warm_json="${warm_text//\\/\\\\}"
warm_json="${warm_json//\"/\\\"}"
# The registration body is a FILE, never an argument. The kernel caps a single
# command-line string at 32 pages — MAX_ARG_STRLEN = 131,072 bytes — and a shipped
# reference clip base64's to about 1.27 MB, so passing the body as one argument
# fails with E2BIG before curl is even executed. The first real boot of the unit
# (2026-10-18, the three shipped packs) proved it end to end: `/usr/bin/curl:
# Argument list too long` for every voice, reported as the misleading
# "registration failed (HTTP no response)". `mktemp` lands in the container's
# /tmp as the unprivileged `tts` user; one file for the whole pass, overwritten
# per voice, removed on any exit — it is container scratch, not host state.
body_file="$(mktemp -t frank-tts-warm.XXXXXX)"
trap 'rm -f "$body_file"' EXIT
fail=0
log() { printf 'frank-tts-engine-warm: %s\n' "$*"; }
# Wait for /health. It answers from the HTTP thread, so a 200 means the server is
# listening; it says nothing about voices, which is what the rest of this script
# is for.
ready=0
for ((i = 1; i <= HEALTH_WAIT; i++)); do
if curl -fsS --max-time 5 "${ENGINE_URL}/health" >/dev/null 2>&1; then
ready=1
break
fi
sleep 1
done
if (( ready == 0 )); then
log "engine did not answer ${ENGINE_URL}/health within ${HEALTH_WAIT}s; nothing warmed"
exit 1
fi
if [[ ! -d "$VOICES_DIR" ]]; then
log "no voice directory at ${VOICES_DIR}; nothing to warm (frank-tts will answer 400 with an empty voice list until packs are built)"
exit 0
fi
warmed=0
for dir in "$VOICES_DIR"/*/; do
[[ -d "$dir" ]] || continue
name="${dir%/}"
name="${name##*/}"
if [[ ! -f "${dir}reference.wav" ]]; then
continue # not a pack; frank-tts refuses this name too
fi
# The engine lowercases every voice name (tts_voice_name) while frank-tts
# matches the directory name exactly, so a mixed-case pack would work by
# accident on one side and not the other. Refuse it loudly instead. Pack
# names are also JSON string content here, so the character class is what
# keeps a directory name from writing structure into the request body.
if [[ ! "$name" =~ ^[a-z0-9][a-z0-9._-]*$ ]]; then
log "voice '${name}': name must be lowercase [a-z0-9._-] (the engine lowercases names; the shim does not) — skipped"
fail=1
continue
fi
# The audio enters through a redirection rather than a command substitution:
# the file is several hundred KB, and both a shell variable and an argument of
# that size are what this script used to die on.
{
printf '{"name":"%s","wav_b64":"' "$name"
base64 -w0 "${dir}reference.wav"
printf '"}'
} > "$body_file"
if ! code="$(curl -sS --max-time "$REQUEST_TIMEOUT" -o /dev/null -w '%{http_code}' \
-H 'Content-Type: application/json' --data-binary "@${body_file}" \
"${ENGINE_URL}/v1/audio/voices")" || [[ "$code" != "200" ]]; then
log "voice '${name}': registration failed (HTTP ${code:-no response}) — the shim will answer 400 for this name"
fail=1
continue
fi
# The cold pass. Its output is discarded; the point is the codec snapshot it
# leaves inside the engine.
start=$(date +%s%3N)
if ! curl -fsS --max-time "$REQUEST_TIMEOUT" -o /dev/null \
-H 'Content-Type: application/json' \
--data-binary "{\"input\":\"${warm_json}\",\"voice\":\"${name}\",\"response_format\":\"wav\"}" \
"${ENGINE_URL}/v1/audio/speech"; then
log "voice '${name}': registered, but the warm synthesis failed — first live request stays cold"
fail=1
continue
fi
cold=$(( $(date +%s%3N) - start ))
# The warm pass, same request. This is the number the fleet actually
# experiences, and it is what "the second request answers under a second" is
# measured against.
start=$(date +%s%3N)
if ! curl -fsS --max-time "$REQUEST_TIMEOUT" -o /dev/null \
-H 'Content-Type: application/json' \
--data-binary "{\"input\":\"${warm_json}\",\"voice\":\"${name}\",\"response_format\":\"wav\"}" \
"${ENGINE_URL}/v1/audio/speech"; then
log "voice '${name}': warm synthesis failed after a cold one that worked"
fail=1
continue
fi
warm=$(( $(date +%s%3N) - start ))
log "voice ${name} cold=${cold}ms warm=${warm}ms"
warmed=$(( warmed + 1 ))
done
# Failure first, emptiness second, and not the other way round: a pass where
# every voice failed also has `warmed == 0`, and reading that as "nothing to do,
# success" is how an endpoint that answers 400 for every voice name gets deployed
# on a Friday.
if (( fail )); then
log "${warmed} voice(s) warm, at least one failed (see the lines above)"
exit 1
fi
if (( warmed == 0 )); then
log "no voice with a reference.wav under ${VOICES_DIR}; engine is up but speaks nothing"
exit 0
fi
log "${warmed} voice(s) registered and warm"
+483
View File
@@ -0,0 +1,483 @@
#!/usr/bin/env bash
# build-voice-pack.sh — turn a SOURCE recording plus a CROP OFFSET into a
# deployable frank-tts voice pack: the reference WAV, the engine's own speaker
# artifacts made from it, and manifest.json provenance.
#
# THE PACK. $PACKS_DIR/<voice>/ carries the three members the pack law in
# scripts/frank-tts.env names:
#
# reference.wav the crop: 24 kHz mono PCM (pcm_s16le), 18-25 s of clean
# single-speaker audio. This is what makes a voice NAME real
# (dankclaw/voiceservice/app.py lists and resolves exactly
# this file), so it is written LAST, once everything else in
# the pack has been verified.
# reference.spk the engine's x-vector: enc_dim 2048 f32 = 8,192 B.
# reference.rvq the engine's RVQ code stream: 16 codebooks x 11 bits = 22 B
# per 12.5 Hz frame = 22 * floor(samples / 1920).
# manifest.json provenance: source path and sha256, crop start/end, sample
# rate, duration, the tool + engine commit that made the
# artifacts, and the voice name.
#
# Both artifacts come from the pinned engine build's speaker extractor — the
# `qwen-codec` tool of qwentts.cpp at VOICE_PACK_ENGINE_COMMIT — run on the pack's
# own WAV, and the extractor names them by swapping the input's extension, so
# they land beside reference.wav with nothing to rename. Required, never a
# replacement for the WAV: a voice another runtime cannot reuse is not saved.
#
# THE 10-SECOND GATE — THE REASON THIS SCRIPT EXISTS. Project-wide source-material
# invariant: the first 10 s of any sample file used to generate voices is
# forbidden material. An offset that starts inside that region is refused with a
# nonzero exit BEFORE anything is created — no pack dir, no WAV, no artifact — and
# the same gate is asserted a second time from the manifest that was just written
# (plus the recorded sha256 against the source it claims). A pack carrying a
# forbidden crop therefore cannot survive a build.
#
# THE ARTIFACT SIZES ARE CHECKED, NOT ASSUMED. They are a property of the codec
# config inside the GGUF, so an engine bump that changes enc_dim or the codebook
# count fails the build instead of shipping a pack the engine will reject at
# synthesis time. Note the interaction with the WAV window: an 18.0 s crop is a
# legal reference.wav but only 22 * 225 = 4,950 B of .rvq, under the recorded
# 5,456 B floor, so the effective crop window is 19.84-25 s. That is a real
# constraint and the builder says so up front rather than failing after a build;
# serverAppConfigs-docs/Dankclaw/Voice Packs.md covers what to do on an engine bump.
#
# WHERE IT RUNS: the host that holds the engine build (Dankclaw). The extractor is
# a CPU ggml binary — no GPU, no container, no fleet endpoint, no network. ffmpeg
# does the crop; every spawned process runs `nice -n 19` so a pack build never
# competes with the text endpoints on that box.
#
# IT NEVER DELETES. An existing pack is refused unless --force, and --force moves
# the whole old directory aside into $PACKS_DIR/.AGENTTRASH/<voice>-<UTC>/ rather
# than overwriting or unlinking a single byte.
#
# Usage:
# scripts/build-voice-pack.sh [OPTIONS] VOICE PACKS_DIR
# scripts/build-voice-pack.sh [OPTIONS] --all PACKS_DIR
#
# Options (anything not given comes from scripts/voice-packs.env):
# --env FILE tunables file (default scripts/voice-packs.env)
# --source FILE source recording for this voice
# --offset SEC crop start, in seconds into the source (must be >= 10)
# --duration SEC crop length; reference.wav comes out this long
# --sha256 HEX expected sha256 of the source file
# --tool PATH the pinned speaker extractor (qwen-codec)
# --all build every voice in VOICE_PACKS, skipping none
# --dry-run everything except the extractor: validate, cut the crop,
# probe it, print the plan and the manifest it would write
# --force retire an existing pack to .AGENTTRASH/ and rebuild
# -h, --help this text
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
ENV_FILE="$REPO_ROOT/scripts/voice-packs.env"
SOURCE_FORBIDDEN_SECONDS=10 # the source-material invariant; not a knob
WORK_DIR="" # scratch dir a dry run owns, removed on exit
# Only ever removes the scratch directory this script created for itself; pack
# bytes on disk are retired to .AGENTTRASH/, never deleted (see --force).
cleanup_work_dir() {
if [ -n "$WORK_DIR" ]; then
rm -rf -- "$WORK_DIR"
fi
}
trap cleanup_work_dir EXIT
die() {
printf 'ERROR: %s\n' "$*" >&2
exit 1
}
usage() {
sed -n '2,/^set -euo/p' "${BASH_SOURCE[0]}" | sed '$d' | sed 's/^# \{0,1\}//'
}
# fcmp A OP B — float comparison (awk); OP is one of < <= > >= == and A/B are
# already validated as plain decimals, so nothing user-shaped reaches awk as
# program text.
fcmp() {
awk -v a="$1" -v b="$3" "BEGIN { exit !(a $2 b) }"
}
# floorn X — greatest integer <= X, printed. Only ever called on positives.
floorn() {
awk -v x="$1" 'BEGIN { printf "%d\n", int(x) }'
}
is_seconds() {
[[ "$1" =~ ^[0-9]+([.][0-9]+)?$ ]]
}
is_hex64() {
[[ "$1" =~ ^[0-9a-f]{64}$ ]]
}
# ---------------------------------------------------------------- arguments --
VOICE=""
PACKS_DIR=""
ARG_SOURCE=""
ARG_OFFSET=""
ARG_DURATION=""
ARG_SHA=""
ARG_TOOL=""
DRY_RUN=0
FORCE=0
BUILD_ALL=0
while [ "$#" -gt 0 ]; do
case "$1" in
--env) ENV_FILE="${2:?--env needs a path}"; shift 2 ;;
--source) ARG_SOURCE="${2:?--source needs a file}"; shift 2 ;;
--offset) ARG_OFFSET="${2:?--offset needs seconds}"; shift 2 ;;
--duration) ARG_DURATION="${2:?--duration needs seconds}"; shift 2 ;;
--sha256) ARG_SHA="${2:?--sha256 needs a hex digest}"; shift 2 ;;
--tool) ARG_TOOL="${2:?--tool needs a path}"; shift 2 ;;
--dry-run) DRY_RUN=1; shift ;;
--force) FORCE=1; shift ;;
--all) BUILD_ALL=1; shift ;;
-h|--help) usage; exit 0 ;;
--*) die "unknown option: $1 (see --help)" ;;
*)
if [ -z "$VOICE" ]; then
VOICE="$1"
elif [ -z "$PACKS_DIR" ]; then
PACKS_DIR="$1"
else
die "unexpected argument: $1"
fi
shift
;;
esac
done
[ -f "$ENV_FILE" ] || die "tunables file not found: $ENV_FILE"
for tool in ffmpeg ffprobe jq sha256sum; do
command -v "$tool" >/dev/null 2>&1 || die "$tool is required and not on PATH"
done
# shellcheck disable=SC1090
source "$ENV_FILE"
# Tunables the env file owes us. Defaults are the engine facts read out of the
# pinned tree; an env file that omits one still builds against the truth.
VOICE_PACKS="${VOICE_PACKS:-}"
SAMPLE_RATE="${VOICE_PACK_SAMPLE_RATE:-24000}"
MIN_SECONDS="${VOICE_PACK_MIN_SECONDS:-18}"
MAX_SECONDS="${VOICE_PACK_MAX_SECONDS:-25}"
FRAME_RATE="${VOICE_PACK_FRAME_RATE:-12.5}"
RVQ_BYTES_PER_FRAME="${VOICE_PACK_RVQ_BYTES_PER_FRAME:-22}"
SPK_BYTES="${VOICE_PACK_SPK_BYTES:-8192}"
RVQ_MIN_BYTES="${VOICE_PACK_RVQ_MIN_BYTES:-5456}"
RVQ_MAX_BYTES="${VOICE_PACK_RVQ_MAX_BYTES:-6864}"
TOOL="${ARG_TOOL:-${VOICE_PACK_TOOL:-}}"
CODEC_MODEL="${VOICE_PACK_CODEC_MODEL:-}"
TALKER_MODEL="${VOICE_PACK_TALKER_MODEL:-}"
ENGINE_REPO="${VOICE_PACK_ENGINE_REPO:-https://github.com/ServeurpersoCom/qwentts.cpp}"
ENGINE_COMMIT="${VOICE_PACK_ENGINE_COMMIT:-5916bf7c79dbb4e66fc059cdc507a79a23a28bb4}"
DEVICE="${VOICE_PACK_DEVICE:-CPU}"
THREADS="${VOICE_PACK_THREADS:-8}"
NICE_LEVEL="${VOICE_PACK_NICE:-19}"
TRASH_DIRNAME="${VOICE_PACK_TRASH_DIRNAME:-.AGENTTRASH}"
DEFAULT_PACKS_DIR="${VOICE_PACK_DIR:-/home/dank/voices}"
[ -n "$PACKS_DIR" ] || PACKS_DIR="$DEFAULT_PACKS_DIR"
[ -n "$PACKS_DIR" ] || die "no packs directory (pass it as an argument)"
is_seconds "$SAMPLE_RATE" || die "VOICE_PACK_SAMPLE_RATE is not a number: $SAMPLE_RATE"
is_seconds "$MIN_SECONDS" || die "VOICE_PACK_MIN_SECONDS is not a number: $MIN_SECONDS"
is_seconds "$MAX_SECONDS" || die "VOICE_PACK_MAX_SECONDS is not a number: $MAX_SECONDS"
# hop / frame arithmetic, derived once: the codec runs at FRAME_RATE Hz over
# SAMPLE_RATE audio, so one frame is HOP_SAMPLES samples and one second is
# FRAME_RATE frames.
HOP_SAMPLES="$(floorn "$(awk -v r="$SAMPLE_RATE" -v f="$FRAME_RATE" 'BEGIN { print r / f }')")"
[ "$HOP_SAMPLES" -gt 0 ] || die "VOICE_PACK_FRAME_RATE ($FRAME_RATE) exceeds the sample rate"
BYTES_PER_SECOND="$(awk -v b="$RVQ_BYTES_PER_FRAME" -v f="$FRAME_RATE" 'BEGIN { print b * f }')"
# rvq_bytes_for DURATION — packed size the extractor will emit for a crop this
# long: whole frames only, because it truncates to the hop boundary.
rvq_bytes_for() {
local samples frames
samples="$(awk -v d="$1" -v r="$SAMPLE_RATE" 'BEGIN { printf "%d\n", int(d * r + 0.5) }')"
frames=$((samples / HOP_SAMPLES))
printf '%d\n' "$((frames * RVQ_BYTES_PER_FRAME))"
}
# ------------------------------------------------------------------- build --
build_voice() {
local voice="$1"
# The pack dir name is the voice name on the wire: app.py's VOICE_NAME_RE
# matches it exactly while the engine lowercases names internally, so an
# uppercase or punctuated name would be reported one way by the façade and
# another by the engine. Lowercase only, and nothing that could escape
# $PACKS_DIR.
[[ "$voice" =~ ^[a-z0-9][a-z0-9._-]{0,63}$ ]] ||
die "voice name '$voice' is not a lowercase [a-z0-9._-] name starting with alphanumerics (app.py resolves it verbatim; the engine lowercases it)"
local prefix
prefix="VOICE_PACK_$(printf '%s' "$voice" | tr '[:lower:]' '[:upper:]' | tr -c '[:alnum:]' '_')"
local source_var="${prefix}_SOURCE" offset_var="${prefix}_OFFSET"
local duration_var="${prefix}_DURATION" sha_var="${prefix}_SHA256"
local source="${ARG_SOURCE:-${!source_var:-}}"
local offset="${ARG_OFFSET:-${!offset_var:-}}"
local duration="${ARG_DURATION:-${!duration_var:-}}"
local want_sha="${ARG_SHA:-${!sha_var:-}}"
if [ -z "$voice" ] || [ -z "$source" ]; then
die "no source for '$voice': pass --source or set ${source_var} in $ENV_FILE"
fi
[ -n "$offset" ] || die "no crop offset for '$voice': pass --offset or set ${offset_var}"
[ -n "$duration" ] || die "no crop duration for '$voice': pass --duration or set ${duration_var}"
is_seconds "$offset" || die "crop offset '$offset' is not a number of seconds"
is_seconds "$duration" || die "crop duration '$duration' is not a number of seconds"
[ -f "$source" ] && [ -r "$source" ] || die "source not readable: $source"
# === THE GATE. Source seconds 0..10 are forbidden voice material, full stop.
if ! fcmp "$offset" '>=' "$SOURCE_FORBIDDEN_SECONDS"; then
die "source-material invariant violated: the crop must start past the first ${SOURCE_FORBIDDEN_SECONDS} s of the source file, and '$voice' asks for ${offset} s into $source. Pick an offset at or past ${SOURCE_FORBIDDEN_SECONDS} s — nothing was written."
fi
local pack_dir="$PACKS_DIR/$voice"
if [ "$DRY_RUN" -ne 1 ] && [ -e "$pack_dir" ] && [ "$FORCE" -ne 1 ]; then
die "$pack_dir already exists; nothing was touched. Re-run with --force to retire it to $PACKS_DIR/$TRASH_DIRNAME/ and rebuild."
fi
local source_duration source_bytes source_sha
source_duration="$(ffprobe -v error -show_entries format=duration -of default=nw=1:nk=1 "$source")"
is_seconds "$source_duration" || die "ffprobe reports no numeric duration for $source: '$source_duration'"
source_bytes="$(stat -c%s "$source")"
if ! fcmp "$(awk -v a="$offset" -v d="$duration" 'BEGIN { print a + d }')" '<=' "$source_duration"; then
die "crop ends at $(awk -v a="$offset" -v d="$duration" 'BEGIN { print a + d }') s but $source is only $source_duration s; the crop would come out short"
fi
if ! fcmp "$duration" '>=' "$MIN_SECONDS" || ! fcmp "$duration" '<=' "$MAX_SECONDS"; then
die "crop duration $duration s is outside the ${MIN_SECONDS}-${MAX_SECONDS} s reference window"
fi
local crop_end planned_rvq
crop_end="$(awk -v a="$offset" -v d="$duration" 'BEGIN { print a + d }')"
planned_rvq="$(rvq_bytes_for "$duration")"
if ! fcmp "$planned_rvq" '>=' "$RVQ_MIN_BYTES" || ! fcmp "$planned_rvq" '<=' "$RVQ_MAX_BYTES"; then
die "a $duration s crop packs to ${planned_rvq} B of .rvq, outside the recorded engine window ${RVQ_MIN_BYTES}-${RVQ_MAX_BYTES} B; lengthen the crop (that window starts at $(awk -v m="$RVQ_MIN_BYTES" -v b="$BYTES_PER_SECOND" 'BEGIN { print m / b }') s) — the source, not the codec, sets this floor"
fi
echo "VOICE $voice"
echo "SOURCE $source ($source_bytes bytes, ${source_duration} s)"
echo "CROP ${offset} s .. ${crop_end} s (${duration} s) — past the ${SOURCE_FORBIDDEN_SECONDS} s forbidden region"
source_sha="$(sha256sum "$source" | awk '{ print $1 }')"
if [ -n "$want_sha" ]; then
is_hex64 "$want_sha" || die "expected sha256 '$want_sha' is not 64 hex digits"
[ "$source_sha" = "$want_sha" ] ||
die "source sha256 is $source_sha, expected $want_sha: $source is not the file these offsets were cut from"
fi
echo "SHA256 $source_sha"
# Where the crop is cut. A dry run keeps its work in a scratch dir it owns;
# a real build stages INSIDE the pack dir so the final rename is on one
# filesystem and the pack becomes visible only when it is whole.
local stage_dir
if [ "$DRY_RUN" -eq 1 ]; then
WORK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/voice-pack-$voice.XXXXXX")"
stage_dir="$WORK_DIR"
else
mkdir -p "$PACKS_DIR"
if [ -e "$pack_dir" ]; then
local trash
trash="$PACKS_DIR/$TRASH_DIRNAME/$voice-$(date -u +%Y%m%dT%H%M%SZ)"
mkdir -p "$(dirname "$trash")"
mv "$pack_dir" "$trash"
echo "RETIRED $pack_dir -> $trash (nothing deleted)"
fi
mkdir "$pack_dir" || die "cannot create $pack_dir (it may exist now); nothing was overwritten"
stage_dir="$pack_dir"
fi
local stage_wav="$stage_dir/staging-reference.wav"
nice -n "$NICE_LEVEL" env OMP_NUM_THREADS="$THREADS" \
ffmpeg -hide_banner -loglevel error -y \
-ss "$offset" -t "$duration" -i "$source" \
-map 0:a:0 -vn -ac 1 -ar "$SAMPLE_RATE" -c:a pcm_s16le "$stage_wav"
local wav_rate wav_channels wav_codec wav_duration
wav_rate="$(ffprobe -v error -select_streams a:0 -show_entries stream=sample_rate -of default=nw=1:nk=1 "$stage_wav")"
wav_channels="$(ffprobe -v error -select_streams a:0 -show_entries stream=channels -of default=nw=1:nk=1 "$stage_wav")"
wav_codec="$(ffprobe -v error -select_streams a:0 -show_entries stream=codec_name -of default=nw=1:nk=1 "$stage_wav")"
wav_duration="$(ffprobe -v error -select_streams a:0 -show_entries stream=duration -of default=nw=1:nk=1 "$stage_wav")"
is_seconds "$wav_rate" || die "ffprobe reports no numeric sample rate for the crop: '$wav_rate'"
is_seconds "$wav_duration" || die "ffprobe reports no numeric duration for the crop: '$wav_duration'"
[ "$wav_rate" = "$SAMPLE_RATE" ] || die "reference.wav came out at ${wav_rate} Hz, not ${SAMPLE_RATE}"
[ "$wav_channels" = "1" ] || die "reference.wav came out with $wav_channels channels, not mono"
[ "$wav_codec" = "pcm_s16le" ] || die "reference.wav came out as $wav_codec, not the pcm_s16le the law names"
if ! fcmp "$wav_duration" '>=' "$MIN_SECONDS" || ! fcmp "$wav_duration" '<=' "$MAX_SECONDS"; then
die "reference.wav is ${wav_duration} s, outside the ${MIN_SECONDS}-${MAX_SECONDS} s window"
fi
echo "REFERENCE ${wav_rate} Hz ${wav_codec} mono, ${wav_duration} s, $(stat -c%s "$stage_wav") bytes"
local spk_file="$stage_dir/staging-reference.spk" rvq_file="$stage_dir/staging-reference.rvq"
local stage_log="$stage_dir/staging-qwen-codec.log"
local spk_bytes=0 rvq_bytes=0 produced=false
if [ "$DRY_RUN" -eq 1 ]; then
if [ -n "$TOOL" ] && [ -x "$TOOL" ]; then
echo "EXTRACTOR $TOOL (present; --dry-run will not run it)"
else
echo "EXTRACTOR not present (${TOOL:-unset}) — --dry-run does not need it"
fi
echo "PLANNED reference.spk ${SPK_BYTES} B, reference.rvq $(rvq_bytes_for "$wav_duration") B"
else
[ -n "$TOOL" ] && [ -x "$TOOL" ] ||
die "speaker extractor missing or not executable: ${TOOL:-VOICE_PACK_TOOL unset} (use --dry-run to build everything up to this point)"
[ -f "$CODEC_MODEL" ] || die "codec model missing: $CODEC_MODEL"
[ -f "$TALKER_MODEL" ] || die "talker model missing: $TALKER_MODEL"
# The extractor takes the WAV and writes its outputs next to it with the
# extension swapped — there is no -o, so the staged name decides where
# the artifacts land.
local extractor=("$TOOL" --model "$CODEC_MODEL" --talker "$TALKER_MODEL" -i "$stage_wav")
if [ -n "$DEVICE" ]; then
extractor+=(--device "$DEVICE")
fi
echo "EXTRACT $(basename "$TOOL") at $ENGINE_COMMIT (device ${DEVICE:-auto}, $THREADS threads)"
if ! nice -n "$NICE_LEVEL" env OMP_NUM_THREADS="$THREADS" \
"${extractor[@]}" 2>&1 | tee "$stage_log" >&2; then
die "$TOOL failed on $stage_wav (its output is in $stage_log); the pack was not published"
fi
[ -f "$spk_file" ] || die "$TOOL wrote no $spk_file (see $stage_log)"
[ -f "$rvq_file" ] || die "$TOOL wrote no $rvq_file (see $stage_log)"
spk_bytes="$(stat -c%s "$spk_file")"
rvq_bytes="$(stat -c%s "$rvq_file")"
local expect_rvq
expect_rvq="$(rvq_bytes_for "$wav_duration")"
[ "$spk_bytes" = "$SPK_BYTES" ] ||
die "reference.spk is $spk_bytes B, expected ${SPK_BYTES} B (enc_dim x f32). The engine changed its speaker encoder — see [[Voice Packs]] before shipping this pack"
[ "$rvq_bytes" = "$expect_rvq" ] ||
die "reference.rvq is $rvq_bytes B, expected ${expect_rvq} B for a ${wav_duration} s crop (22 B per 12.5 Hz frame)"
if ! fcmp "$rvq_bytes" '>=' "$RVQ_MIN_BYTES" || ! fcmp "$rvq_bytes" '<=' "$RVQ_MAX_BYTES"; then
die "reference.rvq is $rvq_bytes B, outside the recorded engine window ${RVQ_MIN_BYTES}-${RVQ_MAX_BYTES} B — an engine bump? see [[Voice Packs]]"
fi
produced=true
echo "ARTIFACTS reference.spk ${spk_bytes} B, reference.rvq ${rvq_bytes} B"
fi
local built stage_manifest
built="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
stage_manifest="$stage_dir/staging-manifest.json"
jq -n \
--arg voice "$voice" \
--arg built "$built" \
--arg builder "scripts/build-voice-pack.sh" \
--arg env_file "$ENV_FILE" \
--arg src "$source" \
--arg src_sha "$source_sha" \
--argjson src_bytes "$source_bytes" \
--argjson src_duration "$source_duration" \
--argjson offset "$offset" \
--argjson end "$crop_end" \
--argjson duration "$duration" \
--argjson forbidden "$SOURCE_FORBIDDEN_SECONDS" \
--argjson rate "$SAMPLE_RATE" \
--argjson wav_duration "$wav_duration" \
--arg wav_codec "$wav_codec" \
--argjson wav_channels "$wav_channels" \
--argjson wav_bytes "$(stat -c%s "$stage_wav")" \
--arg tool "${TOOL:-unset}" \
--arg repo "$ENGINE_REPO" \
--arg commit "$ENGINE_COMMIT" \
--arg codec "$CODEC_MODEL" \
--arg talker "$TALKER_MODEL" \
--arg device "$DEVICE" \
--argjson produced "$produced" \
--argjson spk_bytes "$spk_bytes" \
--argjson rvq_bytes "$rvq_bytes" \
--argjson planned_rvq "$(rvq_bytes_for "$wav_duration")" \
--argjson dry_run "$DRY_RUN" \
'{
voice: $voice,
kind: "voice-pack",
built_utc: $built,
builder: $builder,
tunables: $env_file,
dry_run: ($dry_run == 1),
source: {
path: $src,
sha256: $src_sha,
bytes: $src_bytes,
duration_seconds: $src_duration
},
crop: {
offset_seconds: $offset,
end_seconds: $end,
duration_seconds: $duration
},
reference: {
file: "reference.wav",
codec: $wav_codec,
sample_rate: $rate,
channels: $wav_channels,
duration_seconds: $wav_duration,
bytes: $wav_bytes
},
artifacts: {
produced: $produced,
tool: $tool,
engine_repo: $repo,
engine_commit: $commit,
device: $device,
codec_model: $codec,
talker_model: $talker,
spk: {file: "reference.spk", bytes: $spk_bytes},
rvq: {file: "reference.rvq", bytes: $rvq_bytes, planned_bytes: $planned_rvq}
},
invariant: {
name: "source-material invariant",
rule: "a voice crop MUST start past the first 10 seconds of the source file",
forbidden_source_seconds: $forbidden,
crop_offset_seconds: $offset
}
}' >"$stage_manifest"
# Assert the gate a second time, FROM the manifest, next to the bytes it
# describes. Anything short of a pass leaves the staged pack unpublished.
local recorded_offset recorded_sha
recorded_offset="$(jq -r '.crop.offset_seconds' "$stage_manifest")"
recorded_sha="$(jq -r '.source.sha256' "$stage_manifest")"
jq -e --argjson min "$SOURCE_FORBIDDEN_SECONDS" \
'(.crop.offset_seconds | type == "number") and .invariant.forbidden_source_seconds == $min and (.crop.offset_seconds >= $min) and (.invariant.name == "source-material invariant")' \
"$stage_manifest" >/dev/null ||
die "manifest assertion failed: .crop.offset_seconds ($recorded_offset) does not clear the ${SOURCE_FORBIDDEN_SECONDS} s source-material invariant"
[ "$recorded_sha" = "$source_sha" ] ||
die "manifest assertion failed: .source.sha256 ($recorded_sha) is not the hash of $source ($source_sha)"
jq -e '(.voice | type == "string") and (.reference.sample_rate | type == "number") and (.reference.duration_seconds | type == "number") and (.artifacts.engine_commit | length == 40)' \
"$stage_manifest" >/dev/null ||
die "manifest assertion failed: voice / reference / engine provenance fields are not all present"
echo "MANIFEST crop offset $recorded_offset s clears the ${SOURCE_FORBIDDEN_SECONDS} s invariant"
if [ "$DRY_RUN" -eq 1 ]; then
echo "MANIFEST_BEGIN"
cat "$stage_manifest"
echo "MANIFEST_END"
echo "PLAN would write $pack_dir/{reference.wav,reference.spk,reference.rvq,manifest.json}"
echo "DRYRUN $voice: inputs valid, crop cut and probed, extractor not run"
else
# reference.wav is the gate the shim reads, so it lands last: the voice
# name never exists on disk in a state the artifacts are not behind it.
mv "$stage_log" "$pack_dir/qwen-codec.log"
mv "$spk_file" "$pack_dir/reference.spk"
mv "$rvq_file" "$pack_dir/reference.rvq"
mv "$stage_manifest" "$pack_dir/manifest.json"
mv "$stage_wav" "$pack_dir/reference.wav"
echo "BUILT $pack_dir (voice '$voice': ${wav_duration} s, spk ${spk_bytes} B, rvq ${rvq_bytes} B)"
echo "VOICE_PACK_BUILT $voice $pack_dir"
fi
}
if [ "$BUILD_ALL" -eq 1 ]; then
[ -z "$VOICE" ] || die "--all takes no voice name (got '$VOICE'); the list comes from VOICE_PACKS"
for flag in "$ARG_SOURCE" "$ARG_OFFSET" "$ARG_DURATION" "$ARG_SHA"; do
[ -z "$flag" ] || die "--all reads every crop from $ENV_FILE; --source/--offset/--duration/--sha256 would apply one value to all of them"
done
read -r -a voices <<<"$VOICE_PACKS"
[ "${#voices[@]}" -gt 0 ] || die "VOICE_PACKS is empty in $ENV_FILE"
for voice in "${voices[@]}"; do
build_voice "$voice"
done
else
[ -n "$VOICE" ] || { usage >&2; die "no voice name given"; }
build_voice "$VOICE"
fi
+59
View File
@@ -26,6 +26,7 @@ SERVICE_ORDER = (
"marreta", "marreta",
"pihole", "pihole",
"open-webui", "open-webui",
"open-notebook",
"gitea-postgres", "gitea-postgres",
"gitea-server", "gitea-server",
"comfyui", "comfyui",
@@ -61,6 +62,11 @@ SERVICE_ORDER = (
"sonarr", "sonarr",
"lidarr", "lidarr",
"readarr", "readarr",
# The voice pair keeps the inference family's ordering rule: the engine is
# a dependency of the 7772 facade, so it is planned (and therefore
# restarted) first.
"frank-tts-engine",
"frank-tts",
"frank-dragoon", "frank-dragoon",
"frank-zealot", "frank-zealot",
"frank-archon", "frank-archon",
@@ -74,6 +80,7 @@ DANKCLAW_SERVICES = {
"marreta", "marreta",
"pihole", "pihole",
"open-webui", "open-webui",
"open-notebook",
"gitea-postgres", "gitea-postgres",
"gitea-server", "gitea-server",
"comfyui", "comfyui",
@@ -95,6 +102,8 @@ DANKCLAW_SERVICES = {
"homepage", "homepage",
"frank-dragoon", "frank-dragoon",
"frank-zealot", "frank-zealot",
"frank-tts-engine",
"frank-tts",
} }
DANKSERVER_SERVICES = { DANKSERVER_SERVICES = {
@@ -316,6 +325,35 @@ def services_for_path(path: str, target: str) -> set[str]:
if len(parts) >= 2 and parts[1] == "open-webui": if len(parts) >= 2 and parts[1] == "open-webui":
_add(services, "open-webui") _add(services, "open-webui")
return services return services
if len(parts) >= 2 and parts[1] == "open-notebook":
# One deployable owns the app AND its SurrealDB, like the marreta
# app/companion pair: the app alone cannot write a note and the
# database alone is a volume nothing reads. Any change under the
# directory (unit, network, or the interim episode mount) therefore
# plans the pair, which deploy_open_notebook starts database-first.
_add(services, "open-notebook")
return services
if len(parts) >= 2 and parts[1] == "voiceservice":
# The voice stack's containers share one directory but are TWO
# deployables — the arm A engine and the 7772 façade in front of it
# — so this maps per file, the way the authentik trio does, and not
# per directory. Anything that is not named after one of the two
# services plans nothing, exactly as it did before the pair was
# registered.
if parts[-1] in {"frank-tts-engine.container", "frank-tts-engine.network",
"Containerfile.engine", "tts-engine-start.sh",
"tts-engine-warm.sh"}:
_add(services, "frank-tts-engine")
# The 7772 façade is the other deployable in this directory. The
# bridge file is named after the ENGINE unit (install_quadlet copies
# `$unit.network` beside `$unit.container`), yet both units attach
# to it, so a subnet change is a change to both. Anything else here
# belongs to neither deployable and plans nothing.
if parts[-1] in {"frank-tts.container", "app.py", "Containerfile"}:
_add(services, "frank-tts")
if parts[-1] == "frank-tts-engine.network":
_add(services, "frank-tts")
return services
# frank-dragoon left the quadlet shape on 2026-09-19 (CIRU v4.4.1 host # frank-dragoon left the quadlet shape on 2026-09-19 (CIRU v4.4.1 host
# build, root unit): its files live in scripts/ and map there. # build, root unit): its files live in scripts/ and map there.
if len(parts) >= 2 and parts[1] == "observability": if len(parts) >= 2 and parts[1] == "observability":
@@ -419,6 +457,21 @@ def services_for_path(path: str, target: str) -> set[str]:
# what the live frank-dragoon endpoint renders, so it plans # what the live frank-dragoon endpoint renders, so it plans
# frank-dragoon. # frank-dragoon.
_add(services, "frank-dragoon") _add(services, "frank-dragoon")
if name == "frank-tts-engine.env":
# The engine's knobs are command-line flags, and this file is the
# repo's copy of them (the image entrypoint translates one line to
# one flag), so editing it is a restart of the engine. It also
# carries the boot warmer's three knobs, because the warmer is
# exec-ed into this container and inherits the same EnvironmentFile.
# A restart is what re-registers and re-warms every voice: the
# engine's voice registry is memory-only. (The warmer itself lives
# with the image, mapped above with the files it is built from, like
# the serve scripts of frank-zealot and frank-archon are mapped to
# the units that run them.)
# scripts/download-frank-tts-engine-qwentts.sh deliberately plans
# nothing: it fetches host-side state, like every other download
# script in this directory.
_add(services, "frank-tts-engine")
if name in {"frank-archon.env", "frank-archon.service", "serve-frank-archon.sh"}: if name in {"frank-archon.env", "frank-archon.service", "serve-frank-archon.sh"}:
# llama.cpp frank-archon (2026-09-26, replacing the Lucebox # llama.cpp frank-archon (2026-09-26, replacing the Lucebox
# DS4.1 quadlet) keeps its unit, env and serve files here the # DS4.1 quadlet) keeps its unit, env and serve files here the
@@ -426,6 +479,12 @@ def services_for_path(path: str, target: str) -> set[str]:
# serving Qwen3.8-Flash-Next Q8_0. The download script fetches # serving Qwen3.8-Flash-Next Q8_0. The download script fetches
# the payload, which is host-side state, so it selects nothing. # the payload, which is host-side state, so it selects nothing.
_add(services, "frank-archon") _add(services, "frank-archon")
if name == "open-notebook.env":
# open-notebook's tunables live here — the only repo-tracked part of
# either container's environment (the credentials are seeded on the
# host, so they are never a deploy trigger). Changing it is a
# restart of the pair, which the single deployable performs.
_add(services, "open-notebook")
# Deployment/test/benchmark scripts and other repo tooling do not # Deployment/test/benchmark scripts and other repo tooling do not
# restart a runtime service. # restart a runtime service.
return services return services
+217 -1
View File
@@ -90,7 +90,9 @@ service_is_allowed() {
dankclaw:authentik-postgres|dankclaw:authentik-server|\ dankclaw:authentik-postgres|dankclaw:authentik-server|\
dankclaw:authentik-worker|dankclaw:authentik-outpost|dankclaw:authentik-outpost-ldap|dankclaw:anubis|\ dankclaw:authentik-worker|dankclaw:authentik-outpost|dankclaw:authentik-outpost-ldap|dankclaw:anubis|\
dankclaw:caddy|dankclaw:homepage|dankclaw:seerr|dankclaw:marreta|\ dankclaw:caddy|dankclaw:homepage|dankclaw:seerr|dankclaw:marreta|\
dankclaw:open-notebook|\
dankclaw:frank-dragoon|dankclaw:frank-zealot|\ dankclaw:frank-dragoon|dankclaw:frank-zealot|\
dankclaw:frank-tts-engine|dankclaw:frank-tts|\
dankserver:romm-mariadb|dankserver:romm-redis|dankserver:romm|\ dankserver:romm-mariadb|dankserver:romm-redis|dankserver:romm|\
dankserver:copyparty|dankserver:jellyfin|dankserver:calibre-web|dankserver:calibre-server|dankserver:plex|dankserver:tautulli|\ dankserver:copyparty|dankserver:jellyfin|dankserver:calibre-web|dankserver:calibre-server|dankserver:plex|dankserver:tautulli|\
dankserver:prowlarr|dankserver:qbittorrent|dankserver:radarr|\ dankserver:prowlarr|dankserver:qbittorrent|dankserver:radarr|\
@@ -539,6 +541,14 @@ deploy_observability() {
build_compose_image() { build_compose_image() {
local service="$1" image cfhash oldhash containerfile local service="$1" image cfhash oldhash containerfile
# Every file the image COPIES IN, beside the Containerfile. The skip below
# compares a hash against the image's `cfhash` label, so anything the build
# bakes must be in that hash or the deploy silently keeps serving the old
# bytes: an edited app.py or warm hook leaves the Containerfile untouched,
# the label matches, `podman build` never runs, and the health gate passes on
# a stale container. Services whose Containerfile pulls everything at
# build time (comfyui, loki, alloy) keep an empty list.
local -a extra=()
case "$service" in case "$service" in
comfyui) comfyui)
image=comfyui-rocm:gfx1151 image=comfyui-rocm:gfx1151
@@ -548,11 +558,32 @@ build_compose_image() {
image="franksdank/$service:latest" image="franksdank/$service:latest"
containerfile="dankclaw/$service/Containerfile" containerfile="dankclaw/$service/Containerfile"
;; ;;
frank-tts)
# The 7772 façade: stdlib python + ffmpeg, app.py copied in. Listed
# explicitly because build_compose_image's `*)` arm returns 0 — an
# unlisted service builds nothing and the unit's Pull=never then
# fails on a tag that was never made.
image=franksdank/frank-tts:latest
containerfile=dankclaw/voiceservice/Containerfile
extra=(dankclaw/voiceservice/app.py)
;;
frank-tts-engine)
# The arm A TTS engine (qwentts.cpp `tts-server`). Built from
# source at a pinned upstream commit, so the build needs network
# and the cfhash label below is what keeps an unchanged re-deploy
# offline: the clone only happens when this Containerfile changes.
image=franksdank/frank-tts-engine:latest
containerfile=dankclaw/voiceservice/Containerfile.engine
# start.sh is the image's CMD and the warmer is the unit's
# ExecStartPost; both are COPY'd in, so both decide what this image is.
extra=(dankclaw/voiceservice/tts-engine-start.sh
dankclaw/voiceservice/tts-engine-warm.sh)
;;
*) *)
return 0 return 0
;; ;;
esac esac
cfhash="$(sha256sum "$containerfile" | cut -d' ' -f1)" cfhash="$(sha256sum "$containerfile" "${extra[@]}" | cut -d' ' -f1 | tr -d '\n')"
oldhash="$(podman image inspect "$image" --format '{{index .Config.Labels "cfhash"}}' 2>/dev/null || echo none)" oldhash="$(podman image inspect "$image" --format '{{index .Config.Labels "cfhash"}}' 2>/dev/null || echo none)"
if [[ "$oldhash" != "$cfhash" ]]; then if [[ "$oldhash" != "$cfhash" ]]; then
# --format docker is required, not cosmetic: podman defaults to the OCI # --format docker is required, not cosmetic: podman defaults to the OCI
@@ -646,6 +677,188 @@ deploy_open_webui() {
systemctl --user restart open-webui.service systemctl --user restart open-webui.service
} }
# open-notebook is the fleet's podcast generator: one app container (API,
# worker and web UI under supervisord) in front of a SurrealDB container, with
# a private bridge between them. One handler owns the pair, because restarting
# the app without its database yields a UI that answers /health and fails every
# write; scripts/deploy-plan.py therefore maps every path of the pair here.
#
# Credentials follow the marreta convention (per-service file under the deploy
# user's home, mode 600, seeded from dankclaw/.env) with one difference: they
# are not optional. An empty OPEN_NOTEBOOK_PASSWORD is "authentication OFF",
# not "unconfigured" — the API, episode audio included, then answers anyone on
# the LAN — and an empty SURREAL_PASS lets the database come up with different
# root credentials than the app will present. So this handler refuses to start
# either unit until every key carries a value. An existing file is NEVER
# rewritten: regenerating an operator-set password would desync it from the
# credentials already stored in the database.
seed_open_notebook_env() {
local dest=/home/dank/Containers/open-notebook/env
local var missing=0
for var in OPEN_NOTEBOOK_ENCRYPTION_KEY OPEN_NOTEBOOK_PASSWORD SURREAL_PASSWORD SURREAL_PASS; do
grep -q "^${var}=." "$dest" 2>/dev/null || missing=1
done
if [[ "$missing" == 0 ]]; then
return 0
fi
if [[ ! -e "$dest" ]]; then
if [[ ! -f dankclaw/.env ]]; then
echo "open-notebook: neither $dest nor dankclaw/.env exists; put OPEN_NOTEBOOK_ENCRYPTION_KEY, OPEN_NOTEBOOK_PASSWORD and SURREAL_PASSWORD in dankclaw/.env (see dankclaw/.env.example)" >&2
return 1
fi
mkdir -p /home/dank/Containers/open-notebook
{
awk -F= '/^OPEN_NOTEBOOK_ENCRYPTION_KEY=/ || /^OPEN_NOTEBOOK_PASSWORD=/ || /^SURREAL_PASSWORD=/ { print }' \
dankclaw/.env
# The `surreal` CLI reads SURREAL_PASS from the environment while
# the app reads SURREAL_PASSWORD. One value, written twice, so the
# pair can never be seeded with two different root passwords.
sed -n 's/^SURREAL_PASSWORD=/SURREAL_PASS=/p' dankclaw/.env
} > "$dest"
chmod 600 "$dest"
fi
for var in OPEN_NOTEBOOK_ENCRYPTION_KEY OPEN_NOTEBOOK_PASSWORD SURREAL_PASSWORD SURREAL_PASS; do
grep -q "^${var}=." "$dest" \
|| { echo "open-notebook: $dest carries no value for ${var}; fill it in directly (or in dankclaw/.env before the first seed) — a blank value here means an unauthenticated service or a mismatched database password" >&2; return 1; }
done
}
# Both units ship Pull=never: the digest pins in the .container files are the
# contract, and the generator must never re-resolve a moving tag at start. That
# makes fetching a pin a precondition the deployer satisfies once per pin
# change; the existence check keeps every later re-deploy offline-safe.
ensure_pinned_image() {
local image="$1"
if podman image exists "$image"; then
echo "open-notebook: $image is already in local storage"
return 0
fi
echo "open-notebook: pulling pinned image $image"
podman pull "$image"
}
deploy_open_notebook() {
seed_open_notebook_env || return 1
ensure_pinned_image 'docker.io/surrealdb/surrealdb:v2@sha256:e5d7387e3727bbe2f7cd6d869e48cd34777272258572ead37c9656b25f745723' || return 1
ensure_pinned_image 'docker.io/lfnovo/open_notebook:v1-latest@sha256:54b2025a4a4853c188f74b9b8246eca8c5480e5dd1babfa92db1038ff2f30212' || return 1
# install_quadlet copies the sibling open-notebook.network with the app
# unit. That cp is load-bearing, not tidiness: when a referenced Network=
# file is absent the v1 generator refuses to produce the container unit at
# all ("requested Quadlet unit open-notebook.network was not found").
install_quadlet open-notebook-surrealdb dankclaw/open-notebook/quadlet open-notebook-surrealdb
install_quadlet open-notebook dankclaw/open-notebook/quadlet open-notebook
systemctl --user start open-notebook-network.service
# Interim episode destination (Gitea issue #7, `human-only`): finished
# episode audio is bind-mounted out to this host directory. Created here as
# the deploy user so the container's mapped root writes files dank can
# read, and so the dependency is explicit rather than podman's default.
mkdir -p /home/dank/media/podcasts
# No `systemctl --user enable`: generated Quadlet units cannot be enabled
# on this host (see deploy_authentik); boot persistence is the [Install]
# section inside each .container file. Database first, and both on every
# run — a stale app left attached to a recreated database is exactly the
# half-deployed state this handler exists to prevent.
systemctl --user restart open-notebook-surrealdb.service
wait_container_healthy open-notebook-surrealdb 90 || return 1
systemctl --user restart open-notebook.service
# The gate is the API's own health route: upstream serves `GET /health` on
# 5055 with {"status":"healthy"} and exempts it from the password
# middleware, so it stays a real answer once OPEN_NOTEBOOK_PASSWORD is set.
# Host port 5056, because seerr already holds 127.0.0.1:5055. The UI on
# 8502 is deliberately not gated: supervisord starts it only after the API
# answers (the image's wait-for-api.sh), so this gate already implies it.
wait_for_health open-notebook http://127.0.0.1:5056/health 180
}
# frank-tts-engine is the arm A TTS engine behind frank-tts: one Quadlet unit
# plus the private bridge its unit defines (install_quadlet copies the sibling
# frank-tts-engine.network — without that file the v1 generator refuses to
# produce the container unit at all, the same trap documented above). The
# façade that consumes it is a separate service on a separate port and a
# separate handler.
deploy_frank_tts_engine() {
# Weights BEFORE the build, and refuse rather than fetch. The payload is
# 1.4 GiB of GGUF with byte and sha256 checks; silently pulling it inside a
# deploy would turn a stalled network into a deploy that looks hung, and
# would hide which of the two failure modes the operator is in. The fetcher
# is idempotent, so re-running this handler after the fetch is cheap.
if ! scripts/download-frank-tts-engine-qwentts.sh --verify-only >/dev/null; then
echo "frank-tts-engine: the talker/tokenizer GGUFs are missing or wrong-sized under /home/dank/models/Qwen3-TTS-GGUF; run scripts/download-frank-tts-engine-qwentts.sh and re-deploy" >&2
return 1
fi
# Build first: Image= is a locally built tag with Pull=never, so the
# generator cannot start a container from it before it exists.
build_compose_image frank-tts-engine
install_quadlet frank-tts-engine dankclaw/voiceservice/quadlet frank-tts-engine
systemctl --user start frank-tts-engine-network.service
# No `systemctl --user enable` (generated Quadlet units cannot be enabled
# on this host); boot persistence is the [Install] section in the unit.
#
# `restart` blocks until the unit reaches started, which includes the
# ExecStartPost warm pass — a boot of this service is therefore already
# warm by the time the next line runs.
systemctl --user restart frank-tts-engine.service
# NOT wait_for_health: this container publishes nothing, so there is no URL
# the deployer can dial from the host. The unit's HealthCmd runs inside the
# container's own namespace, and podman's recorded health status is what the
# host can actually observe.
wait_container_healthy frank-tts-engine 150 || return 1
# The warmer runs a second time here on purpose. ExecStartPost carries a
# `-` prefix so a failed warm can never crash-loop the endpoint, which also
# means the unit cannot fail the deploy on it. This call is where a voice
# that did not warm becomes a deploy result, and where the per-voice
# cold=/warm= numbers land on the operator's console instead of only in the
# journal. The pass itself is idempotent and the second cold cost is gone:
# the voices are already warm, so this costs about a second per voice. It is
# exec-ed into the container for the same reason the health gate is: no host
# address exists.
podman exec frank-tts-engine /app/warm-voices.sh
}
deploy_frank_tts() {
# The 7772 façade in front of frank-tts-engine. Weights and the engine build
# are not this handler's business, but the BRIDGE FILE is: this unit
# references frank-tts-engine.network, and with a referenced Network= file
# absent the v1 generator refuses to produce the container unit at all. So
# `--only frank-tts` installs the engine's quadlet pair too — idempotent, and
# it is what makes the façade deployable on a host where the engine was
# planned separately or not at all.
#
# The voice packs are host state (the pack builder writes them), but the
# directory is created here as the deploy user on purpose: rootless podman
# would otherwise auto-create a missing bind source as root-owned, and the
# container runs as uid 10000.
mkdir -p /home/dank/voices
build_compose_image frank-tts
install_quadlet frank-tts-engine dankclaw/voiceservice/quadlet frank-tts-engine
install_quadlet frank-tts dankclaw/voiceservice/quadlet frank-tts
systemctl --user start frank-tts-engine-network.service
# No `systemctl --user enable` (generated Quadlet units cannot be enabled on
# this host); boot persistence is the [Install] section in each unit. The
# façade unit carries Wants=/After= on frank-tts-engine.service, so this
# restart pulls the engine — and its ExecStartPost warm pass — ahead of it:
# an engine that will not start delays this gate instead of taking the
# endpoint down with it.
systemctl --user restart frank-tts.service
# The gate is podman's recorded health, driven by the unit's own HealthCmd
# (`wget --spider -q http://127.0.0.1:7772/health`, inside the container's
# namespace). /health answers 200 with the backend name and the resolved
# voice list, so an empty /voices still reads healthy while a dead app does
# not — and note what this gate cannot see: the engine publishes no port on
# purpose, so an engine that is down shows up in the /health payload and in
# the first synthesis, never here.
wait_container_healthy frank-tts 120
}
deploy_comfyui() { deploy_comfyui() {
# Build first: the unit's Image= resolves against local storage, so the # Build first: the unit's Image= resolves against local storage, so the
# image has to exist before the generator starts a container from it. # image has to exist before the generator starts a container from it.
@@ -1573,6 +1786,7 @@ deploy_service() {
gluetun) deploy_gluetun ;; gluetun) deploy_gluetun ;;
searxng-valkey) deploy_searxng_valkey ;; searxng-valkey) deploy_searxng_valkey ;;
open-webui) deploy_open_webui ;; open-webui) deploy_open_webui ;;
open-notebook) deploy_open_notebook ;;
pihole) deploy_pihole ;; pihole) deploy_pihole ;;
gitea-postgres) deploy_gitea_unit gitea-postgres gitea-postgres ;; gitea-postgres) deploy_gitea_unit gitea-postgres gitea-postgres ;;
gitea-server) deploy_gitea_unit gitea-server gitea-server ;; gitea-server) deploy_gitea_unit gitea-server gitea-server ;;
@@ -1591,6 +1805,8 @@ deploy_service() {
frank-dragoon) deploy_llm_dragoon ;; frank-dragoon) deploy_llm_dragoon ;;
frank-zealot) deploy_llm_zealot ;; frank-zealot) deploy_llm_zealot ;;
frank-archon) deploy_llm_archon ;; frank-archon) deploy_llm_archon ;;
frank-tts-engine) deploy_frank_tts_engine ;;
frank-tts) deploy_frank_tts ;;
hermes|hermes-dashboard|hermes-webui) deploy_hermes ;; hermes|hermes-dashboard|hermes-webui) deploy_hermes ;;
romm|romm-mariadb|romm-redis|mariadb|redis) deploy_romm_stack "$service" ;; romm|romm-mariadb|romm-redis|mariadb|redis) deploy_romm_stack "$service" ;;
jellyfin) deploy_jellyfin ;; jellyfin) deploy_jellyfin ;;
+2
View File
@@ -141,6 +141,8 @@ valid_service() {
dankclaw:alertmanager|\ dankclaw:alertmanager|\
dankclaw:node-exporter|dankclaw:authentik-postgres|dankclaw:authentik-server|\ dankclaw:node-exporter|dankclaw:authentik-postgres|dankclaw:authentik-server|\
dankclaw:authentik-worker|dankclaw:authentik-outpost|dankclaw:authentik-outpost-ldap|dankclaw:anubis|dankclaw:seerr|\ dankclaw:authentik-worker|dankclaw:authentik-outpost|dankclaw:authentik-outpost-ldap|dankclaw:anubis|dankclaw:seerr|\
dankclaw:open-notebook|\
dankclaw:frank-tts-engine|dankclaw:frank-tts|\
dankclaw:frank-dragoon|dankclaw:frank-zealot|\ dankclaw:frank-dragoon|dankclaw:frank-zealot|\
dankserver:romm-mariadb|dankserver:romm-redis|dankserver:romm|\ dankserver:romm-mariadb|dankserver:romm-redis|dankserver:romm|\
dankserver:copyparty|dankserver:jellyfin|dankserver:calibre-web|dankserver:calibre-server|dankserver:plex|dankserver:tautulli|\ dankserver:copyparty|dankserver:jellyfin|dankserver:calibre-web|dankserver:calibre-server|dankserver:plex|dankserver:tautulli|\
+93
View File
@@ -0,0 +1,93 @@
#!/usr/bin/env bash
# Download the frank-tts-engine payload: the two GGUF files the arm A engine
# (qwentts.cpp `tts-server`, dankclaw/voiceservice/Containerfile.engine) loads
# once at startup — Serveurperso/Qwen3-TTS-GGUF's **1.7 B base talker at
# Q4_K_M** and the **12 Hz tokenizer at Q8_0**. Public repo — no auth header,
# and never a token on the command line (world-readable in /proc/<pid>/cmdline).
#
# THE BASE VARIANT IS MANDATORY, NOT A PREFERENCE. The engine refuses every
# voice registration unless the loaded model reports type "base"
# (tools/tts-server.cpp, register_voice): on a customvoice or voicedesign talker
# the cloned-voice product does not exist. The 0.6 B talker (628,905,056 B for
# its Q4_K_M) also exists and is NOT the decided artifact — 1.7 B is what live
# output was measured with on 2026-10-18.
#
# Fresh downloads, no resume (the same rule the frank-zealot fetcher learned the
# hard way: `curl -C -` once produced an oversized file on a multi-GB payload);
# -f/--fail-with-body so an HTTP error is never a success; then exact byte count
# and sha256 against the Hub's own LFS oid. This repo IS LFS, so the sha values
# below are the pointer oids the Hub publishes — verified at fetch, not
# computed locally. Idempotent: a complete, correctly-hashed file is skipped,
# and `--verify-only` reports without downloading (that is the mode the deployer
# and the runbook use to answer "are the weights there?").
#
# Both files together are 1.4 GiB and the engine mmaps them: peak RSS measured
# 5.7 GiB with both resident. Nothing here is deleted on a swap — the previous
# talker stays on disk, because the rollback for a bad engine build is to point
# QWENTTS_MODEL at a different file, not to re-download 1.4 GiB under pressure.
#
# Usage:
# scripts/download-frank-tts-engine-qwentts.sh [DEST_DIR] [--verify-only]
# Default DEST_DIR is the host directory the frank-tts-engine unit mounts
# read-only at /models.
set -euo pipefail
DEST=""
VERIFY_ONLY=0
for arg in "$@"; do
case "$arg" in
--verify-only) VERIFY_ONLY=1 ;;
*) DEST="$arg" ;;
esac
done
DEST="${DEST:-/home/dank/models/Qwen3-TTS-GGUF}"
# repo | hub-name | dest-name | bytes | sha256 (Hub LFS oid, resolved 2026-10-18)
FILES=(
"Serveurperso/Qwen3-TTS-GGUF|qwen-talker-1.7b-base-Q4_K_M.gguf|qwen-talker-1.7b-base-Q4_K_M.gguf|1219245248|ea393ebaf2167ea23ce9fc18b093822851358a950d7075cd47ab4f6ce23e887d"
"Serveurperso/Qwen3-TTS-GGUF|qwen-tokenizer-12hz-Q8_0.gguf|qwen-tokenizer-12hz-Q8_0.gguf|291150624|1883beeed99348fc35e23dd225e9082f93f6f8c109330a33d935baa8acdbfd94"
)
mkdir -p "$DEST"
status=0
for entry in "${FILES[@]}"; do
IFS='|' read -r repo hubname destname size sha <<<"$entry"
url="https://huggingface.co/${repo}/resolve/main/${hubname}"
f="$DEST/$destname"
have=0
if [[ -f "$f" ]] && [[ "$(stat -c%s "$f")" == "$size" ]]; then
if sha256sum -c --quiet <(printf '%s %s\n' "$sha" "$f"); then
have=1
fi
fi
if (( have )); then
echo "OK $destname"
continue
fi
if (( VERIFY_ONLY )); then
echo "MISSING $destname"
status=1
continue
fi
echo "FETCHING $destname (from $repo)"
tmp="$f.part"
curl -fsSL --fail-with-body -o "$tmp" "$url"
mv "$tmp" "$f"
if [[ "$(stat -c%s "$f")" != "$size" ]]; then
echo "SIZE FAIL $destname ($(stat -c%s "$f") != $size)"
status=1
continue
fi
if ! sha256sum -c --quiet <(printf '%s %s\n' "$sha" "$f"); then
echo "SHA FAIL $destname"
status=1
continue
fi
echo "VERIFIED $destname"
done
if (( status == 0 )); then
echo "DOWNLOAD_DONE $DEST"
else
echo "DOWNLOAD_FAILED $DEST" >&2
fi
exit "$status"
+147
View File
@@ -0,0 +1,147 @@
# frank-tts-engine.env — tunables for **frank-tts-engine**, the arm A TTS
# engine on Dankclaw (qwentts.cpp `tts-server`, container port **8880**, never a
# fleet endpoint). Read by the Quadlet unit
# (dankclaw/voiceservice/quadlet/frank-tts-engine.container) as its only
# EnvironmentFile=, which is why every path below is the value the CONTAINER
# sees. The host directories those mounts come from are named in
# dankclaw/voiceservice/quadlet/frank-tts-engine.container and
# scripts/download-frank-tts-engine-qwentts.sh.
#
# WHAT SITS HERE AND WHAT DOES NOT. `tts-server` reads its configuration from
# command-line flags only — it looks at no environment variable — so this file
# is consumed by the image's entrypoint
# (dankclaw/voiceservice/tts-engine-start.sh), which turns each key below into
# the matching flag. Every value has a default baked into that script, and the
# default equals the value below: this file exists so the decided shape is
# written down in the repo and one line can be changed without a rebuild, not
# because the engine would otherwise fail to boot.
#
# THE THREAD LAW (read this before reaching for CPUQuota=, CPUAffinity=,
# taskset or a systemd slice). There is **no `-t`/`--threads` flag** in this
# build: src/backend.h computes the CPU thread count as
# `std::thread::hardware_concurrency() / 2` (its own comment: GEMM shares SIMD
# units across hyperthreads). The engine therefore sizes its pools from what the
# kernel *reports*, not from what it is allowed to use. Pin the container to a
# cpuset and the thread count silently changes with it — different GEMM
# tiling, different timings, no log line saying so, and the measured numbers
# below stop describing the process you are running. Nothing in this fleet
# pins this container; that is a decision, not an oversight.
#
# THE REGISTRY LAW (why the unit has an ExecStartPost at all). Registrations live
# in memory only, so a restart loses them, and re-registration alone does NOT warm
# a voice — the warm pass has to synthesize something. Without it the façade
# answers 400 for every name until somebody asks for audio. So
# dankclaw/voiceservice/tts-engine-warm.sh (in the image at /app/warm-voices.sh)
# runs on every start and speaks a short sentence twice per voice. The penalty it
# removes is about 0.6 s, NOT the trial's 6.2-8.0 s: measured on the built
# container 2026-10-18 against an empty registry, the first synthesis cost
# 1,883 ms and the requests after it 1,226-1,283 ms. The hook runs INSIDE this
# container — `podman exec`, from the unit's ExecStartPost — so it reads /voices
# here and the keys at the bottom of this file are its knobs too.
#
# STREAMING IS NOT BUFFERED. The same seed gives audio that differs in 92.2 %
# of samples (-41.6 dB RMS) between `response_format=pcm` (the streaming path,
# codec chunks pushed as they decode) and `response_format=wav` (the buffered
# path). Nothing in this stack may compare the two, cache one against the
# other, or assume a seed pins output across `response_format`. frank-tts always
# asks for `wav` and re-encodes to mp3 itself; the warmer below also asks for
# `wav`, for that reason and because the streamed path would need a reader.
#
# THE ROUTE LIST, verbatim from tts-server at the pinned commit, so nobody
# debugs against a route that does not exist: GET /health, GET /v1/models,
# GET|POST /v1/audio/voices, DELETE /v1/audio/voices/{name},
# POST /v1/audio/speech (response_format wav or pcm). Everything else is a bare
# 404. Note two traps in that surface: `GET /v1/audio/voices` returns an empty
# JSON object rather than the voice list at this commit (the handler builds the
# array and never attaches it to the document), and a `POST /v1/audio/voices`
# carrying `spk_b64` without `rvq_b64` is rejected outright — it is `wav_b64` or
# BOTH of the pair.
# --- the two weights (see scripts/download-frank-tts-engine-qwentts.sh) -------
# Container paths; the host directory /home/dank/models/Qwen3-TTS-GGUF is
# mounted read-only at /models. 1,219,245,248 B + 291,150,624 B, fetched from
# https://huggingface.co/Serveurperso/Qwen3-TTS-GGUF and byte-checked at fetch.
#
# The 1.7 B talker is the decided artifact (Frank, 2026-10-18). A 0.6 B talker
# exists at 628,905,056 B and is NOT what live output was measured with; the
# Q4_K_M quantization is the size that leaves the measured RTF margin intact on
# this box, and the tokenizer rides at Q8_0 because the codec is where clone
# fidelity is lost first.
#
# It has to be the **base** variant, not customvoice/voicedesign: the engine
# refuses any voice registration unless qt_model_type() reports "base"
# (tools/tts-server.cpp, the register_voice lambda). Cloned voices — the whole
# product — are impossible on the other two variants.
QWENTTS_MODEL=/models/qwen-talker-1.7b-base-Q4_K_M.gguf
QWENTTS_CODEC=/models/qwen-tokenizer-12hz-Q8_0.gguf
# What GET /v1/models reports, and what scripts/frank-tts.env's
# TTS_UPSTREAM_MODEL names. A model id, never a file name.
QWENTTS_ALIAS=frank-tts-engine
# 0.0.0.0 is required and is not an exposure decision: tts-server binds
# 127.0.0.1 by default, and inside its own netns that address is reachable by no
# one, not even the façade on the same bridge — which is why a host-loopback
# publish would not have helped either (netavark DNATs published ports only, and
# the unit publishes nothing at all). Binding 0.0.0.0 exposes the server to the
# one network it is attached to: the private voice bridge
# (systemd-frank-tts-engine, 10.89.5.0/24), whose only other member is frank-tts.
QWENTTS_HOST=0.0.0.0
QWENTTS_PORT=8880
# Language label applied when a request omits one. `auto` is upstream's default
# and what the trial ran; naming a language is a per-request field on
# /v1/audio/speech, so this is only the fallback.
QWENTTS_LANG=auto
# Serialised FIFO: one synthesis at a time, everyone else queued. Warm RTF on
# this box is 0.55-0.67 against real time 1.0, which is the entire margin a live
# stream has; two concurrent syntheses split the same cores and the margin is
# gone, with the added effect that the second caller's time-to-first-audio now
# includes the first one's whole utterance. The façade's own budget
# (SHIM_TIMEOUT=600, and open-notebook's ESPERANTO_TTS_TIMEOUT=660 sitting one
# round-trip outside it) is what absorbs the queue.
QWENTTS_MAX_BATCH=1
# --- the boot warmer (dankclaw/voiceservice/tts-engine-warm.sh) --------------
# These three are read by the warm hook, not by tts-server. They live here
# because the hook is exec-ed into this container and therefore inherits this
# same EnvironmentFile — one file, one restart, no second source of truth.
# Container path of the voice packs; the host directory is /home/dank/voices,
# mounted read-only by the unit and mounted (read-only, same source) by
# frank-tts as well. A directory counts as a voice exactly when it contains
# reference.wav, which is the same rule frank-tts applies.
VOICES_DIR=/voices
# How long the hook waits for /health before giving up. Model load dominates
# this: both GGUFs are read and the context is built before the HTTP thread
# binds, and a boot competes with every other unit on the box. 300 s is generous
# against a cold cache; the unit's TimeoutStartSec (900 s) is what bounds the
# whole pass, warming included.
HEALTH_WAIT=300
# The sentence each voice speaks to snapshot its codec state, and the text the
# second (warm-timing) pass repeats. Fixed on purpose: the cold=/warm= numbers in
# the journal are comparable across boots only if the input is the same. Kept
# short because warming a library is serial — two syntheses per voice, ~5 s each,
# inside the unit's start deadline.
# QUOTED, and the quoting means two different things to the two readers of this
# file, which is the trap here:
# * bash (`set -a; source`, how the repo's seams and any human read it) needs
# the quotes — an unquoted value with spaces is word-split and the second
# word is executed as a command.
# * podman's `--env-file` (how the unit actually reads it) does NOT strip
# quotes: measured 2026-10-18 against the image's pinned base, the value
# arrives as `"Warm up, one two three."` WITH them. Interpolated into the
# hook's JSON body that is `""Warm up…""`, the engine's parser rejects it,
# every voice stays cold and the boot still looks healthy.
# So the value stays quoted for bash and the hook strips one matched pair before
# it builds the request, then escapes what is left. Both readers now agree, and a
# sentence containing a quote or a backslash still arrives verbatim.
WARM_TEXT="Warm up, one two three."
# Per-request ceiling for the hook. A cold voice measures 6-8 s, so 120 s is
# ~15x margin: reaching it means the engine is wedged, and systemd is still
# waiting on this pass before it calls the unit started.
REQUEST_TIMEOUT=120
+18 -8
View File
@@ -30,21 +30,31 @@
# #
# BACKEND SWITCH. TTS_BACKEND picks the shape and nothing else changes for the # BACKEND SWITCH. TTS_BACKEND picks the shape and nothing else changes for the
# consumer: # consumer:
# omni-http forward to an OpenAI-shaped upstream on the host (:8880), # omni-http forward to an OpenAI-shaped upstream, carrying the voice along
# carrying the voice along as base64 reference_audio. The # as base64 reference_audio. The engine does the inference; the
# engine does the inference; the shim resolves the voice and # shim resolves the voice and re-encodes to mp3 when asked. The
# re-encodes to mp3 when asked. # shipped engine (frank-tts-engine) speaks a voice it has
# REGISTERED on itself, from the same /home/dank/voices packs,
# and warms them at boot — see [[Frank TTS Engine]] for what that
# boot warm is for (a memory-only registry: without it every name
# is a 400) and what it saves (~0.6 s).
# llama-tts-cli run LLAMA_TTS_BIN once per request with # llama-tts-cli run LLAMA_TTS_BIN once per request with
# --tts-speaker-file <reference.wav>, nice 19 so a narration # --tts-speaker-file <reference.wav>, nice 19 so a narration
# never starves the text endpoints on this box. Needs # never starves the text endpoints on this box. Needs
# LLAMA_TTS_BIN + LLAMA_TTS_MODEL filled in; LLAMA_TTS_MMPROJ # LLAMA_TTS_BIN + LLAMA_TTS_MODEL filled in; LLAMA_TTS_MMPROJ
# only if that binary takes a projector. # only if that binary takes a projector.
# The upstream address is host.containers.internal — the gateway name a bridge
# container uses for the host. It is NOT the LAN IP and it is NOT # THE UPSTREAM ADDRESS. The engine is a container on the private bridge this
# 127.0.0.1: a bridge netns has neither. # unit also joins (dankclaw/voiceservice/quadlet/frank-tts-engine.network), so
# this is its container DNS name. It used to read
# `http://host.containers.internal:8880`, which is a shape that cannot work: a
# bridge container reaches the host's network namespace, netavark DNATs PUBLISHED
# ports only, and an engine on the host's loopback is published to nobody
# ([[Container Network Namespaces]] — the trap that 502'd hermes). Inert under
# TTS_BACKEND=llama-tts-cli, which needs no upstream at all.
VOICES_DIR=/voices VOICES_DIR=/voices
TTS_BACKEND=omni-http TTS_BACKEND=omni-http
TTS_UPSTREAM=http://host.containers.internal:8880 TTS_UPSTREAM=http://frank-tts-engine:8880
TTS_UPSTREAM_MODEL=voxcpm2 TTS_UPSTREAM_MODEL=voxcpm2
# CLI-backend knobs, inert while TTS_BACKEND=omni-http. Empty means "not # CLI-backend knobs, inert while TTS_BACKEND=omni-http. Empty means "not
+100
View File
@@ -0,0 +1,100 @@
# open-notebook.env — configuration for **open-notebook**, the fleet's podcast
# generator on Dankclaw (UI host port **8502**, API host port **5056**). Read by
# BOTH Quadlet units (dankclaw/open-notebook/quadlet/) as the FIRST
# EnvironmentFile= line, which is why every path and host name below is the
# value the CONTAINER must see.
#
# TWO FILES, ONE ORDER. This file is tracked; it holds knobs, not credentials.
# The three secret keys below are declared EMPTY on purpose — the `scripts/*.env`
# gitignore exception exists for "paths and numbers, never credentials" — and the
# real values live in the host file the deployer seeds at
# /home/dank/Containers/open-notebook/env (mode 600, seeded from dankclaw/.env,
# the same convention grafana/marreta/pihole follow). Both files are handed to
# podman in EnvironmentFile= order and the LAST one wins (verified against the
# generated run line), so the host file overrides these blanks. Do not reorder
# the two lines.
#
# WHY THE DEPLOYER REFUSES TO START THESE UNITS WITHOUT THE SECRET FILE. An empty
# OPEN_NOTEBOOK_PASSWORD is not "unconfigured", it is **authentication off**, and
# the episode-audio download is one of the routes the API serves without a
# session; an empty SURREAL_PASS lets the database come up with different root
# credentials than the app will present; an empty OPEN_NOTEBOOK_ENCRYPTION_KEY
# means no provider credential can be saved at all. So the guard is in
# deploy-remote.sh (seed_open_notebook_env), not in the app's opinion.
#
# THE CPU-SHUTDOWN GUARD (the two knobs that matter most on this box). Defaults
# are TTS_BATCH_SIZE=5 and OPEN_NOTEBOOK_WORKER_MAX_TASKS=5 — five parallel
# synthesis requests from five parallel jobs. frank-tts answers each request by
# spawning its own engine process (~8 GiB resident, 8 threads, measured in the
# frank-tts CPU trial of 2026-10-18), so the defaults mean ~40 GiB and forty
# threads aimed at a machine that also serves frank-dragoon and frank-zealot and
# whose TTS engine runs on the SAME CPU. Both numbers are 1 here: one episode at
# a time, one voice line synthesized at a time. That is a throughput choice, not
# a correctness one — a podcast is a background job — and it is the difference
# between a slow render and a box that stops answering the text endpoints
# mid-sentence. Raise them only with a TTS engine that batches internally.
#
# THE TTS CLOCK. ESPERANTO_TTS_TIMEOUT is the worker's patience per DIALOGUE
# LINE, not per episode: one episode is dozens of POSTs to /v1/audio/speech
# (the app synthesises each speaker turn separately and concatenates the clips
# with ffmpeg afterwards), and a CPU engine takes single-digit seconds for a
# line, with the tail of a long turn well past the 300 s default. 660 sits one
# round-trip OUTSIDE SHIM_TIMEOUT=600 in scripts/frank-tts.env — the shim's own
# per-request ceiling — so a frank-tts give-up arrives as frank-tts's error and
# not as open-notebook abandoning a synthesis still in flight. An abandoned
# request does not cancel the engine process; it just leaves the episode stuck in
# "generating" while the audio arrives.
#
# THE SCRIPTWRITER. ESPERANTO_LLM_TIMEOUT is the app's own default (180 s), kept
# visible beside the TTS number because both belong to one podcast run: the
# outline call and the transcript call are the slow text work, and frank-zealot
# answers them well inside 180. Provider choice is made in the UI, not here —
# see [[Open Notebook]] for the one-time credential provisioning — and it is
# frank-zealot on purpose: script writing asks for a JSON-schema response, which
# the OpenAI-compatible llama.cpp endpoint is known to serve, while halogen's
# structured-output behaviour on frank-dragoon is UNVERIFIED. halogen remains
# the unproven alternative, not the fallback.
#
# THE DATABASE COORDINATES. SURREAL_URL addresses the sibling container BY NAME
# on systemd-open-notebook (container DNS is scoped to the owning network, so
# this is the only form that works). NAMESPACE/DATABASE/USER are plain names.
# SURREAL_PASSWORD and SURREAL_PASS are ONE value under two spellings: the app
# reads SURREAL_PASSWORD, the `surreal` CLI reads SURREAL_PASS. Keep them equal;
# the deployer writes both into the seeded file from a single SURREAL_PASSWORD in
# dankclaw/.env, so hand-editing one line is the only way to desync them.
SURREAL_URL=ws://open-notebook-surrealdb:8000/rpc
SURREAL_NAMESPACE=open_notebook
SURREAL_DATABASE=open_notebook
SURREAL_USER=open_notebook
# Secrets — filled on the host, never here (see the header).
SURREAL_PASSWORD=
SURREAL_PASS=
OPEN_NOTEBOOK_ENCRYPTION_KEY=
OPEN_NOTEBOOK_PASSWORD=
# WHERE THE BROWSER FINDS THE API. The UI auto-detects `<host-you-opened>:5055`,
# and 5055 on this host belongs to seerr, so the API leaves the container on 5056
# and this line has to say so. Both origins are listed in CORS_ORIGINS because
# the API is called cross-origin from the UI's own port: the LAN origin is the
# normal one, the localhost one covers a browser opened on Dankclaw itself. The
# app logs a warning at start while CORS_ORIGINS is unset and defaults to `*`,
# which is a wildcard in front of an endpoint that serves every episode's audio.
API_URL=http://192.168.0.143:5056
CORS_ORIGINS=http://192.168.0.143:8502,http://localhost:8502
# Concurrency: see "THE CPU-SHUTDOWN GUARD" above. Defaults are 5 and 5.
TTS_BATCH_SIZE=1
OPEN_NOTEBOOK_WORKER_MAX_TASKS=1
# Timeouts: see "THE TTS CLOCK" and "THE SCRIPTWRITER" above.
ESPERANTO_TTS_TIMEOUT=660
ESPERANTO_LLM_TIMEOUT=180
# Heavy extraction runtimes stay OFF, which is also what keeps boot fast: each
# one installs a multi-gigabyte ML/Chromium stack into the image's venv on first
# startup (cached on the /app/data volume after that). Podcast generation reads
# notes and sources that are already text, so neither is needed here; enable one
# deliberately, per the upstream docs, if source ingestion starts to matter.
# OPEN_NOTEBOOK_ENABLE_DOCLING=true
# OPEN_NOTEBOOK_ENABLE_CRAWL4AI=true
+46
View File
@@ -128,6 +128,31 @@ for path in "${CHANGED[@]}"; do
add_test tests/docs_completeness.bats add_test tests/docs_completeness.bats
add_test tests/deployment_scoping.bats add_test tests/deployment_scoping.bats
;; ;;
# open-notebook is the fleet's podcast generator: an app quadlet, the
# SurrealDB quadlet behind it, the private network between them, and the
# one env file both units read. Like the caddy block above, this case
# has to precede the dankclaw/*.container glob, whose `*` crosses `/`
# and would otherwise swallow dankclaw/open-notebook/quadlet/*.
dankclaw/open-notebook/*|scripts/open-notebook.env)
add_test tests/open_notebook.bats
add_test tests/docs_completeness.bats
add_test tests/deployment_scoping.bats
;;
# The voice stack: the arm A engine unit, the private bridge it owns,
# its image and its entrypoint. Like the open-notebook block above,
# this case MUST precede the dankclaw/*.container glob — that `*`
# crosses `/`, so without this case
# dankclaw/voiceservice/quadlet/frank-tts-engine.container is routed to
# the open-webui/pihole/searxng seams and its own seam never runs.
# The façade's seam rides along: the two share a directory, a network
# and the request path a consumer takes.
dankclaw/voiceservice/*)
add_test tests/frank_tts_engine.bats
add_test tests/frank_tts_unit.bats
add_test tests/deployment_flow.bats
add_test tests/deployment_scoping.bats
add_test tests/docs_completeness.bats
;;
dankclaw/*.container|dankclaw/*.network) dankclaw/*.container|dankclaw/*.network)
add_test tests/open_webui_quadlet.bats add_test tests/open_webui_quadlet.bats
add_test tests/pihole.bats add_test tests/pihole.bats
@@ -187,6 +212,19 @@ for path in "${CHANGED[@]}"; do
# as additions, with "no focused-test mapping". # as additions, with "no focused-test mapping".
.AGENTTRASH/*|*/.AGENTTRASH/*) .AGENTTRASH/*|*/.AGENTTRASH/*)
;; ;;
# The two voice env files both reach a container through a Quadlet unit
# (EnvironmentFile=), so they select the voice seams plus the
# script-syntax seam. This case has to precede the generic
# scripts/*.service and scripts/* fallbacks below, or a change to
# scripts/frank-tts-engine.env would run nothing but bash -n. The
# warmer is NOT here: it ships inside the engine image and is routed
# with the rest of dankclaw/voiceservice/* above.
scripts/frank-tts-engine.env|scripts/frank-tts.env)
add_test tests/frank_tts_engine.bats
add_test tests/frank_tts_unit.bats
add_test tests/deployment_scoping.bats
add_test tests/scripts_syntax.bats
;;
# frank-archon is llama.cpp (the unsloth b11030-mix prebuilt) on the # frank-archon is llama.cpp (the unsloth b11030-mix prebuilt) on the
# two R9700s as a ROOT unit — the 2026-09-26 swap that replaced the # two R9700s as a ROOT unit — the 2026-09-26 swap that replaced the
# Lucebox DS4.1 quadlet on :7770. Its env, unit and serve script all # Lucebox DS4.1 quadlet on :7770. Its env, unit and serve script all
@@ -219,6 +257,14 @@ for path in "${CHANGED[@]}"; do
add_test tests/scripts_syntax.bats add_test tests/scripts_syntax.bats
add_test tests/deployment_scoping.bats add_test tests/deployment_scoping.bats
;; ;;
# The voice-pack builder is the only thing that enforces the 10-second
# source-material invariant and the artifact sizes the packs are pinned
# to, so it selects its own seam rather than landing on the generic
# scripts/* syntax fallback. This case has to precede that fallback.
scripts/build-voice-pack.sh|scripts/voice-packs.env)
add_test tests/voice_pack_unit.bats
add_test tests/scripts_syntax.bats
;;
scripts/*) scripts/*)
add_test tests/scripts_syntax.bats add_test tests/scripts_syntax.bats
;; ;;
+123
View File
@@ -0,0 +1,123 @@
# voice-packs.env — tunables for the **frank-tts voice packs**: the three
# numbers per voice (source file, crop offset, crop length) plus the pinned
# engine facts that turn a crop into a pack. Read by
# `scripts/build-voice-pack.sh`, which is the only thing that consumes this
# file — the running service reads none of it, because the packs themselves are
# the interface (see scripts/frank-tts.env for what the shim resolves).
#
# WHAT IS TRACKED HERE AND WHAT IS NOT. Paths and numbers, never credentials,
# which is the same rule the other `scripts/*.env` files follow. The pack
# DIRECTORIES are host state under $VOICE_PACK_DIR and stay out of the repo; the
# crops that produce them are exactly the kind of decision worth a diff, so the
# offsets live here. If a crop changes, change it here and rebuild with
# --force — the manifest inside the pack records what was actually cut.
#
# THE OFFSETS ARE NOT ARBITRARY. Two rules pick them:
# 1. The source-material invariant: seconds 0-10 of any sample file are
# forbidden voice material. The builder refuses a crop that starts inside
# that region and asserts the same thing again from the manifest it just
# wrote, so the values below are checked, not trusted.
# 2. Speech density: the trial cut these from a silencedetect scan of the
# source (noise -35 dB, 0.6 s) and took the longest run of continuous
# speech at or past the scan window — a reference that starts mid-word or
# contains a page-turn of silence clones the artefacts, not the voice.
# serverAppConfigs-docs/Dankclaw/Voice Packs.md carries the recipe.
#
# WHY THE SHA256 LINES EXIST. A crop offset is a statement about one exact
# file: 98.739 s into a re-encode of the same audiobook is a different sentence
# and therefore a different voice. The builder hashes the source, compares it
# with the pinned digest when one is set, and writes the digest into the pack's
# manifest — so a pack can always be traced back to the bytes it was cut from.
# The three values below are the trial's digests (2026-10-18), verified against
# the files on disk.
#
# THE ENGINE BLOCK IS A PIN, NOT A DEFAULT. The speaker artifacts come from
# `qwen-codec` in the qwentts.cpp tree at $VOICE_PACK_ENGINE_COMMIT: it takes a
# 24 kHz WAV, writes the RVQ code stream next to it with the extension swapped,
# and with --talker also runs that talker's speaker encoder to write the
# x-vector .spk. The talker has to be a **Base** build — the customvoice and
# voicedesign GGUFs carry no speaker encoder and the extractor says so. The
# sizes the builder then verifies (8,192 B of .spk, 5,456-6,864 B of .rvq) are
# derived from the codec config in the GGUF, so an engine bump that changes them
# fails the build on purpose; see [[Voice Packs]] for the bump procedure.
#
# HOST PATHS, NOT CONTAINER PATHS. This runs on the host that holds the engine
# build (Dankclaw), outside any container, with no GPU and no fleet endpoint.
# The model paths below are where scripts/download-frank-tts-engine-qwentts.sh
# puts the GGUFs; until that fetch has run, the same two files sit in the trial
# tree at /home/dank/voice-audition/qwentts/models/ and the tool is only in its
# build directory. The SOURCE paths are the third thing to place: the three
# sample files live on the workstation today and have to be copied to
# $VOICE_PACK_SOURCE_DIR on Dankclaw before a build can read them.
# The packs root on the host: the directory frank-tts mounts read-only at
# /voices, one sub-directory per voice.
VOICE_PACK_DIR=/home/dank/voices
# The pack list. `--all` walks it in order; every name has to carry the four
# per-voice values below.
VOICE_PACKS="narrator1 narrator2 narrator3"
# Where the source recordings live on the engine host.
VOICE_PACK_SOURCE_DIR=/home/dank/voice-sources
# narrator1 — Randall Munroe "How To", read by Wil Wheaton. Longest speech run
# at or past the scan window: 98.739-118.636 s, 19.897 s, which packs to the
# 5,456 B floor of the .rvq window.
VOICE_PACK_NARRATOR1_SOURCE=${VOICE_PACK_SOURCE_DIR}/audiosample.mp3
VOICE_PACK_NARRATOR1_OFFSET=98.739
VOICE_PACK_NARRATOR1_DURATION=19.897
VOICE_PACK_NARRATOR1_SHA256=2299510467eb9143455d8c1e6a21601c3b451dd59c1dc99b99d4aacb54d1d850
# narrator2 — "Abyssal Archive" (Lokey). 25 s is the ceiling of the reference
# window and packs to exactly 6,864 B of .rvq.
VOICE_PACK_NARRATOR2_SOURCE=${VOICE_PACK_SOURCE_DIR}/audiosample2.m4b
VOICE_PACK_NARRATOR2_OFFSET=191.13
VOICE_PACK_NARRATOR2_DURATION=25
VOICE_PACK_NARRATOR2_SHA256=8e1e33317baf67b2cca67e4ce1b095ae477a18285c7373ec18ad013f2cc320e0
# narrator3 — "Children of Time" (Adrian Tchaikovsky, author-read).
VOICE_PACK_NARRATOR3_SOURCE=${VOICE_PACK_SOURCE_DIR}/audiosample3.m4b
VOICE_PACK_NARRATOR3_OFFSET=489.232
VOICE_PACK_NARRATOR3_DURATION=25
VOICE_PACK_NARRATOR3_SHA256=6875d2c1db421ab96ccbb68855fa9efd4e054297e94dd1b4345eb6a9869da945
# The pinned extractor and the two GGUFs it needs. $VOICE_PACK_TOOL is a build
# output, not a download: rebuild it at $VOICE_PACK_ENGINE_COMMIT after a bump
# rather than pointing this line at a newer tree.
VOICE_PACK_TOOL=/home/dank/voice-audition/qwentts/qwentts.cpp/build/qwen-codec
VOICE_PACK_CODEC_MODEL=/home/dank/models/Qwen3-TTS-GGUF/qwen-tokenizer-12hz-Q8_0.gguf
VOICE_PACK_TALKER_MODEL=/home/dank/models/Qwen3-TTS-GGUF/qwen-talker-1.7b-base-Q4_K_M.gguf
VOICE_PACK_ENGINE_REPO=https://github.com/ServeurpersoCom/qwentts.cpp
VOICE_PACK_ENGINE_COMMIT=5916bf7c79dbb4e66fc059cdc507a79a23a28bb4
# CPU only, and never in the way of the text endpoints on this box: ggml thread
# count and `nice -n`. The extractor picks the best backend it is given, so the
# device is stated rather than guessed.
VOICE_PACK_DEVICE=CPU
VOICE_PACK_THREADS=8
VOICE_PACK_NICE=19
# The reference window and the shape the packs are cut to. 24 kHz mono PCM is
# the codec's own input rate, so no resampling happens inside the engine.
VOICE_PACK_SAMPLE_RATE=24000
VOICE_PACK_MIN_SECONDS=18
VOICE_PACK_MAX_SECONDS=25
# Artifact arithmetic, all four values read out of the pinned tree: the codec
# runs at 12.5 Hz (1,920 samples per frame at 24 kHz), packs 16 codebooks at 11
# bits = 22 bytes per frame, and the Base talker's speaker encoder emits
# enc_dim 2048 f32 = 8,192 bytes. The .rvq window is 22 x 248 = 5,456 B up to
# 22 x 312 = 6,864 B, i.e. crops of 19.84-25 s: an 18 s crop is a legal
# reference.wav but too little code for the engine, and the builder says so
# before it runs anything.
VOICE_PACK_FRAME_RATE=12.5
VOICE_PACK_RVQ_BYTES_PER_FRAME=22
VOICE_PACK_SPK_BYTES=8192
VOICE_PACK_RVQ_MIN_BYTES=5456
VOICE_PACK_RVQ_MAX_BYTES=6864
# Where a --force rebuild puts the pack it replaced. Nothing is ever deleted;
# the retired directory stays readable beside its replacement until a human
# clears it.
VOICE_PACK_TRASH_DIRNAME=.AGENTTRASH
@@ -0,0 +1,133 @@
---
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."
tags:
- dankclaw
- tts
- voice
- quadlet
- cpu
timestamp: 2026-10-18
---
# Frank TTS Engine
`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.
**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.
## Facts (verified 2026-10-18 against the pinned upstream commit, the Hub, and the CPU trial on this host)
| Item | Value |
|------|-------|
| Engine | `tts-server` from qwentts.cpp, pinned at commit `5916bf7c79dbb4e66fc059cdc507a79a23a28bb4` (`dankclaw/voiceservice/Containerfile.engine`). The source is **not vendored**: the image clones that commit and its recorded `ggml` submodule, so the build needs outbound HTTPS and the `cfhash` label is what keeps an unchanged re-deploy offline |
| Build line | `cmake -B build -DCMAKE_BUILD_TYPE=Release -DGGML_CCACHE=OFF -DGGML_BACKEND_DL=OFF -DGGML_CPU_ALL_VARIANTS=OFF -DGGML_OPENMP=ON -DGGML_BLAS=OFF -DGGML_CUDA=OFF -DGGML_HIP=OFF -DGGML_VULKAN=OFF -DGGML_METAL=OFF -DGGML_SYCL=OFF -DGGML_RPC=OFF && cmake --build build -j 8` — the exact line the 2026-10-18 trial ran on this box. `GGML_BLAS=OFF` is **forced**: Dankclaw has `libopenblas.so.0` and no BLAS dev headers, so upstream's own `Dockerfile` and `buildcpu.sh` (both of which pass BLAS on) cannot be copied. No `-DGGML_NATIVE` is passed, and ggml defaults it on, so the binary is `-march=native` for the machine that built it: **build this image on the host that runs it** |
| Image | built locally as `franksdank/frank-tts-engine:latest`, two stages from one digest-pinned base `docker.io/library/ubuntu:24.04@sha256:68ee22004a9109b44973313b02ff7b1de250f1de0f5ecb628d73f579aaff33ac` (index digest resolved 2026-10-18). Runtime stage: `libgomp1` (OpenMP is on), `curl` (the unit's `HealthCmd` runs as `/bin/sh -c`, and the warmer speaks HTTP), uid **10000**, `LD_LIBRARY_PATH=/app` because ggml builds shared by default and the link-time RPATH names the build tree |
| Talker | `qwen-talker-1.7b-base-Q4_K_M.gguf`, **1 219 245 248 B**, sha256 `ea393ebaf2167ea23ce9fc18b093822851358a950d7075cd47ab4f6ce23e887d` (the Hub's LFS pointer oid) |
| Tokenizer | `qwen-tokenizer-12hz-Q8_0.gguf`, **291 150 624 B**, sha256 `1883beeed99348fc35e23dd225e9082f93f6f8c109330a33d935baa8acdbfd94` |
| Source of both | `https://huggingface.co/Serveurperso/Qwen3-TTS-GGUF`, fetched by `scripts/download-frank-tts-engine-qwentts.sh` into `/home/dank/models/Qwen3-TTS-GGUF`, mounted read-only at `/models`. It has to be the **base** variant: the engine refuses every voice registration unless the model reports type `base`, so on `customvoice`/`voicedesign` the cloned-voice product does not exist. A 0.6 B talker (628 905 056 B) exists and is **not** the decided artifact |
| Memory | **5.7 GiB peak RSS** with both files resident (2026-10-18 trial). No `LimitMEMORY`/`MemoryMax`: a limit would turn a busy moment into an OOM kill in the middle of an utterance |
| Concurrency | `--max-batch 1`: a serialised FIFO. Warm RTF on this box is 0.55–0.67 against real time 1.0, and that margin is the entire headroom a live stream has; two concurrent syntheses split the same cores and the second caller's first byte additionally waits for the first utterance to finish |
| Network | `systemd-frank-tts-engine` (10.89.5.0/24, `dankclaw/voiceservice/quadlet/frank-tts-engine.network`) and **nothing else**, with **no `PublishPort` at all**. `frank-tts` joins the same bridge and dials `http://frank-tts-engine:8880` |
| Voices | `/home/dank/voices` mounted read-only at `/voices`, and it is the **warmer** that reads it, not the server: `tts-server` receives reference audio as base64 in a registration body and keeps its registry in memory only |
| Health | `GET /health` → `{"status":"ok"}`, served by the HTTP thread rather than the synthesis queue, so a busy engine still reads healthy. Probed by the unit (`HealthCmd`, 30 s interval, 120 s start period) **inside the container's namespace**, because there is no published port to probe from outside |
| Routes | `GET /health`, `GET /v1/models`, `GET|POST /v1/audio/voices`, `DELETE /v1/audio/voices/{name}`, `POST /v1/audio/speech` (`response_format` = `wav` or `pcm`; **default `pcm`**). Everything else is a bare 404. `POST /v1/audio/speech` requires `input`; unknown `voice`, missing `input` and a bad `speed` are 400s, an empty generation is a 502 |
## Why this engine, and not the alternatives
The criterion Frank set on 2026-10-18 was **"whatever is better for live output"**, and live output is decided by time-to-first-audio and by whether the stream stays ahead of real time — not by benchmark throughput. RTF only has to stay under 1.0 with margin; what separates the arms is first-byte latency and **spread**. Measured on this host the same day (CPU; the full A/B is the section *"Decision: arm A for live output (2026-10-18)"* in [[Local TTS and Voice Cloning]], which is the source of these numbers):
| Measure | Resident `tts-server` (arm A, chosen) | Mainline per-request CLI path (arm B) |
|---|---|---|
| first audio byte | **86–451 ms** end to end; **94–112 ms** server-side | model load, per request |
| warm real-time factor while streaming | **0.55–0.67** | 0.93–0.99 |
| run-to-run spread | **±0.01** | ±0.25 |
| HTTP surface | serves `/v1/audio/speech` itself | none — façade CLI only |
The spread is the whole argument: at ±0.01 the engine finishes synthesizing 1.5–1.8× faster than the audio plays, every run; at ±0.25 some runs fall behind real time and a live listener hears the stream stall mid-sentence. The rollback path stays in the repo untouched — `TTS_BACKEND=llama-tts-cli` in `scripts/frank-tts.env` still selects the per-request CLI engine, and that file's header documents what it costs.
## Three laws this service lives under
**1. The registry law → the unit warms at boot.** The voice registry is **memory only**: a restart forgets every voice, and re-registering one does **not** warm it — only synthesizing does. A boot that registers nothing leaves the façade answering 400 for every name, which is why `frank-tts-engine.container` carries
```
ExecStartPost=-/usr/bin/podman exec frank-tts-engine /app/warm-voices.sh
TimeoutStartSec=900
```
and the warm pass runs on every start including boot. What it buys is registration (mandatory) plus a measurably smaller first request — and the size of that saving is **not** the number the trial published. **Re-measured on the deployed build, 2026-10-18,** against an empty registry (register, then time the synthesis): the first request for `narrator1` cost **1 883 ms**, the second **1 283 ms**, the third **1 226 ms** — about **0.6 s** of saved silence. A ~110-word request cost **9.4 s** and then **9.8 s**, so request time tracks the audio generated, not snapshot state. The trial's **6.2–8.0 s / first audio byte 5 530 ms / later requests 0.5 ms** ladder does **not** reproduce here; the 0.5 ms figure is an internal state-restore cost that was never an end-to-end request time, and the honest claim is the ~0.6 s above. The `-` prefix is load-bearing: with `Restart=always`, a warmer that exits non-zero would restart the container and crash-loop the endpoint forever. The hook is `podman exec` rather than a host script **because this container publishes nothing** — there is no address the host could dial — and a consequence worth knowing: an exec inherits the unit's `EnvironmentFile`, so the warmer's knobs (`VOICES_DIR`, `HEALTH_WAIT`, `WARM_TEXT`, `REQUEST_TIMEOUT`) live in `scripts/frank-tts-engine.env` beside the engine's own. The deployer runs the same hook a second time so a failed warm becomes a deploy result rather than only a journal line, and so the per-voice numbers land on the console:
```
frank-tts-engine-warm: voice narrator1 cold=1402ms warm=1812ms
frank-tts-engine-warm: 3 voice(s) registered and warm
```
**2. The thread law → nothing is ever pinned.** `tts-server` in this build has **no `-t`/`--threads` flag**: `src/backend.h` computes the CPU thread count as `std::thread::hardware_concurrency() / 2` (its comment: GEMM shares SIMD units across hyperthreads). Thread count therefore follows what the kernel *reports*, not what a cgroup *allows* — so `CPUQuota=`, `CPUAffinity=`, `taskset` or a slice silently changes the engine's GEMM tiling and its timings, with no log line. Nothing in this fleet pins this container. If a neighbour starves it, fix the neighbour.
**3. Streaming is not buffered.** At the same seed, `response_format=pcm` (chunked, codec chunks pushed as they decode) and `response_format=wav` (buffered) differ in **92.2 % of samples**, −41.6 dB RMS. Nothing may compare the two paths, cache one against the other, or assume a seed pins output across `response_format`. `frank-tts` always asks for `wav`; the warmer asks for `wav`; the test seam is forbidden from pinning either.
## The voice registry contract
A voice becomes known to the engine through `POST /v1/audio/voices`, and only these shapes are accepted:
- `{"name": …, "wav_b64": …}` — the reference clip travels as base64 WAV (the engine resamples to 24 kHz internally). **This is what the boot warmer sends.**
- `{"name": …, "spk_b64": …, "rvq_b64": …}` — the extracted embedding plus the reference codec codes. **`spk_b64` alone is rejected** (`src/tts-server.h:480`): it is `wav_b64` or *both* of the pair.
Whether a registered voice is *embedding-only* or *in-context-learning* is then decided by `ref_text`: `tools/tts-server.cpp:258` selects ICL only when **both** `ref_text` and the reference codes are present. Registering `{name, wav_b64}` with no `ref_text` therefore yields the **x-vector voice** — the shipped default (0.57–0.63 warm RTF against ICL's 0.67) and the only one this fleet can produce honestly, because the audition crops have no trustworthy transcript to put in `ref_text`. The ICL door stays open for batch work like the podcast render: a pack that wants it needs its text in the pack plus one key in the payload built by `dankclaw/voiceservice/tts-engine-warm.sh`.
The packs themselves are host state at `/home/dank/voices/<name>/reference.wav` (24 kHz mono PCM), alongside the `reference.spk` / `reference.rvq` extraction and a manifest. Both halves of the voice stack read that directory — `frank-tts` for its name list, the warmer for its registrations — and `tts-server` never opens it. Two naming facts, both from the source: pack names must be **lowercase**, because `tts_voice_name` lowercases every name engine-side while the façade matches the directory name exactly (the warmer refuses anything outside `[a-z0-9._-]`); and `GET /v1/audio/voices` at this commit returns an empty JSON object rather than the voice list (the handler builds the array and never attaches it to the document), so **use the façade's `/v1/voices` for "which voices exist"** and treat the engine's listing route as broken, not empty.
## Status visibility
The engine is scraped by **nothing in Prometheus** and has **no homepage card**, and both are shape decisions rather than gaps. It exposes no metrics endpoint; and since it publishes no port, there is no address the host's status checker (`scripts/homepage-status.py`, which dials `host:port`) or the `dankclaw_default` Prometheus container could reach. That absence is the point of the network shape, not a cost of it (see [[Container Network Namespaces]] for the netavark rule that makes a host-loopback publish useless to a peer container anyway). The pair's public signal is the endpoint in front of it: [[Frank TTS]] carries the card and is what a consumer can dial. Its up-state is visible in four places, and the runbook uses all of them:
- `systemctl --user status frank-tts-engine` — the unit, including whatever the warm hook logged on the way to `active`.
- `podman healthcheck run frank-tts-engine` — runs the unit's own `HealthCmd` against `/health` on demand and exits non-zero when the engine does not answer. This is also what `scripts/deploy-remote.sh` gates the deploy on (`wait_container_healthy`), precisely because there is no URL to gate on.
- `podman exec frank-tts-engine curl -fsS http://127.0.0.1:8880/health` — loopback is valid **inside** the container's namespace, which is where the address lives.
- The journal of the warm pass: `journalctl --user -u frank-tts-engine`, whose per-voice `cold=` / `warm=` lines are the only signal that voices are actually registered — a live HTTP listener with an empty registry still looks healthy.
The **façade** on 7772 is the pair's public surface and carries the probe. Note the honest limit there: a façade that answers `/health` does **not** prove the engine behind it is alive — it reports its backend and its voice list, not an upstream ping. That is exactly why the warm pass is part of the deploy and not only of the boot.
## Files this service owns
| Path | Role |
|---|---|
| `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-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/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 |
| `scripts/frank-tts-engine.env` | paths and numbers only, with the thread law and the registry law in its header |
| `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 |
| `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)
Run 2026-10-18 as `./scripts/deploy.sh dankclaw --service frank-tts-engine`, then
`--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).
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.
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`).
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.
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.
7. **Open:** [[Open Notebook]] is still not installed, so nothing renders a podcast yet; the voice leg it waits on now exists.
## Troubleshooting
| Symptom | Meaning | Move |
|---|---|---|
| Unit restarts every 10 s, journal says `cannot read /models/…` | weights missing or the mount is empty | run the fetcher; re-deploy |
| Unit `active`, but every voice name returns 400 from the façade | the engine restarted and lost its in-memory registry, or the warm pass failed | `journalctl --user -u frank-tts-engine` for the warmer's lines; `podman exec frank-tts-engine /app/warm-voices.sh` |
| Every voice logs `registration failed (HTTP no response)`, with `/usr/bin/curl: Argument list too long` on the line above it | 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 `reference.wav` base64's to about 1.27 MB | the body goes through a temp file and `--data-binary @file` (2026-10-18). Any pack over ~96 KB trips this, so no small fixture can see it — the seam ships a 300 KB pack for exactly that reason |
| Everything is ~2× slower after a systemd tweak | a cpuset/cap pin changed `hardware_concurrency()` and the engine resized its thread pools | remove the pin (law 2) |
| `502` from the façade with `frank-tts upstream request failed` | the façade cannot resolve or reach the engine | `podman network inspect systemd-frank-tts-engine` and `podman inspect frank-tts --format '{{json .NetworkSettings.Networks}}'` — the façade must be attached to the bridge; a `127.0.0.1:8880` or `host.containers.internal:8880` upstream in `scripts/frank-tts.env` is the old shape and cannot work |
| A voice is missing from the list | its directory has no `reference.wav`, or the name is not lowercase | fix the pack; both are refused loudly by design |
| `podman exec` in the deploy says `no such file or directory` for `/app/warm-voices.sh` | the image predates the warmer | re-deploy: since 2026-10-18 `build_compose_image` hashes every file the image `COPY`s, not only `Containerfile.engine`, so editing the warmer rebuilds it — see [[deployment]] |
| The warm pass logs `registered, but the warm synthesis failed` for EVERY voice, while a hand-run `/app/warm-voices.sh` succeeds | `WARM_TEXT` arrived **with its quotes**. podman's `--env-file` does no shell parsing — it takes the literal remainder of the line — so the env-file reader got `"Warm up, one two three."` where `source` gives `Warm up, one two three.`; the quotes plus a backslash then make the synthesis body invalid JSON | the hook strips one matched pair and escapes what is left; prove the value with `podman exec frank-tts-engine env \| grep WARM_TEXT`. The seam pins both readers, because a test that only sources the env file cannot see this |
| A `502` for `/v1/voices` where the engine answers `200` on `/health` from the same boot | the engine restarted and its in-memory registry is empty again — `/health` is model-loaded, NOT voices-registered | re-run `podman exec frank-tts-engine /app/warm-voices.sh`; that is the same distinction the `frank-tts` healthcheck enforces |
+200
View File
@@ -0,0 +1,200 @@
---
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."
tags:
- dankclaw
- tts
- voice
- quadlet
---
# Frank TTS
`frank-tts` is the fleet's only speech endpoint: an OpenAI-shaped
`POST /v1/audio/speech` on **Dankclaw :7772** that answers `voice=<name>` with
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
never learn a path.
**Deployed 2026-10-18** — `./scripts/deploy.sh dankclaw --service frank-tts`,
both units `active`, no drop-ins, and the homepage card's `wip` gate came off in
the same change. Probes on the deployed endpoint: `/health` answers
`{"status":"ok","backend":"omni-http","voices":["narrator1","narrator2","narrator3"]}`,
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.
| | |
| --- | --- |
| Host | Dankclaw (`192.168.0.143`), rootless podman, user unit `frank-tts.service` |
| Port | **7772**, published (`PublishPort=7772:7772`); the family is 7770 dragoon, 7771 zealot, 7772 tts |
| Routes | `GET /health`, `GET /v1/audio/voices`, `POST /v1/audio/speech`; everything else 404, and speech is POST-only |
| Image | `localhost/franksdank/frank-tts:latest`, built from `dankclaw/voiceservice/Containerfile`, `Pull=never` |
| Networks | `dankclaw_default` first (it owns the default route and the consumers), then `frank-tts-engine.network` (10.89.5.0/24, the bridge to the engine) |
| Voice packs | `/home/dank/voices` mounted read-only at `/voices` — host state, never repo state |
| Upstream | `http://frank-tts-engine:8880` (`scripts/frank-tts.env`, `TTS_UPSTREAM`) |
| Rollback | `TTS_BACKEND=llama-tts-cli` runs a CLI binary per request and needs no upstream at all |
## Naming a voice
A voice **is** a directory name. `voice=narrator1` resolves to
`/voices/narrator1/reference.wav`, and that file — not a manifest, not a config
entry — is what makes the name real. omp's TTS device may send `voice_id`; the
shim accepts either.
The usable shape is `^[a-z0-9][a-z0-9._-]*$`: lowercase, starting with a letter
or digit, then letters, digits, `.`, `_`, `-`, at most 64 characters. No slash,
no `..`, no space, no uppercase. The lowercase requirement is where two rules
meet: `app.py` would accept `[A-Za-z0-9…]`, but `tts-server` **lowercases
names**, so a pack called `Narrator1` resolves through the façade and not
through the engine. The boot warmer therefore refuses a mixed-case directory
loudly instead of letting it half-work.
Names are user-visible: an unknown voice comes back as
`400 unknown voice 'x'; available voices: …`, and that list is the honest
census. The podcast speakers are `narrator1`, `narrator2`, `narrator3` —
[[Open Notebook]] is configured with exactly those three, so renaming them
breaks episode rendering, not just speech. A personal voice gets the person's
name, lowercase.
## Voice packs
One directory per voice under `/home/dank/voices`:
```
reference.wav 24 kHz mono PCM, 18-25 s of clean single-speaker audio
reference.spk engine speaker embedding (8 192 B)
reference.rvq reference codec codes (22 B per 12.5 Hz frame)
manifest.json source file, offsets, engine, sha256
```
`reference.wav` is the engine-agnostic term the façade gates on; the other two
are what makes a voice reusable by this engine, and they come from the pack
builder ([[Voice Packs]]) — never by hand, never fabricated from the WAV. Two
laws apply to the
source material: offsets always start **past the first 10 seconds** of the
sample, and packs are host state that stays out of this repo. Because the
container runs as uid 10000, files must be world-readable (`644`/`755`), and the
directory must exist before the first deploy: rootless podman auto-creates a
missing bind source **root-owned**, which reads as "no voices" rather than as an
error.
Adding a voice to a running stack is a two-step thing — drop the pack, then
register **and warm** it with
`podman exec frank-tts-engine /app/warm-voices.sh` — because registering alone
does not warm it. The reason is the cold-voice law in [[Frank TTS Engine]], and
the procedure with its proof (the second request under a second) is the
`frank-tts-add-cloned-voice` skill.
## Why the upstream is a container name
`scripts/frank-tts.env` used to point at `http://host.containers.internal:8880`.
That cannot work, and it is the trap this shape exists to avoid: a bridge
container reaches the host's network namespace, netavark DNATs **published**
ports only, and an engine on the host's loopback is published to nobody — the
same shape that 502'd hermes ([[Container Network Namespaces]]). So both units
attach to one committed Quadlet network and the façade dials the engine's
container DNS name.
The consequence worth stating before an operator meets it: **the engine is not
reachable from the host at all** — not over the LAN, not over loopback. Its
health, voice registry and logs are observable only through
`podman exec frank-tts-engine …` and `systemctl --user status`. And the façade's
own `/health` answers 200 with the backend name and the voice list it resolved
without dialing the engine, so "façade healthy, engine down" is a real state: it
shows up as a failed synthesis, never as a down probe.
## Files
| Path | What |
| --- | --- |
| `dankclaw/voiceservice/app.py` | the shim: stdlib only — resolves the voice, forwards, re-encodes mp3 |
| `dankclaw/voiceservice/Containerfile` | python 3.12-alpine pinned by digest + ffmpeg, uid 10000 |
| `dankclaw/voiceservice/quadlet/frank-tts.container` | the unit: two networks, `Restart=always`, `HealthCmd`, `[Install]` |
| `dankclaw/voiceservice/quadlet/frank-tts-engine.network` | the shared bridge, named after the engine unit and attached by both |
| `scripts/frank-tts.env` | backend, upstream, port, limits — paths and numbers, no credentials |
| `tests/frank_tts_unit.bats` | the shim against stub engines over real sockets |
| `tests/frank_tts_engine.bats` | the pair's wiring: generator output, planner, homepage, deployer |
## Deploying it
`./scripts/deploy.sh dankclaw --service frank-tts` (or `--service frank-tts-engine --service frank-tts` for the pair):
builds the façade image, installs **both** Quadlet units — including the
engine's `.network` file, because a referenced `Network=` file the generator
cannot find means the façade unit is not produced at all — creates
`/home/dank/voices` as the deploy user, restarts the unit (whose `Wants=` and
`After=` pull the engine and its warm pass ahead of it) and gates on podman's
recorded health. It never runs `systemctl --user enable`; boot persistence is
the `[Install]` section in each unit. Order matters in a full deploy:
[[Frank TTS Engine]] is planned before this, and both before [[Open Notebook]].
## Not scraped, and why
`shared/prometheus/` gets **no** frank-tts job, and that is a considered
absence. The façade exports no `/metrics` — `app.py` serves `/health` plus the
speech route only — and this Prometheus 3 install refuses non-metrics content
types: [[Prometheus]] records that the existing HTML probes (`comfyui`, the two
`services` targets) sit **down** with
`received unsupported Content-Type … and no fallback_scrape_protocol specified`.
A `/health` probe job would therefore be structurally never-green, and because
`LlmEndpointDown` is scoped `up{job=~"frank-.*"} == 0`, a permanently red
`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
scraped at all: it publishes nothing. Status lives in `systemctl --user status`,
`podman healthcheck run`, the two unit journals, and the homepage card, which
probes `127.0.0.1:7772` for real now — the `wip: down` gate came off with the
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
| Symptom | Cause |
| --- | --- |
| `400 unknown voice 'x'; available voices: …` | the name is not a pack: case, spelling, missing `reference.wav`, or an empty/root-owned `/home/dank/voices` |
| `(none: no voice pack with a reference.wav in /voices)` | the mount, not the voices: `podman inspect frank-tts --format '{{json .Mounts}}'`, then the directory owner |
| Endpoint answers, synthesis fails | the engine: `podman healthcheck run frank-tts-engine`, then `systemctl --user status frank-tts-engine.service` |
| First request of the day is ~0.6 s slower than the rest — or every name returns 400 | the warm pass did not run; it carries `-`, so it cannot fail the unit. `journalctl --user -u frank-tts-engine` for the warmer's lines, then `podman exec frank-tts-engine /app/warm-voices.sh` — the registry is memory-only (law 1 of [[Frank TTS Engine]]) |
| The unit never appears after a deploy | the generator dropped it: `frank-tts-engine.network` is missing from `~/.config/containers/systemd/` |
@@ -0,0 +1,132 @@
---
type: reference
description: "open-notebook is 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."
tags:
- dankclaw
- open-notebook
- podcasts
- quadlet
timestamp: 2026-10-18
---
# Open Notebook
open-notebook ([lfnovo/open-notebook](https://github.com/lfnovo/open-notebook)) is the notebook layer that turns existing notes and sources into a **spoken episode**: an outline, then a dialogue transcript, then one synthesized clip per speaker turn, concatenated with ffmpeg into an MP3. In the fleet it is the consumer that closes the loop Frank's audio stack was built for — the script is written by **frank-zealot**, the voices are rendered by **frank-tts**, and nothing leaves the LAN.
**It is NOT deployed.** This page documents repo-owned configuration — the Quadlet units, the env file, the deployer path, the homepage card — and the contract those files were written against. On 2026-10-18 no `open-notebook` or `open-notebook-surrealdb` container exists on Dankclaw (`podman ps -a` census, 2026-10-18), and the homepage card ships `wip: "down"` with `showLink: false`, so the card is hidden and reported `wip-down`. Deploying it is a deliberate next step, not a pending one.
**Host:** Dankclaw (rootless Podman, user `dank`) — **Ports:** 8502 UI and 5056 API, published on the LAN, **no vhost and no Caddy route** — **Deployer:** `open-notebook` (one service, two units) — **Docs:** https://github.com/lfnovo/open-notebook
## Facts (verified 2026-10-18 against the pinned images, upstream source, and the live host)
| Item | Value |
|------|-------|
| App image | `docker.io/lfnovo/open_notebook:v1-latest@sha256:54b2025a4a4853c188f74b9b8246eca8c5480e5dd1babfa92db1038ff2f30212` (`open-notebook.container:79`) — the tag upstream's own `docker-compose.yml:27` pins for the multi-container shape. `v1-latest` is a moving tag, so the manifest-list digest (resolved from the registry 2026-10-18) is what makes this reproducible |
| Database image | `docker.io/surrealdb/surrealdb:v2@sha256:e5d7387e3727bbe2f7cd6d869e48cd34777272258572ead37c9656b25f745723` (`open-notebook-surrealdb.container:66`) — that digest is **SurrealDB 2.7.0** (image built 2026-09-23; measured by running the digest: `surreal version`) |
| Do NOT use | the `-single` tags (`v1-latest-single`). That variant bundles SurrealDB inside the app image and is deprecated upstream; here it would give the host a second, hidden database |
| Ports | container 8502 (Next.js UI) + 5055 (FastAPI API) → published `0.0.0.0:8502:8502` and `0.0.0.0:5056:5055` (`open-notebook.container:83-84`). **Host 5055 is taken**: seerr publishes `127.0.0.1:5055` (`dankclaw/seerr/quadlet/seerr.container`, and `ss -ltn` on Dankclaw shows `127.0.0.1:5055` live, 2026-10-18). The API therefore leaves on 5056, which is what forces `API_URL` (below) |
| Database port | 8000 inside its own namespace, **never published** (`open-notebook-surrealdb.container` has no `PublishPort`). Upstream publishes `127.0.0.1:8000` for debugging (`docker-compose.yml:18`); this unit does not, and the seam asserts that it stays that way |
| Data | `open-notebook-data:/app/data` (named volume: uploads, LangGraph SQLite checkpoints, extraction caches) **plus** the interim host bind `/home/dank/media/podcasts:/app/data/podcasts` (`open-notebook.container:87-88`); database in the `open-notebook-db` named volume |
| Networks | app: `dankclaw_default` first, then the private `systemd-open-notebook` (10.89.4.0/24, `open-notebook.network:33`); database: the private bridge only. Order is load-bearing — the generated `--network` flags keep file order, so the default route rides the network where `host.containers.internal` is proven |
| Units | `dankclaw/open-notebook/quadlet/open-notebook.container` → `open-notebook.service`; `open-notebook-surrealdb.container` → `open-notebook-surrealdb.service`; both `Restart=always`, `RestartSec=10` / `5`, `[Install] WantedBy=default.target` — boot-persistent per [[Quadlet Lifecycle]] (generated units cannot be `enable`d on this host; the `[Install]` section is what the v1 generator honors at user-manager start) |
| Health | app: `curl -fsS http://127.0.0.1:5055/health` (`open-notebook.container:89`); database: `/surreal is-ready` (`open-notebook-surrealdb.container:73`) |
| Env | `scripts/open-notebook.env` (repo, knobs) then `/home/dank/Containers/open-notebook/env` (host, secrets, mode 600) — two `EnvironmentFile=` lines per unit, repo file **first** because the last file wins |
| Deployer | `deploy_open_notebook` in `scripts/deploy-remote.sh`: seed secrets → ensure both digest pins are in local storage → install both units + the network → `mkdir -p /home/dank/media/podcasts` → restart database, wait for its container health → restart app → `wait_for_health open-notebook http://127.0.0.1:5056/health 180` |
## What the image actually runs
One container, three processes under supervisord (`supervisord.conf` upstream): `uvicorn api.main:app --port 5055` (`:12`), the job worker (`:25`), and the Next.js frontend (`:39`). Two consequences the config depends on:
- **One unit owns the whole app.** There is no separate worker or frontend container to scale or restart, and the frontend refuses to start until the API answers (`wait-for-api.sh && node server.js`, `supervisord.conf:39`) — so gating the API gates the UI.
- **The worker's concurrency comes from the environment**, not a flag baked into the image: `--max-tasks ${OPEN_NOTEBOOK_WORKER_MAX_TASKS:-5}` (`supervisord.conf:25`). Setting it in the env file is a real change, not decoration.
Also from the image itself: `WORKDIR /app` and `EXPOSE 8502 5055` (`Dockerfile:84`, `:126`), **no `USER` line** (the app runs as container root, which in this host's rootless map lands as `dank` — the [[Seerr]] UID note measured `0 → 1000` on this machine), and `curl` **and** `ffmpeg` installed in the runtime layer (`Dockerfile:74-76`) — curl makes the health probe possible without a shell wrapper, ffmpeg is how the per-line clips become one file.
`DATA_FOLDER` is hard-coded `"./data"` (`open_notebook/config.py:5`) and `PODCASTS_FOLDER` is `"./data/podcasts"` (`:19`); the database stores each episode's audio path **relative** to that folder (`episodes/<uuid>/audio/<uuid>.mp3`), and the download route is `GET /api/podcasts/episodes/{id}/audio`. That is why covering one subdirectory with a bind mount is sufficient — nothing in the database has to change if the mount moves.
## Provisioning the providers (after first boot; UI or REST, never a unit)
Credentials are stored in the database, encrypted, and are **not expressible in env or a Quadlet file**. The sequence is one-time, in **Manage → Models**, and both entries use provider type **OpenAI Compatible** (upstream: any server that implements `/v1/audio/speech` works this way — `docs/5-CONFIGURATION/local-tts.md:5`).
1. **TTS credential** — base URL **`http://frank-tts:7772/v1`**. The `/v1` is part of the URL: the client posts to `<base_url>/audio/speech`, so a base URL without `/v1` 404s on every dialogue line (`local-tts.md:72-80`, the base-URL table, always carries `/v1`). `frank-tts` resolves by container name because the app is attached to `dankclaw_default`, the network frank-tts's unit joins (`dankclaw/voiceservice/quadlet/frank-tts.container:51-52`). If frank-tts is ever moved off that network, the published-port form is `http://192.168.0.143:7772/v1` — a published port is dialable at the host's LAN address from any netns. The API-key field stays empty; frank-tts does not authenticate.
2. **LLM credential** — base URL **`http://host.containers.internal:7771/v1`**, model row `frank-zealot`. frank-zealot is a **host process** on `0.0.0.0:7771` (`ss -ltn` on Dankclaw shows the listener with no container owning it, 2026-10-18), and on this netavark host a bridge container reaches the host's LAN IP only at a *published* port — which is why Prometheus scrapes the same endpoint as `host.containers.internal:7771` (`shared/prometheus/prometheus.yml:89-111`, "not their own LAN addresses"). See [[Container Network Namespaces]]. `http://192.168.0.143:7771/v1` is the variant to try only if the gateway name ever stops resolving; it is expected to be refused from a bridge netns.
3. **Model rows are created by name.** Discovery is optional (see the next section): type the model id and confirm the add. One base URL per credential, so the TTS and LLM credentials are necessarily two separate configurations.
**Verify reachability before saving**, from inside the container, because both addresses are netns-dependent:
```bash
podman exec open-notebook curl -sS http://frank-tts:7772/health
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.
**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.
## Speakers and voices: 1–4, and the IDs are free text
A **speaker profile** carries one to four speakers (`docs/3-USER-GUIDE/creating-podcasts.md:27`), each with a personality and a **Voice ID that is typed exactly and passed through to the TTS request untouched** (`:29`) — there is no whitelist, no enum, no validation against a voice list. So the fleet's voice-pack names work verbatim:
| Speaker | Voice ID to type |
|---------|------------------|
| Speaker 1 | `narrator1` |
| Speaker 2 | `narrator2` |
| Speaker 3 | `narrator3` |
Those are the pack names frank-tts resolves from `/home/dank/voices` (`scripts/frank-tts.env` header: a voice is real exactly when `/voices/<name>/reference.wav` exists). Two traps, both from upstream's shipped defaults:
- The three seeded speaker profiles carry **OpenAI voice names** (`nova`, `alloy`, …) and **no voice model at all** — they show a "Setup required" badge, and generation fails with a voice error until they are edited (`creating-podcasts.md:13`, `:33`; `local-tts.md:89`). Add a `narrator1`/`narrator2` pair (or a third/fourth) rather than editing the seeded ones in place.
- Two speakers with the same voice are hard to follow (`creating-podcasts.md:89`). Distinct packs per speaker is the point of the 1–4 shape.
## Two buttons that fail against frank-tts, and why that is cosmetic
- **Test Connection** probes `GET <base_url>/models` (`open_notebook/ai/connection_tester.py:208-229`). frank-tts serves exactly two GET routes — `/health` and `/v1/audio/voices` — and 404s every other path (`dankclaw/voiceservice/app.py:228-238`). The button reports a failure against a healthy server.
- **Discover Models** does the same `GET <base>/models` (`open_notebook/ai/model_discovery.py:26-28`) and finds nothing to list.
- The TTS test path also synthesises with a **hard-coded voice `alloy`** for `openai_compatible` (`connection_tester.py:328-336`), which is not a fleet voice pack and would be refused even if the probe reached frank-tts.
None of it blocks anything: model rows are added **by name** ("If a model isn't listed, type its id and click Add", `local-tts.md:82`) and the Voice ID field is free text. The failure is a missing `/models` route on a deliberately minimal shim, not a broken credential. The honest end-to-end test is a real generation job, or the `podman exec … curl /v1/audio/speech` call frank-tts's own seam documents.
## The CPU guard, and why both numbers are 1
Defaults are `TTS_BATCH_SIZE=5` and `OPEN_NOTEBOOK_WORKER_MAX_TASKS=5` (`docs/5-CONFIGURATION/environment-reference.md:129-130`) — five parallel synthesis requests from five parallel jobs. frank-tts spawns an engine process per request (about 8 GiB resident, 8 threads, from the frank-tts CPU trial of 2026-10-18; `scripts/frank-tts.env` pins `LLAMA_TTS_THREADS=8`), on a machine that also serves frank-dragoon and frank-zealot. Five of those is ~40 GiB and forty threads competing with two LLM endpoints for the same cores. `scripts/open-notebook.env:87-88` sets both to 1: one episode at a time, one voice line at a time. Upstream's own guidance is the same direction ("Set 1 for a local model on a single GPU", "Lower it … for rate-limited or single-threaded TTS servers").
The clock next to it: `ESPERANTO_TTS_TIMEOUT` (`:91`) is patience **per dialogue line**, not per episode — a three-minute episode is dozens of separate POSTs, and the tail of a long turn on a CPU engine goes past upstream's 300 s default. It is set to **660, one round-trip outside `SHIM_TIMEOUT=600`** in `scripts/frank-tts.env` (frank-tts's own per-request ceiling): nested budgets go inside-out — the same rule that file states for `LLAMA_TIMEOUT` — so a frank-tts give-up arrives labelled as frank-tts's error instead of the worker's own timeout, and re-measuring frank-tts cannot silently strand this number below it. An abandoned request does not cancel the engine process; it only strands the episode in "generating".
## Secrets, and the difference between blank and unset
`OPEN_NOTEBOOK_PASSWORD` unset means **authentication off** (`environment-reference.md:46`) — every API route, including the episode-audio download, answers anyone who can reach the published port. That is a real exposure on a LAN-published service with no vhost and no guard chain in front of it, which is why `deploy_open_notebook` refuses to start either unit unless the seeded host file carries a value for all four secret keys (`OPEN_NOTEBOOK_ENCRYPTION_KEY`, `OPEN_NOTEBOOK_PASSWORD`, `SURREAL_PASSWORD`, `SURREAL_PASS`). `OPEN_NOTEBOOK_ENCRYPTION_KEY` is separately load-bearing: it is **required to save any provider credential**, and changing it later makes stored keys undecryptable (`:44`).
The tracked file declares the secrets **empty** and the host file supplies them; podman applies `--env-file` arguments in order and the last one wins (verified against the generated `ExecStart` line, 2026-10-18). Reordering the two `EnvironmentFile=` lines in a unit would blank every credential — that is the trap the seam's ordering assertion exists for.
The database has the same trap in a different spelling: the app reads `SURREAL_PASSWORD`, the `surreal` CLI reads `SURREAL_PASS` (its `start --help` prints `[env: SURREAL_PASS=]`; measured against the pinned digest). One value, written twice, by the seed function — so the pair cannot be given different root passwords. Both arrive through the environment, **not** through `Exec=`: `--pass` on the command line would put the database password in `/proc/<pid>/cmdline`, readable by any `ps` on the host. Measured with the pinned image: env-only credentials produce `Initialising credentials user=…`, the root user is created on first boot, a correct login authenticates and a wrong password is refused — no `--user`/`--pass` flags needed, and no `--bind` either, because the image's own `SURREAL_BIND=0.0.0.0:8000` is what makes the RPC reachable from another container at all.
## Episode destination: interim, and reversible (issue #7)
**Episode files currently exist in two places by design.** The app's own state stays in the `open-notebook-data` named volume; finished audio additionally lands on the host, because `/home/dank/media/podcasts` is bind-mounted over `/app/data/podcasts` (`open-notebook.container:88`). That is the **interim** answer to where episodes live: the destination is an open decision tracked as **Gitea issue #7, labelled `human-only` — agents must not work it.** Until Frank settles it, episode MP3s are ordinary files in his home directory, readable without `podman unshare`, which the database volume would require (measured with the pinned SurrealDB image).
Reversibility is one line: delete the `Volume=/home/dank/media/podcasts:/app/data/podcasts` line and the episodes go back to living solely inside the named volume. Nothing else in the config assumes the bind. See [[Storage Policy]] for the repo's storage conventions this interim state deliberately bends.
## Deploying, and the digest-pull step
```bash
# on Dankclaw, as dank — the pins are Pull=never, so fetch them first
podman pull docker.io/surrealdb/surrealdb:v2@sha256:e5d7387e3727bbe2f7cd6d869e48cd34777272258572ead37c9656b25f745723
podman pull docker.io/lfnovo/open_notebook:v1-latest@sha256:54b2025a4a4853c188f74b9b8246eca8c5480e5dd1babfa92db1038ff2f30212
```
`deploy_open_notebook` runs the same two pulls when the image is absent from local storage, so the manual step is only needed to pre-seed an offline host or to bump a pin. Then `scripts/deploy.sh --target dankclaw --service open-notebook`. Both `Pull=never` and the pin comment live in the unit files; upgrading means **editing the digest**, not repulling a tag — see [[Deployment Flow]].
## Rollback
- **Nothing deployed (today's state):** revert the commits. There is no host state to undo — no unit, no container, no volume, no `/home/dank/media/podcasts` directory beyond what the deployer would create.
- **Deployed:** `systemctl --user stop open-notebook.service open-notebook-surrealdb.service`, then move the three files (`open-notebook.container`, `open-notebook-surrealdb.container`, `open-notebook.network`) out of `~/.config/containers/systemd/` into `~/.AGENTTRASH/` — never `rm` — and `systemctl --user daemon-reload`. The generated units disappear with their sources.
- **Kept on purpose:** the `open-notebook-data` and `open-notebook-db` named volumes and `/home/dank/media/podcasts`. Episode audio and the notebook database are user data; removing them is a separate, deliberate act, and episode files are host files precisely so they can outlive the container.
- **Homepage:** the card is `wip: "down"` + `showLink: false`, so retiring the service needs no card surgery beyond setting it back to that state.
## Citations
- Repo: `dankclaw/open-notebook/quadlet/{open-notebook.container,open-notebook-surrealdb.container,open-notebook.network}`, `scripts/open-notebook.env`, `dankclaw/.env.example`, `scripts/deploy-plan.py`, `scripts/deploy-remote.sh` (`seed_open_notebook_env`, `ensure_pinned_image`, `deploy_open_notebook`), `scripts/deploy.sh`, `scripts/test-changed.sh`, `tests/open_notebook.bats`, `shared/homepage/services.json`, `dankclaw/seerr/quadlet/seerr.container` (the 5055 collision), `dankclaw/voiceservice/{quadlet/frank-tts.container,app.py}`, `scripts/frank-tts.env`, `shared/prometheus/prometheus.yml`
- Upstream `lfnovo/open-notebook` @ main, read 2026-10-18: `docker-compose.yml:3,10,11,18,27,29-30,39-43`, `Dockerfile:69-76,84,126,140,158`, `supervisord.conf:12,23-25,39`, `api/main.py:73,227-230,239-241,414`, `api/credentials_service.py:418-420,552`, `api/routers/credentials.py:426-429,433-437`, `open_notebook/config.py:5,19`, `open_notebook/podcasts/audio_paths.py`, `open_notebook/ai/connection_tester.py:208-238,326-336`, `open_notebook/ai/model_discovery.py:26-28`, `docs/3-USER-GUIDE/creating-podcasts.md:13,27,29,33,89`, `docs/5-CONFIGURATION/environment-reference.md:44,46,106,110,129,130`, `docs/5-CONFIGURATION/local-tts.md:5,45,72-82,89`
- Registry (2026-10-18): `lfnovo/open_notebook` tags (`v1-latest`, `latest`, the `-single` set) and manifest-list digests; `surrealdb/surrealdb` `v2` → 2.7.0
- Local measurements (2026-10-18, workstation, pinned digests): `surreal start --help` (credential and bind options carry `[env: SURREAL_*]`); running the pinned SurrealDB digest with env-only credentials (credential init in the log, correct login authenticates, wrong password refused, `/surreal is-ready` → `OK` exit 0, named volume owned by the mapped uid); the v1-compatible Quadlet generator dry-run of all three unit files (two `--env-file` in order, two `--network` in order, named volume + host bind, both health probes, `--pull never`)
- Host evidence (Dankclaw, 2026-10-18, read-only): `ss -ltn` (`127.0.0.1:5055` held by seerr, `0.0.0.0:7771` a host listener, nothing on 7772/8502/5056/8000), `podman ps -a` (no open-notebook container, `dankclaw_default` present), subnet registry from `dankclaw/open-webui/quadlet/open-webui.network`
@@ -0,0 +1,261 @@
---
type: reference
description: "How a frank-tts voice pack is built on Dankclaw: the three files every pack must carry, the 10-second source-material gate scripts/build-voice-pack.sh enforces, how to add a fourth voice, and what to do when a pinned engine bump changes the extractor's output sizes."
tags:
- dankclaw
- frank-tts
- voices
- tts
timestamp: 2026-10-18
---
# Voice Packs
A **voice pack** is the unit of identity for **frank-tts**: one directory under
`/home/dank/voices`, named by the voice, carrying the reference audio that makes
the name real plus the pinned engine's own speaker artifacts and a provenance
record. `voice=narrator1` on `POST /v1/audio/speech` resolves to
`/home/dank/voices/narrator1/reference.wav` and to nothing else — a name without
that file is a 400 that lists the names that do exist, because a silent fallback
voice is how a wrong-voice endpoint stays wrong for weeks.
**Host:** Dankclaw (192.168.0.143) — **Pack root:** `/home/dank/voices`, host
state that stays out of the repo, mounted read-only at `/voices` by the façade
unit — **Builder:** `scripts/build-voice-pack.sh` with tunables in
`scripts/voice-packs.env` — **Seam:** `tests/voice_pack_unit.bats` — **Engine:**
arm A `qwentts.cpp`, decided in [[Local TTS and Voice Cloning]].
Packs are **world-readable on purpose** (`drwxr-xr-x`, owner `dank`): the façade
container runs as uid 10000 and reads the mount from outside its own uid map.
## The required files
| File | What it is | Who reads it |
|------|------------|--------------|
| `reference.wav` | the crop: 24 kHz mono `pcm_s16le`, **18–25 s** of clean single-speaker audio | the façade (it is the existence gate for the name) and the engine's boot warmer, which base64s it and registers the voice with no transcript |
| `reference.spk` | the engine's x-vector: **8,192 B** = `enc_dim` 2048 × f32 | the engine, for the ICL/`--ref-spk` path |
| `reference.rvq` | the engine's RVQ code stream: **5,456–6,864 B**, 16 codebooks × T frames packed at 11 bits = 22 B per 12.5 Hz frame | the engine, for the ICL/`--ref-rvq` path |
| `manifest.json` | provenance: source path and sha256, crop start/end offsets, sample rate, duration, the tool and engine commit that made the artifacts, and the `voice` name | humans, and the builder itself on its second gate assertion |
`qwen-codec.log` rides along with the extractor's own record of the run; the
façade ignores anything it does not name and never deletes anything.
Division of enforcement, which is deliberate and not a gap: the **façade** gates
on `reference.wav` because that is the only engine-agnostic term (see the
voice-pack law in `scripts/frank-tts.env`); the **builder** is what refuses to
emit a pack without its engine artifacts; the **engine** registers a voice from
the WAV alone because its own `POST /v1/audio/voices` refuses a lone embedding —
it wants `wav_b64` or **both** `spk_b64` and `rvq_b64`. So the artifacts are in
the pack to make ICL a per-voice opt-in, not to boot it.
## How a pack is built
```bash
# everything except the extractor: gate, crop, probe, plan, the manifest it would write
scripts/build-voice-pack.sh --dry-run narrator1 /home/dank/voices
# the real thing, on the host that holds the engine build
scripts/build-voice-pack.sh narrator1 /home/dank/voices
```
Crops come from `scripts/voice-packs.env` — per voice: source path, offset,
duration, and the source's pinned sha256 — and any of them can be overridden on
the command line (`--source`, `--offset`, `--duration`, `--sha256`, `--tool`).
`--all` walks `VOICE_PACKS`. The builder assumes no GPU, no container, no fleet
endpoint and no network: `ffmpeg` cuts the crop, the pinned extractor makes the
artifacts, and both run `nice -n 19` so a build never competes with the text
endpoints on that box.
The extractor is `qwen-codec` in the `qwentts.cpp` tree at
`VOICE_PACK_ENGINE_COMMIT` (`5916bf7c79dbb4e66fc059cdc507a79a23a28bb4`). Its
usage text, read out of that tree and not guessed:
```
Usage: qwen-codec --model <gguf> [-i <input>] [--talker <gguf>] [--format <fmt>]
Required:
--model <gguf> Codec GGUF (qwen-tokenizer-12hz-*.gguf)
Optional:
--device <name> Compute device: CUDA0, Vulkan0, MTL0, CPU...
(default: best available)
-i <path> Input. WAV -> encode, .rvq -> decode
--talker <gguf> Talker GGUF (Base only). Encode also extracts the speaker
embedding and writes it next to the .rvq as a .spk file
--format <fmt> WAV output format: wav16, wav24, wav32 (default: wav16)
Output is auto-named next to input : clip.wav -> clip.rvq, clip.rvq -> clip.wav.
```
Three consequences shape the builder. **There is no `-o`**, so the pack's WAV is
staged under its final stem and the artifacts land beside it with nothing to
rename. **`--talker` must be a _Base_ talker** — the customvoice and voicedesign
GGUFs carry no speaker encoder and the tool says `has no speaker encoder (Base
only)`. **Encode truncates to the hop boundary**, so the `.rvq` size is arithmetic,
not chance: `22 × floor(samples / 1920)` bytes, which is where the recorded
5,456–6,864 B window comes from and why the **effective crop window is
19.84–25 s** even though the WAV law says 18–25 s. A crop shorter than that is
refused before the extractor runs, with the number in the message.
The build is staged and published, never assembled in place: `reference.wav` is
the file the façade reads, so it moves into position **last**, after the
artifacts and the manifest. A failed extractor therefore leaves a pack directory
that is not a voice. Nothing is ever deleted — an existing pack is refused unless
`--force`, and `--force` moves the whole old directory to
`/home/dank/voices/.AGENTTRASH/<voice>-<UTC>/` and builds fresh beside it.
Measured on the shipped `narrator1` crop (2026-10-18, `--dry-run` against the
real source): 98.739–118.636 s → `955,378 B` of 24 kHz mono `pcm_s16le`, 19.897
s, planned `.rvq` 5,456 B — the same byte counts the trial produced by hand, and
the extractor itself costs 1.8–2.1 s and 1.3–1.5 GiB peak per voice.
## The 10-second gate
Project-wide source-material invariant: **the first 10 seconds of any sample
file used to generate voices is forbidden material.** An offset that starts
inside it is refused with exit 1 and a message that names the invariant, before
a directory is created:
```
ERROR: source-material invariant violated: the crop must start past the first
10 s of the source file, and 'narrator1' asks for 4.5 s into … — nothing was
written.
```
The same gate is then asserted **from the manifest** that was just written —
`.crop.offset_seconds >= .invariant.forbidden_source_seconds`, plus the recorded
sha256 against the source it claims — so a pack cannot reach disk carrying a
forbidden crop even if the offsets in the env file are edited into nonsense.
`10` is not a knob: `scripts/voice-packs.env` carries the offsets, the sample
rate, the windows and the engine facts, but the forbidden region is a constant in
the builder. `tests/voice_pack_unit.bats` pins both halves (a refusal that
creates nothing, and one legal crop that builds the whole pack) and applies the
invariant to every crop in the tracked pack list.
## Adding a fourth voice
1. Pick the source file and hash it: `sha256sum /path/to/sample.wav`. A crop
offset is a claim about one exact file, so the digest goes in the env with it.
2. Find a speech-dense stretch **past 10 s**. The trial's recipe, applied once:
`ffmpeg -ss 60 -t 600 -i SRC -af silencedetect=noise=-35dB:d=0.6 -f null -`,
build the runs of non-silence, keep runs ≥ 18 s, take the longest (earliest
on ties), and cap at 25 s. Aim for 20–25 s: shorter than 19.84 s and the
`.rvq` falls under the floor.
3. Add the four lines to `scripts/voice-packs.env` and the name to
`VOICE_PACKS`:
```
VOICE_PACKS="narrator1 narrator2 narrator3 narrator4"
VOICE_PACK_NARRATOR4_SOURCE=/home/dank/voice-sources/audiosample4.m4b
VOICE_PACK_NARRATOR4_OFFSET=142.31
VOICE_PACK_NARRATOR4_DURATION=24
VOICE_PACK_NARRATOR4_SHA256=<64 hex>
```
The name must be lowercase `[a-z0-9._-]`: the façade matches the directory
name verbatim while `tts-server` lowercases names internally, so an uppercase
name would be reported one way by the façade and another by the engine. The
builder refuses one anyway.
4. `scripts/build-voice-pack.sh --dry-run narrator4 /home/dank/voices`, read the
plan, then drop `--dry-run`.
5. `./tests/run.sh` (the pack-list invariant test covers the new row for free).
6. Register it with the running engine. The engine's voice registry is
memory-only and warm-on-boot, but a restart re-decodes **every** voice (6–8 s
each), so adding one pack uses the hook instead:
```bash
podman exec frank-tts-engine /app/warm-voices.sh
curl -s localhost:7772/v1/audio/voices # the façade's list, not the engine's
```
The hook walks `/voices` and registers **every** pack that has a
`reference.wav`, on every run — 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."*) — then synthesizes twice per voice and logs
`voice <name> cold=…ms warm=…ms` to the unit's journal. The two numbers should
be close; see law 1 of [[Frank TTS Engine]] for what they measure. The rootless
unit restart — `systemctl --user restart frank-tts-engine.service` — is for when
the unit or its env changed, not for a new pack.
## When the extractor's output size changes on an engine bump
The builder treats the two artifact sizes as properties of the pinned engine, so
an engine bump that changes them **fails the build**: `reference.spk is N B,
expected 8192 B` or `reference.rvq is N B, expected …`. That is the failure being
loud, not a bug. Work through it in order:
1. Re-read the tool's own numbers rather than trusting this page: `qwen-codec`
prints `SpeakerEncoder] Loaded: enc_dim=…` and `Pipeline] Ready: hop … , …
codebooks @ … Hz` on every run, and the pinned tree's
`tools/qwen-codec.cpp` documents the packing. `enc_dim × 4` is the `.spk`;
`codebooks × code_bits / 8` bytes per frame × frames is the `.rvq`.
2. Decide whether the new number is intended. A changed `enc_dim` means the old
`.spk` files are unreadable by the new engine, so **every** pack must be
rebuilt, not just the failing one.
3. Update the four arithmetic lines in `scripts/voice-packs.env`
(`VOICE_PACK_SPK_BYTES`, `VOICE_PACK_RVQ_MIN_BYTES`, `VOICE_PACK_RVQ_MAX_BYTES`,
and `VOICE_PACK_RVQ_BYTES_PER_FRAME` if the codebook packing changed) **and**
`VOICE_PACK_ENGINE_COMMIT`, `VOICE_PACK_TOOL`, `VOICE_PACK_CODEC_MODEL`,
`VOICE_PACK_TALKER_MODEL` to the new build. An env diff that moves the sizes
without moving the commit is the mistake to avoid.
4. Rebuild all three packs with `--force` (the retired directories keep the old
bytes readable beside their replacements until a human clears them), then
re-run the warm hook: `podman exec frank-tts-engine /app/warm-voices.sh`. That
hook registers **every** pack it finds on every run, and the engine replaces a
name it already holds — `tools/tts-server.cpp:148` at the pinned commit says so
in its own words: *"Re-registering a name replaces the previous entry."* So the
rebuilt `reference.wav` is what the running engine speaks afterwards, and the
hook's cold/warm pass is what re-snapshots it. A restart
(`systemctl --user restart frank-tts-engine.service`) is what an ENGINE bump
needs — the registry is memory-only and lives in that process — and the unit
runs the same hook on every start anyway, so a restart is never worse, only
slower.
5. `./tests/voice_pack_unit.bats` — the stub in that seam reproduces the packing
law, so it needs the same arithmetic update as the env file. The seam's
engine-bump test is exactly this scenario with a wrong `.spk` size, and its
assertion — no `reference.wav`, no manifest — is the property that matters.
## Host state (census 2026-10-18, read-only)
- `/home/dank/voices/` holds `narrator1`, `narrator2`, `narrator3`, each with
`reference.wav`, a `reference16k.wav`, and a `manifest.json` **in the trial's
own schema** (`kind: voice-reference-pack`, a silence-scan record). They carry
**no** `reference.spk` / `reference.rvq`: the trial's artifacts live in its own
work directory (`/home/dank/voice-audition/qwentts/voices/`, and only for
narrator1 and narrator2). The builder's first real run is therefore a
migration, and it should be a `--force` run per voice so the old directories
are retired rather than left half-updated.
- The three source files (`/home/blank/audiosample.mp3`,
`audiosample2.m4b`, `audiosample3.m4b`, digests pinned in
`scripts/voice-packs.env`) live on the workstation, not on Dankclaw; the
builder reads them from `$VOICE_PACK_SOURCE_DIR=/home/dank/voice-sources`, so
they have to be copied there once before a build.
- `VOICE_PACK_TOOL` points at the trial's build tree
(`/home/dank/voice-audition/qwentts/qwentts.cpp/build/qwen-codec`, checked out
at exactly `5916bf7c`), and the two GGUFs at
`/home/dank/models/Qwen3-TTS-GGUF/`, which is where the engine slice's
download script puts them — that directory does not exist yet, so until it
does the models have to come from the trial's `models/` directory.
- Nothing is deployed: nothing listens on 7772 or 8880 today, so building packs
is possible on that host while serving them is not.
## Known gaps, on purpose
- **No transcript in the manifest.** ICL wants a `ref_text` that matches the
crop, and the trial's machine transcripts came back wrong ("Demon Souls" for
*Dark Souls*). Transcribing on the host is not a fix, so the pack carries none
and the engine registers the WAV-only voice, which is what its boot warmer
does anyway. When ICL becomes a per-voice opt-in, the curated transcript
belongs in this manifest — a human-supplied field, not something the builder
can derive.
- **No 16 kHz variant.** The packs the trial left behind carry
`reference16k.wav` for the `llama-tts-cli` rollback engine; arm A is native
24 kHz, so the builder emits one reference. `--force` retires the whole
directory including that variant, so if the rollback engine is ever revived:
`ffmpeg -i reference.wav -ar 16000 -c:a pcm_s16le reference16k.wav`.
- **No loudness pass.** The trial's pipeline also measured and normalised
(two-pass `loudnorm`, `astats`, `ebur128`, spectrograms) to a `-16 LUFS` spec.
None of that is in the pack law, which asks for 24 kHz mono inside the
duration window, so the builder does only that; a future engine that is
loudness-sensitive should get the pass as an explicit, documented step.
+28
View File
@@ -116,6 +116,34 @@ On a later ordinary deployment, a service remains pending only while its
recorded revision differs from the checkout and the planner still finds a recorded revision differs from the checkout and the planner still finds a
change in that service's inputs. change in that service's inputs.
## Locally built images
`build_compose_image` in `scripts/deploy-remote.sh` builds the images that have
no registry digest — `comfyui-rocm`, `franksdank/loki`, `franksdank/alloy` and
the voice pair `franksdank/frank-tts` / `franksdank/frank-tts-engine` — and
**skips** the build when a `cfhash` label on the installed image matches a hash
it computes from the checkout now. That skip is what keeps the engine's
ten-minute `git clone --recursive` off every re-deploy, so the rule has to be
honest about what the image bakes:
- The hash covers the `Containerfile` **plus the files its `COPY` lines pull
in**, named as `extra=(…)` in each case arm. Hashing the `Containerfile`
alone was wrong in practice: editing `dankclaw/voiceservice/app.py` or
`tts-engine-warm.sh` left the label matching, `podman build` never ran, and
the redeployed container still carried the previous code while `/health`
answered — invisible to every gate. Corrected 2026-10-18;
`tests/frank_tts_engine.bats` drives the function against a stub `podman`
(first deploy builds, unchanged tree does not, a `COPY`'d file rebuilds).
- A base-image assumption is part of the image's identity too. The engine's
runtime stage creates its unprivileged user with `useradd`, not `adduser`,
because the pinned `ubuntu:24.04` base ships no `adduser` — checked by
running the command against the digest on 2026-10-18. That failure mode is a
build that dies at the `RUN` line, so it is loud, unlike the stale-hash one.
Both engines keep serving during a rebuild: `build_compose_image` runs after
`install_quadlet` only for the LLM-style handlers, and the voice deploy gates on
recorded health rather than a fixed sleep — see [[Frank TTS Engine]].
## Manual recovery ## Manual recovery
If the LLM handler fails after the target pull: If the LLM handler fails after the target pull:
@@ -1,7 +1,7 @@
--- ---
type: research type: research
title: Local TTS and Voice Cloning title: Local TTS and Voice Cloning
description: How to create a new local TTS voice from sample files in oh-my-pi, the 2026 open-weight voice-cloning landscape, the full local AI audio stack, and a recommended homelab deployment. description: How to create a new local TTS voice from sample files in oh-my-pi, the 2026 open-weight voice-cloning landscape, the measured CPU engine A/B behind frank-tts and the arm A decision, the full local AI audio stack, and a recommended homelab deployment.
tags: tags:
- research - research
- tts - tts
@@ -10,7 +10,7 @@ tags:
- stt - stt
- vllm-omni - vllm-omni
- kokoro - kokoro
timestamp: 2026-10-18T00:00:00Z timestamp: 2026-10-18T16:05:00Z
--- ---
# Local TTS and Voice Cloning # Local TTS and Voice Cloning
@@ -47,13 +47,16 @@ greenfield: every fleet endpoint declares `input: [text, image]`
local server needs a speech-role entry, not a patch. local server needs a speech-role entry, not a patch.
- **Fleet plan (2026-10-18): CPU-only on Dankclaw, not the 3090.** Gitea issue - **Fleet plan (2026-10-18): CPU-only on Dankclaw, not the 3090.** Gitea issue
#2 rules out every GPU option (the 3090 has carried frank-zealot since #2 rules out every GPU option (the 3090 has carried frank-zealot since
2026-10-17; both R9700s are frank-archon's), so the shape is a VoxCPM2 GGUF 2026-10-17; both R9700s are frank-archon's). The shape is a TTS engine on
engine on **8880** behind a voice-registry shim on **8890** **8880** behind **frank-tts**, the fleet's speech endpoint on **7772**
(`scripts/voiceshim.env`, `dankclaw/voiceservice/`) that speaks OpenAI (`scripts/frank-tts.env`, `dankclaw/voiceservice/`): one OpenAI
`/v1/audio/speech` and resolves voices by name. Nothing is deployed yet — on `/v1/audio/speech` façade that resolves a voice *by name* and never shows a
2026-10-18 `voiceshim` has no unit installed and nothing answers on 8880 or path. Nothing is deployed — no `frank-tts` unit and no container exist on the
8890. STT (Parakeet TDT 1.1B, CC-BY-4.0) stays on CPU. **No RTF or quality host and nothing listens on 7772 or 8880 (checked 2026-10-18). STT (Parakeet
number has been measured for this shape**, so this page states none. TDT 1.1B, CC-BY-4.0) stays on CPU. **The engine is chosen: arm A**
`qwentts.cpp` **resident `tts-server`, 1.7 B talker** — measured in
*Measured CPU engine A/B* below and picked on the live-output criterion in
[Decision: arm A for live output](#decision-arm-a-for-live-output-2026-10-18).
## Can Kokoro learn a new voice? ## Can Kokoro learn a new voice?
@@ -133,11 +136,11 @@ accepts only its built-in voice list, so a custom pack needs a patch.
Paths for a Frank-cloned voice (2026-10-18): Paths for a Frank-cloned voice (2026-10-18):
1. **Local server + speech role, no patch.** Register the local engine (or the 1. **Local server + speech role, no patch.** Register frank-tts (port 7772) as
8890 voice shim) as a provider model on the `openai-speech` API and point a provider model on the `openai-speech` API and point `modelRoles.speech` at
`modelRoles.speech` at it; the shim turns a voice *name* into a reference it; the façade turns a voice *name* into that pack's reference clip and
clip and forwards it as base64 `reference_audio` forwards it as base64 `reference_audio` (`scripts/frank-tts.env`,
(`scripts/voiceshim.env:19-35`). Not deployed as of 2026-10-18. `TTS_BACKEND=omni-http`). Not deployed as of 2026-10-18.
2. **xAI console custom voice — works today, zero code.** Record 2–3 min clean 2. **xAI console custom voice — works today, zero code.** Record 2–3 min clean
speech (≤120 s, 24 kHz 16-bit mono WAV) → console → `voice_id` → speech speech (≤120 s, 24 kHz 16-bit mono WAV) → console → `voice_id` → speech
tool. Free (≤30 voices), cloud, US-only. tool. Free (≤30 voices), cloud, US-only.
@@ -206,29 +209,169 @@ The CPU shape does not need a Python server at all:
`VoxCPM2-Acoustic-F16.gguf`, Apache-2.0 card) or from the fork's own `VoxCPM2-Acoustic-F16.gguf`, Apache-2.0 card) or from the fork's own
`tools/omni/voxcpm2/convert_voxcpm2_to_gguf.py`. `tools/omni/voxcpm2/convert_voxcpm2_to_gguf.py`.
Nothing has been measured on this fleet for any of these engines — no RTF, no Arm B (`llama-tts`) and arm A (`qwentts.cpp`) have both been measured on this
latency, no MOS. That absence is the finding, not an omission. box — RTF, first-audio latency and RSS are in the next section. The other
servers in the table above (vLLM-Omni, Kokoro-FastAPI, CosyVoice, F5-TTS,
GPT-SoVITS) remain unmeasured here; that absence is still the finding.
## Measured CPU engine A/B (2026-10-18)
Both candidate upstreams for frank-tts were measured on Dankclaw's CPU — 8
threads, no GPU touched, three runs per arm, seed 42, identical text, against
the fleet's own audition voice packs.
- **Arm A — [`ServeurpersoCom/qwentts.cpp`](https://github.com/ServeurpersoCom/qwentts.cpp)**
at commit `5916bf7c79dbb4e66fc059cdc507a79a23a28bb4` (2026-10-16), weights
from [`Serveurperso/Qwen3-TTS-GGUF`](https://huggingface.co/Serveurperso/Qwen3-TTS-GGUF):
`qwen-talker-1.7b-base-Q4_K_M.gguf` (1,219,245,248 B),
`qwen-talker-0.6b-base-Q4_K_M.gguf` (628,905,056 B),
`qwen-tokenizer-12hz-Q8_0.gguf` (291,150,624 B) — byte counts verified
against the Hub API. Builds with zero compiler warnings; `GGML_BLAS=OFF` is
forced on this host (only `libopenblas.so.0` exists, no dev headers, so the
vendor `buildcpu.sh` cannot configure).
- **Arm B — mainline `llama-tts`** with `--tts-speaker-file`, i.e. the
`llama-tts-cli` backend `scripts/frank-tts.env` already implements.
RTF is **seconds of compute per second of audio** (lower is better; below 1 is
faster than real time).
| Arm | RTF | Run spread | Peak RSS |
|---|---|---|---|
| A · 0.6 B talker, CLI | 0.44 | ±0.002 | 3.4 GiB |
| A · 1.7 B, CLI, uncloned | 0.57–0.58 | ±0.006 | 4.7 GiB |
| A · 1.7 B, CLI, embedding-only clone | 0.63 | ±0.02 | n/m |
| A · 1.7 B, CLI, ICL clone | 0.92 | ±0.01 | n/m |
| A · 1.7 B, resident `tts-server`, warm | 0.55–0.57 | ±0.01 | 5.7 GiB |
| A · same, ICL voice warm / **cold (first request)** | 0.67 / **1.65** | one-shot | 5.7 GiB |
| B · mainline `llama-tts` | 0.93–0.99 | ±0.25 | n/m |
**Read RTF only within an arm.** Conditioning changes clip length — the same
seed yields 18.00 s of audio embedding-only and 15.12 s under ICL — so the
denominator is the arm's own audio duration.
Arm A's resident server is ~1.7× faster than arm B and about 40× tighter in
run-to-run spread, and it already serves the HTTP shape frank-tts's façade
speaks: `/health`, `/v1/models`, `/v1/audio/voices` (GET/POST/DELETE),
`/v1/audio/speech` (`wav`/`pcm`), chunked streaming with the first audio byte
at 86–451 ms and a server-side time-to-first-audio of 94–112 ms. Arm B has no
HTTP path at all — that is what the façade's CLI backend exists for.
Four laws came out of the trial, each worth a line of configuration:
- **Boot-warm every voice.** The trial measured the first request for a
registered voice at 6.2–8.0 s (first audio byte 5,530 ms) and later requests
restoring the snapshot in 0.5 ms. **That ladder did not survive deployment:**
measured 2026-10-18 on the built container against an empty registry — register,
then time — the first synthesis of a freshly registered voice cost **1,883 ms**
against **1,226–1,283 ms** for the requests after it, and a ~110-word request
cost **9.4 s** then **9.8 s**, so request time tracks the audio generated rather
than snapshot state. What still holds is the reason to warm at all: the registry
is memory-only and re-registering does not warm, so an engine unit must run one
throwaway synthesis per voice at start. It buys registration and about 0.6 s,
not five seconds.
- **Speaker artifacts are reusable and exact.** `qwen-codec` turns a reference
WAV into `.spk` (8,192 B x-vector) + `.rvq` (5,456–6,864 B, 16 codebooks × T,
11-bit packed) in 1.8–2.1 s at 1.3–1.5 GiB peak; replaying them yields a
**byte-identical** WAV and saves 1.4 s per request. That pair is the
engine-artifact term the voice-pack law in `scripts/frank-tts.env` reserves a
slot for — arm A pins it as `.spk` + `.rvq` beside `reference.wav`.
- **Streaming is not bit-identical to buffered**, whatever the upstream README
claims: same text, seed and voice, 92.2 % of samples differ (peak delta
1139/32768, −41.6 dB RMS — inaudible). A fixed seed does not pin output
across `response_format`, so never mix the two paths in a cache or a golden
fixture.
- **A voice cannot be registered from an embedding alone**: `POST
/v1/audio/voices` accepts `wav_b64` or **both** `spk_b64` and `rvq_b64`
(`src/tts-server.h:480`), so an embedding-only voice has to be sent as the
WAV, or the lone-`spk` path has to be upstreamed.
**Likeness is not measurable at this resolution.** x-vector cosine against each
speaker's own reference: ICL 0.9886 / 0.9901, embedding-only 0.9844 / 0.9812,
uncloned 0.9228 — but **two different speakers score 0.9497** on the same
metric. The whole clone effect lives in a 0.04-wide band and the
ICL-over-embedding gap is inside noise, so the four audition clips
(`/home/blank/voice-audition/qwentts-*.mp3`, each against its `armB-*.mp3`
mainline partner, guide in `LISTEN-engine.md`) are the instrument. **The
operator heard them on 2026-10-18 and passed every clone**, which removed the
last axis measurement could not reach; the engine was then picked on
live-output grounds ([Decision](#decision-arm-a-for-live-output-2026-10-18)).
The cosine
extractor is still worth keeping: it is engine-neutral (it scores mainline
output through the same encoder) and it separates an uncloned voice cleanly.
Two operational details. The engine takes 8 threads and exposes **no `-t`
flag** — `src/backend.h` uses `hardware_concurrency()/2` and prints the choice
at load, so pinning a cpuset on the quadlet changes that number implicitly. And
ICL needs a reference transcript that did not exist: the fleet has no ASR
endpoint and no audio input, so the crops were transcribed with
`vosk-small-en-us` inside the work dir, and two of three transcripts came back
suspect ("demon souls" for *Dark Souls*) — a reason to curate `ref_text` into
the pack manifest instead of transcribing on the host.
## Decision: arm A for live output (2026-10-18)
Every audition pair passed, so likeness stopped being the tiebreak. The
question became **which engine is better for live output** — the harness
speaking a reply, an alert read aloud, a click-to-speak — and on that criterion
RTF only has to stay under 1.0 with margin: what separates the arms is **time to
first audio** and the **spread**, because an arm whose RTF can cross 1.0 stalls
a live stream mid-sentence.
| | arm A · `qwentts.cpp` `tts-server` | arm B · mainline `llama-tts` |
|---|---|---|
| first audio byte | 86–451 ms | model load, per request |
| warm RTF | 0.55–0.67 | 0.93–0.99 |
| run spread | ±0.01 | ±0.25 |
| HTTP | serves `/v1/audio/speech` | none — façade CLI only |
| RAM | 5.7 GiB resident | per-request, freed after |
**Chosen: arm A, 1.7 B talker, resident `tts-server` in a `frank-tts-engine`
container, with `TTS_BACKEND=llama-tts-cli` kept in the repo as the rollback.**
Three consequences are configuration, not opinion: the unit **boot-warms every
voice** — the registry is memory-only, so an unwarm'd name is a 400 and the first
synthesis otherwise costs about 0.6 s more than the ones after it, as corrected
above —, it **does not pin a cpuset** casually (threads are
`hardware_concurrency()/2`, no flag), and every pack registers with the **WAV**
because the engine rejects a lone embedding.
Two follow-ups stay open on purpose, and neither blocks the deploy:
- **Talker size.** The 0.6 B arm measured 0.44 RTF at 3.4 GiB and has never
been auditioned — every clip above is 1.7 B. It is the lever for a CPU
co-tenancy problem, not a preemptive saving.
- **ICL vs embedding-only.** Both passed the ear test. Embedding-only is the
shipped default: 0.57–0.63 vs 0.67 warm, and it needs no `ref_text`, which
this fleet cannot produce honestly (the ASR crops came back suspect). Packs
still carry `.spk` + `.rvq`, so ICL remains a per-voice opt-in for batch work
like the podcast render, where total time matters more than first byte.
## Recommended deployment (Dankclaw) ## Recommended deployment (Dankclaw)
As decided in Gitea issue #2, this stays **CPU-only on Dankclaw**: the RTX 3090 As decided in Gitea issue #2, this stays **CPU-only on Dankclaw**: the RTX 3090
has carried frank-zealot since 2026-10-17 and both R9700s are frank-archon's, has carried frank-zealot since 2026-10-17 and both R9700s are frank-archon's,
so no card may be asked to co-host TTS. The shape is one engine plus a thin so no card may be asked to co-host TTS. The shape is one engine plus one
voice shim, and **nothing here is deployed as of 2026-10-18** — `systemctl façade, and **nothing here is deployed as of 2026-10-18**: `frank-tts` has
--user is-enabled voiceshim` reports `not-found` and nothing answers on 8880 or neither a unit nor a container on the host, and nothing listens on 7772 or
8890. 8880.
| Service | Image / install | Port | CPU | Notes | | Service | Image / install | Port | CPU | Notes |
|---|---|---|---|---| |---|---|---|---|---|
| TTS engine | `llama-tts-server` (`tc-mb/llama.cpp-omni`) on a VoxCPM2 GGUF; `vllm/vllm-omni` + Qwen3-TTS only if a GPU is ever deliberately freed | 8880 | engine decision still open | OpenAI `/v1/audio/speech`; `n_gpu_layers` is there for a GPU boot | | TTS engine | **arm A `qwentts.cpp` `tts-server`** on the Qwen3-TTS GGUFs (decided 2026-10-18, above); arm B mainline `llama-tts` stays in the repo as the `llama-tts-cli` rollback; `vllm/vllm-omni` + Qwen3-TTS only if a GPU is ever deliberately freed | 8880 (container-internal) | open, evidence above | arm A serves OpenAI `/v1/audio/speech` itself and boot-warms each voice; arm B has no HTTP path and runs through the façade's CLI backend |
| Voice shim | `dankclaw/voiceservice/` (stdlib container) + `scripts/voiceshim.env` + `dankclaw/voiceservice/quadlet/voiceshim.container` | 8890 | yes | voice *name* → `reference.wav`, `voice`/`voice_id` on the wire, mp3 re-encode; backends `omni-http` (forwards base64 `reference_audio`) and `llama-tts-cli` (`--tts-speaker-file`, `nice 19`) | | frank-tts | `dankclaw/voiceservice/` (stdlib container) + `scripts/frank-tts.env` + its quadlet | 7772 | yes | voice *name* → `/voices/<name>/reference.wav`, `voice`/`voice_id` on the wire, mp3 re-encode; backends `omni-http` (forwards base64 `reference_audio`) and `llama-tts-cli` (`--tts-speaker-file`, `nice 19`) |
| STT (English) | Parakeet TDT 1.1B behind a FastAPI wrapper, or whisper.cpp server | 8882 | yes | CC-BY-4.0; LibriSpeech-clean WER 1.39 (model card) | | STT (English) | Parakeet TDT 1.1B behind a FastAPI wrapper, or whisper.cpp server | 8882 | yes | CC-BY-4.0; LibriSpeech-clean WER 1.39 (model card) |
| STT (multilingual, opt.) | faster-whisper `large-v3` int8 | 8883 | yes | MIT | | STT (multilingual, opt.) | faster-whisper `large-v3` int8 | 8883 | yes | MIT |
| VAD | Silero (embedded) | — | yes | MIT; model file 2 327 524 B | | VAD | Silero (embedded) | — | yes | MIT; model file 2 327 524 B |
8880/8890 are what `scripts/voiceshim.env:35,49` carry (`TTS_UPSTREAM= 7772 is frank-tts's host port — the next slot in the fleet's model-endpoint
http://host.containers.internal:8880`, `SHIM_PORT=8890`); 8882-8883 for STT family (7770 frank-dragoon, 7771 frank-zealot). The engine behind it keeps 8880,
keep clear of the fleet's LLM ports 7770/7771. but **not as a host-loopback publish**: netavark DNATs published ports only, and
a bridge container cannot reach a `127.0.0.1` bind on the host — that is the
lesson that 502'd hermes (`AGENTS.md`). `host.containers.internal`
(169.254.1.2) is the gateway, so it reaches a host service only when that
service binds a routable address; `scripts/frank-tts.env` names it as the
engine's address, which holds for a `0.0.0.0` bind and not for a loopback one.
The deployed pair therefore shares one quadlet network and the façade addresses
the engine by container name.
## Voice-creation workflow (the "from sample files" answer) ## Voice-creation workflow (the "from sample files" answer)
@@ -284,6 +427,10 @@ keep clear of the fleet's LLM ports 7770/7771.
https://huggingface.co/openbmb/VoxCPM2 https://huggingface.co/openbmb/VoxCPM2
- llama.cpp-omni TTS server: https://github.com/tc-mb/llama.cpp-omni · - llama.cpp-omni TTS server: https://github.com/tc-mb/llama.cpp-omni ·
https://raw.githubusercontent.com/tc-mb/llama.cpp-omni/master/tools/omni/voxcpm2/README.md https://raw.githubusercontent.com/tc-mb/llama.cpp-omni/master/tools/omni/voxcpm2/README.md
- qwentts.cpp (arm A): https://github.com/ServeurpersoCom/qwentts.cpp at commit
`5916bf7c79dbb4e66fc059cdc507a79a23a28bb4` · weights
https://huggingface.co/Serveurperso/Qwen3-TTS-GGUF (byte counts against the
Hub API, 2026-10-18)
- mainline `llama-tts`: - mainline `llama-tts`:
https://raw.githubusercontent.com/ggml-org/llama.cpp/master/tools/tts/README.md · https://raw.githubusercontent.com/ggml-org/llama.cpp/master/tools/tts/README.md ·
Pocket-TTS weights: https://huggingface.co/kyutai/pocket-tts (CC-BY-4.0) Pocket-TTS weights: https://huggingface.co/kyutai/pocket-tts (CC-BY-4.0)
+4
View File
@@ -79,6 +79,10 @@ macOS mini) as quadlets inside its podman machine — not on Dankserver.
- [[Postgres]] — Gitea database backend - [[Postgres]] — Gitea database backend
- [[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
- [[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]] — 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
- [[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
+423
View File
@@ -2311,3 +2311,426 @@ every restart since the cutover.
Kokoro-enum-only — so "the agent talks in my voice" needs that provider row or Kokoro-enum-only — so "the agent talks in my voice" needs that provider row or
a `pi.registerProvider` extension, decided in phase 2. 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.
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96"><polygon points="48,6 86,27 86,69 48,90 10,69 10,27" fill="#101a12" stroke="#7ee787" stroke-width="3"/><text x="48" y="60" font-family="monospace" font-size="30" font-weight="bold" fill="#7ee787" text-anchor="middle">FT</text></svg>

After

Width:  |  Height:  |  Size: 293 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96"><polygon points="48,6 86,27 86,69 48,90 10,69 10,27" fill="#101a12" stroke="#7ee787" stroke-width="3"/><text x="48" y="60" font-family="monospace" font-size="30" font-weight="bold" fill="#7ee787" text-anchor="middle">ON</text></svg>

After

Width:  |  Height:  |  Size: 293 B

+26
View File
@@ -28,6 +28,19 @@
"art_bg": "#f5f5f5", "art_bg": "#f5f5f5",
"warn": "Chats stay on Frank's server, but OpenWebUI's web search rides the same SearXNG path as the search app: obfuscated, not anonymous. The upstream search API keys are tied to Frank’s identity." "warn": "Chats stay on Frank's server, but OpenWebUI's web search rides the same SearXNG path as the search app: obfuscated, not anonymous. The upstream search API keys are tied to Frank’s identity."
}, },
{
"name": "open-notebook",
"category": "user",
"section": "AI",
"description": "Podcast generator — notes in, spoken episode out: script written by frank-zealot, voices rendered by frank-tts. No public route; LAN UI only once it is deployed.",
"url": "",
"docs": "",
"probe": "127.0.0.1:8502",
"method": "http",
"wip": "down",
"showLink": false,
"art": "/img/apps/mono-on.svg"
},
{ {
"name": "Gitea", "name": "Gitea",
"category": "user", "category": "user",
@@ -523,5 +536,18 @@
"wip": false, "wip": false,
"showLink": true, "showLink": true,
"art": "/img/apps/mono-cu.svg" "art": "/img/apps/mono-cu.svg"
},
{
"name": "frank-tts",
"category": "admin",
"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.",
"url": "",
"docs": "",
"probe": "127.0.0.1:7772",
"method": "http",
"wip": false,
"showLink": false,
"art": "/img/apps/mono-ft.svg"
} }
] ]
+985
View File
@@ -0,0 +1,985 @@
#!/usr/bin/env bats
# Seam: frank-tts-engine — the arm A TTS engine (qwentts.cpp `tts-server`) that
# sits behind frank-tts. What is pinned here is the shape the fleet depends on,
# observed through the tools that act on it: the Quadlet generator's own output,
# the image entrypoint driven with a stub binary, the boot warmer driven against
# a stub engine over a real socket, and the fetcher's own verifier.
#
# Nothing in this file synthesizes audio, builds an image, starts a container or
# touches a fleet host. The engine's real behaviour (TTFA, RTF, the cold-voice
# cost) was measured on Dankclaw on 2026-10-18 and is documented in
# serverAppConfigs-docs/Dankclaw/Frank TTS Engine.md; a test that re-derived it
# would need the weights, the CPU and the time the endpoint is meant to serve.
#
# What this seam refuses to assert, on purpose: any comparison between streamed
# (`pcm`) and buffered (`wav`) audio. At the same seed those two paths differ in
# 92.2 % of samples (-41.6 dB RMS), so a fixture that pinned one against the
# other would be a bug waiting to be "fixed".
load helpers
UNIT="$REPO_ROOT/dankclaw/voiceservice/quadlet/frank-tts-engine.container"
NETF="$REPO_ROOT/dankclaw/voiceservice/quadlet/frank-tts-engine.network"
# The façade this engine serves: its wiring is tested here because the bridge
# both units attach to is what makes the pair one deployable shape.
FACADE="$REPO_ROOT/dankclaw/voiceservice/quadlet/frank-tts.container"
FACADE_ENV="$REPO_ROOT/scripts/frank-tts.env"
CF="$REPO_ROOT/dankclaw/voiceservice/Containerfile.engine"
START="$REPO_ROOT/dankclaw/voiceservice/tts-engine-start.sh"
ENVF="$REPO_ROOT/scripts/frank-tts-engine.env"
WARM="$REPO_ROOT/dankclaw/voiceservice/tts-engine-warm.sh"
FETCH="$REPO_ROOT/scripts/download-frank-tts-engine-qwentts.sh"
PLANNER="$REPO_ROOT/scripts/deploy-plan.py"
REMOTE="$REPO_ROOT/scripts/deploy-remote.sh"
# The decided artifacts, exactly as the 2026-10-18 Hub census recorded them.
TALKER="qwen-talker-1.7b-base-Q4_K_M.gguf"
TALKER_BYTES=1219245248
TALKER_SHA="ea393ebaf2167ea23ce9fc18b093822851358a950d7075cd47ab4f6ce23e887d"
CODEC="qwen-tokenizer-12hz-Q8_0.gguf"
CODEC_BYTES=291150624
CODEC_SHA="1883beeed99348fc35e23dd225e9082f93f6f8c109330a33d935baa8acdbfd94"
setup() {
TEST_TMP="$(mktemp -d)"
PIDS="$TEST_TMP/pids"
: > "$PIDS"
: > "$TEST_TMP/journal.jsonl"
# One real pack, and one directory that LOOKS like a pack but has no
# reference.wav so must never be offered as a voice. A third case — a pack
# whose name the two halves of the voice stack would resolve differently —
# is created only by the test that asserts on it, because every other run
# of the warmer has to be able to report success.
VOICES="$TEST_TMP/voices"
mkdir -p "$VOICES/narrator1" "$VOICES/justamanifest"
python3 -c 'import sys; sys.stdout.buffer.write(b"RIFF" + bytes(range(128)) * 2)' \
> "$VOICES/narrator1/reference.wav"
printf 'a manifest is not a voice\n' > "$VOICES/justamanifest/manifest.json"
}
teardown() {
while IFS= read -r pid; do
[ -n "$pid" ] && kill "$pid" 2>/dev/null || true
done < "$PIDS"
wait 2>/dev/null || true
rm -rf "$TEST_TMP"
}
# write_engine_stub — a stand-in for tts-server's HTTP surface, route for
# route: GET /health, GET /v1/models, POST /v1/audio/voices, POST
# /v1/audio/speech, and a bare 404 for anything else (that last part is the
# point of the stub: a warmer dialing a route the engine does not have must
# fail here, not in production). Every request is appended to $STUB_JOURNAL in
# the order it arrived, with the registration reduced to its key set, its name
# and the sha256 of the decoded wav — so "exactly which bytes were registered"
# is observable without shipping a 1 MB fixture around.
write_engine_stub() {
cat > "$TEST_TMP/stub_engine.py" <<'PY'
import base64, hashlib, json, os, time
from http.server import BaseHTTPRequestHandler, HTTPServer
JOURNAL = os.environ["STUB_JOURNAL"]
HANG = float(os.environ.get("STUB_HANG_SPEECH") or 0)
class Stub(BaseHTTPRequestHandler):
def _record(self, entry):
with open(JOURNAL, "a") as fh:
fh.write(json.dumps(entry) + "\n")
def _reply(self, status, body, ctype="application/json"):
self.send_response(status)
self.send_header("content-type", ctype)
self.send_header("content-length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self):
self._record({"method": "GET", "path": self.path})
if self.path == "/health":
self._reply(200, b'{"status":"ok"}')
elif self.path == "/v1/models":
self._reply(200, b'{"object":"list","data":[{"id":"frank-tts-engine"}]}')
else:
self._reply(404, b'{"detail":"not found"}')
def do_POST(self):
length = int(self.headers.get("content-length") or 0)
raw = self.rfile.read(length) or b"{}"
try:
body = json.loads(raw)
except ValueError:
self._record({"method": "POST", "path": self.path, "invalid_json": True})
self._reply(400, b'{"error":{"message":"request body is not valid JSON"}}')
return
if self.path == "/v1/audio/voices":
wav = body.get("wav_b64") or ""
try:
digest = hashlib.sha256(base64.b64decode(wav)).hexdigest()
except Exception:
digest = "undecodable"
self._record({"method": "POST", "path": self.path,
"keys": sorted(body), "name": body.get("name"),
"wav_sha256": digest, "wav_b64_len": len(wav),
"has_ref_text": "ref_text" in body})
self._reply(200, json.dumps({"name": body.get("name"),
"status": "registered"}).encode())
elif self.path == "/v1/audio/speech":
self._record({"method": "POST", "path": self.path,
"voice": body.get("voice"),
"response_format": body.get("response_format"),
"input": body.get("input")})
# STUB_HANG_SPEECH stands in for an engine that takes the request
# and never answers it: the branch the warmer's ceiling guards.
if HANG:
time.sleep(HANG)
audio = b"RIFF" + b"\x00" * 64
self._reply(200, audio, "audio/wav")
else:
self._record({"method": "POST", "path": self.path})
self._reply(404, b'{"detail":"not found"}')
def log_message(self, *args):
pass
server = HTTPServer(("127.0.0.1", int(os.environ.get("STUB_BOUND_PORT") or 0)), Stub)
print(server.server_address[1], flush=True)
server.serve_forever()
PY
}
start_engine_stub() {
# start_engine_stub [port] — with a port the stub binds that exact one,
# which is what lets a test have a listener appear on an address the warmer
# is already dialing.
write_engine_stub
: > "$TEST_TMP/stub.log"
STUB_JOURNAL="$TEST_TMP/journal.jsonl" STUB_BOUND_PORT="${1:-}" \
STUB_HANG_SPEECH="${STUB_HANG_SPEECH:-}" \
python3 "$TEST_TMP/stub_engine.py" > "$TEST_TMP/stub.log" 2>&1 &
echo "$!" >> "$PIDS"
local waited=0
while [ "$waited" -lt 200 ]; do
STUB_PORT="$(head -n1 "$TEST_TMP/stub.log")"
[ -n "$STUB_PORT" ] && return 0
sleep 0.05
waited=$((waited + 1))
done
echo "the stub engine never bound a port:"
cat "$TEST_TMP/stub.log"
return 1
}
# quadlet_generator — locate the Quadlet generator the target host also has.
# podman moved it over the releases the fleet runs, so try every known home and
# skip the test if this box has none: the assertions below are only meaningful
# against a real generator, not a guess at one.
quadlet_generator() {
local candidate
for candidate in \
/usr/lib/systemd/system-generators/podman-system-generator \
/usr/libexec/podman/quadlet-generator \
/usr/lib/podman/quadlet-generator \
/usr/lib/podman/quadlet; do
[ -x "$candidate" ] && { printf '%s' "$candidate"; return 0; }
done
return 1
}
# render_units — run the generator in a throwaway HOME over a copy of the voice
# stack's source files and print everything it emits. Both units are rendered
# together on purpose: the façade references the engine's .network, and a
# referenced file that is not in the directory makes the generator drop that
# container unit outright — which is the failure this seam must be able to see.
# --dryrun prints instead of writing, so this neither contacts the podman daemon
# nor creates a network.
render_units() {
local gen home
gen="$(quadlet_generator)" || { echo "no Quadlet generator on this box"; return 1; }
home="$TEST_TMP/fakehome"
mkdir -p "$home/.config/containers/systemd"
cp "$NETF" "$UNIT" "$FACADE" "$home/.config/containers/systemd/"
HOME="$home" "$gen" --user --dryrun > "$TEST_TMP/generated.txt" 2> "$TEST_TMP/generated.err"
cat "$TEST_TMP/generated.txt"
}
# unit_block <name> — one unit's block from the last render_units output. The
# end of the block is the next `---` marker: two units are rendered now, and a
# slice that ran to the end of the output would read the façade's RestartSec
# into the engine's assertions, which is exactly how a generator test starts
# asserting the wrong container.
unit_block() {
awk -v u="$1" 'index($0, "---" u "---") == 1 { f = 1; next }
/^---/ { f = 0 }
f' "$TEST_TMP/generated.txt"
}
# free_port — an address with nothing bound to it, for the tests that need the
# warmer to be dialing before a listener exists.
free_port() {
python3 -c 'import socket
s = socket.socket()
s.bind(("127.0.0.1", 0))
print(s.getsockname()[1])
s.close()'
}
@test "the voice bridge is the next free pinned subnet and no two committed networks share one" {
# Two networks claiming one subnet is a boot-time collision that shows up as
# the later container failing to start, on the host, weeks from now. The
# registry lives in the network files' comments, so the subnets themselves
# are the only honest thing to compare.
mine="$(sed -nE 's/^Subnet=([0-9./]+)$/\1/p' "$NETF")"
[ "$mine" = "10.89.5.0/24" ] || { echo "expected 10.89.5.0/24, the next free /24, got: $mine"; false; }
run grep -hoE '^Subnet=[0-9./]+' "$REPO_ROOT"/dankclaw/*/quadlet/*.network "$REPO_ROOT"/dankserver/*/quadlet/*.network
dupes="$(printf '%s\n' "$output" | sort | uniq -d)"
[ -z "$dupes" ] || { echo "subnets claimed by more than one network file: $dupes"; false; }
printf '%s\n' "$output" | grep -qxF "Subnet=$mine"
}
@test "the Quadlet generator renders the engine on its private bridge, publishing nothing" {
rendered="$(render_units)" || { echo "$rendered"; skip "no Quadlet generator here"; }
line="$(printf '%s\n' "$rendered" | grep -E '^ExecStart=/usr/bin/podman run .*--name frank-tts-engine ')"
[ -n "$line" ] || { printf '%s\n' "$rendered"; false; }
# The data path: container DNS on the network the engine owns. One network
# and nothing else attached — the façade joins this one rather than the
# engine moving onto the fleet-wide bridge.
printf '%s' "$line" | grep -qE -- '--network systemd-frank-tts-engine( |$)'
[ "$(printf '%s' "$line" | grep -oE -- '--network [^ ]+' | wc -l)" = "1" ]
# NO publish of any kind, not even 127.0.0.1:8880. netavark DNATs published
# ports only, so a host-loopback publish is not a path to a peer container —
# the façade could never have used it (the trap that 502'd hermes), and it
# would be the one LAN-reachable surface of an unauthenticated synthesiser.
! printf '%s' "$line" | grep -qE -- '--publish'
# Repo-owned config: the tracked env file, the read-only weights mount, the
# read-only voice packs the in-container warmer reads, and a health probe
# that survives the generator's CMD-SHELL wrapping because the image ships
# curl.
printf '%s' "$line" | grep -qF -- '--env-file /home/dank/serverAppConfigs/scripts/frank-tts-engine.env'
printf '%s' "$line" | grep -qF -- '-v /home/dank/models/Qwen3-TTS-GGUF:/models:ro'
printf '%s' "$line" | grep -qF -- '-v /home/dank/voices:/voices:ro'
printf '%s' "$line" | grep -qF -- '--health-cmd'
printf '%s' "$line" | grep -qF 'curl'
# Boot persistence and self-healing, as the generator actually emits them —
# and the warm hook: ExecStartPost must carry the `-` prefix, because
# Restart=always plus a failing warmer is a crash loop on the endpoint.
unit="$(unit_block frank-tts-engine.service)"
printf '%s' "$unit" | grep -qE '^Restart=always$'
restart_sec="$(printf '%s' "$unit" | sed -nE 's/^RestartSec=([0-9]+)s?$/\1/p')"
[ -n "$restart_sec" ] && [ "$restart_sec" -le 30 ]
printf '%s' "$unit" | grep -qE '^WantedBy=default\.target$'
# The warm hook is an exec, because there is no host-visible address to run
# a host script against (see the publish assertions above).
printf '%s' "$unit" | grep -qE '^ExecStartPost=-/usr/bin/podman exec frank-tts-engine /app/warm-voices\.sh$'
# Warming a library is serialised: it must not fit inside the default 90 s.
start_deadline="$(printf '%s' "$unit" | sed -nE 's/^TimeoutStartSec=([0-9]+)$/\1/p')"
[ -n "$start_deadline" ] && [ "$start_deadline" -gt 90 ]
}
@test "the generator renders the bridge creation command with the pinned subnet" {
rendered="$(render_units)" || { echo "$rendered"; skip "no Quadlet generator here"; }
net="$(unit_block frank-tts-engine-network.service)"
printf '%s' "$net" | grep -qE '^ExecStart=/usr/bin/podman network create --ignore --driver bridge --subnet 10\.89\.5\.0/24 --gateway 10\.89\.5\.1 systemd-frank-tts-engine$'
# The container unit must be tied to the network unit, not merely mention it:
# a referenced .network that the generator cannot find kills the container
# unit outright, and an untied one starts the container before the bridge.
unit="$(unit_block frank-tts-engine.service)"
printf '%s' "$unit" | grep -qE '^Requires=frank-tts-engine-network\.service$'
printf '%s' "$unit" | grep -qE '^After=frank-tts-engine-network\.service$'
}
@test "the entrypoint turns the tracked env file into the engine's own flags" {
# Not a grep of the env file: source it the way podman's --env-file feeds
# it to the container, then require the stub binary to be handed the flags
# those values mean. A key the entrypoint forgot to translate shows up here
# as a missing argument, which is what "the knob does nothing" looks like.
cat > "$TEST_TMP/fake-tts-server" <<'FAKE'
#!/usr/bin/env bash
printf '%s\n' "$@" > "$ARGS_FILE"
FAKE
chmod +x "$TEST_TMP/fake-tts-server"
mkdir -p "$TEST_TMP/models"
: > "$TEST_TMP/models/$TALKER"
: > "$TEST_TMP/models/$CODEC"
set -a
# shellcheck disable=SC1090
source "$ENVF"
set +a
# The env file names CONTAINER paths; the stub stands in for the mount.
export QWENTTS_MODEL="$TEST_TMP/models/$TALKER"
export QWENTTS_CODEC="$TEST_TMP/models/$CODEC"
export QWENTTS_BIN="$TEST_TMP/fake-tts-server"
export ARGS_FILE="$TEST_TMP/argv.txt"
run bash "$START"
[ "$status" -eq 0 ] || { echo "$output"; false; }
argv() { awk -v flag="$1" 'p { print; exit } $0 == flag { p = 1 }' "$ARGS_FILE"; }
[ "$(argv --model)" = "$TEST_TMP/models/$TALKER" ]
[ "$(argv --codec)" = "$TEST_TMP/models/$CODEC" ]
[ "$(argv --alias)" = "frank-tts-engine" ]
# 0.0.0.0 is the one value that makes the container reachable at all from
# the bridge; tts-server's own default (127.0.0.1) is unreachable in a netns.
[ "$(argv --host)" = "0.0.0.0" ]
[ "$(argv --port)" = "8880" ]
[ "$(argv --lang)" = "auto" ]
[ "$(argv --max-batch)" = "1" ]
# The two flags that must never appear: there is no thread count to set (it
# comes from hardware_concurrency()/2), and naming a device on an image with
# every GPU backend compiled out is only a new way to be wrong.
! grep -qx -- '--device' "$ARGS_FILE"
! grep -qE -- '^--?t$|^--threads$' "$ARGS_FILE"
}
@test "missing weights are a refusal that names the fetcher, before the port is bound" {
cat > "$TEST_TMP/fake-tts-server" <<'FAKE'
#!/usr/bin/env bash
echo "the engine binary must not run when its weights are absent" > "$RAN_FILE"
FAKE
chmod +x "$TEST_TMP/fake-tts-server"
export RAN_FILE="$TEST_TMP/ran.txt"
export QWENTTS_MODEL="$TEST_TMP/nope/$TALKER"
export QWENTTS_CODEC="$TEST_TMP/nope/$CODEC"
export QWENTTS_BIN="$TEST_TMP/fake-tts-server"
run bash "$START"
[ "$status" -eq 1 ] || { echo "$output"; false; }
[ ! -e "$RAN_FILE" ] || { echo "the engine was started without its weights"; false; }
[[ "$output" == *"$TEST_TMP/nope/$TALKER"* ]] || { echo "$output"; false; }
[[ "$output" == *"download-frank-tts-engine-qwentts.sh"* ]]
}
@test "the boot warmer registers each pack from its reference.wav, embedding-only" {
start_engine_stub
run env ENGINE_URL="http://127.0.0.1:$STUB_PORT" VOICES_DIR="$VOICES" \
bash "$WARM"
[ "$status" -eq 0 ] || { echo "$output"; false; }
[[ "$output" == *"voice narrator1 cold="*"ms warm="*"ms"* ]] || { echo "$output"; false; }
# The registration is the whole product: name + wav_b64, and NOTHING else.
# ref_text present would flip the voice into in-context-learning mode (the
# slower voice) — so its absence is the shipped-default assertion.
python3 - "$TEST_TMP/journal.jsonl" "$VOICES/narrator1/reference.wav" <<'PY'
import hashlib, json, sys
entries = [json.loads(l) for l in open(sys.argv[1])]
regs = [e for e in entries if e["path"] == "/v1/audio/voices"]
assert len(regs) == 1, entries
reg = regs[0]
assert reg["name"] == "narrator1", reg
assert reg["keys"] == ["name", "wav_b64"], reg
assert reg["has_ref_text"] is False, reg
want = hashlib.sha256(open(sys.argv[2], "rb").read()).hexdigest()
assert reg["wav_sha256"] == want, (reg, want)
PY
# Two speech calls for the one voice: the cold pass that creates the codec
# snapshot, then the warm pass whose number is the fleet's real experience.
# Both ask for `wav`, which is what frank-tts asks for too.
python3 - "$TEST_TMP/journal.jsonl" <<'PY'
import json, sys
entries = [json.loads(l) for l in open(sys.argv[1])]
paths = [e["path"] for e in entries]
assert paths[0] == "/health", paths
assert paths[1] == "/v1/audio/voices", paths
speech = [e for e in entries if e["path"] == "/v1/audio/speech"]
assert len(speech) == 2, entries
assert {s["voice"] for s in speech} == {"narrator1"}, speech
assert {s["response_format"] for s in speech} == {"wav"}, speech
PY
}
@test "a reference clip too big for one argument still registers" {
# The kernel caps a SINGLE command-line string at 32 pages = 131,072 bytes
# (MAX_ARG_STRLEN), and a shipped reference clip base64's to about 1.27 MB —
# so a warmer that hands the registration body to curl as an argument dies
# before curl runs. That is exactly what the first real boot of 2026-10-18
# hit with the real packs: `/usr/bin/curl: Argument list too long`, then the
# misleading "registration failed (HTTP no response)" for all three voices.
# The stub engine is a real listener and curl is the real binary, so this
# test fails for the same kernel reason the host did.
start_engine_stub
local big="$TEST_TMP/bigvoices/bigvoice"
mkdir -p "$big"
head -c 300000 /dev/zero | tr '\0' 'A' > "$big/reference.wav"
run env ENGINE_URL="http://127.0.0.1:$STUB_PORT" VOICES_DIR="$TEST_TMP/bigvoices" \
bash "$WARM"
[ "$status" -eq 0 ] || { echo "$output"; false; }
[[ "$output" == *"voice bigvoice cold="*"ms warm="*"ms"* ]] || { echo "$output"; false; }
# And the audio that arrived is the audio that was on disk, whole.
python3 - "$TEST_TMP/journal.jsonl" "$big/reference.wav" <<'PY'
import hashlib, json, sys
entries = [json.loads(l) for l in open(sys.argv[1])]
regs = [e for e in entries if e["path"] == "/v1/audio/voices"]
assert len(regs) == 1, entries
assert regs[0]["name"] == "bigvoice", regs[0]
want = hashlib.sha256(open(sys.argv[2], "rb").read()).hexdigest()
assert regs[0]["wav_sha256"] == want, (len(regs[0]["wav_sha256"]), want)
PY
}
@test "the warm sentence survives both readers of the env file intact" {
# scripts/frank-tts-engine.env is read two ways and they disagree about
# quotes. bash (`set -a; source`, what the seams and a human do) strips the
# quotes around a value with spaces. podman's --env-file — how the unit really
# reads it — does NOT: measured 2026-10-18 against the image's pinned base,
# the value lands in the container WITH its quotes. Interpolated raw into the
# hook's JSON body that is `""Warm up…"",` — the engine answers 400, no voice
# warms, and the boot still reads healthy. So the hook unwraps one
# shell-style layer and then escapes, and BOTH shapes must arrive as the
# sentence the operator wrote.
start_engine_stub
# Shape A: what podman delivers for `WARM_TEXT="Warm up, one two three."`.
run env ENGINE_URL="http://127.0.0.1:$STUB_PORT" VOICES_DIR="$VOICES" \
WARM_TEXT='"Warm up, one two three."' bash "$WARM"
[ "$status" -eq 0 ] || { echo "$output"; false; }
[[ "$output" == *"voice narrator1 cold="*"ms warm="*"ms"* ]] || { echo "$output"; false; }
python3 - "$TEST_TMP/journal.jsonl" <<'PY'
import json, sys
entries = [json.loads(l) for l in open(sys.argv[1])]
assert not [e for e in entries if e.get("invalid_json")], entries
speech = [e for e in entries if e["path"] == "/v1/audio/speech"]
assert len(speech) == 2, entries
assert {s["input"] for s in speech} == {"Warm up, one two three."}, speech
PY
# Shape B: a value that starts AND ends with a quote but carries more quotes
# INSIDE — exactly the shape an unwrap rule must refuse, because peeling the
# outer pair would delete characters the operator wrote. It must arrive
# character for character (trailing quote included), which also proves the
# escaping does not mangle what it is protecting.
: > "$TEST_TMP/journal.jsonl"
run env ENGINE_URL="http://127.0.0.1:$STUB_PORT" VOICES_DIR="$VOICES" \
WARM_TEXT='"He said "hi"\ once"' bash "$WARM"
[ "$status" -eq 0 ] || { echo "$output"; false; }
python3 - "$TEST_TMP/journal.jsonl" <<'PY'
import json, sys
entries = [json.loads(l) for l in open(sys.argv[1])]
assert not [e for e in entries if e.get("invalid_json")], entries
speech = [e for e in entries if e["path"] == "/v1/audio/speech"]
assert len(speech) == 2, entries
assert {s["input"] for s in speech} == {'"He said "hi"\\ once"'}, speech
PY
}
@test "a directory without reference.wav is not a voice, and a mixed-case name is refused" {
# `justamanifest` must be invisible; `Narrator2` must be loud. The engine
# lowercases every voice name (tts_voice_name) while frank-tts matches the
# directory name exactly, so a mixed-case pack is a voice that works on one
# side of the pair and not the other. Silence here is how a wrong-voice pack
# survives for weeks.
mkdir -p "$VOICES/Narrator2"
printf 'RIFFwrongcase' > "$VOICES/Narrator2/reference.wav"
start_engine_stub
run env ENGINE_URL="http://127.0.0.1:$STUB_PORT" VOICES_DIR="$VOICES" \
bash "$WARM"
[ "$status" -eq 1 ] || { echo "$output"; false; }
[[ "$output" == *"Narrator2"* ]]
[[ "$output" == *"lowercase"* ]]
run cat "$TEST_TMP/journal.jsonl"
[[ "$output" != *"justamanifest"* ]]
[[ "$output" != *"Narrator2"* ]]
# The refusal is a journal line plus a non-zero exit — not a crash: the
# narrator1 pack still got registered and warmed above the failure.
grep -q 'narrator1' "$TEST_TMP/journal.jsonl"
}
@test "an engine that never answers /health is reported, and nothing is registered" {
# HEALTH_WAIT is a deadline, not a retry-forever: an engine that dies on
# boot must produce one clear journal line and a non-zero exit (which the
# unit's `-` prefix turns into "nothing happened", not a crash loop).
port="$(free_port)"
run env ENGINE_URL="http://127.0.0.1:$port" VOICES_DIR="$VOICES" HEALTH_WAIT=1 \
bash "$WARM"
[ "$status" -eq 1 ] || { echo "$output"; false; }
[[ "$output" == *"/health"* ]] || { echo "$output"; false; }
[[ "$output" == *"did not answer"* ]]
[ ! -s "$TEST_TMP/journal.jsonl" ]
}
@test "the warmer waits for a late engine instead of racing model load" {
# Boot order: ExecStartPost fires the moment the container starts, while the
# two GGUFs (1.2 GiB + 291 MB) are still being read and the HTTP thread has
# not bound yet. The warmer is launched against an address with nothing on
# it, the listener appears a beat later, and the requirement is that the
# warmer notices and does the work — an early exit leaves every voice
# unregistered, and the façade then answers 400 for all of them.
port="$(free_port)"
( ENGINE_URL="http://127.0.0.1:$port" VOICES_DIR="$VOICES" HEALTH_WAIT=60 \
bash "$WARM" > "$TEST_TMP/race.log" 2>&1; echo "$?" > "$TEST_TMP/race.rc" ) &
echo "$!" >> "$PIDS"
sleep 0.7
start_engine_stub "$port"
local waited=0
while [ ! -f "$TEST_TMP/race.rc" ] && [ "$waited" -lt 200 ]; do
sleep 0.05
waited=$((waited + 1))
done
[ -f "$TEST_TMP/race.rc" ] || { echo "warmer never finished:"; cat "$TEST_TMP/race.log"; false; }
[ "$(cat "$TEST_TMP/race.rc")" = "0" ] || { cat "$TEST_TMP/race.log"; false; }
grep -q '/v1/audio/voices' "$TEST_TMP/journal.jsonl"
grep -q '/v1/audio/speech' "$TEST_TMP/journal.jsonl"
}
@test "a synthesis that never returns is abandoned at REQUEST_TIMEOUT, not waited out" {
# systemd counts the WHOLE warm pass against TimeoutStartSec, so the price
# of a hung engine is paid by the unit: the endpoint stays "activating" and
# never reaches started. The warmer therefore puts a ceiling on every
# request. Here the engine registers the voice and then never answers the
# synthesis — the failure mode a wedged GEMM loop actually produces.
STUB_HANG_SPEECH=30
export STUB_HANG_SPEECH
start_engine_stub
run env ENGINE_URL="http://127.0.0.1:$STUB_PORT" VOICES_DIR="$VOICES" \
REQUEST_TIMEOUT=1 bash "$WARM"
[ "$status" -eq 1 ] || { echo "$output"; false; }
[[ "$output" == *"warm synthesis failed"* ]] || { echo "$output"; false; }
# It got as far as registering, so the stall — not a missing route — is
# what ended the pass.
grep -q '/v1/audio/voices' "$TEST_TMP/journal.jsonl"
}
@test "an empty voice root is an explanation, not a failure" {
# No packs yet is the state right after this unit is deployed and before the
# pack builder runs. Failing there would mark the engine deploy incomplete
# and, worse, read as a broken warmer to whoever is debugging a real one.
start_engine_stub
run env ENGINE_URL="http://127.0.0.1:$STUB_PORT" VOICES_DIR="$TEST_TMP/nodir" \
bash "$WARM"
[ "$status" -eq 0 ] || { echo "$output"; false; }
[[ "$output" == *"no voice directory"* ]]
# No registration, no synthesis. Health polls are expected — the stub
# journals them, and this is about what the warmer must NOT send.
run grep -c '/v1/audio' "$TEST_TMP/journal.jsonl"
[ "$output" = "0" ]
}
@test "the warmer's knobs come from the same env file the engine reads" {
# The warm hook is exec-ed into the container, so systemd hands it the unit's
# EnvironmentFile — one file for both. Sourcing it here is how that is
# reproduced without a container: if a key were renamed on one side only, the
# hook would silently fall back to its built-in default and the knob would do
# nothing, which no grep of either file alone can catch.
start_engine_stub
set -a
# shellcheck disable=SC1090
source "$ENVF"
set +a
[ -n "${WARM_TEXT:-}" ] || { echo "scripts/frank-tts-engine.env defines no WARM_TEXT"; false; }
run env ENGINE_URL="http://127.0.0.1:$STUB_PORT" VOICES_DIR="$VOICES" \
bash "$WARM"
[ "$status" -eq 0 ] || { echo "$output"; false; }
python3 - "$TEST_TMP/journal.jsonl" "$WARM_TEXT" <<'PY'
import json, sys
texts = {json.loads(l).get("input") for l in open(sys.argv[1])
if json.loads(l)["path"] == "/v1/audio/speech"}
assert texts == {sys.argv[2]}, (texts, sys.argv[2])
PY
# The engine-side keys are in the same file and must not collide with the
# warmer's: QWENTTS_PORT is what the server binds, ENGINE_URL is where the
# hook dials, and they are different values by design (container bind address
# versus in-namespace loopback).
grep -qE '^QWENTTS_PORT=8880$' "$ENVF"
grep -qE '^VOICES_DIR=/voices$' "$ENVF"
}
@test "the fetcher pins the decided weights and puts no credential on the wire" {
# The byte counts and the Hub pointer oids ARE the contract for the payload,
# the same way an image digest is: they came off the Hub on 2026-10-18 and a
# silent change means someone moved a file upstream.
run grep -h '^ "Serveurperso/Qwen3-TTS-GGUF|' "$FETCH"
[ "$(printf '%s\n' "$output" | grep -c .)" = "2" ]
printf '%s\n' "$output" | grep -qF "|$TALKER|$TALKER|$TALKER_BYTES|$TALKER_SHA"
printf '%s\n' "$output" | grep -qF "|$CODEC|$CODEC|$CODEC_BYTES|$CODEC_SHA"
# sha256 oids, not the 40-char git blob ids the same API also returns.
run grep -oE '\|[0-9a-f]{64}"$' "$FETCH"
[ "$(printf '%s\n' "$output" | grep -c .)" = "2" ]
# Public repo: no auth header, and no token in the invocation.
! grep -qE 'HF_TOKEN|Authorization|--header' "$FETCH"
grep -qE 'curl -fsSL --fail-with-body' "$FETCH"
}
@test "the fetcher reports absent and wrong-sized weights as MISSING, non-zero" {
run bash "$FETCH" "$TEST_TMP/payload" --verify-only
[ "$status" -eq 1 ] || { echo "$output"; false; }
[[ "$output" == *"MISSING $TALKER"* ]]
[[ "$output" == *"MISSING $CODEC"* ]]
[[ "$output" == *"DOWNLOAD_FAILED"* ]]
# A partial download of the right NAME is still missing: the byte count is
# what gates, and a truncated GGUF would mmap fine and then produce noise.
mkdir -p "$TEST_TMP/payload"
head -c 4096 /dev/zero > "$TEST_TMP/payload/$TALKER"
: > "$TEST_TMP/payload/$CODEC"
run bash "$FETCH" "$TEST_TMP/payload" --verify-only
[ "$status" -eq 1 ]
[[ "$output" == *"MISSING $TALKER"* ]]
[[ "$output" == *"MISSING $CODEC"* ]]
}
@test "the image build is the pinned commit with the proven cmake line" {
# GGML_BLAS=OFF is forced by the host (libopenblas.so.0 present, no dev
# headers) and upstream's own Dockerfile passes it ON, so this file is not a
# copy of upstream on purpose. -j 8 and the OFF-for-every-GPU-backend list
# are what the 2026-10-18 trial ran.
grep -qE '^ARG QWENTTS_COMMIT=[0-9a-f]{40}$' "$CF"
grep -qF '5916bf7c79dbb4e66fc059cdc507a79a23a28bb4' "$CF"
grep -qE 'git -C /src checkout --detach' "$CF"
grep -qE 'submodule update --init' "$CF" # ggml is a submodule; without it the build cannot configure
for flag in -DGGML_CCACHE=OFF -DGGML_BACKEND_DL=OFF -DGGML_CPU_ALL_VARIANTS=OFF \
-DGGML_OPENMP=ON -DGGML_BLAS=OFF -DGGML_CUDA=OFF -DGGML_HIP=OFF \
-DGGML_VULKAN=OFF -DGGML_METAL=OFF -DGGML_SYCL=OFF -DGGML_RPC=OFF; do
grep -qF -- "$flag" "$CF" || { echo "build line lost $flag"; false; }
done
grep -qE 'cmake --build build -j 8' "$CF"
# OpenMP on means the runtime stage needs the OpenMP runtime, and the probe
# runs as /bin/sh -c, so the runtime stage needs curl.
grep -qE 'libgomp1' "$CF"
grep -qE '^\s+ca-certificates curl libgomp1' "$CF"
# Both scripts ship in the image: the server's entrypoint, and the warmer the
# unit execs. A warmer that is not in the image makes the unit's
# ExecStartPost fail on every boot, silently (`-` prefix by design).
grep -qF 'COPY dankclaw/voiceservice/tts-engine-start.sh /app/start.sh' "$CF"
grep -qF 'COPY dankclaw/voiceservice/tts-engine-warm.sh /app/warm-voices.sh' "$CF"
grep -qE 'chmod 755 /app/start.sh /app/warm-voices.sh' "$CF"
! grep -qE '^HEALTHCHECK' "$CF" # the probe lives in the Quadlet unit
grep -qE '^USER tts$' "$CF"
# Base pinned by digest, both stages from one image.
grep -qE '^ARG BASE_IMAGE=docker\.io/library/ubuntu:24\.04@sha256:[0-9a-f]{64}$' "$CF"
[ "$(grep -cE '^FROM \$\{BASE_IMAGE\}' "$CF")" = "2" ]
}
@test "the planner maps every engine path to frank-tts-engine, and nothing to dankserver" {
# Everything named after the engine, EXCEPT the bridge file: both units
# attach to it, so a subnet change is a change to two deployables and that
# pairing is asserted in the façade's planner test below.
run python3 "$PLANNER" --target dankclaw --paths \
dankclaw/voiceservice/quadlet/frank-tts-engine.container \
dankclaw/voiceservice/Containerfile.engine \
dankclaw/voiceservice/tts-engine-start.sh \
scripts/frank-tts-engine.env \
dankclaw/voiceservice/tts-engine-warm.sh
[ "$status" -eq 0 ]
[ "$output" = "frank-tts-engine" ]
# Host-side payload and repo tooling restart nothing: the weights are host
# state, exactly like every other download script.
run python3 "$PLANNER" --target dankclaw --paths \
scripts/download-frank-tts-engine-qwentts.sh
[ "$status" -eq 0 ]
[ -z "$output" ]
# The engine is a Dankclaw container. On Dankserver these paths must plan
# nothing rather than queue a service that does not exist there.
run python3 "$PLANNER" --target dankserver --paths \
dankclaw/voiceservice/quadlet/frank-tts-engine.container \
scripts/frank-tts-engine.env
[ "$status" -eq 0 ]
[ -z "$output" ]
}
@test "changed-path routing selects this seam" {
# The regression this guards is real and was hit next door: the generic
# dankclaw/*.container case matches across `/`, so a quadlet path placed
# below it never reaches its own seam.
run "$REPO_ROOT/scripts/test-changed.sh" --list --paths \
dankclaw/voiceservice/quadlet/frank-tts-engine.container
[ "$status" -eq 0 ]
printf '%s\n' "$output" | grep -qx "tests/frank_tts_engine.bats"
run "$REPO_ROOT/scripts/test-changed.sh" --list --paths \
dankclaw/voiceservice/quadlet/frank-tts-engine.network
[ "$status" -eq 0 ]
printf '%s\n' "$output" | grep -qx "tests/frank_tts_engine.bats"
run "$REPO_ROOT/scripts/test-changed.sh" --list --paths scripts/frank-tts-engine.env
[ "$status" -eq 0 ]
printf '%s\n' "$output" | grep -qx "tests/frank_tts_engine.bats"
printf '%s\n' "$output" | grep -qx "tests/scripts_syntax.bats"
}
@test "both deployer entry points know the engine and its handler gates on health, then warm" {
grep -qF 'dankclaw:frank-tts-engine|' "$REMOTE"
grep -qF 'dankclaw:frank-tts-engine|' "$REPO_ROOT/scripts/deploy.sh"
grep -qF 'frank-tts-engine) deploy_frank_tts_engine ;;' "$REMOTE"
grep -qF 'containerfile=dankclaw/voiceservice/Containerfile.engine' "$REMOTE"
handler="$(sed -n '/^deploy_frank_tts_engine()/,/^}/p' "$REMOTE")"
[ -n "$handler" ] || { echo "no deploy_frank_tts_engine handler"; false; }
# Weights before build, build before start, health before warm: in that
# order, because each step's failure message is only true in that order.
local weights build quadlet start health warm pos_prev=-1 pos
weights="$(printf '%s' "$handler" | grep -n 'download-frank-tts-engine-qwentts.sh --verify-only' | head -1 | cut -d: -f1)"
build="$(printf '%s' "$handler" | grep -n 'build_compose_image frank-tts-engine' | head -1 | cut -d: -f1)"
quadlet="$(printf '%s' "$handler" | grep -n 'install_quadlet frank-tts-engine' | head -1 | cut -d: -f1)"
start="$(printf '%s' "$handler" | grep -n 'systemctl --user restart frank-tts-engine.service' | head -1 | cut -d: -f1)"
health="$(printf '%s' "$handler" | grep -n 'wait_container_healthy frank-tts-engine' | head -1 | cut -d: -f1)"
warm="$(printf '%s' "$handler" | grep -n 'podman exec frank-tts-engine /app/warm-voices.sh' | head -1 | cut -d: -f1)"
for pos in "$weights" "$build" "$quadlet" "$start" "$health" "$warm"; do
[ -n "$pos" ] || { echo "handler is missing a step (weights/build/quadlet/start/health/warm)"; false; }
[ "$pos" -gt "$pos_prev" ] || { echo "handler steps are out of order"; false; }
pos_prev="$pos"
done
# The gate is podman's recorded health, not a URL: this container publishes
# nothing, so the host has no address to dial.
printf '%s' "$handler" | grep -qE 'wait_container_healthy frank-tts-engine [0-9]+'
! printf '%s' "$handler" | grep -qE 'wait_for_health frank-tts-engine'
# Generated Quadlet units cannot be enabled on this host: the handler must
# not try, and the unit carries [Install] instead.
! printf '%s' "$handler" | grep -qE '^[[:space:]]*systemctl --user enable'
}
@test "the two scripts that ship in the image pass the repo's shell gates" {
# scripts_syntax.bats walks scripts/ only, and these two no longer live
# there: the entrypoint is the container's only way to read the env file,
# and the warmer is exec-ed by the unit. A syntax error in either is a
# container that never starts, or a deploy that reports an engine nobody
# told it to talk to, so they get the same gates as scripts/*.sh.
local s
for s in "$START" "$WARM"; do
bash -n "$s" || { echo "syntax error in $s"; false; }
[ -x "$s" ] || { echo "$s is not executable — git must record the mode"; false; }
if command -v shellcheck >/dev/null 2>&1; then
shellcheck -S error "$s" || { echo "shellcheck errors in $s"; false; }
fi
done
}
@test "the generator puts the façade on both bridges and behind the engine" {
rendered="$(render_units)" || { echo "$rendered"; skip "no Quadlet generator here"; }
line="$(printf '%s\n' "$rendered" | grep -E '^ExecStart=/usr/bin/podman run .*--name frank-tts ')"
[ -n "$line" ] || { printf '%s\n' "$rendered"; false; }
# Two attachments, and the fleet-wide bridge FIRST: that is the network
# consumers address (open-notebook dials `http://frank-tts:7772/v1` by
# container name), so it owns the default route. The engine's private bridge
# is second and exists so the shim can resolve the engine at all.
[ "$(printf '%s' "$line" | grep -oE -- '--network [^ ]+' | head -1)" = "--network dankclaw_default" ]
printf '%s' "$line" | grep -qE -- '--network systemd-frank-tts-engine( |$)'
# Published exactly once, on the fleet port — and this is the only publish in
# the voice stack.
[ "$(printf '%s' "$line" | grep -oE -- '--publish [^ ]+' | wc -l)" = "1" ]
printf '%s' "$line" | grep -qE -- '--publish (\[::\]:)?7772:7772( |$)'
unit="$(unit_block frank-tts.service)"
printf '%s' "$unit" | grep -qE '^Wants=frank-tts-engine\.service$'
printf '%s' "$unit" | grep -qE '^After=.*frank-tts-engine\.service'
# Wants, never Requires: the CLI rollback backend needs no upstream, and an
# engine that will not start must not take this endpoint's unit down.
! printf '%s' "$unit" | grep -qE '^Requires=frank-tts-engine\.service$'
# And it is tied to the bridge as well, or the container would be started
# before netavark had created the network it dials over.
printf '%s' "$unit" | grep -qE '^Requires=frank-tts-engine-network\.service$'
}
@test "the upstream the env file names is only resolvable on a network the unit joins" {
# The pair's one real wiring contract, checked as a join rather than as a
# string: take the hostname out of TTS_UPSTREAM the way the container will
# get it, and require the façade unit to attach to the Quadlet network file
# named after that hostname. A revert to host.containers.internal, or a unit
# that drops the second Network= line, breaks here — both mistakes leave the
# endpoint answering and every synthesis failing.
set -a
# shellcheck disable=SC1090
source "$FACADE_ENV"
set +a
host="${TTS_UPSTREAM#*://}"
host="${host%%:*}"
host="${host%%/*}"
[ -n "$host" ] || { echo "TTS_UPSTREAM names no host: $TTS_UPSTREAM"; false; }
[ "$host" != localhost ] || { echo "localhost is not reachable across netns"; false; }
if ! grep -qE "^Network=${host}\.network$" "$FACADE"; then
echo "frank-tts.container does not attach to ${host}.network"
false
fi
if ! [ -f "$REPO_ROOT/dankclaw/voiceservice/quadlet/${host}.network" ]; then
echo "no Quadlet network file for $host — the generator would drop the unit"
false
fi
}
@test "the planner keeps the two voice deployables separate and ordered" {
# `--only` on the façade must not drag the engine's rebuild along, and a
# bridge change must restart both: the network file is named after the engine
# unit only because install_quadlet copies `$unit.network` beside the unit.
run python3 "$PLANNER" --target dankclaw --paths dankclaw/voiceservice/app.py
[ "$output" = "frank-tts" ]
run python3 "$PLANNER" --target dankclaw --paths dankclaw/voiceservice/Containerfile
[ "$output" = "frank-tts" ]
run python3 "$PLANNER" --target dankclaw --paths \
dankclaw/voiceservice/quadlet/frank-tts.container
[ "$output" = "frank-tts" ]
run python3 "$PLANNER" --target dankclaw --paths \
dankclaw/voiceservice/quadlet/frank-tts-engine.container
[ "$output" = "frank-tts-engine" ]
run python3 "$PLANNER" --target dankclaw --paths \
dankclaw/voiceservice/quadlet/frank-tts-engine.network
printf '%s\n' "$output" | grep -qx frank-tts
printf '%s\n' "$output" | grep -qx frank-tts-engine
# Order: dependency before consumer, so a full dankclaw deploy starts a warm
# engine and then the endpoint that forwards into it.
run python3 "$PLANNER" --target dankclaw --paths \
dankclaw/voiceservice/quadlet/frank-tts.container \
dankclaw/voiceservice/quadlet/frank-tts-engine.container
local e f
e="$(printf '%s\n' "$output" | grep -n '^frank-tts-engine$' | cut -d: -f1)"
f="$(printf '%s\n' "$output" | grep -n '^frank-tts$' | cut -d: -f1)"
[ -n "$e" ] && [ -n "$f" ] && [ "$e" -lt "$f" ]
}
@test "the façade handler builds its image, installs the bridge file, and gates on recorded health" {
grep -qF 'dankclaw:frank-tts|' "$REMOTE"
grep -qF 'dankclaw:frank-tts|' "$REPO_ROOT/scripts/deploy.sh"
grep -qF 'frank-tts) deploy_frank_tts ;;' "$REMOTE"
# build_compose_image's default arm returns 0 without building: an unlisted
# service means a unit whose Pull=never image was never made.
grep -qE '^\s+containerfile=dankclaw/voiceservice/Containerfile$' "$REMOTE"
handler="$(sed -n '/^deploy_frank_tts()/,/^}/p' "$REMOTE")"
[ -n "$handler" ] || { echo "no deploy_frank_tts handler"; false; }
printf '%s' "$handler" | grep -qF 'build_compose_image frank-tts'
printf '%s' "$handler" | grep -qF 'install_quadlet frank-tts dankclaw/voiceservice/quadlet frank-tts'
# The engine's quadlet pair is installed by this handler too: with the
# referenced .network file missing the generator produces no façade unit at
# all, and `--only frank-tts` has no other way to get it onto the host.
printf '%s' "$handler" | grep -qF 'install_quadlet frank-tts-engine dankclaw/voiceservice/quadlet frank-tts-engine'
printf '%s' "$handler" | grep -qE 'mkdir -p /home/dank/voices'
printf '%s' "$handler" | grep -qE 'systemctl --user restart frank-tts\.service'
printf '%s' "$handler" | grep -qE 'wait_container_healthy frank-tts [0-9]+'
! printf '%s' "$handler" | grep -qE '^[[:space:]]*systemctl --user enable'
}
@test "an image rebuild is triggered by every file the image bakes in" {
# build_compose_image skips `podman build` when the image's cfhash label
# matches a hash — the thing that keeps the engine's 10-minute source clone
# from running on every re-deploy. If that hash covers only the Containerfile,
# then editing app.py (COPY'd into the façade) or the warm hook (COPY'd into
# the engine) rebuilds NOTHING: the deploy reports success, /health answers,
# and the container is still running last week's code. Observable output, not
# a grep of the handler: did the deploy ask podman to build, or did it skip?
awk '/^build_compose_image\(\) \{/,/^\}/' "$REMOTE" > "$TEST_TMP/bci.sh"
[ -s "$TEST_TMP/bci.sh" ] || { echo "build_compose_image not extractable from $REMOTE"; false; }
repo="$TEST_TMP/repo"
mkdir -p "$repo/dankclaw/voiceservice"
: > "$repo/dankclaw/voiceservice/Containerfile"
: > "$repo/dankclaw/voiceservice/Containerfile.engine"
: > "$repo/dankclaw/voiceservice/app.py"
: > "$repo/dankclaw/voiceservice/tts-engine-start.sh"
: > "$repo/dankclaw/voiceservice/tts-engine-warm.sh"
# Stub podman: `image inspect` replays the label this stub stored on the last
# build of THAT image (nothing the first time), `build` records the tag and
# writes the label it was given. One state file per image, so the pair's two
# images cannot borrow each other's freshness.
mkdir -p "$TEST_TMP/bin" "$TEST_TMP/labels"
cat > "$TEST_TMP/bin/podman" <<'STUB'
#!/usr/bin/env bash
# Image tags contain a slash (`franksdank/foo:latest`), so the state file name
# flattens it — otherwise the write lands in a directory that does not exist and
# every image looks permanently unbuilt.
case "$1" in
image) # invoked as `podman image inspect <image> --format …`, so $3 is the tag
f="$LABEL_DIR/${3//\//_}"
[[ -s "$f" ]] && { cat "$f"; exit 0; }
exit 1
;;
build)
# Two passes: `podman build` puts `--label cfhash=…` BEFORE `-t <tag>`, so
# the tag is not known when the label argument goes by.
tag=""
label=""
for a in "$@"; do
case "$a" in
*:*) tag="$a" ;;
cfhash=*) label="${a#cfhash=}" ;;
esac
done
[[ -n "$label" ]] && printf '%s' "$label" > "$LABEL_DIR/${tag//\//_}"
printf 'BUILD %s\n' "$tag" >> "$BUILD_LOG"
;;
esac
STUB
chmod +x "$TEST_TMP/bin/podman"
cat > "$TEST_TMP/drive.sh" <<'DRV'
#!/usr/bin/env bash
set -euo pipefail
source "$BCI"
build_compose_image "$1"
DRV
: > "$TEST_TMP/builds"
builds() { grep -cF "BUILD $1" "$TEST_TMP/builds"; }
drive() {
( cd "$repo" && BCI="$TEST_TMP/bci.sh" PATH="$TEST_TMP/bin:$PATH" \
LABEL_DIR="$TEST_TMP/labels" BUILD_LOG="$TEST_TMP/builds" \
bash "$TEST_TMP/drive.sh" "$1" )
}
drive frank-tts-engine
[ "$(builds franksdank/frank-tts-engine:latest)" -eq 1 ] \
|| { echo "the first deploy of an unbuilt image must build"; false; }
drive frank-tts-engine
[ "$(builds franksdank/frank-tts-engine:latest)" -eq 1 ] \
|| { echo "an unchanged tree must not rebuild — that is the 10-minute clone"; false; }
printf '# the boot warmer grew a line\n' >> "$repo/dankclaw/voiceservice/tts-engine-warm.sh"
drive frank-tts-engine
[ "$(builds franksdank/frank-tts-engine:latest)" -eq 2 ] \
|| { echo "editing a script the image COPYs must rebuild it"; false; }
drive frank-tts
[ "$(builds franksdank/frank-tts:latest)" -eq 1 ] || { echo "the façade must build once"; false; }
printf '# the shim grew a route\n' >> "$repo/dankclaw/voiceservice/app.py"
drive frank-tts
[ "$(builds franksdank/frank-tts:latest)" -eq 2 ] \
|| { 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" {
# 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
# green is worse than no card. The façade carries the pair's signal — and
# 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'
import json, os, sys
root = sys.argv[1]
cards = {c["name"]: c for c in json.load(open(f"{root}/shared/homepage/services.json"))}
assert "frank-tts-engine" not in cards, \
"the engine publishes no port: a card for it could never be probed green"
fts = cards["frank-tts"]
assert fts["probe"] == "127.0.0.1:7772", fts["probe"]
assert fts["wip"] is False, \
"the endpoint is deployed; a wip card would hide its real state: " + repr(fts.get("wip"))
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']}"
PY
}
@test "either deployable's paths select both voice seams" {
# A change to the bridge or the env file can break the other half of the
# pair, and the seams are cheap; routing them together is what keeps a
# façade-only commit from landing without the engine's wiring tests.
run "$REPO_ROOT/scripts/test-changed.sh" --list --paths \
dankclaw/voiceservice/quadlet/frank-tts.container
[ "$status" -eq 0 ]
printf '%s\n' "$output" | grep -qx "tests/frank_tts_unit.bats"
printf '%s\n' "$output" | grep -qx "tests/frank_tts_engine.bats"
run "$REPO_ROOT/scripts/test-changed.sh" --list --paths dankclaw/voiceservice/app.py
[ "$status" -eq 0 ]
printf '%s\n' "$output" | grep -qx "tests/frank_tts_unit.bats"
}
+274
View File
@@ -0,0 +1,274 @@
#!/usr/bin/env bats
# Seam: open-notebook, the fleet's podcast generator on Dankclaw — the Quadlet
# pair (app + SurrealDB), the private network between them, the env file both
# units read, the deployer routing, and the homepage card. Runbook:
# serverAppConfigs-docs/Dankclaw/Open Notebook.md.
#
# Structure only: what the files declare and what the deployer can see. Nothing
# here starts a container, opens a socket, or touches the network — the app's
# behaviour inside the image is upstream's contract, cited in the docs page.
load helpers
UNIT_DIR="dankclaw/open-notebook/quadlet"
APP="$REPO_ROOT/$UNIT_DIR/open-notebook.container"
DB="$REPO_ROOT/$UNIT_DIR/open-notebook-surrealdb.container"
NET="$REPO_ROOT/$UNIT_DIR/open-notebook.network"
ENVF="$REPO_ROOT/scripts/open-notebook.env"
PLANNER="$REPO_ROOT/scripts/deploy-plan.py"
REMOTE="$REPO_ROOT/scripts/deploy-remote.sh"
# The host-side path the units read (they run on Dankclaw, not here), spelled
# out once so the ordering test below compares against a real constant.
HOST_TUNABLES="EnvironmentFile=/home/dank/serverAppConfigs/scripts/open-notebook.env"
HOST_SECRETS="EnvironmentFile=/home/dank/Containers/open-notebook/env"
# The pins are part of the contract, so a silent re-resolution to a different
# build has to fail here rather than at the next deploy. Both digests were
# resolved from the registry on 2026-10-18; the SurrealDB one is 2.7.0.
APP_PIN="sha256:54b2025a4a4853c188f74b9b8246eca8c5480e5dd1babfa92db1038ff2f30212"
DB_PIN="sha256:e5d7387e3727bbe2f7cd6d869e48cd34777272258572ead37c9656b25f745723"
@test "open-notebook ships a network, a database unit and an app unit" {
[ -f "$NET" ]
[ -f "$DB" ]
[ -f "$APP" ]
}
@test "both units are boot-persistent and self-healing" {
for unit in "$APP" "$DB"; do
grep -qE '^Restart=(always|on-failure)$' "$unit" || {
echo "$(basename "$unit") has no Restart="; false; }
# "short RestartSec": a podcast service has no reason to wait minutes.
restart_sec="$(sed -n 's/^RestartSec=\([0-9]*\)s\?$/\1/p' "$unit")"
[ -n "$restart_sec" ] || {
echo "$(basename "$unit") has no RestartSec"; false; }
[ "$restart_sec" -le 30 ] || {
echo "$(basename "$unit") RestartSec=${restart_sec}s is not short"; false; }
grep -qE '^\[Install\]' "$unit"
grep -qE '^WantedBy=default\.target$' "$unit"
done
}
@test "both images are digest-pinned and never re-resolved at start" {
grep -qxF "Image=docker.io/lfnovo/open_notebook:v1-latest@$APP_PIN" "$APP"
grep -qxF "Image=docker.io/surrealdb/surrealdb:v2@$DB_PIN" "$DB"
grep -qxF 'Pull=never' "$APP"
grep -qxF 'Pull=never' "$DB"
# Pull=never is only honest if the fetch it depends on is written down where
# the operator reads the unit, and wired into the deployer's precondition.
grep -qF 'podman pull' "$APP"
grep -qF 'podman pull' "$DB"
grep -qF 'ensure_pinned_image' "$REMOTE"
}
@test "the database port is never published" {
# 8000 belongs to the private bridge. A published 8000 would put the RPC,
# root credentials included, on every interface of the host.
! grep -qE '^PublishPort=' "$DB"
! grep -qE '^PublishPort=.*:8000(:|$)' "$APP"
}
@test "the app publishes its UI and moves the API off the host port seerr holds" {
grep -qxF 'PublishPort=0.0.0.0:8502:8502' "$APP"
# seerr already binds 127.0.0.1:5055 on this host, so the API cannot leave
# the container on the host port upstream uses.
publish="$(grep -E '^PublishPort=[^:]+:[0-9]+:5055$' "$APP")"
[ -n "$publish" ] || { echo "no PublishPort mapping to the API's 5055"; false; }
mapping="${publish#PublishPort=}" # e.g. 0.0.0.0:5056:5055
host_port="${mapping#*:}"
host_port="${host_port%%:*}"
[ "$host_port" != "5055" ] || {
echo "the API is published on host 5055, which seerr already holds"; false; }
# The frontend auto-detects the API as <host it was opened on>:5055, so a
# remap is only correct if API_URL names the published port.
api_url="$(sed -n 's/^API_URL=//p' "$ENVF")"
[ "$api_url" = "http://192.168.0.143:${host_port}" ] || {
echo "API_URL ($api_url) does not name the published API port ($host_port)"; false; }
}
@test "the pair shares one network and the app finds the database by container name" {
grep -qxF 'Network=open-notebook.network' "$DB"
grep -qxF 'Network=open-notebook.network' "$APP"
[ "$(grep -cE '^Network=' "$DB")" = "1" ]
# dankclaw_default is the app's second network and must come FIRST: the
# generated --network flags keep file order, so the default route (and the
# gateway name proven on that network) rides it.
[ "$(grep -cE '^Network=' "$APP")" = "2" ]
[ "$(grep -E '^Network=' "$APP" | head -1)" = "Network=dankclaw_default" ]
db_name="$(sed -n 's/^ContainerName=//p' "$DB")"
[ "$db_name" = "open-notebook-surrealdb" ]
grep -qxF "SURREAL_URL=ws://${db_name}:8000/rpc" "$ENVF"
}
@test "each unit reads the repo tunables first and the host secrets second" {
# podman applies --env-file in argument order and the LAST file wins, so
# swapping these two lines would blank every credential the app needs.
for unit in "$APP" "$DB"; do
files="$(grep -E '^EnvironmentFile=' "$unit")"
[ "$(echo "$files" | wc -l)" = "2" ] || {
echo "$(basename "$unit") must read exactly two env files"; false; }
[ "$(echo "$files" | head -1)" = "$HOST_TUNABLES" ] || {
echo "$(basename "$unit") must read the tracked env file first"; false; }
[ "$(echo "$files" | tail -1)" = "$HOST_SECRETS" ] || {
echo "$(basename "$unit") must read the host secret file last"; false; }
done
}
@test "the interim episode destination is mounted exactly once" {
# The bind that answers Gitea issue #7 provisionally: two of them is a
# shadowing bug, none is episode audio locked inside a named volume.
[ "$(grep -cE '^Volume=/home/dank/media/podcasts:/app/data/podcasts$' "$APP")" = "1" ]
# The app's own state stays in a named volume; the database gets its own.
grep -qE '^Volume=open-notebook-data:/app/data$' "$APP"
grep -qE '^Volume=open-notebook-db:/mydata$' "$DB"
# The deployer creates the host directory as the deploy user.
grep -qF 'mkdir -p /home/dank/media/podcasts' "$REMOTE"
}
@test "the env file declares the CPU-shutdown guard and both timeouts" {
# Upstream defaults are 5 and 5; frank-tts spawns ~8 GiB / 8 threads per
# request on a box that also serves two LLM endpoints.
grep -qxF 'TTS_BATCH_SIZE=1' "$ENVF"
grep -qxF 'OPEN_NOTEBOOK_WORKER_MAX_TASKS=1' "$ENVF"
# Per dialogue line, and strictly beyond frank-tts's own per-request
# ceiling: nested budgets go inside-out (the rule scripts/frank-tts.env
# states for LLAMA_TIMEOUT), so a shim give-up surfaces as frank-tts's
# error instead of the worker's own timeout. The seam reads the ceiling
# rather than restating it, so re-measuring frank-tts cannot leave this
# file silently behind.
tts="$(sed -n 's/^ESPERANTO_TTS_TIMEOUT=//p' "$ENVF")"
shim="$(sed -n 's/^SHIM_TIMEOUT=//p' "$REPO_ROOT/scripts/frank-tts.env")"
[ -n "$tts" ] || { echo "no ESPERANTO_TTS_TIMEOUT in the env file"; false; }
[ -n "$shim" ] || { echo "no SHIM_TIMEOUT in scripts/frank-tts.env"; false; }
[ "$tts" -gt "$shim" ] || {
echo "ESPERANTO_TTS_TIMEOUT=$tts is not beyond frank-tts SHIM_TIMEOUT=$shim"; false; }
# Upstream: keep the scriptwriting call under the web UI's ~600 s patience,
# or the failure arrives as a UI timeout with no name on it.
llm="$(sed -n 's/^ESPERANTO_LLM_TIMEOUT=//p' "$ENVF")"
[[ "$llm" =~ ^[0-9]+$ ]] && [ "$llm" -lt 600 ] || {
echo "ESPERANTO_LLM_TIMEOUT=$llm is not a number under 600"; false; }
}
@test "secrets stay declared-but-empty in the tracked env file" {
# scripts/*.env is un-ignored for "paths and numbers, never credentials":
# the values belong to the host file the deployer seeds.
for key in OPEN_NOTEBOOK_ENCRYPTION_KEY OPEN_NOTEBOOK_PASSWORD SURREAL_PASSWORD SURREAL_PASS; do
grep -qxF "${key}=" "$ENVF" || {
echo "$key is not declared, empty, in $ENVF"; false; }
done
has_no_leaked_secret "$ENVF"
has_no_leaked_sudo_pw "$ENVF"
# And the deployer treats blank as fatal, because blank means "auth off",
# not "unconfigured".
grep -qF 'seed_open_notebook_env' "$REMOTE"
}
@test "the database password never rides in a command line" {
# `surreal start` reads SURREAL_USER / SURREAL_PASS from the environment;
# --pass would put it in /proc/<pid>/cmdline for any ps on the host.
grep -qE '^Exec=start' "$DB"
! grep -qE '^Exec=.*--(pass|user)' "$DB"
}
@test "both health probes are argument-only and name real paths" {
for unit in "$APP" "$DB"; do
cmd="$(sed -n 's/^HealthCmd=//p' "$unit")"
[ -n "$cmd" ] || { echo "$(basename "$unit") has no HealthCmd"; false; }
# The quadlet generator splits HealthCmd on spaces, so anything needing
# a shell (quotes, pipes, &&) would be silently mis-sent.
! printf '%s' "$cmd" | grep -qE '["&;|`]'
done
# The API's own route, exempt from its password middleware upstream.
grep -qxF 'HealthCmd=curl -fsS http://127.0.0.1:5055/health' "$APP"
# The distroless database image has no shell and no curl; it ships a probe.
grep -qxF 'HealthCmd=/surreal is-ready' "$DB"
}
@test "the deployer gates open-notebook on the health path that actually answers" {
grep -qF 'open-notebook) deploy_open_notebook ;;' "$REMOTE"
gate="$(grep -E '^[[:space:]]*wait_for_health open-notebook ' "$REMOTE" | head -1)"
[ -n "$gate" ] || { echo "deploy_open_notebook has no wait_for_health gate"; false; }
# /health is the route upstream serves; the suite must not bless a guess.
printf '%s' "$gate" | grep -qE '/health( |$)'
# The gate is a host-loopback URL (the publish is wildcard, so 127.0.0.1
# answers) on the PUBLISHED port, never the container's 5055.
printf '%s' "$gate" | grep -qE 'http://127\.0\.0\.1:[0-9]+/health'
! printf '%s' "$gate" | grep -qE ':5055/'
# The database is gated by its container health before the app is started.
grep -qE 'wait_container_healthy open-notebook-surrealdb' "$REMOTE"
}
@test "the deployer refuses to start the pair on blank secrets and never rewrites a seeded file" {
# Read the seed function itself: the refusal, and the no-clobber rule that
# protects a password an operator set by hand.
seed="$(sed -n '/^seed_open_notebook_env()/,/^}/p' "$REMOTE")"
[ -n "$seed" ]
printf '%s' "$seed" | grep -qF 'return 1'
printf '%s' "$seed" | grep -qF 'if [[ ! -e "$dest" ]]'
printf '%s' "$seed" | grep -qF 'chmod 600'
# One value, two spellings: the app reads SURREAL_PASSWORD, the CLI SURREAL_PASS.
printf '%s' "$seed" | grep -qF 'SURREAL_PASS='
}
@test "the planner maps every open-notebook path to the open-notebook service" {
run python3 "$PLANNER" --target dankclaw --paths \
dankclaw/open-notebook/quadlet/open-notebook.container \
dankclaw/open-notebook/quadlet/open-notebook-surrealdb.container \
dankclaw/open-notebook/quadlet/open-notebook.network \
scripts/open-notebook.env
[ "$status" -eq 0 ]
[ "$output" = "open-notebook" ]
# One deployable owns the pair: the unit files never plan separately.
run python3 "$PLANNER" --target dankclaw --paths \
dankclaw/open-notebook/quadlet/open-notebook-surrealdb.container
[ "$output" = "open-notebook" ]
# And the service belongs to dankclaw only.
run python3 "$PLANNER" --target dankserver --paths scripts/open-notebook.env
[ "$status" -eq 0 ]
[ -z "$output" ]
}
@test "changed-path routing selects this seam" {
run "$REPO_ROOT/scripts/test-changed.sh" --list --paths \
dankclaw/open-notebook/quadlet/open-notebook.container
[ "$status" -eq 0 ]
printf '%s\n' "$output" | grep -qx "tests/open_notebook.bats"
run "$REPO_ROOT/scripts/test-changed.sh" --list --paths scripts/open-notebook.env
[ "$status" -eq 0 ]
printf '%s\n' "$output" | grep -qx "tests/open_notebook.bats"
}
@test "both deployer entry points know the service" {
grep -qF 'dankclaw:open-notebook|' "$REMOTE"
grep -qF 'dankclaw:open-notebook|' "$REPO_ROOT/scripts/deploy.sh"
}
@test "the homepage card exists and stays hidden until the service is deployed" {
python3 - "$REPO_ROOT/shared/homepage/services.json" <<'PY'
import json, os, sys
data = json.load(open(sys.argv[1]))
cards = [s for s in data if s["name"] == "open-notebook"]
assert len(cards) == 1, f"expected exactly one open-notebook card, got {len(cards)}"
card = cards[0]
assert card["category"] == "user" and card["section"] == "AI", card
assert card["probe"] == "127.0.0.1:8502", card["probe"]
assert card["method"] == "http"
# Not deployed yet: the card is reported wip-down and carries no link, so the
# page shows no dead button (the Seerr precedent).
assert card.get("wip") == "down", card.get("wip")
assert card.get("showLink") is False, card.get("showLink")
assert not card.get("url"), "a hidden card must not carry a url"
art = card["art"].lstrip("/")
assert os.path.exists(os.path.join(os.path.dirname(sys.argv[1]), art)), art
PY
}
@test "the runbook page exists and names the interim mount's owning issue" {
page="$REPO_ROOT/serverAppConfigs-docs/Dankclaw/Open Notebook.md"
[ -f "$page" ]
grep -qF 'issue #7' "$page"
grep -qF 'human-only' "$page"
# The interim decision is one mount line; the page has to say how to undo it.
grep -qF '## Rollback' "$page"
}
+268
View File
@@ -0,0 +1,268 @@
#!/usr/bin/env bats
# Seam: the voice-pack builder (scripts/build-voice-pack.sh).
#
# The contract this pins is the one frank-tts's packs live or die by. A crop is
# only usable voice material if it starts PAST the first 10 seconds of the
# source file — the project-wide source-material invariant — and this builder is
# the thing that enforces it, because nothing downstream looks at offsets again.
# A pack is three files plus provenance: reference.wav at 24 kHz mono inside the
# 18-25 s window, the engine's own speaker artifacts beside it, and a
# manifest.json whose sha256 still matches the bytes the crop came from. Builds
# refuse to clobber what is already on disk, and --force retires rather than
# deletes.
#
# No engine binary, no model, no GPU, no network and no fleet host: the crop is
# cut from a locally generated tone and the pinned extractor is stood in by a
# stub that mirrors the real tool's I/O shape (no -o flag: outputs named next to
# the input with the extension swapped, at the sizes the codec's config
# implies), which is how frank_tts_unit.bats drives its backends too.
load helpers
setup() {
BUILDER="$REPO_ROOT/scripts/build-voice-pack.sh"
ENV_FILE="$REPO_ROOT/scripts/voice-packs.env"
TEST_TMP="$(mktemp -d)"
PACKS="$TEST_TMP/packs"
mkdir -p "$PACKS"
STUB_ARGV="$TEST_TMP/stub-argv.tsv"
export STUB_ARGV
# Forty seconds of "recording": stereo 44.1 kHz, so a legal build has to
# downmix and resample just as it does on a real audiobook. Every crop this
# file takes starts at or past 12 s, well out of the forbidden region.
ffmpeg -v error -y -f lavfi -i "sine=frequency=330:sample_rate=44100:duration=40" \
-ac 2 -c:a pcm_s16le "$TEST_TMP/source.wav"
SOURCE_SHA="$(sha256sum "$TEST_TMP/source.wav" | awk '{ print $1 }')"
write_stub_tool
# The shipped tunables file, then the three machine-local overrides that
# point the engine at the stub: the real env stays under test.
cat "$ENV_FILE" > "$TEST_TMP/env"
{
printf 'VOICE_PACK_DIR=%s\n' "$PACKS"
printf 'VOICE_PACK_TOOL=%s\n' "$TEST_TMP/qwen-codec"
printf 'VOICE_PACK_CODEC_MODEL=%s\n' "$TEST_TMP/codec.gguf"
printf 'VOICE_PACK_TALKER_MODEL=%s\n' "$TEST_TMP/talker.gguf"
} >> "$TEST_TMP/env"
printf 'stub-codec-weights\n' > "$TEST_TMP/codec.gguf"
printf 'stub-talker-weights\n' > "$TEST_TMP/talker.gguf"
}
teardown() {
rm -rf "$TEST_TMP"
}
# write_stub_tool — stand-in for qwen-codec. Records what it was handed and
# writes <input-stem>.spk / <input-stem>.rvq, exactly where the real tool would.
# STUB_SPK_BYTES lets one test be an engine that changed its x-vector size.
write_stub_tool() {
cat > "$TEST_TMP/qwen-codec" <<'STUB'
#!/usr/bin/env bash
set -euo pipefail
input="" model="" talker=""
while [ "$#" -gt 0 ]; do
case "$1" in
--model) model="$2"; shift 2 ;;
--talker) talker="$2"; shift 2 ;;
--device) shift 2 ;;
-i) input="$2"; shift 2 ;;
*) shift ;;
esac
done
printf 'model=%s\ttalker=%s\tinput=%s\n' "$model" "$talker" "$input" >> "$STUB_ARGV"
[ -n "$input" ] || exit 2
[ -s "$model" ] || exit 3
[ -s "$talker" ] || exit 4
dur="$(ffprobe -v error -select_streams a:0 -show_entries stream=duration -of default=nw=1:nk=1 "$input")"
frames="$(awk -v d="$dur" 'BEGIN { printf "%d\n", int(int(d * 24000 + 0.5) / 1920) }')"
head -c "${STUB_SPK_BYTES:-8192}" /dev/zero > "${input%.wav}.spk"
head -c "$((22 * frames))" /dev/zero > "${input%.wav}.rvq"
printf '[Stub] Wrote %s.rvq: %d packed bytes\n' "${input%.wav}" "$((22 * frames))" >&2
STUB
chmod +x "$TEST_TMP/qwen-codec"
}
# build — a real (non-dry) run against the stub tool with the tone as source.
build() {
local voice="$1"
shift
run "$BUILDER" --env "$TEST_TMP/env" \
--source "$TEST_TMP/source.wav" --sha256 "$SOURCE_SHA" "$@" "$voice" "$PACKS"
}
pack_files() {
ls "$PACKS/$1" 2>/dev/null | sort | tr '\n' ' '
}
@test "a crop offset inside the first ten seconds is refused and names the invariant" {
build narrator1 --offset 4.5 --duration 20
[ "$status" -ne 0 ] || { echo "$output"; false; }
[[ "$output" == *"source-material invariant"* ]]
[[ "$output" == *"first 10 s"* ]]
# Refused before anything was created, not cleaned up afterwards.
[ ! -e "$PACKS/narrator1" ]
[ ! -s "$STUB_ARGV" ]
}
@test "the identical crop at a legal offset builds the whole pack" {
build narrator1 --offset 12 --duration 20
[ "$status" -eq 0 ] || { echo "$output"; false; }
[[ "$output" == *"CROP 12 s .. 32 s"* ]]
[[ "$output" == *"VOICE_PACK_BUILT narrator1 $PACKS/narrator1"* ]]
[ -f "$PACKS/narrator1/reference.wav" ]
[ -f "$PACKS/narrator1/reference.spk" ]
[ -f "$PACKS/narrator1/reference.rvq" ]
[ -f "$PACKS/narrator1/manifest.json" ]
}
@test "reference.wav comes out 24 kHz mono pcm inside the 18-25 s window" {
build narrator1 --offset 12 --duration 20
[ "$status" -eq 0 ] || { echo "$output"; false; }
local wav="$PACKS/narrator1/reference.wav"
[ "$(ffprobe -v error -select_streams a:0 -show_entries stream=codec_name -of default=nw=1:nk=1 "$wav")" = "pcm_s16le" ]
[ "$(ffprobe -v error -select_streams a:0 -show_entries stream=sample_rate -of default=nw=1:nk=1 "$wav")" = "24000" ]
[ "$(ffprobe -v error -select_streams a:0 -show_entries stream=channels -of default=nw=1:nk=1 "$wav")" = "1" ]
local dur
dur="$(ffprobe -v error -show_entries format=duration -of default=nw=1:nk=1 "$wav")"
awk -v d="$dur" 'BEGIN { exit !(d >= 18 && d <= 25) }'
}
@test "the manifest round-trips through jq with offsets and a sha that matches the source" {
build narrator2 --offset 15 --duration 22.5
[ "$status" -eq 0 ] || { echo "$output"; false; }
local manifest="$PACKS/narrator2/manifest.json"
jq -e '
.voice == "narrator2"
and (.crop.offset_seconds == 15)
and (.crop.end_seconds == 37.5)
and (.reference.sample_rate == 24000)
and (.reference.channels == 1)
and (.reference.duration_seconds >= 18 and .reference.duration_seconds <= 25)
and (.artifacts.engine_commit | test("^[0-9a-f]{40}$"))
and (.artifacts.produced == true)
and (.invariant.forbidden_source_seconds == 10)
' "$manifest"
# The recorded hash is the source's hash, not a hash of something else.
[ "$(jq -r .source.sha256 "$manifest")" = "$(sha256sum "$TEST_TMP/source.wav" | awk '{ print $1 }')" ]
# The pack's provenance names the tool that made the artifacts and the files
# it wrote beside the WAV.
[ "$(jq -r .artifacts.tool "$manifest")" = "$TEST_TMP/qwen-codec" ]
grep -q "input=$PACKS/narrator2/staging-reference.wav" "$STUB_ARGV"
}
@test "a wrong pinned sha stops the build before a crop is cut" {
run "$BUILDER" --env "$TEST_TMP/env" --source "$TEST_TMP/source.wav" \
--sha256 "$(printf '0%.0s' {1..64})" --offset 12 --duration 20 narrator1 "$PACKS"
[ "$status" -ne 0 ]
[[ "$output" == *"sha256"* ]]
[ ! -e "$PACKS/narrator1" ]
}
@test "a crop that runs off the end of the source is refused" {
build narrator1 --offset 30 --duration 20
[ "$status" -ne 0 ]
[[ "$output" == *"crop would come out short"* ]]
[ ! -e "$PACKS/narrator1" ]
}
@test "a crop under the engine's artifact window is refused before the extractor runs" {
# 18.5 s is inside the reference window but packs to 22 * 231 = 5,082 B of
# .rvq, under the 5,456 B the pinned codec config produces — the builder
# says so rather than running an extractor that would emit it anyway.
build narrator1 --offset 12 --duration 18.5
[ "$status" -ne 0 ] || { echo "$output"; false; }
[[ "$output" == *"5456"* ]]
[ ! -e "$PACKS/narrator1" ]
[ ! -s "$STUB_ARGV" ]
}
@test "an extractor whose artifact size changed does not get a published voice" {
# The engine-bump case: a 4,096 B x-vector is not the 8,192 B the pinned
# encoder emits, so the pack must not reach the state the shim reads.
export STUB_SPK_BYTES=4096
build narrator1 --offset 12 --duration 20
[ "$status" -ne 0 ]
[[ "$output" == *"reference.spk"* ]]
[ ! -e "$PACKS/narrator1/reference.wav" ]
[ ! -e "$PACKS/narrator1/manifest.json" ]
}
@test "re-running without --force refuses and leaves every byte alone" {
build narrator1 --offset 12 --duration 20
[ "$status" -eq 0 ] || { echo "$output"; false; }
sha256sum "$PACKS"/narrator1/* > "$TEST_TMP/before.sha"
build narrator1 --offset 12 --duration 20
[ "$status" -ne 0 ] || { echo "$output"; false; }
[[ "$output" == *"--force"* ]]
sha256sum -c --quiet "$TEST_TMP/before.sha"
[ "$(find "$PACKS" -name .AGENTTRASH -prune -o -type f -newer "$TEST_TMP/before.sha" -print | wc -l)" -eq 0 ]
}
@test "--force retires the previous pack instead of deleting anything" {
build narrator1 --offset 12 --duration 20
[ "$status" -eq 0 ] || { echo "$output"; false; }
cp "$PACKS/narrator1/reference.wav" "$TEST_TMP/first-reference.wav"
build narrator1 --offset 12 --duration 20 --force
[ "$status" -eq 0 ] || { echo "$output"; false; }
[[ "$output" == *"RETIRED"* ]]
# The old bytes survive, whole, under the trash directory beside the pack.
local retired
retired="$(find "$PACKS/.AGENTTRASH" -name reference.wav | head -n1)"
[ -n "$retired" ]
cmp "$retired" "$TEST_TMP/first-reference.wav"
[ -f "$PACKS/narrator1/reference.wav" ]
}
@test "--dry-run needs no extractor at all" {
# Everything up to the engine: the gate, the crop, the probe, the plan and
# the manifest it would write — with the tool path pointed at nothing.
run "$BUILDER" --env "$TEST_TMP/env" --tool "$TEST_TMP/no-such-tool" \
--source "$TEST_TMP/source.wav" --sha256 "$SOURCE_SHA" \
--offset 12 --duration 20 --dry-run narrator1 "$PACKS"
[ "$status" -eq 0 ] || { echo "$output"; false; }
[[ "$output" == *"not present"* ]]
[ ! -e "$PACKS/narrator1" ]
[ ! -s "$STUB_ARGV" ]
printf '%s\n' "$output" | awk '/^MANIFEST_BEGIN$/{p=1;next} /^MANIFEST_END$/{p=0} p' \
| jq -e '.voice == "narrator1" and .crop.offset_seconds == 12 and .artifacts.produced == false'
}
@test "a name the façade could not resolve is refused" {
# app.py matches the directory name verbatim while the engine lowercases
# names, and a slash would escape $VOICES_DIR: both are refused here, at the
# only moment anyone can still fix them.
for bad in 'Narrator1' '../evil' 'voice name'; do
run "$BUILDER" --env "$TEST_TMP/env" --source "$TEST_TMP/source.wav" \
--offset 12 --duration 20 "$bad" "$PACKS"
[ "$status" -ne 0 ] || { echo "accepted '$bad': $output"; false; }
[[ "$output" == *"voice name"* ]]
done
[ ! -e "$PACKS/Narrator1" ]
[ ! -e "$PACKS/evil" ]
}
@test "every crop in the tracked pack list clears the invariant" {
# The law applied to the values the repo actually ships: no pack may be
# described by an offset inside the forbidden region, a length outside the
# reference window, or a source with no digest to verify it against.
set -a
# shellcheck disable=SC1090
source "$ENV_FILE"
set +a
local checked=0 pack upper
read -r -a packs <<<"$VOICE_PACKS"
for pack in "${packs[@]}"; do
upper="$(printf '%s' "$pack" | tr '[:lower:]' '[:upper:]')"
local offset_var="VOICE_PACK_${upper}_OFFSET" duration_var="VOICE_PACK_${upper}_DURATION"
local sha_var="VOICE_PACK_${upper}_SHA256" source_var="VOICE_PACK_${upper}_SOURCE"
[ -n "${!source_var:-}" ] || { echo "$pack has no source path"; false; }
printf '%s' "${!sha_var}" | grep -Eq '^[0-9a-f]{64}$' || { echo "$pack has no pinned sha256"; false; }
awk -v o="${!offset_var}" 'BEGIN { exit !(o >= 10) }' || { echo "$pack crops at ${!offset_var} s, inside the forbidden first 10 s"; false; }
awk -v d="${!duration_var}" 'BEGIN { exit !(d >= 18 && d <= 25) }' || { echo "$pack crops ${!duration_var} s, outside 18-25 s"; false; }
checked=$((checked + 1))
done
[ "$checked" -gt 0 ]
}