← All posts

Speech generation from the inside

Post 8 of 8

Same model, new language: fine-tuning Pocket TTS to speak Khmer

Posts in this series · 8

Pocket TTS is a small English text-to-speech (TTS) model from Kyutai. Earlier posts explained how it generates speech and how Mimi, its codec, turns audio into numbers and back. This post asks a different question. You have the English model. How do you make it speak Khmer, in many voices, without designing anything new?

This is my own project, and every number below comes from my runs. Each section follows one step of the work, with a small example first and the details after.

The language, and what “fine-tuning” means

Khmer is the language of Cambodia, spoken by about 17 million people, but with few open speech datasets. Three things about its writing matter for everything that follows:

If Khmer writing is new to you, How Khmer writing looks to a text-to-speech model walks through it letter by letter, byte by byte.

Fine-tuning means starting from a model that is already trained and continuing to train it on new data. Think of a pianist learning a new style: their hands already know how to play, they only need new pieces. The English Pocket TTS already knows how to produce natural speech; it needs to learn how Khmer letters sound and what about 20 new voices sound like.

It was full fine-tuning: every weight of the language model was updated. There was no shortcut such as LoRA (low-rank adaptation, which trains only small add-on matrices). Section 8 shows exactly which parts.

All training ran on one NVIDIA A100 GPU (graphics processor) with 80 GB of memory, one run at a time, so there were no large searches over settings. And there was one native listener: me.

Nine stages from licensed audio to a selected checkpointLicensedaudio13 sourcesabout 855hoursNormalisetext2450,396transcriptsReadnumbers316,736of 18,220verbalisedQualitycontrol4800rows rejectedSplit51% · 1%dev · sealedtestAlign& cut660/60cuts heardcleanTokenizer74,000Khmer piecesFine-tune8316 MweightstrainedPickcheckpoint9200dev sentences,CERData work is seven of the nine stations. The trainer itself is upstream's, unmodified, with a project config.
Figure 1. The recipe on one line. Each station has one number from the project. Seven of the nine stations are about data; the training code itself is upstream’s, unmodified.

Where the speech comes from

A model can only learn voices and words it has heard, and data whose licence forbids your use is useless however good it is. So every source was admitted only after a licence review. All three use Creative Commons licences: CC BY (credit the author) or CC BY-SA (credit the author and share derived work under the same terms).

SourceLicenceHoursSpeakersWhat it is
DDD Khmer ASR Cultural Dataset, by Digital Divide DataCC BY-SA 4.0≈ 84918–20read speech, 16 kHz; about 90k sentences from 2,092 documents
FLEURS-R km_kh, train split (Google)CC BY 4.02.3manyrestored read speech, 24 kHz; final run only
OpenSLR 42, Google Khmer TTSCC BY-SA 4.02.9≈ 17studio read speech; final run only

The first source dominates: 849 of 855 hours. In it, each sentence was read by about 4 different speakers. So a sentence such as “the market opens at seven” might exist as four recordings in four voices. Keep that in mind; it matters for the split in Section 5.

Two limits belong here, not in a footnote. Whether weights trained on CC BY-SA data must themselves be shared under CC BY-SA is unresolved. And none of these speakers agreed to have their voice cloned, which the base model’s terms require. A consented voice is needed before anything is released.

Text that matches the audio

A TTS model learns “these characters sound like this audio”. If the text says one thing and the audio says another, the model learns the mismatch. So every cleaning step obeys one rule: never change what the text says aloud.

Normalisation: change little, flag a lot

I first listed every character used in all 450,396 transcripts, then wrote four rules from that list:

  1. Unicode NFC. The same Khmer letter and vowel sign can be stored as different byte sequences that look identical. NFC (Normalization Form C) picks one standard order. Here it changed nothing.
  2. Remove zero-width spaces (Unicode U+200B). They are invisible: “ខ្ញុំ​ទៅ” and “ខ្ញុំទៅ” look the same on screen, but the first contains one, added by some editors to hint at a word boundary. About 5.5% of rows had them. They are never spoken, so they go.
  3. Collapse repeated spaces.
  4. Flag, never silently rewrite, anything that changes what is spoken. A row containing “15%”, “iPhone” or ”&” is not edited by guesswork; it is flagged.

A flagged row has no spoken form, meaning the text written exactly as it is read aloud, until a rule or a person gives it one. The model trains only on spoken forms.

