Teacher to Student -- SanoTTS
How are Piper teachers distilled to form a student with the lowest possible number of parameters, while achieving a similar intelligibility score to that of the teacher?
1. Audition
We first download the Piper teacher voice from Hugging Face that we want to distill.
Download the Teacher
hf download rhasspy/piper-voices \
pl/pl_PL/gosia/medium/pl_PL-gosia-medium.onnx \
pl/pl_PL/gosia/medium/pl_PL-gosia-medium.onnx.json \
--local-dir models/teachers
Then, we test each teacher’s score in terms of CER (Character Error Rate). From these results, we select the teacher with the lowest CER to distill from because the student learns from an expert, not from a teacher that is still a beginner (i.e., a teacher with a higher CER).
The teacher audition is done using the audition_piper_teacher.py script, which is located in:
tools/audition_piper_teacher.py
The command looks like:
python3 tools/audition_piper_teacher.py \
--voices pl_PL-darkman-medium pl_PL-gosia-medium pl_PL-mc_speech-medium \
--texts flores24-pl.json \
--out experiments/evidence/piper-teacher-asr-pl-20260921.json
What is flores24-pl.json?
There is a file called flores24-pl.json.
Question: Which script creates this flores24 file?
I created it using a command/script to extract the Polish (pl) sentences from flores24-all.json, which contains 24 Polish sentences and was stored on my Mac.
Polish Teacher Audition Results
| Voice | Language | CER | Verdict |
|---|---|---|---|
pl_PL-gosia-medium | pl | 0.062 | pass |
pl_PL-mc_speech-medium | pl | 0.066 | pass |
pl_PL-darkman-medium | pl | 0.070 | pass |
We choose pl_PL-gosia-medium because it has the lowest CER among the three teachers.
2. Corpus Creation for Student Training
After selecting the teacher, we create the text corpus that will be used to train the student by distilling knowledge from the teacher.
Option A
FLORES dev (997)
├── 12 sentences → Smoke test
├── 128 sentences → Internal evaluation
└── 857 sentences → Training
FLORES devtest (1,012)
└── 1,012 sentences → Training
Tatoeba (177)
└── 177 sentences → Training
────────────────────────────────
Total training data = 2,188 rows
Option B — Skipped
FLORES dev (997)
├── First 140 sentences
│ ├── Smoke test
│ └── Internal evaluation
│
└── Remaining 857 sentences
└── Training
Tatoeba (177)
└── 177 sentences → Training
FLORES devtest (1,012)
└── KEEP COMPLETELY ASIDE
└── Not used for training, smoke testing, or internal evaluation
────────────────────────────────
Total training data = 1,034 rows
Download the Raw Data
First, we download the FLORES dev, FLORES devtest, and Tatoeba files.
Tatoeba
For Tatoeba, we download the export for the respective language. For Polish, the command is:
mkdir -p data/raw/tatoeba
wget "https://downloads.tatoeba.org/exports/per_language/pol/pol_sentences.tsv.bz2" \
-O data/raw/tatoeba/pol_sentences.tsv.bz2
You can check the URL below to see the exports for other languages:
https://downloads.tatoeba.org/exports/per_language/
The downloaded file is stored as:
data/
└── raw/
└── tatoeba/
└── pol_sentences.tsv.bz2
What is Tatoeba?
Tatoeba is a community-built collection of multilingual sentences, where users contribute sentences and translations.
The file we download contains Polish sentences only, structured as:
ID Language Sentence
FLORES
For Polish, the FLORES files are stored in the saanotts repository under:
data/
└── raw/
└── flores_plus/
├── dev/
│ └── pol_Latn.jsonl
└── devtest/
└── pol_Latn.jsonl
Create the directory:
mkdir -p data/raw/flores_plus
Then download the Polish dev and devtest files:
huggingface-cli download openlanguagedata/flores_plus \
dev/pol_Latn.jsonl \
devtest/pol_Latn.jsonl \
--repo-type dataset \
--local-dir data/raw/flores_plus
Create the Polish Distillation Corpus
Now we create the corpus that will be used during student training.
I created the following script:
tools/make_polish_text_distill_corpus.py
This script takes the three downloaded files:
FLORES dev
FLORES devtest
Tatoeba Polish
and creates the corresponding Polish textset files.
Note:
pl_PL.devtest-heldout.jsonlis created only in Mode B, which we skipped.
Run the script from the saanotts repository with the default Mode A:
python3 tools/make_polish_text_distill_corpus.py \
--flores-dev data/raw/flores_plus/dev/pol_Latn.jsonl \
--flores-devtest data/raw/flores_plus/devtest/pol_Latn.jsonl \
--tatoeba data/raw/tatoeba/pol_sentences.tsv.bz2
Generated Files
data/
└── textsets/
└── polish-distill-a-v1/
├── pl_PL.train.jsonl
├── pl_PL.eval-textset.json
├── pl_PL.ood16.json
└── pl_PL.corpus-report.json
| File | Mode A | Mode B |
|---|---|---|
pl_PL.train.jsonl | ✓ | ✓ |
pl_PL.ood16.json | ✓ | ✓ |
pl_PL.devtest-heldout.jsonl | — | ✓ |
pl_PL.corpus-report.json | ✓ | ✓ |
How pl_PL.train.jsonl Is Created
In Mode A, make_polish_text_distill_corpus.py combines:
FLORES dev
+
FLORES devtest
+
Tatoeba
↓
Cleaning + filtering + deduplication
↓
pl_PL.train.jsonl
The resulting pl_PL.train.jsonl is then passed to the student-training script.
OOD Evaluation Set
OOD means Out-of-Distribution.
The pl_PL.ood16.json file contains 16 Polish sentences reserved as a separate evaluation set. They are used to check how the trained student behaves on sentences that were not included in its training corpus.
These 16 sentences are taken from the Tatoeba Polish sentence export.
Tatoeba Polish sentences
↓
Shuffle
↓
Select 16
↓
pl_PL.ood16.json
These 16 sentences are excluded from pl_PL.train.jsonl.
3. Student Training
Now, we have the corpus to train the student using the teacher. The training runs through multiple stages, from S0 to S11. The main training script is:
tools/train_voice_from_piper.py
Command
Practical tip: before committing hours to the full run, do one cheap plumbing pass:
python3 tools/train_voice_from_piper.py --voice pl_PL-gosia-medium \
--text data/textsets/polish-distill-a-v1/pl_PL.train.jsonl \
--size standard --profile smoke
If the smoke profile completes S0–S11 without errors, relaunch with --profile shipped for the real run.
Both options are already the defaults:
--profiledefaults toshipped--sizedefaults tostandard
So the command also works identically without specifying them.
Pipeline Stages
| Stage | Script | Main Purpose | Key Output |
|---|---|---|---|
| Preflight | make_multilang_text_distill_corpus.py | Validate teacher/model setup | Preflight passed |
| S1 | build_piper_vits_roota_probe_pack.py | Extract teacher targets from text | 4 data packs |
| S2 | extract_piper_vits_decoder_cut.py | Cut decoder from teacher ONNX | decoder-cut/*.onnx |
| S3 | train_roota_piper_duration_student.py | Train duration student | duration/duration-student.pt |
| S4 | train_roota_piper_latent_student.py | Train acoustic/latent student | acoustic/latent-student.pt |
| S5 | verify_piper_decoder_torch_parity.py | Verify decoder + export initial weights | parity/piper-decoder-teacher.pt |
| S6 | build_roota_piper_vits_decoder_signature_pack.py | Capture decoder activation signatures | signatures/summary.json |
| S7 | train_roota_piper_decoder_student.py | Train decoder on teacher latents | decoder/decoder-student.pt |
| S8 | train_roota_piper_decoder_student.py | Harden decoder with z-mix | decoder-zmix/decoder-student.pt |
| S9 | train_roota_joint_z_finetune.py | Jointly fine-tune acoustic + decoder | joint/*.pt |
| S10 | run_roota_feedback_loop.py | Evaluate final stack | feedback/ |
| S11 | export_roota_self_contained_package.py | Package final model | package/manifest.json |
S1 Data Packs
S1 generates the teacher-supervised data packs used by the later training stages.
| Pack | Purpose | Key Content |
|---|---|---|
smoke12/ | Smoke validation | 12 teacher-generated rows |
eval128/ | Held-out evaluation | 128 rows |
train-acoustic/ | Acoustic training | 10,826 rows |
train512-decoder/ | Decoder training | 512 rows |
Each pack can contain:
rows.json
phoneme_ids/
w_ceil/
generator_input/
audio/
path/ # optional
attn_mask/ # optional
other tensors # optional
Main Verification Checklist
| Stage | Verify |
|---|---|
| Preflight | No errors; teacher/eSpeak/config valid |
| S1a | 12 rows/audio files |
| S1b | 128 held-out rows |
| S1c | 10,826 acoustic rows |
| S1d | 512 decoder rows |
| S2 | Decoder ONNX exists |
| S3 | Duration checkpoint; 4,000 steps |
| S4 | Acoustic checkpoint; 5,000 steps |
| S5 | Teacher parity + initial weights |
| S6 | 6 signatures captured |
| S7 | Decoder checkpoint; 2,800 steps |
| S8 | Decoder checkpoint; 2,500 steps; mix_prob=0.5 |
| S9 | Both acoustic + decoder checkpoints |
| S10 | Feedback/SCOREQ results + audio |
| S11 | Self-contained package; ~1.56M parameters |
Final Artifact Directory
Everything is stored under:
artifacts/
└── voices/
└── pl_PL-gosia-medium/
├── acoustic/
├── dashboard/
├── decoder/
├── decoder-cut/
├── decoder-zmix/
├── duration/
├── eval128/
├── feedback/
├── joint/
├── logs/
├── package/
├── parity/
├── signatures/
├── smoke12/
├── train-acoustic/
└── train512-decoder/
One-Line Flow
Preflight
↓
S1: Teacher Data
↓
S2: Decoder Cut
↓
S3: Duration Student ─────────┐
↓
S4: Acoustic Student ───────→ S7: Decoder
↓ ↓
S5: Decoder Init → S6: Signatures S8: Z-Mix
↓
S9: Joint Fine-tune
↓
S10: Quality Gate
↓
S11: Package
In short
S1creates the teacher supervision →S3/S4learn the acoustic side →S5/S6/S7/S8build the decoder →S9synchronizes everything →S10evaluates it →S11packages the final model.
Error in S10
S10 is the quality gate. It takes the complete student model and runs it through the scoring script scoreq_roota_under2m_benchmark.py.
The S10 command was initially run separately as follows:
python3 tools/run_roota_feedback_loop.py \
--eval-pack artifacts/voices/pl_PL-gosia-medium/eval128 \
--text-set data/textsets/polish-distill-a-v1/pl_PL.eval-textset.json \
--acoustic-checkpoint artifacts/voices/pl_PL-gosia-medium/joint/latent-student.pt \
--duration-checkpoint artifacts/voices/pl_PL-gosia-medium/duration/duration-student.pt \
--piper-model models/teachers/pl_PL-gosia-medium/pl_PL-gosia-medium.onnx \
--piper-config models/teachers/pl_PL-gosia-medium/pl_PL-gosia-medium.onnx.json \
--decoder-report artifacts/voices/pl_PL-gosia-medium/decoder/train-report.json \
--candidate-label pl_PL-gosia-medium-joint \
--out-dir artifacts/voices/pl_PL-gosia-medium/feedback \
--rows 64 \
--param-budget 1500000 \
--force-render --force-score \
--device cpu
There were four issues.
1. Wrong Name Tag
The script looked for the candidate name:
pl_PL-gosia-medium-joint
However, S9 saved the final model under the joint directory. Therefore, the candidate name generated from the report directory was:
joint
The S10 candidate label therefore did not match the actual S9 output.
2. Missing Map — Hardcoded Path
The script tried to load an evaluation text file from a hardcoded path:
data/textsets/polish-distill-a-v1/pl_PL.eval-textset.json
This evaluation text file was not available at the expected path.
3. Weight Limit Too Strict
The standard size model contains approximately 1.56 million parameters.
However, the scoring script was given:
--param-budget 1500000
Even if the model had been found successfully, it would have failed the parameter-size check.
4. Junk in the System
Corrupted or outdated ONNX cache files from previous SCOREQ runs were still present. These could cause silent failures or stale results during the scoring process.
Fixes
| Issue | Fix | Result |
|---|---|---|
| Missing Map | Made --text-set optional in the script. | Stops the script from crashing if a specific text file is not provided. |
| Wrong Name Tag | Pointed S10 to the S9 joint report directory. | The candidate label becomes joint, matching where S9 saved the files. |
| Weight Limit | Raised --param-budget to 2,000,000. | Gives the ~1.56M parameter model enough room to pass the size check. |
| Junk in System | Deleted corrupted SCOREQ ONNX cache files. | Forces the system to build a fresh, clean cache for the new model. |
Solution Command
python3 tools/run_roota_feedback_loop.py \
--eval-pack artifacts/voices/pl_PL-gosia-medium/eval128 \
--acoustic-checkpoint artifacts/voices/pl_PL-gosia-medium/joint/latent-student.pt \
--duration-checkpoint artifacts/voices/pl_PL-gosia-medium/duration/duration-student.pt \
--piper-model models/teachers/pl_PL-gosia-medium/pl_PL-gosia-medium.onnx \
--piper-config models/teachers/pl_PL-gosia-medium/pl_PL-gosia-medium.onnx.json \
--decoder-report artifacts/voices/pl_PL-gosia-medium/joint/train-report.json \
--candidate-label joint \
--out-dir artifacts/voices/pl_PL-gosia-medium/feedback \
--rows 64 \
--param-budget 2000000 \
--force-render --force-score \
--device cpu
Changes Made
tools/run_roota_feedback_loop.py
Made --text-set optional at the relevant argument parsing and evaluation sections:
- Lines 39–45
- Lines 174–175
- Lines 202–203
- Line 310
The following steps were performed:
- Fixed the missing
--text-setcontract. - Read the SCOREQ code and discovered that candidate labels come from the report directory name.
- Switched S10 to the S9
jointreport so the candidate becomesjoint. - Raised the parameter budget from
1,500,000to2,000,000. - Deleted and re-downloaded corrupted SCOREQ ONNX cache files.
- Verified that the final gate scores the shipped joint decoder.
- Confirmed that the gate passes with actual SCOREQ outputs.
In detail for Student Traning: Click Me
4. Browser WASM Version
Now we have the complete student model outputs:
artifacts/
└── voices/
└── pl_PL-gosia-medium/
├── acoustic/
├── dashboard/
├── decoder/
├── decoder-cut/
├── decoder-zmix/
├── duration/
├── eval128/
├── feedback/
├── joint/
├── logs/
├── package/
├── parity/
├── signatures/
├── smoke12/
├── train-acoustic/
└── train512-decoder/
Now, we prepare the browser WASM version.
Steps
1. Add the Polish Voice
In:
custom_components/sanotts/const.py
add:
VoiceInfo("pl", "Polish (1.57M)", "pl-PL"),
2. Add Polish eSpeak Data
Add the following directories:
mcu/ports/wasm/espeak-data-multi/lang/zlw/pl
mcu/ports/wasm/espeak-data-multi/lang/pl_dict
These were added by first downloading eSpeak-ng:
git clone --depth 1 https://github.com/espeak-ng/espeak-ng.git /tmp/espeak-ng
Then copying the required Polish language data:
cp -r /tmp/espeak-ng/espeak-ng-data/lang/zlw/pl \
ampixa/sanoTTS/mcu/ports/wasm/espeak-data-multi/lang/zlw/ && \
cp -r /tmp/espeak-ng/espeak-ng-data/lang/pl_dict \
ampixa/sanoTTS/mcu/ports/wasm/espeak-data-multi/lang/
3. Add the Polish Teacher Configuration
Add:
pl_PL-gosia-medium.onnx.json
as the teacher model configuration in:
mcu/ports/wasm/voice-configs/pl_PL-gosia-medium.onnx.json
4. Add the Polish Voice Configuration
In:
pypkg/sanotts/tables/voices.json
add:
"pl": {
"package": "pl-gosia-1p56m",
"language": "pl_PL",
"sample_rate": 22050
}
5. Generate the Polish CP-ID Table
The following was manually added to:
mcu/ports/wasm/cp_id_tables_multi.h
#define SNT_NUM_VOICE_TABS 19
static const snt_voice_tab_t SNT_VOICE_TABS[19] = {
{"pl", "pl", CP_ID_PL, 152}, /* slot 18 */
};
And:
static const cp_id_t CP_ID_PL[152] = {
{0x20,3}, {0x21,4}, {0x22,150}, {0x23,149}, {0x24,2}, {0x27,5},
{0x28,6}, {0x29,7}, {0x2C,8}, {0x2D,9}, {0x2E,10}, {0x30,130},
{0x31,131}, {0x32,132}
};
However, cp_id_tables_multi.h is actually generated by:
tools/gen_cp_id_tables.py
There was an error while generating the CP-ID table for Polish. Therefore, the script was modified so that it could print the Polish table correctly.
The following entry was added to the script:
("pl", REPO / "models/teachers/pl_PL-gosia-medium/pl_PL-gosia-medium.onnx.json"),
There was still an error because the script expected other voices that could not be found.
The generation command is:
python3 tools/gen_cp_id_tables.py
6. Export the Student Model for the Browser
Add the following files under:
web/voices/polish/
dec_f32.bin
front_f32.bin
meta.json
These files are generated using:
tools/export_front_golden.py
tools/export_piperlite_golden.py
Run:
mkdir -p sanoTTS/web/voices/polish && \
python3 tools/export_front_golden.py \
artifacts/voices/pl_PL-gosia-medium/duration/duration-student.pt \
artifacts/voices/pl_PL-gosia-medium/joint/latent-student.pt \
--pack artifacts/voices/pl_PL-gosia-medium/eval128 \
--out web/voices/polish && \
python3 tools/export_piperlite_golden.py \
artifacts/voices/pl_PL-gosia-medium/joint/decoder-student.pt \
--pack artifacts/voices/pl_PL-gosia-medium/eval128 \
--out web/voices/polish && \
mv web/voices/polish/front_weights_f32.bin web/voices/polish/front_f32.bin 2>/dev/null || true && \
mv web/voices/polish/weights_f32.bin web/voices/polish/dec_f32.bin 2>/dev/null || true && \
mv web/voices/polish/golden.json web/voices/polish/meta.json 2>/dev/null || true
The resulting directory is:
web/
└── voices/
└── polish/
├── dec_f32.bin
├── front_f32.bin
└── meta.json
7. Generate the SNT G2P Files
The following files are generated under web/:
web/
├── snt_g2p.data
├── snt_g2p.js
└── snt_g2p.wasm