Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d70f115d1 | ||
|
|
7e391d4325 | ||
|
|
5768680542 | ||
|
|
d2ce6693da | ||
|
|
2dc31ff09c | ||
|
|
0315a62d84 | ||
|
|
0773b26559 | ||
|
|
4235c1d4cb | ||
|
|
747c6656d5 |
@@ -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
|
||||||
|
|||||||
@@ -16,17 +16,17 @@
|
|||||||
# voice that did not warm becomes a deploy result instead of only a journal
|
# voice that did not warm becomes a deploy result instead of only a journal
|
||||||
# line (the unit's `-` prefix deliberately cannot fail the unit).
|
# 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
|
# WHY IT EXISTS AT ALL (the registry law). The voice registry is MEMORY ONLY: a
|
||||||
# registered voice decodes the whole reference clip to snapshot its codec state:
|
# restart forgets every voice, and re-registering a voice does not warm it — only
|
||||||
# 6.2-8.0 s, first audio byte at 5,530 ms. Every later request for that voice
|
# synthesizing does. A boot that registers nothing therefore leaves frank-tts
|
||||||
# restores the snapshot in 0.5 ms. Two facts make that a boot problem rather than
|
# answering 400 for every name, so this script registers every pack it finds. The
|
||||||
# a curiosity: the voice registry is MEMORY ONLY (a restart forgets every voice),
|
# per-voice cold penalty is real but small, and smaller than the trial reported:
|
||||||
# and re-registering a voice does not warm it — only synthesizing does. So a boot
|
# measured on the deployed build 2026-10-18 against an empty registry, the FIRST
|
||||||
# that registers the voices and stops leaves the first caller of the day paying
|
# synthesis of a freshly registered voice cost 1,883 ms against 1,226-1,283 ms for
|
||||||
# 5.5 s of silence. This script speaks one short sentence per voice instead, then
|
# the requests after it — about 0.6 s, not the trial's 6.2-8.0 s. Each voice gets
|
||||||
# speaks a second one so the journal carries the warm number too: the line
|
# two identical requests so the journal carries both numbers, `voice <name>
|
||||||
# `voice <name> cold=…ms warm=…ms` is the repo's standing proof that the second
|
# cold=…ms warm=…ms`; cold ≈ warm is the expected shape, and a large gap is what
|
||||||
# request of a voice answers well under a second.
|
# would mean the snapshot path is broken.
|
||||||
#
|
#
|
||||||
# THE VOICES. /voices — the host directory /home/dank/voices mounted read-only by
|
# 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
|
# 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
|
# 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
|
# 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
|
set -euo pipefail
|
||||||
|
|
||||||
# The engine's own address, from inside its namespace. Loopback is valid HERE:
|
# 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_text//\\/\\\\}"
|
||||||
warm_json="${warm_json//\"/\\\"}"
|
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
|
fail=0
|
||||||
log() { printf 'frank-tts-engine-warm: %s\n' "$*"; }
|
log() { printf 'frank-tts-engine-warm: %s\n' "$*"; }
|
||||||
|
|
||||||
@@ -146,9 +160,16 @@ for dir in "$VOICES_DIR"/*/; do
|
|||||||
continue
|
continue
|
||||||
fi
|
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}' \
|
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
|
"${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"
|
log "voice '${name}': registration failed (HTTP ${code:-no response}) — the shim will answer 400 for this name"
|
||||||
fail=1
|
fail=1
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
type: reference
|
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 — repo-owned, not yet deployed."
|
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:
|
tags:
|
||||||
- dankclaw
|
- dankclaw
|
||||||
- tts
|
- tts
|
||||||
@@ -14,7 +14,7 @@ timestamp: 2026-10-18
|
|||||||
|
|
||||||
`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.
|
`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.
|
||||||
|
|
||||||
**Nothing here is deployed.** This page documents repo-owned configuration — the Quadlet unit and network, the image build, the weight fetcher, the boot warmer, the deployer path — and the measured facts those files were written against. No container, image, weight file or network exists on Dankclaw yet, and the engine publishes no port at all, so there is nothing for the homepage checker or a browser to reach. Deploying it is a deliberate next step, in the order at the bottom of this 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.
|
**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.
|
||||||
|
|
||||||
@@ -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
|
## 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
|
ExecStartPost=-/usr/bin/podman exec frank-tts-engine /app/warm-voices.sh
|
||||||
TimeoutStartSec=900
|
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
|
frank-tts-engine-warm: 3 voice(s) registered and warm
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -96,25 +96,27 @@ The **façade** on 7772 is the pair's public surface and carries the probe. Note
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `dankclaw/voiceservice/Containerfile.engine` | two-stage build of the pinned commit, proven cmake line, digest-pinned base, ships both scripts |
|
| `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-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`, speak one sentence, report cold/warm |
|
| `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.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 |
|
| `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 cold-voice law in its header |
|
| `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/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 |
|
| `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 |
|
| `tests/frank_tts_engine.bats` | the seam: generator output, entrypoint argv, warmer against a stub engine, planner and routing maps |
|
||||||
|
|
||||||
## Deploy order (host steps, in order)
|
## Deploy record (host steps, in the order they ran)
|
||||||
|
|
||||||
Nothing below has been run. Steps 1–5 are this service; 6–7 are what unblocks the podcast pipeline.
|
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).
|
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). Builds the image (needs github.com once), installs the unit and the network file, restarts, gates on the container's health status, then warms.
|
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.
|
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 must name every pack; 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`).
|
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. **Prove the cold-voice law is paid at boot, not by a listener** — two identical synthesis requests for one voice, back to back, from inside the engine's namespace; the **second** must answer well under a second (`warm=` in the journal is already that number; the manual pair is the confirmation on the real artifact).
|
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): nothing may be published there until the engine answers, because open-notebook's render fails the moment the façade is up and the engine is not.
|
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. Nothing on 7772 exists yet — that is why open-notebook's podcast render cannot run. See [[Open Notebook]].
|
7. **Open:** [[Open Notebook]] is still not installed, so nothing renders a podcast yet; the voice leg it waits on now exists.
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
@@ -122,7 +124,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 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` |
|
| 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) |
|
| 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 |
|
| `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 |
|
| 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,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
type: reference
|
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; repo-owned, not yet deployed."
|
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:
|
tags:
|
||||||
- dankclaw
|
- dankclaw
|
||||||
- tts
|
- tts
|
||||||
@@ -16,10 +16,15 @@ 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
|
the engine ([[Frank TTS Engine]]) publishes no port on purpose, and consumers
|
||||||
never learn a path.
|
never learn a path.
|
||||||
|
|
||||||
**Nothing here is deployed.** No host was touched while this was written: no
|
**Deployed 2026-10-18** — `./scripts/deploy.sh dankclaw --service frank-tts`,
|
||||||
image built, no container started, nothing listening on 7772. That is also why
|
both units `active`, no drop-ins, and the homepage card's `wip` gate came off in
|
||||||
[[Open Notebook]] cannot render a podcast episode yet — its pipeline calls this
|
the same change. Probes on the deployed endpoint: `/health` answers
|
||||||
endpoint.
|
`{"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.
|
||||||
|
|
||||||
| | |
|
| | |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
@@ -138,8 +143,51 @@ A `/health` probe job would therefore be structurally never-green, and because
|
|||||||
`frank-tts` target would page on Signal every few minutes for an endpoint that
|
`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
|
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`,
|
scraped at all: it publishes nothing. Status lives in `systemctl --user status`,
|
||||||
`podman healthcheck run`, the two unit journals, and the homepage card, which is
|
`podman healthcheck run`, the two unit journals, and the homepage card, which
|
||||||
gated `wip: down` until the deploy and probes `127.0.0.1:7772`.
|
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
|
## Troubleshooting
|
||||||
|
|
||||||
@@ -148,5 +196,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/` |
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ podman exec open-notebook curl -sS http://frank-tts:7772/health
|
|||||||
podman exec open-notebook curl -sS http://host.containers.internal:7771/v1/models
|
podman exec open-notebook curl -sS http://host.containers.internal:7771/v1/models
|
||||||
```
|
```
|
||||||
|
|
||||||
**The blocker to state plainly:** frank-tts is built, named and tested but **not deployed** — nothing answers on 7772 (`ss -ltn`, Dankclaw, 2026-10-18; [[Local TTS and Voice Cloning]] records the same census). open-notebook deployed today writes scripts and fails at the first voice line. frank-tts ships first, or podcast generation waits.
|
**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.
|
**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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
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
|
The hook walks `/voices` and registers **every** pack that has a
|
||||||
seen, and logs `voice <name> cold=…ms warm=…ms` to the unit's journal. The
|
`reference.wav`, on every run — the engine replaces a name it already holds
|
||||||
rootless unit restart — `systemctl --user restart frank-tts-engine.service` —
|
(`tools/tts-server.cpp:148` at the pinned commit: *"Re-registering a name
|
||||||
is for when the unit or its env changed, not for a new pack.
|
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
|
## 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:
|
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
|
- **Boot-warm every voice.** The trial measured the first request for a
|
||||||
the whole reference to snapshot codec state: 6.2–8.0 s, first audio byte at
|
registered voice at 6.2–8.0 s (first audio byte 5,530 ms) and later requests
|
||||||
5,530 ms. Later requests restore that state in 0.5 ms. An engine unit must
|
restoring the snapshot in 0.5 ms. **That ladder did not survive deployment:**
|
||||||
run one throwaway synthesis per voice at start, or the first request of the
|
measured 2026-10-18 on the built container against an empty registry — register,
|
||||||
day pays five seconds.
|
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
|
- **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,
|
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
|
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`
|
**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:
|
||||||
|
|
||||||
|
|||||||
@@ -80,8 +80,8 @@ macOS mini) as quadlets inside its podman machine — not on Dankserver.
|
|||||||
- [[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
|
- [[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; 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; repo-owned config, not deployed
|
- [[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
|
- [[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
|
||||||
|
|||||||
@@ -2643,3 +2643,94 @@ every restart since the cutover.
|
|||||||
and a new unit on a host that serves people — Frank's call), no voice pack
|
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
|
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.
|
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.
|
||||||
|
|||||||
@@ -541,12 +541,12 @@
|
|||||||
"name": "frank-tts",
|
"name": "frank-tts",
|
||||||
"category": "admin",
|
"category": "admin",
|
||||||
"section": "AI",
|
"section": "AI",
|
||||||
"description": "The fleet's cloned-voice speech endpoint: POST /v1/audio/speech with voice=<name>, audio back in that voice. Public on 7772; the engine behind it (frank-tts-engine) is unpublished on purpose. Hidden until deployed.",
|
"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": "",
|
"url": "",
|
||||||
"docs": "",
|
"docs": "",
|
||||||
"probe": "127.0.0.1:7772",
|
"probe": "127.0.0.1:7772",
|
||||||
"method": "http",
|
"method": "http",
|
||||||
"wip": "down",
|
"wip": false,
|
||||||
"showLink": false,
|
"showLink": false,
|
||||||
"art": "/img/apps/mono-ft.svg"
|
"art": "/img/apps/mono-ft.svg"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -394,6 +394,37 @@ assert {s["response_format"] for s in speech} == {"wav"}, speech
|
|||||||
PY
|
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" {
|
@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
|
# 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. 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
|
# 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" ) &
|
||||||
@@ -912,10 +943,14 @@ DRV
|
|||||||
|| { echo "editing app.py must rebuild the façade image"; false; }
|
|| { echo "editing app.py must rebuild the façade image"; false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@test "the voice stack's homepage state: one card for the pair, and it is hidden while undeployed" {
|
@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
|
# 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
|
# 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.
|
# 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'
|
python3 - "$REPO_ROOT" <<'PY'
|
||||||
import json, os, sys
|
import json, os, sys
|
||||||
root = sys.argv[1]
|
root = sys.argv[1]
|
||||||
@@ -924,8 +959,12 @@ assert "frank-tts-engine" not in cards, \
|
|||||||
"the engine publishes no port: a card for it could never be probed green"
|
"the engine publishes no port: a card for it could never be probed green"
|
||||||
fts = cards["frank-tts"]
|
fts = cards["frank-tts"]
|
||||||
assert fts["probe"] == "127.0.0.1:7772", fts["probe"]
|
assert fts["probe"] == "127.0.0.1:7772", fts["probe"]
|
||||||
assert fts["wip"] == "down" and fts["showLink"] is False, \
|
assert fts["wip"] is False, \
|
||||||
"nothing listens on 7772 until the deploy, so a visible card reads as down forever"
|
"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']}"
|
assert os.path.exists(f"{root}/shared/homepage{fts['art']}"), f"missing art {fts['art']}"
|
||||||
PY
|
PY
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user