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

This commit was merged in pull request #16.
This commit is contained in:
2026-10-18 18:59:13 -07:00
8 changed files with 44 additions and 30 deletions
@@ -56,11 +56,14 @@
# the registration body. The façade mounts the same host directory read-only for # 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. # 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 # REGISTRY LAW, THE REASON EXECSTARTPOST EXISTS. The voice registry is memory
# a registered voice decodes the whole reference clip to snapshot codec state: # only: a restart forgets every voice, and re-registering does not warm one —
# 6.2-8.0 s, first audio byte at 5,530 ms. Every later request restores the # only synthesizing does — so a boot without this hook leaves the façade
# snapshot in 0.5 ms. So the unit warms every voice at boot, serially (~6-8 s # answering 400 for every name. The per-voice cold penalty is real but small, and
# each), and `TimeoutStartSec=900` is what keeps a growing voice library from # 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 # tripping systemd's default 90 s start deadline. The leading `-` on
# ExecStartPost is load-bearing: with `Restart=always`, a warmer that exits # 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 # non-zero (a pack missing its reference.wav, the engine still loading past the
@@ -104,7 +107,7 @@ HealthStartPeriod=120s
[Service] [Service]
Restart=always Restart=always
RestartSec=10 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 TimeoutStartSec=900
ExecStartPost=-/usr/bin/podman exec frank-tts-engine /app/warm-voices.sh 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 # 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. # 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 # 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 # is part of its start — so the first request of the day meets a REGISTERED voice
# registry instead of paying the 5.5 s cold decode. # (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
+13 -14
View File
@@ -27,18 +27,17 @@
# below stop describing the process you are running. Nothing in this fleet # below stop describing the process you are running. Nothing in this fleet
# pins this container; that is a decision, not an oversight. # pins this container; that is a decision, not an oversight.
# #
# THE COLD-VOICE LAW (why the unit has an ExecStartPost at all). The first # THE REGISTRY LAW (why the unit has an ExecStartPost at all). Registrations live
# speech request for a registered voice decodes the ENTIRE reference clip to # in memory only, so a restart loses them, and re-registration alone does NOT warm
# snapshot its codec state: 6.2-8.0 s total, first audio byte at 5,530 ms. Every # a voice — the warm pass has to synthesize something. Without it the façade
# later request for that voice restores the snapshot in 0.5 ms. Registrations # answers 400 for every name until somebody asks for audio. So
# live in memory only, so a restart loses them and re-registration alone does # dankclaw/voiceservice/tts-engine-warm.sh (in the image at /app/warm-voices.sh)
# NOT warm the voice — the warm pass has to synthesize something. So # runs on every start and speaks a short sentence twice per voice. The penalty it
# dankclaw/voiceservice/tts-engine-warm.sh (in the image at # removes is about 0.6 s, NOT the trial's 6.2-8.0 s: measured on the built
# /app/warm-voices.sh) runs on every start and speaks one short sentence per # container 2026-10-18 against an empty registry, the first synthesis cost
# voice. Skip it and the first person to ask for audio that day waits 5.5 s in # 1,883 ms and the requests after it 1,226-1,283 ms. The hook runs INSIDE this
# silence. It runs INSIDE this container — `podman exec`, from the unit's # container — `podman exec`, from the unit's ExecStartPost — so it reads /voices
# ExecStartPost — so it reads /voices here and the keys at the bottom of this # here and the keys at the bottom of this file are its knobs too.
# file are its knobs too.
# #
# STREAMING IS NOT BUFFERED. The same seed gives audio that differs in 92.2 % # 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, # 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 # 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 # 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 # 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 # short because warming a library is serial — two syntheses per voice, ~5 s each,
# each, inside the unit's start deadline. # inside the unit's start deadline.
# QUOTED, and the quoting means two different things to the two readers of this # QUOTED, and the quoting means two different things to the two readers of this
# file, which is the trap here: # file, which is the trap here:
# * bash (`set -a; source`, how the repo's seams and any human read it) needs # * 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 # shim resolves the voice and re-encodes to mp3 when asked. The
# shipped engine (frank-tts-engine) speaks a voice it has # shipped engine (frank-tts-engine) speaks a voice it has
# REGISTERED on itself, from the same /home/dank/voices packs, # REGISTERED on itself, from the same /home/dank/voices packs,
# and warms them at boot — see [[Frank TTS Engine]] for why an # and warms them at boot — see [[Frank TTS Engine]] for what that
# unwarmed voice costs the first caller 5.5 s. # 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
+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` | | `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 | | `(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` | | 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/` | | The unit never appears after a deploy | the generator dropped it: `frank-tts-engine.network` is missing from `~/.config/containers/systemd/` |
@@ -328,9 +328,11 @@ a live stream mid-sentence.
**Chosen: arm A, 1.7 B talker, resident `tts-server` in a `frank-tts-engine` **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.** 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 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 voice** — the registry is memory-only, so an unwarm'd name is a 400 and the first
(threads are `hardware_concurrency()/2`, no flag), and every pack registers with synthesis otherwise costs about 0.6 s more than the ones after it, as corrected
the **WAV** because the engine rejects a lone embedding. 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: Two follow-ups stay open on purpose, and neither blocks the deploy:
+8
View File
@@ -2682,3 +2682,11 @@ every restart since the cutover.
probe for the voice pair (neither container exposes `/metrics`; the blackbox 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 question is still open), and the podcast render — open-notebook is on `main` but
not installed. 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.
+2 -2
View File
@@ -510,8 +510,8 @@ PY
# two GGUFs (1.2 GiB + 291 MB) are still being read and the HTTP thread has # 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 # 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 # 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 # warmer notices and does the work — an early exit leaves every voice
# caller paying 5.5 s of silence for a voice nobody warmed. # unregistered, and the façade then answers 400 for all of them.
port="$(free_port)" port="$(free_port)"
( ENGINE_URL="http://127.0.0.1:$port" VOICES_DIR="$VOICES" HEALTH_WAIT=60 \ ( 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" ) & bash "$WARM" > "$TEST_TMP/race.log" 2>&1; echo "$?" > "$TEST_TMP/race.rc" ) &