Daemon side of Decision B: parse {"response","mood"} across the 4 consumers
(replier, nudges, reminders, chat), fall back to legacy formats. Drop the
LLM router — the classifier handles routing; replier/phraser share one
llm.Client (timeout 20s->60s). llm.Client reads reasoning_content when
content is empty (thinking models).
Docs: TTS piper-student plan (OmniVoice teacher -> piper student, from
scratch, phoneme-first). CLAUDE.md training guide.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
12 KiB
Plan — Maven TTS: OmniVoice teacher → piper student (from scratch)
Goal: A tiny, CPU-real-time Russian TTS voice for Maven that replaces the piper
irinafloor. A heavy zero-shot teacher (k2-fsa/OmniVoice) clones/designs Maven's voice and mass-generates a labelled dataset; we train a piper voice from scratch on it. The whole quality game is phoneme correctness (stress + homographs), not the model — the student aligns perfectly to whatever phonemes we feed it, so garbage stress in = garbage voice out.Read the whole file before starting. Phases in order. Each has a DONE-CHECK.
0. Context an agent picking this up must know
Two machines (from CLAUDE.md):
| Role | Box | Constraint |
|---|---|---|
| Generate + train | workstation, RX 7900 GRE gfx1100, 16GB VRAM, ROCm 7.2 | teacher + piper-train both run here; GPU may be busy with the LLM CPT — TTS gen waits its turn, but the phoneme/dataset-prep scripts are CPU/file work and run anytime |
| Deploy | homesrv, Ryzen 5 5600U CPU, 13GB RAM swapping | piper .onnx runs real-time on CPU; replaces the irina floor |
Working dirs:
- Teacher + dataset:
/home/kami/Programs/esp32-whisper-fine-tune/tts/(this is the SSHFS-remote-vs-local split;/home/kami/Programs/is local to the workstation). - New piper student files:
tts/piper/(created by this plan). - Daemon that consumes the final voice:
/mnt/server/home/kami/apps/Maven/.
What already exists in tts/ (reuse, don't rebuild):
find_voice.py— audition teacher voice-designs → saveref/<N>/maven_reference.{wav,txt}.generate_synthetic_voice.py— clone the reference, synth per-mood →dataset/<mood>/NNNN.wav+dataset/metadata.csv.homograph_processor.py— LLM rewrites sentences to remove stress homographs.data/*-voice-dataset-list.txt— the mood transcript lists (neutral/happy/thinking/confused/tired).ruaccent(RUAccent) +plus_to_acute— stress-marking, already imported in the gen script.- A large qwen-generated dataset already exists in
dataset/— but it was made by the old teacher (Qwen3-TTS) and its text was not stress-marked (thepreprocesscall is commented out). See Phase 1 decision: regenerate with OmniVoice + stressed text, or reuse.
Decisions locked this session:
- Teacher = k2-fsa/OmniVoice (0.6B on Qwen3-0.6B, 600+ langs incl RU, zero-shot clone + voice-design, RTF 0.025, 24kHz out). Code Apache-2.0; weights CC-BY-NC — fine, the teacher never ships, only the piper student deploys.
- Student = piper, trained from scratch (no warm-start from irina or any piper voice — GPU/patience is free, and warm-start's only benefit was fast convergence we don't need).
- Phoneme correctness is the priority. Stress-mark + homograph-resolve every transcript so espeak-ng produces correct RU phonemes, and synth the audio from the same stressed text so text↔audio↔phonemes stay locked.
1. Pipeline overview
Phase 2 transcripts → stress-marked + homograph-clean mood lists
Phase 3 teacher swap → OmniVoice into find_voice.py / generate_synthetic_voice.py
Phase 4 generate → dataset/<mood>/NNNN.wav + metadata.csv (24kHz)
Phase 5 build_dataset → piper LJSpeech dir: wav 22.05k + metadata (id|stressed_text)
Phase 6 check_phonemes → espeak-ng sanity — eyeball stress before training
Phase 7 train.sh → piper preprocess → train from scratch → export ONNX
Phase 8 deploy → scp .onnx+json to homesrv; point mavend.json; drop irina
Run order = build order here (unlike the CPT plan). Phase 2 (phonemes) gates quality and is CPU work — do it while the LLM CPT still owns the GPU.
2. Transcripts — stress + homographs (the quality lever)
The data/*-voice-dataset-list.txt files are raw Russian, one sentence per line.
Two defects to fix before synthesis, because the audio must match the phonemes:
- Homographs — words whose stress (thus meaning) is ambiguous (
за́мок/замо́к,сто́ят/стоя́т).homograph_processor.pyalready rewrites these away via a local LLM. Run it per mood list →*-clean.txt. - Stress marks —
ruaccent(RUAccent().process_all) inserts+before every stressed vowel. This is what makes espeak-ng stress correctly later. The gen script already haspreprocess()(accent →plus_to_acute) but it's commented out — the decision is to run stress-marking on the transcripts and keep the+-marked form as the canonical text (see Phase 5 for the two acute conventions).
cd /home/kami/Programs/esp32-whisper-fine-tune/tts
# 1. de-homograph each mood list (LLM at localhost:10000 must be up)
python homograph_processor.py data/*-voice-dataset-list.txt --all
# 2. stress-mark → keep the +VOWEL form as canonical (Phase 5 converts per target)
DONE-CHECK 2: each mood has a cleaned, +-stress-marked list; a 30-line random
sample eyeballed — every multisyllable word has exactly one +, no homographs left.
3. Teacher swap — OmniVoice into the two gen scripts
find_voice.py and generate_synthetic_voice.py currently call qwen_tts.Qwen3TTSModel
(generate_voice_design, generate_voice_clone). Swap the teacher to OmniVoice via the
thin adapter tts/piper/omnivoice_tts.py (created by this plan) so only two call-sites
change and the rest of the resume/metadata logic is untouched.
pip install omnivoice(after torch), on the workstation, GPU free (post-CPT).- Verify the exact OmniVoice API from its README /
pip show omnivoice— the adapter is written against the documented surface (ref-audio + transcription clone; attribute voice-design) but the method names/signature must be confirmed before running. - Keep synthesis text = the stressed transcripts from Phase 2 (re-enable
preprocess, or pass the pre-stressed lists). Teacher and student then share identical text.
DONE-CHECK 3: find_voice.py produces an OmniVoice sample that plays; one clone
sample from generate_synthetic_voice.py --dry-run-then-real matches the reference timbre.
4. Generate the dataset
cd /home/kami/Programs/esp32-whisper-fine-tune/tts
python find_voice.py # audition → ref/<N>/maven_reference.{wav,txt}
python generate_synthetic_voice.py # all moods → dataset/<mood>/NNNN.wav + metadata.csv
# resume-safe (.progress.json); --moods / --limit / --dry-run available
Reuse-or-regenerate call: the existing dataset/ was made by Qwen3-TTS from
unstressed text. For phoneme-locked training you want OmniVoice audio from
stressed text → regenerate. (Keeping the old set is only OK if you accept the
text↔audio stress mismatch, which is the exact defect this plan exists to kill.)
DONE-CHECK 4: dataset/metadata.csv rows all point to existing wavs; total
duration logged (aim ≥ ~2–3h across moods for a from-scratch single-speaker voice);
5 random wavs play and match their transcript.
5. Build the piper dataset (tts/piper/build_dataset.py)
Piper wants an LJSpeech-format dir: wav/<id>.wav (its target sample rate) +
metadata.csv as id|text. Our metadata.csv is file_path,text,mood,emotion_id and
wavs are 24kHz. build_dataset.py:
- Read
dataset/metadata.csv. - Resample 24000 → 22050 Hz (piper
mediumdefault), mono, intopiper/dataset/wav/. - Stress → espeak convention: piper phonemizes via espeak-ng, which reads the
combining acute U+0301 after the stressed vowel (NOT the uppercase form
plus_to_acutemakes for qwen). Convert the+VOWELmarks tovowel+U+0301. - Write
piper/dataset/metadata.csvasid|stressed_text(single speaker).
Self-check (__main__): assert +а → а́, id/text counts match wav count.
DONE-CHECK 5: piper/dataset/ has N wavs at 22050Hz and a metadata line each;
soxi/soundfile confirms sample rate.
6. Phoneme sanity (tts/piper/check_phonemes.py)
Before burning GPU-days, confirm espeak-ng stresses correctly. For a sample of
transcripts, print espeak-ng -v ru --ipa output and flag any multisyllable word
whose IPA carries no primary-stress mark ˈ. This is the cheapest catch for the
"sounds bad" failure — wrong stress shows here, not after training.
Requires espeak-ng (sudo apt install espeak-ng). ponytail: thin wrapper over
the espeak CLI, ceiling = it only flags missing stress, not wrong-position stress
(that still needs an ear on Phase 4 audio).
DONE-CHECK 6: sample run shows every content word carrying ˈ; obvious homograph
words stressed as intended.
7. Train piper from scratch (tts/piper/train.sh)
Install piper-train from the piper repo (rhasspy/piper, src/python): needs torch +
pytorch-lightning. ROCm env like train_cpt.py (HSA_OVERRIDE_GFX_VERSION=11.0.0).
# preprocess: text → espeak-ng phonemes → training cache
python -m piper_train.preprocess \
--language ru --input-dir piper/dataset --output-dir piper/train \
--dataset-format ljspeech --single-speaker --sample-rate 22050
# train FROM SCRATCH (no --resume_from_checkpoint), medium quality
HSA_OVERRIDE_GFX_VERSION=11.0.0 python -m piper_train \
--dataset-dir piper/train --accelerator gpu --devices 1 \
--batch-size 16 --quality medium --precision 32 \
--max_epochs 4000 --checkpoint-epochs 100 --validation-split 0.02
# export best checkpoint → ONNX
python -m piper_train.export_onnx piper/train/lightning_logs/version_0/checkpoints/last.ckpt \
piper/maven.onnx
cp piper/train/config.json piper/maven.onnx.json
- From scratch = no warm-start (decision 2). Early checkpoints sound broken until MAS alignment settles — expected; that's the patience cost we accepted.
--quality medium(22.05k) is the CPU-real-time sweet spot;highonly if homesrv latency allows (measure).
DONE-CHECK 7: training loss/mel decreases; a mid-run checkpoint synthesizes an
intelligible Russian sentence with correct stress; final ONNX exported + its .json.
8. Deploy to homesrv
scp piper/maven.onnx piper/maven.onnx.json kami@192.168.1.104:<voices dir>
# point deploy/mavend.json at maven.onnx; drop the irina floor
Measure CPU tok→audio latency with --mlock-class care (homesrv swaps). If too slow,
that's a quality/latency knob (medium already chosen), not a re-train.
DONE-CHECK 8: Maven speaks on homesrv in the new voice, real-time enough for conversation, correct Russian stress in live output.
9. File manifest (what this plan creates in tts/piper/)
| File | Phase | Purpose |
|---|---|---|
omnivoice_tts.py |
3 | thin adapter isolating the OmniVoice API (design + clone) — the single swap point |
build_dataset.py |
5 | our metadata.csv + 24k wav → piper LJSpeech dir (22.05k, espeak-acute stress) |
check_phonemes.py |
6 | espeak-ng stress sanity on a transcript sample |
train.sh |
7 | piper preprocess → train-from-scratch → export ONNX (runbook) |
Reused, unchanged: find_voice.py, generate_synthetic_voice.py (two call-sites swapped
to the adapter), homograph_processor.py, ruaccent.
10. Hard rules (do not violate)
- Text↔audio↔phonemes locked: synth the audio from the same stressed text espeak-ng later phonemizes. Never train piper on audio whose transcript stress differs from what you feed the preprocessor.
- Two acute conventions — don't confuse them:
plus_to_acute(uppercase vowel) is for the qwen/OmniVoice teacher; espeak-ng wants combining U+0301 after the vowel.build_dataset.pyconverts. - From scratch — no warm-start (decision 2). Do not
--resume_from_checkpointoff irina or any piper voice. - Teacher weights are CC-BY-NC — fine for the never-shipped teacher; the deployed piper voice is your own weights. Don't redistribute the OmniVoice-generated dataset as a product.
- Homographs out first (Phase 2), then stress-mark. Order matters — rewriting a sentence changes which words need stress.
- Don't advance a phase until its DONE-CHECK passes. Phase 6 (phoneme sanity) is the real gate — a bad phoneme table wastes the whole GPU-days train.