Numbers: teach them, with a listener checking the rules

“២០២៤” on a page is not a sound. The model would have to guess whether it is “two thousand twenty-four” or “two zero two four”. So digits are written out as words before training, and the same rules run when the model is used, so it always sees the same kind of text.

About 18k recordings contained Khmer digits: 4,551 different sentences with 7,593 numbers. Dropping them would teach the model that numbers don’t exist, so I chose to teach them. A small program, the verbaliser, proposes a reading for each number: years, counts, ordinals (“the 3rd”), times, percentages, money, thousands and simple decimals. Then I listened to 121 sentences next to their proposed readings. Figure 2 shows the three things that can happen.

Three example number readings and the decision for eachWRITTEN TEXTRULE PROPOSESSPEAKER ACTUALLY SAIDDECISIONឆ្នាំ ២០២៤year 2024→ឆ្នាំ ពីរពាន់ម្ភៃបួនyear two-thousand twenty-four→ឆ្នាំ ពីរពាន់ម្ភៃបួនsame as the ruleRule confirmedtrain on the spoken form១៥ នាក់15 people→ដប់ប្រាំ នាក់fifteen people→ដប់ប្រាំមួយ នាក់sixteen peopleRecording droppedthe speaker misread: 13 such១០០%100% (typo)→មួយរយភាគរយone hundred percent→ដប់ភាគរយten percent, as the source meantText correctedkeep the audio, fix the textAfter 121 sentences checked by ear, only confirmed rules run on their own.They agreed 169 / 169 times, and 40 fresh recordings exposed 2 gaps, both fixed.digit recordings that got a spoken form; grey = patterns too ambiguous to read automatically (100,000 and above, "dot + 3 digits")
Figure 2. The rule proposes, the listener decides. The three example sentences are illustrative, with English glosses; the counts below them are the project’s. Only rules confirmed on correct readings are applied automatically.

The confirmed rules agreed with the recordings 169 out of 169 times. A fresh check on 40 new recordings found 2 gaps, both fixed. In the end 16,736 of 18,220 digit recordings received a spoken form. Hard cases such as numbers of 100,000 and more were left without one, so those recordings are not used.

The point is that I checked the rules, not every row. Listening to 121 sentences made 16,736 recordings usable.

Quality control: reject the broken, keep the hard

Some rows are simply broken. Two checks caught them.

The same audio file under two different sentences. One recording cannot say two different things, so at least one transcript is wrong. 768 rows were rejected.

A speaking rate no human could produce. Divide the number of characters in the transcript by the length of the audio in seconds. Across the corpus, the typical (median) value is 12 characters per second. A clip far outside that range almost always means the text and the audio don’t belong together:

The accepted range was set to 4 to 36 characters per second, that is, from a third of the median to three times it. I fixed that range before checking how many rows it would remove, so it could not be adjusted to make the numbers look good. It removed only 32 rows.

Speaking rate of three clips against the accepted range0412243648characters per second of audiomedian 124 = 12 ÷ 336 = 12 × 3kept
Try a clip
Figure 3. The speaking-rate check. The three clips are illustrative; the range and the median are the project’s. Click a clip to see where it lands.

What is never rejected: a sentence that is merely difficult, with rare words or long clusters. Hard sentences are exactly what the model needs to learn from.

Data filters and the final training pool450,396 transcriptsinventoried before any rule was writtenSame audio, different transcriptsone file cannot say two sentences− 768 rowsSpeaking rate outside 4–36 chars/s⅓ to 3× the median of 12 chars/s; fixed before counting− 32 rowsSpeaker deviated from the number textfound while checking number rules− 13 recordingsDigits without a confirmed reading18,220 with digits − 16,736 verbalised− 1,484 recordingsOnly one phrase, no place to cutcannot split into prompt + target− 125.6 hFoundation set: 854.5 h350,470 recordings, all three sourcesFilters overlap and use different units, so the exits do not add up to the gap.
Figure 4. Everything the filters took out. The 1,484 is 18,220 minus 16,736. The last exit is explained in Section 6. The filters overlap and count different units, rows or hours, so they are drawn as separate exits rather than one subtraction.

Splits: keep the exam honest

To know whether the model learned to read Khmer, you test it on sentences it never trained on. That held-out part of the data is the development set, or dev set; a second, untouched part is the test set.

