feat(voice-packs): the builder that turns a crop into a deployable voice pack #13

Merged
dank merged 4 commits from feat/voice-pack-builder into main 2026-10-18 17:41:11 -07:00
Owner

What lands

scripts/build-voice-pack.sh (483 lines) + scripts/voice-packs.env (tracked crops) + tests/voice_pack_unit.bats (13 tests) + the Voice Packs runbook. It turns a source recording and a crop offset into $VOICE_PACK_DIR/<voice>/: reference.wav (24 kHz mono pcm_s16le, 18-25 s), the pinned engine's reference.spk + reference.rvq made from that WAV, and manifest.json provenance. Nothing is deployed here; this is the tool and its gate.

The gate is the product

The project-wide source-material invariant - no voice material from the first 10 s of a sample file - had only ever been a sentence in scripts/frank-tts.env. Now: an offset inside the forbidden region exits 1 before a directory is created, and the same assertion runs a second time against the manifest that was just written, next to the recorded source sha256. 10 is a constant in the builder; the env file carries offsets, never the law.

Sizes are arithmetic, so they are verified rather than assumed: 16 codebooks x 11 bits = 22 B per 12.5 Hz frame (22 * floor(samples/1920)) and enc_dim 2048 x f32 = 8,192 B. That makes the effective crop window 19.84-25 s, narrower than the WAV law's 18-25 s, and a too-short crop is refused before the extractor runs. An engine bump that changes either number fails the build on purpose.

Integrating-session audit (independent of the author)

  • ./tests/run.sh on the final merged tree: ==> ALL TESTS PASSED, 416 tests, wall 76.6 s. 403 on main before this branch; the seam is picked up by run.sh's *.bats glob and routed by a new scripts/test-changed.sh case placed above the generic scripts/* fallback.
  • Live dry-run against the real narrator1 source (audiosample.mp3, 180,602,038 B, 22,569 s): pinned sha256 2299510467eb91… matched, crop 98.739-118.636 s came out 24000 Hz pcm_s16le mono, 19.897000 s, 955378 bytes with reference.rvq planned at 5456 B - byte-for-byte the trial's hand-cut numbers. Exit 0, and PACKS_DIR/narrator1 was not created.
  • --offset 4.5 -> exit 1, names the invariant, and the packs root does not exist afterwards. --sha256 ffff… -> exit 1 after hashing, before the crop.
  • Extractor argv checked against the pinned tree, not memory: qwen-codec --model <codec.gguf> --talker <base-talker.gguf> -i <wav>, no -o, outputs named next to the input. The builder stages under the final stem and moves reference.wav - the file the facade gates on - last, so a failed extractor leaves a directory that is not a voice.

Cross-branch reconciliation done here

  • app.py resolves a voice as $VOICES_DIR/<voice>/reference.wav and lists only depth-1 directories holding that file (list_voices, L119-124), so the .AGENTTRASH/ dir that --force retires into produces no phantom voice, and the extra reference.spk / reference.rvq / qwen-codec.log members are ignored. The builder's name regex is a lowercase subset of VOICE_NAME_RE, which also matches the engine's lowercasing.
  • Model pins agree with the engine slice: scripts/frank-tts-engine.env / scripts/download-frank-tts-engine-qwentts.sh (branch feat/frank-tts-engine) default DEST=/home/dank/models/Qwen3-TTS-GGUF with the same two filenames this env pins, so no edit is needed once the fetch runs.
  • The runbook's systemctl --user restart frank-tts-engine.service is right: that unit ships as a rootless quadlet (dankclaw/voiceservice/quadlet/frank-tts-engine.container), like frank-tts.container already on main.

