Compare commits

..
Author SHA1 Message Date
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
11 changed files with 172 additions and 57 deletions
@@ -56,11 +56,14 @@
# 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.
#
# COLD-VOICE LAW, THE REASON EXECSTARTPOST EXISTS. The FIRST speech request for
# a registered voice decodes the whole reference clip to snapshot codec state:
# 6.2-8.0 s, first audio byte at 5,530 ms. Every later request restores the
# snapshot in 0.5 ms. So the unit warms every voice at boot, serially (~6-8 s
# each), and `TimeoutStartSec=900` is what keeps a growing voice library from
# 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
@@ -104,7 +107,7 @@ HealthStartPeriod=120s
[Service]
Restart=always
RestartSec=10
# Model load (tens of seconds) plus one cold decode per registered voice.
# 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
@@ -37,8 +37,9 @@
# 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 warm voice
# registry instead of paying the 5.5 s cold decode.
# 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
# READ-ONLY: this endpoint has no business writing voice packs. Because the
+35 -14
View File
@@ -16,17 +16,17 @@
# 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 cold-voice law). The FIRST speech request for a
# registered voice decodes the whole reference clip to snapshot its codec state:
# 6.2-8.0 s, first audio byte at 5,530 ms. Every later request for that voice
# restores the snapshot in 0.5 ms. Two facts make that a boot problem rather than
# a curiosity: the voice registry is MEMORY ONLY (a restart forgets every voice),
# and re-registering a voice does not warm it — only synthesizing does. So a boot
# that registers the voices and stops leaves the first caller of the day paying
# 5.5 s of silence. This script speaks one short sentence per voice instead, then
# speaks a second one so the journal carries the warm number too: the line
# `voice <name> cold=…ms warm=…ms` is the repo's standing proof that the second
# request of a voice answers well under a second.
# 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
@@ -47,7 +47,9 @@
#
# 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 straight into the request body without ever being echoed.
# 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:
@@ -103,6 +105,18 @@ 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' "$*"; }
@@ -146,9 +160,16 @@ for dir in "$VOICES_DIR"/*/; do
continue
fi
body="$(printf '{"name":"%s","wav_b64":"%s"}' "$name" "$(base64 -w0 "${dir}reference.wav")")"
# 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" \
-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
+13 -14
View File
@@ -27,18 +27,17 @@
# below stop describing the process you are running. Nothing in this fleet
# pins this container; that is a decision, not an oversight.
#
# THE COLD-VOICE LAW (why the unit has an ExecStartPost at all). The first
# speech request for a registered voice decodes the ENTIRE reference clip to
# snapshot its codec state: 6.2-8.0 s total, first audio byte at 5,530 ms. Every
# later request for that voice restores the snapshot in 0.5 ms. Registrations
# live in memory only, so a restart loses them and re-registration alone does
# NOT warm the voice — the warm pass has to synthesize something. So
# dankclaw/voiceservice/tts-engine-warm.sh (in the image at
# /app/warm-voices.sh) runs on every start and speaks one short sentence per
# voice. Skip it and the first person to ask for audio that day waits 5.5 s in
# silence. It 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.
# 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,
@@ -125,8 +124,8 @@ 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 — one cold decode per voice, 6-8 s
# each, inside the unit's start deadline.
# 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
+3 -2
View File
@@ -35,8 +35,9 @@
# shim resolves the voice and re-encodes to mp3 when asked. The
# 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 why an
# unwarmed voice costs the first caller 5.5 s.
# 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
# --tts-speaker-file <reference.wav>, nice 19 so a narration
# never starves the text endpoints on this box. Needs
@@ -50,17 +50,17 @@ The spread is the whole argument: at ±0.01 the engine finishes synthesizing 1.5
## Three laws this service lives under
**1. The cold-voice law → the unit warms at boot.** The **first** speech request for a registered voice decodes the whole reference clip to snapshot its codec state: **6.2–8.0 s, first audio byte at 5 530 ms**. Every later request for that voice restores the snapshot in **0.5 ms**. Registrations live in memory only — a restart forgets every voice — and re-registering does **not** warm a voice, only synthesizing does. So `frank-tts-engine.container` carries
**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. Without it, whoever asks for audio first that day pays 5.5 s of silence. 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:
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=6821ms warm=412ms
frank-tts-engine-warm: voice narrator1 cold=1402ms warm=1812ms
frank-tts-engine-warm: 3 voice(s) registered and warm
```
@@ -122,7 +122,7 @@ Nothing below has been run. Steps 1–5 are this service; 6–7 are what unblock
|---|---|---|
| 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` |
| First request of the day is slow, the rest are fast | the warm pass did not run (the hook carries `-`, so it cannot fail the unit) | look for `did not answer` in the journal — the warmer gave up while the model was still loading |
| 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 |
+1 -1
View File
@@ -148,5 +148,5 @@ gated `wip: down` until the deploy and probes `127.0.0.1:7772`.
| `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 takes ~5.5 s | the warm pass did not run — cold-voice law in [[Frank TTS Engine]] |
| 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/` |
@@ -169,10 +169,14 @@ invariant to every crop in the tracked pack list.
curl -s localhost:7772/v1/audio/voices # the façade's list, not the engine's
```
The hook walks `/voices`, registers any pack with a `reference.wav` it has not
seen, and logs `voice <name> cold=…ms warm=…ms` to the unit's journal. 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.
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
@@ -258,11 +258,17 @@ 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 first request for a registered voice decodes
the whole reference to snapshot codec state: 6.2–8.0 s, first audio byte at
5,530 ms. Later requests restore that state in 0.5 ms. An engine unit must
run one throwaway synthesis per voice at start, or the first request of the
day pays five seconds.
- **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
@@ -322,9 +328,11 @@ a live stream mid-sentence.
**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** (5.5 s cold first byte otherwise), 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.
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:
+47
View File
@@ -2643,3 +2643,50 @@ every restart since the cutover.
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.
+33 -2
View File
@@ -394,6 +394,37 @@ 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
@@ -479,8 +510,8 @@ PY
# 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 here is the day's first
# caller paying 5.5 s of silence for a voice nobody warmed.
# 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" ) &