Here is the trap. Each sentence was read by about 4 speakers, and the same sentence can appear in several documents. Say “the market opens at seven” is in document 1 and in document 2. If document 1 goes to training and document 2 to dev, the model is tested on a sentence it has already heard, and the dev score partly measures memory. Figure 5 shows this on a toy example: with “document, at random”, red arcs mark the sentences that leak.

The fix: any documents that share a sentence are merged into one group, 2,059 groups in all, and whole groups go to one side. Then 1% of the hours goes to dev and 1% to test. No sentence appears on both sides.

The dev set has three separate jobs:

The 1,209-sentence test set is sealed. Its SHA-256 hash, a fingerprint that changes if a single byte changes, was recorded, and the set has never been opened.

Splitting documents with and without groupingTOY: 8 DOCUMENTS, EACH SHARES A SENTENCE WITH A NEIGHBOURTRAINDEVREAL: ONE DEV SET, THREE JOBS200 "core": picks checkpoints1,060: compares systems (23 of the 1,260 are listening-only)Dev: 1,260 sentences, 1% of the hours. The 96 items from 2 held-out speakers (0.34 h) are the only unseen voices.🔒 test: 1,209, sealedhash committed, never opened
Split by
Figure 5. Why documents are grouped before splitting. Top: a toy with eight documents. Splitting them at random puts the same sentence on both sides; splitting by group does not. Bottom: how the real dev set is divided between its three jobs.

One recording becomes a prompt and a target

When you use Pocket TTS, you give it a few seconds of someone’s voice, the voice prompt, and some text, and it speaks the text in that voice. To learn this, each training recording teaches itself. Take a 12-second recording: its first few seconds become the prompt, and the rest becomes the target, the audio the model must learn to produce. This is a self-prompted example.

Where to cut: forced alignment

The hard part is the cut. Cut in the middle of a word and the prompt ends in half a syllable, while the target starts with the other half. The cut must fall in a pause, and to find the pauses you need to know when each part of the sentence is spoken.

That is what forced alignment does. Unlike speech recognition, it does not need to discover the words: the transcript is already known. It only finds the timestamps, for example “phrase 1 from 0.25 s to 1.90 s, phrase 2 from 2.20 s to 3.60 s”.

To do it, the aligner uses a speech recogniser trained with CTC (Connectionist Temporal Classification). Such a model looks at the audio in slices of about 20 milliseconds and, for each slice, gives a probability for every letter, plus a special “blank” meaning “no new letter here”. Its output for one word looks like ស ស _ _ ួ ួ _ …: one label per slice, with repeats and blanks. Because every slice gets a label, you can read off when each letter, and so each phrase, starts and ends. For this project:

A recording cut at a pause into a voice prompt and a targetvoice prompttarget: what the model must say0s1s2s3s4s5s6s7s8s9s10s11s12sWHAT THE MODEL SEES, IN MIMI FRAMES (12.5 PER SECOND)Khmer text tokensThe prompt says who is speaking, the text says what to say, and the model learns to produce the target.
Recording
Figure 6. Cutting one recording into prompt and target. The waveform and timings are synthetic; the rules are the project’s. Move the cut between pauses to see which positions are allowed, then switch to a one-phrase recording. Mimi turns each second of audio into 12.5 frames, so a 3.75-second prompt is about 47 frames.

Before training, measure the floor

The model never produces sound waves directly. It produces Mimi’s compressed frames, and the frozen Mimi decoder turns them into audio. So the model can never sound better than a recording that has simply been passed through Mimi and back. It is worth measuring that limit before blaming training.

To measure it, I used CER (character error rate): an automatic speech recogniser (ASR), the “judge”, transcribes the audio, and CER is the fraction of characters it gets wrong. On 100 characters, 12 wrong means 12%. (The evaluation post explains CER and why the judge has its own biases.)

On 44 real clips, CER was 10.4% on the original audio and 14.7% after a trip through Mimi: 4.2 points worse, with a 95% interval of 3.3 to 5.3. Yet I could not hear any difference in 44 of 44 pairs, so the codec was kept. The judge notices Mimi’s small artefacts more than an ear does.

Balancing voices: square-root sampling

A few speakers recorded far more than the others. Draw training examples in proportion to hours, and the model mostly hears those few voices. Draw every speaker equally, and the small ones repeat so often the model memorises their clips. The square root sits in between. For a speaker group gg with hgh_g hours:

P(g)=hg∑khkP(g) = \frac{\sqrt{h_g}}{\sum_k \sqrt{h_k}}

P(g)P(g) is the chance that the next training example comes from group gg. The top is that group’s square-rooted hours; the bottom, ∑k\sum_k, adds the square-rooted hours of every group kk, so all the chances add up to 1.

A toy example with three speakers who recorded 100, 25 and 1 hours. In proportion to hours, they get 79.4%, 19.8% and 0.8% of draws. With square roots, 10, 5 and 1 out of 16, they get 62.5%, 31.25% and 6.25%. The smallest voice goes from under 1% to about 6%, while the largest is still the most heard.

In the final run, on all 854.5 hours, this cut the share of the three largest voices from 65% to 38%. The price was repetition: clips of the smallest speakers were heard up to about 240 times. I did not test the balancing on its own, so I cannot say how much it helped.

Share of training draws per speaker group as the exponent changesSHARE OF TRAINING DRAWS100 h speaker25 h speaker1 h speakerReal foundation set, at α = 0.5: the three largest voices fell from 65% of draws to 38%; the smallest groups were repeated up to about 240 times.
Presets
Figure 7. How much each voice is heard. Toy hours; the real result is in the last line. Slide toward uniform and the small voice is heard more, at the price of hearing each of its clips many times.

A tokenizer for the script

A model cannot read letters directly. A tokenizer first chops the text into pieces, called tokens, and gives each piece a number. In English, “unbelievable” might become “un”, “believ” and “able”. The model then looks up a learned list of numbers, a vector, for each piece. The table of all these vectors is the text embedding.

The English tokenizer has no pieces for Khmer. Given Khmer text, it falls back to raw bytes: each Khmer character becomes 3 meaningless byte tokens (Figure 8, bottom row). So the English tokenizer and its embedding table were both thrown away.

The new tokenizer is a SentencePiece model with 4,000 pieces. Each piece gets an ID from 0 to 3,999, and the new text embedding has exactly one row per ID: 4,000 rows, created fresh and learned during fine-tuning (step 4 of Figure 9 shows the lookup). SentencePiece learns which chunks of text occur most often directly from raw sentences, without needing spaces between words, which Khmer does not have. It learned from 108,242 different training sentences, in their spoken forms, and nothing from dev or test.

It worked cleanly on all 1,237 dev sentences: no unknown pieces, no fallback to bytes. A typical sentence became 27 tokens, about 3.8 Unicode characters per token. In 5.9% of pieces, a piece starts in the middle of a stacked cluster; that was noted and kept.

There is no pronunciation dictionary: the model reads written pieces and learns how they sound from the audio. The one known gap is Latin script, which never appears in the training text. “ipad” becomes four byte tokens the model has never learned. A separate text front-end handles that, and belongs to a later post.

A Khmer word as written, as code points and as bytesAS WRITTENAS UNICODE CODE POINTS, WHAT A KHMER TOKENIZER BUILDS PIECES FROMconsonantvowel signcoeng ្: stacks the next consonant underneathother signAS BYTES, WHAT A TOKENIZER WITH NO KHMER PIECES FALLS BACK TOThe same happens in reverse: Latin never appears in the Khmer training text, so “ipad” becomes <0x69><0x70><0x61><0x64>.
Word
Figure 8. What a Khmer word is made of. Three words from the project’s pronunciation work; the code points and bytes are computed in your browser. A Khmer tokenizer builds its pieces from the middle row. A tokenizer with no Khmer pieces sees only the bottom row: three bytes per character, none with a learned meaning.

What is trained and what is frozen

The idea is to keep what transfers from English and retrain what does not:

In total, 316,015,874 weights were trained, starting from Kyutai’s English checkpoint english_2026-04_24l. Figure 9 follows one training step through all of these parts.