Explicitly not done

  • No pack built, nothing deployed. The three sources live on the workstation; step 1 is scp them to /home/dank/voice-sources. The first real run is a migration (--force, because the trial packs already occupy those names) and it retires reference16k.wav along with the directory - regenerate it with one ffmpeg line only if the llama-tts-cli rollback engine is revived.
  • VOICE_PACK_TOOL points at the trial's host build tree (checked out at exactly the pinned commit) because that is the only qwen-codec on disk; the engine image also carries the tool, and building packs is a host batch job, so the host path stays primary. Worth revisiting if the tree is ever removed.
  • No ref_text in the manifest (ICL wants a curated transcript; the trial's machine transcripts were wrong), no 16 kHz variant, no loudness pass. All three are written into the runbook as deliberate gaps.
  • tests/voice_pack_unit.bats ends with a test that checks the tracked pack list against the invariant. That is a lint over shipped data rather than a behaviour test; it duplicates the builder's own refusal. Kept as authored - delete it if you consider it a config assertion.

cwd: /home/blank/.omp/wt/wt-20261017-181502-6d1fc1f · session: 01a14c93-8870-75d9-9900-9a731559feef

Build worktree: /home/blank/.omp/wt/wt-voice-pack (branch feat/voice-pack-builder, merged origin/main e13f875 with no conflicts).

## What lands `scripts/build-voice-pack.sh` (483 lines) + `scripts/voice-packs.env` (tracked crops) + `tests/voice_pack_unit.bats` (13 tests) + the [[Voice Packs]] runbook. It turns a source recording and a crop offset into `$VOICE_PACK_DIR/<voice>/`: `reference.wav` (24 kHz mono `pcm_s16le`, 18-25 s), the pinned engine's `reference.spk` + `reference.rvq` made from that WAV, and `manifest.json` provenance. Nothing is deployed here; this is the tool and its gate. ## The gate is the product The project-wide source-material invariant - no voice material from the first 10 s of a sample file - had only ever been a sentence in `scripts/frank-tts.env`. Now: an offset inside the forbidden region exits 1 **before a directory is created**, and the same assertion runs a second time against the manifest that was just written, next to the recorded source sha256. `10` is a constant in the builder; the env file carries offsets, never the law. Sizes are arithmetic, so they are verified rather than assumed: 16 codebooks x 11 bits = 22 B per 12.5 Hz frame (`22 * floor(samples/1920)`) and `enc_dim 2048 x f32` = 8,192 B. That makes the **effective** crop window 19.84-25 s, narrower than the WAV law's 18-25 s, and a too-short crop is refused before the extractor runs. An engine bump that changes either number fails the build on purpose. ## Integrating-session audit (independent of the author) - `./tests/run.sh` on the final merged tree: **`==> ALL TESTS PASSED`, 416 tests**, wall 76.6 s. 403 on main before this branch; the seam is picked up by `run.sh`'s `*.bats` glob and routed by a new `scripts/test-changed.sh` case placed above the generic `scripts/*` fallback. - Live dry-run against the real narrator1 source (`audiosample.mp3`, 180,602,038 B, 22,569 s): pinned sha256 `2299510467eb91…` **matched**, crop 98.739-118.636 s came out `24000 Hz pcm_s16le mono, 19.897000 s, 955378 bytes` with `reference.rvq` planned at **5456 B** - byte-for-byte the trial's hand-cut numbers. Exit 0, and `PACKS_DIR/narrator1` was **not** created. - `--offset 4.5` -> exit 1, names the invariant, and the packs root does not exist afterwards. `--sha256 ffff…` -> exit 1 after hashing, before the crop. - Extractor argv checked against the pinned tree, not memory: `qwen-codec --model <codec.gguf> --talker <base-talker.gguf> -i <wav>`, **no `-o`**, outputs named next to the input. The builder stages under the final stem and moves `reference.wav` - the file the facade gates on - **last**, so a failed extractor leaves a directory that is not a voice. ## Cross-branch reconciliation done here - `app.py` resolves a voice as `$VOICES_DIR/<voice>/reference.wav` and lists only depth-1 directories holding that file (`list_voices`, L119-124), so the `.AGENTTRASH/` dir that `--force` retires into produces **no phantom voice**, and the extra `reference.spk` / `reference.rvq` / `qwen-codec.log` members are ignored. The builder's name regex is a lowercase subset of `VOICE_NAME_RE`, which also matches the engine's lowercasing. - Model pins agree with the engine slice: `scripts/frank-tts-engine.env` / `scripts/download-frank-tts-engine-qwentts.sh` (branch `feat/frank-tts-engine`) default `DEST=/home/dank/models/Qwen3-TTS-GGUF` with the same two filenames this env pins, so no edit is needed once the fetch runs. - The runbook's `systemctl --user restart frank-tts-engine.service` is right: that unit ships as a rootless quadlet (`dankclaw/voiceservice/quadlet/frank-tts-engine.container`), like `frank-tts.container` already on main. ## Explicitly not done - **No pack built, nothing deployed.** The three sources live on the workstation; step 1 is `scp` them to `/home/dank/voice-sources`. The first real run is a migration (`--force`, because the trial packs already occupy those names) and it retires `reference16k.wav` along with the directory - regenerate it with one `ffmpeg` line only if the `llama-tts-cli` rollback engine is revived. - `VOICE_PACK_TOOL` points at the trial's host build tree (checked out at exactly the pinned commit) because that is the only `qwen-codec` on disk; the engine image also carries the tool, and building packs is a host batch job, so the host path stays primary. Worth revisiting if the tree is ever removed. - No `ref_text` in the manifest (ICL wants a curated transcript; the trial's machine transcripts were wrong), no 16 kHz variant, no loudness pass. All three are written into the runbook as deliberate gaps. - `tests/voice_pack_unit.bats` ends with a test that checks the **tracked pack list** against the invariant. That is a lint over shipped data rather than a behaviour test; it duplicates the builder's own refusal. Kept as authored - delete it if you consider it a config assertion. --- `cwd: /home/blank/.omp/wt/wt-20261017-181502-6d1fc1f · session: 01a14c93-8870-75d9-9900-9a731559feef` Build worktree: `/home/blank/.omp/wt/wt-voice-pack` (branch `feat/voice-pack-builder`, merged `origin/main` e13f875 with no conflicts).
dank added 4 commits 2026-10-18 17:32:09 -07:00
Turns a source recording plus a crop offset into the pack frank-tts
resolves by name: reference.wav (24 kHz mono pcm_s16le, 18-25 s), the
engine's own reference.spk / reference.rvq made from it, and manifest.json
provenance (source path + sha256, crop start/end, sample rate, duration,
tool, engine commit, voice name).

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

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

reference.wav is the file the façade gates on, so it lands last, after
the artifacts and the manifest: a voice name never exists on disk without
its artifacts behind it. Nothing is ever deleted — an existing pack is
refused unless --force, which retires it whole to .AGENTTRASH/ — and
--dry-run runs every step except the extractor, so the seam can test the
gate without the engine.
scripts/voice-packs.env carries the decisions worth a diff and nothing
else: the pack list, each voice's source file, crop offset, crop length
and pinned source digest, the extractor path with its engine repo and
commit, and the artifact arithmetic. Paths and numbers only — the pack
directories themselves stay host state under /home/dank/voices.

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

test-changed.sh routes both new paths to that seam instead of letting
them land on the generic scripts/* syntax fallback.
The page a new operator needs: what the three required files are and who
reads each one, how a pack is built (with the extractor's usage text
verbatim, because the flags are not guessable and there is no -o), the
10-second gate and where it fires twice, how to add a fourth voice
including the warm hook that registers a pack without restarting the
engine, and the engine-bump procedure for when the artifact sizes move.
Plus the host census as of 2026-10-18 — the trial packs carry no
artifacts and their manifest is the trial's own schema, so the builder's
first real run is a --force migration — and the three gaps left open on
purpose: no curated ICL transcript, no 16 kHz variant, no loudness pass.

Linked from the Dankclaw services list; log entry appended oldest-first.
dank merged commit d421cb69cf into main 2026-10-18 17:41:11 -07:00
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: dank/serverAppConfigs#13