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

VoiceLanguageCERVerdict
pl_PL-gosia-mediumpl0.062pass
pl_PL-mc_speech-mediumpl0.066pass
pl_PL-darkman-mediumpl0.070pass

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.jsonl is 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
FileMode AMode 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:

  • --profile defaults to shipped
  • --size defaults to standard

So the command also works identically without specifying them.

Pipeline Stages

StageScriptMain PurposeKey Output
Preflightmake_multilang_text_distill_corpus.pyValidate teacher/model setupPreflight passed
S1build_piper_vits_roota_probe_pack.pyExtract teacher targets from text4 data packs
S2extract_piper_vits_decoder_cut.pyCut decoder from teacher ONNXdecoder-cut/*.onnx
S3train_roota_piper_duration_student.pyTrain duration studentduration/duration-student.pt
S4train_roota_piper_latent_student.pyTrain acoustic/latent studentacoustic/latent-student.pt
S5verify_piper_decoder_torch_parity.pyVerify decoder + export initial weightsparity/piper-decoder-teacher.pt
S6build_roota_piper_vits_decoder_signature_pack.pyCapture decoder activation signaturessignatures/summary.json
S7train_roota_piper_decoder_student.pyTrain decoder on teacher latentsdecoder/decoder-student.pt
S8train_roota_piper_decoder_student.pyHarden decoder with z-mixdecoder-zmix/decoder-student.pt
S9train_roota_joint_z_finetune.pyJointly fine-tune acoustic + decoderjoint/*.pt
S10run_roota_feedback_loop.pyEvaluate final stackfeedback/
S11export_roota_self_contained_package.pyPackage final modelpackage/manifest.json

S1 Data Packs

S1 generates the teacher-supervised data packs used by the later training stages.

PackPurposeKey Content
smoke12/Smoke validation12 teacher-generated rows
eval128/Held-out evaluation128 rows
train-acoustic/Acoustic training10,826 rows
train512-decoder/Decoder training512 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

StageVerify
PreflightNo errors; teacher/eSpeak/config valid
S1a12 rows/audio files
S1b128 held-out rows
S1c10,826 acoustic rows
S1d512 decoder rows
S2Decoder ONNX exists
S3Duration checkpoint; 4,000 steps
S4Acoustic checkpoint; 5,000 steps
S5Teacher parity + initial weights
S66 signatures captured
S7Decoder checkpoint; 2,800 steps
S8Decoder checkpoint; 2,500 steps; mix_prob=0.5
S9Both acoustic + decoder checkpoints
S10Feedback/SCOREQ results + audio
S11Self-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

S1 creates the teacher supervision → S3/S4 learn the acoustic side → S5/S6/S7/S8 build the decoder → S9 synchronizes everything → S10 evaluates it → S11 packages 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

IssueFixResult
Missing MapMade --text-set optional in the script.Stops the script from crashing if a specific text file is not provided.
Wrong Name TagPointed S10 to the S9 joint report directory.The candidate label becomes joint, matching where S9 saved the files.
Weight LimitRaised --param-budget to 2,000,000.Gives the ~1.56M parameter model enough room to pass the size check.
Junk in SystemDeleted 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:

  1. Fixed the missing --text-set contract.
  2. Read the SCOREQ code and discovered that candidate labels come from the report directory name.
  3. Switched S10 to the S9 joint report so the candidate becomes joint.
  4. Raised the parameter budget from 1,500,000 to 2,000,000.
  5. Deleted and re-downloaded corrupted SCOREQ ONNX cache files.
  6. Verified that the final gate scores the shipped joint decoder.
  7. 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

← back