One training step, walked through from batch to updated weightsBATCH (8 OF 64 SHOWN)TEXTខ្ញុំទៅផ្សារText embedding: one row per piece,4,000 rows, created fresh for Khmer.Each row is a vector of learned numbers.AUDIO❄ Mimi encoderfrozen,run once before trainingprompt framestarget framesTRANSFORMER24 layers, from English weightsCAUSAL MASKrow = frame tcoloured = framesit may look atht: the instructionreal frame↑ the real answer comes from herenoiseblend (τ)Flow headits guesstrue way backloss = flow error + 0.1 × EOS error✕ stops hereAdamW · gradient clip 1.0 · learning rate 2 × 10⁻⁴ONE WEIGHT, BEFORE AND AFTER THIS STEPtrained weightEMA copy (saved)smoothed = 0.999 × smoothed + 0.001 × current
Controls
How far into training (illustrative)
Figure 9. One training step. Use Next and Back, drag the slider, or press Play all; each step has its own explanation at the top. Switch between the very first update and after 10,000 updates to see the embedding go from noise to structure and the guesses move closer to the truth. Every setting shown is the project’s; the frames, noise, IDs, embedding values and losses are illustrative.

The training settings, one at a time

One recipe was kept fixed across all runs, so every experiment differs in a single setting. Here is each one in plain terms, with the step of Figure 9 where you can see it:

Picking the checkpoint: CER, not validation loss

Training saves a snapshot of the weights, a checkpoint, every 5,000 steps. Which one should be kept? The usual habit is the one with the lowest validation loss, the training error measured on held-out data. But here that error measures how well the model predicts Mimi frames, which is not the same as whether a listener can understand the words.

So in every run, the checkpoint was chosen by CER on the 200 core dev sentences, under a rule written into the code before training started: take the best one, or a later one if it is within 0.5 points. The judge was chosen before any training too: Meta’s omniASR_CTC_300M_v2, which scored 10.7% CER on 296 real Khmer recordings, the best of four candidates.

Two runs show why this mattered:

Character error rate by training step for two runs10%12%14%16%18%0k10k20k30k40ktraining stepdev-core CER (lower is better)validation loss, foundation run (shape only)13.3714.8314.8617.2518.0711.4911.5311.7012.0112.0212.06100 h baseline854.5 hFOUNDATION CHECKPOINTCool-down test: val loss−0.127 → −0.184, CER +0.34 pt.Better loss, worse speech: rejected.
Pick the checkpoint by
Figure 10. Loss keeps falling, CER does not. CER points are the project’s; the validation-loss curve shows its shape only, since only its direction was recorded per step. Switch the rule to see which checkpoint each would pick.

What the experiments taught about the recipe

Each experiment was registered before it ran: I wrote down the hypothesis, the metric, the decision rule, and the smallest effect worth acting on, usually 1.0 CER point with a 95% interval that does not include zero. A smaller effect counts as “no effect”, even if it is statistically real. Every comparison is made on the same sentences, and the intervals come from bootstrapping: re-sampling the sentences many times to see how much the result could vary.

Effect of each training experiment on CER with 95% intervals−1.0 pt: smallest effect worth acting on-1.5-1-0.50+0.5+1CER change in points (← better · worse →)Coverage-picked 100 h vs random 100 h-0.56 [-0.87, -0.25]Coverage-picked 100 h vs matched 100 h-0.26 [-0.55, +0.05]300 h vs 100 h, same compute-0.39 [-0.71, -0.06]Muon vs AdamW optimiser-0.23 [-0.52, +0.07]Cosine cool-down vs constant LR+0.34 [0.09, +0.62]WHAT CER MISSED IN THE 300 H RUNRare words mispronounced, per 100, judged by the native listener:17 with 100 h5 with 300 h
Figure 11. Five experiments, none past the bar. Every row is a CER change, where negative is better; the 300-hour result is reported as a 0.39-point gain and drawn as −0.39 to match. The green tick on the Muon row is its own 0.5-point bar. Below: the improvement CER barely registered.

What made it work

The trainer was Kyutai’s own, unmodified, with a project configuration file. What made the model work was everything around it: licensed data, text that says exactly what the audio says, a listener checking rules rather than rows, splits that keep shared sentences apart and hold voices out, a tokenizer built for the script, and a checkpoint rule written down before looking at the results.

Two lessons surprised me. Training shorter served new voices better, while validation loss said the opposite. And more real data fixed exactly the mistakes the automatic metric could not see.

The system I use today reaches 10.9% CER on the 1,060 comparison sentences and runs faster than real time on a CPU. The limits are real: one seed per comparison, one listener, only two unseen speakers in dev, and a test set still sealed. How it runs, how it reads mixed Khmer and English text, and how pronunciation was fixed by ear will come in a later post.