Upload README.md with huggingface_hub
Browse files
README.md
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
license: apache-2.0
|
| 3 |
+
tags:
|
| 4 |
+
- executorch
|
| 5 |
+
- xnnpack
|
| 6 |
+
- pte
|
| 7 |
+
- on-device
|
| 8 |
+
- text-to-speech
|
| 9 |
+
base_model:
|
| 10 |
+
- hexgrad/Kokoro-82M
|
| 11 |
+
---
|
| 12 |
+
# Kokoro-82M β ExecuTorch (text to speech, 54 voices)
|
| 13 |
+
|
| 14 |
+
Phonemes in, a waveform out, in one `.pte` with two methods. StyleTTS2-shaped: a
|
| 15 |
+
12-layer phoneme BERT and a duration predictor decide how long each sound lasts, and
|
| 16 |
+
an iSTFTNet vocoder turns the stretched features into 24 kHz audio. A single
|
| 17 |
+
256-dimensional style vector picks the voice and colours the prosody.
|
| 18 |
+
|
| 19 |
+
```
|
| 20 |
+
predict input_ids (1, N) int64, ref_s (1, 256) fp32, speed (1) fp32
|
| 21 |
+
-> d (1, 640, N), t_en (1, 512, N), duration (N) int64
|
| 22 |
+
vocode d, t_en, aln (1, N, F) fp32, ref_s
|
| 23 |
+
-> waveform (F * 600) fp32 @ 24 kHz
|
| 24 |
+
```
|
| 25 |
+
|
| 26 |
+
- **File**: `kokoro_82m_xnnpack_fp32.pte` β **325.4 MB**, two methods
|
| 27 |
+
- **Source**: [hexgrad/Kokoro-82M](https://huggingface.co/hexgrad/Kokoro-82M) β 81.8M parameters
|
| 28 |
+
- **License**: apache-2.0
|
| 29 |
+
- **Voices**: 54, shipped separately as `voices/*.pt` in the source repo
|
| 30 |
+
|
| 31 |
+
Both axes β phonemes and frames β are **dynamic**, and both are **exact**. Nothing is
|
| 32 |
+
padded, nothing is stretched, and there is no ladder of fixed-size methods. That took
|
| 33 |
+
some doing; see below.
|
| 34 |
+
|
| 35 |
+
## Running it
|
| 36 |
+
|
| 37 |
+
**1. Text to phonemes, outside the graph.** Kokoro's vocabulary is IPA, and getting
|
| 38 |
+
there is [`misaki`](https://github.com/hexgrad/misaki) (or espeak-ng) β a lexicon and a
|
| 39 |
+
G2P model, not arithmetic. Same line this shelf takes with E5's prefix and Whisper's
|
| 40 |
+
mel: the recipe is here, the graph takes what the recipe produces.
|
| 41 |
+
|
| 42 |
+
```python
|
| 43 |
+
from misaki import en
|
| 44 |
+
ps, _ = en.G2P(trf=False, british=False)(text)
|
| 45 |
+
ids = [vocab[c] for c in ps if c in vocab] # config.json's 178-entry vocab
|
| 46 |
+
input_ids = torch.LongTensor([[0, *ids, 0]]) # wrapped in the boundary token
|
| 47 |
+
```
|
| 48 |
+
|
| 49 |
+
**2. Pick the style row by phoneme count.** The voice pack is 510 rows and the row is
|
| 50 |
+
`pack[len(ps) - 1]` β indexed by the length of the phoneme **string**, which is what
|
| 51 |
+
upstream's own pipeline does, and not by the number of ids, which is smaller whenever
|
| 52 |
+
the string holds a character the vocabulary does not have.
|
| 53 |
+
|
| 54 |
+
**3. Run `predict`,** which returns the features and one duration per phoneme.
|
| 55 |
+
|
| 56 |
+
**4. Build the alignment matrix yourself, at exactly `sum(duration)` frames.**
|
| 57 |
+
Upstream builds it with `repeat_interleave`, whose width is the sum of the durations β
|
| 58 |
+
the model's own output deciding the shape of its next input, which no single graph can
|
| 59 |
+
express. That is the only reason this is two methods rather than one. The same matrix
|
| 60 |
+
is comparisons only:
|
| 61 |
+
|
| 62 |
+
```python
|
| 63 |
+
ends = torch.cumsum(duration, 0)
|
| 64 |
+
starts = ends - duration
|
| 65 |
+
frame = torch.arange(int(ends[-1])) # exactly sum(duration)
|
| 66 |
+
aln = ((frame[None, :] >= starts[:, None]) &
|
| 67 |
+
(frame[None, :] < ends[:, None])).float()[None]
|
| 68 |
+
```
|
| 69 |
+
|
| 70 |
+
**Give it exactly `sum(duration)` frames.** Not more β see the next section.
|
| 71 |
+
|
| 72 |
+
**5. Run `vocode`.** Out comes `600 * F` samples at 24 kHz. `speed` above 1 speaks
|
| 73 |
+
faster; it divides the durations before they are rounded.
|
| 74 |
+
|
| 75 |
+
## Do not pad either axis
|
| 76 |
+
|
| 77 |
+
Both axes are dynamic, so there is no window to pad into β but it is worth saying why
|
| 78 |
+
the file is built that way, because the obvious fixed-window design does not work here
|
| 79 |
+
and the damage does not show up in a transcript.
|
| 80 |
+
|
| 81 |
+
| axis | what forbids padding | measured |
|
| 82 |
+
|---|---|---|
|
| 83 |
+
| phonemes | five bidirectional LSTMs β state flows in from the padding | speaking rate moves up to **19%** |
|
| 84 |
+
| frames | a bidirectional LSTM **and** `AdaIN1d`, which is `InstanceNorm` over time | log-mel **0.18β0.86** against a 0.04 noise floor |
|
| 85 |
+
|
| 86 |
+
The frame axis is the surprising one. `AdaIN1d` normalises over **time**, so one extra
|
| 87 |
+
frame changes the statistics the entire signal is divided by. Padding to the next
|
| 88 |
+
16-frame rung, appending 256 frames, and padding out to 1024 all land far outside what
|
| 89 |
+
the model does to itself, and it is not a level change β taking out one global gain
|
| 90 |
+
factor leaves the distance where it was.
|
| 91 |
+
|
| 92 |
+
Padding with spaces rather than zeros roughly halves the damage on the phoneme axis,
|
| 93 |
+
and a recogniser transcribes **every** padded arm correctly. That is exactly why the
|
| 94 |
+
gate here is not a recogniser alone.
|
| 95 |
+
|
| 96 |
+
## The LSTMs are rolled, not unrolled
|
| 97 |
+
|
| 98 |
+
`nn.LSTM` will not export with a dynamic sequence axis: `torch.export` pins it to
|
| 99 |
+
whatever it was traced at. The reason is that `to_edge` **unrolls** the recurrence β
|
| 100 |
+
this model's `predict` graph is 1238 ATen nodes at any length, and
|
| 101 |
+
`1651 + 108 per phoneme` in edge dialect.
|
| 102 |
+
|
| 103 |
+
That makes a ladder of fixed-length methods look like the only option, and then makes
|
| 104 |
+
the ladder impossible. The XNNPACK partitioner is superlinear in node count and cuts an
|
| 105 |
+
unrolled LSTM into hundreds of tiny delegates β 383 partitions at 32 phonemes β so
|
| 106 |
+
lowering one method costs:
|
| 107 |
+
|
| 108 |
+
| phonemes | edge nodes | lowering |
|
| 109 |
+
|---|---|---|
|
| 110 |
+
| 8 | 1651 | 33 s |
|
| 111 |
+
| 16 | 2515 | 63 s |
|
| 112 |
+
| 32 | 4243 | 153 s |
|
| 113 |
+
| 128 | 14611 | ~19 min, extrapolated |
|
| 114 |
+
|
| 115 |
+
A rung per phoneme count from 8 to 128 is upwards of **16 hours**, and the frame axis
|
| 116 |
+
would need its own ladder on top of that.
|
| 117 |
+
|
| 118 |
+
A `scan` higher-order op keeps the loop rolled. ExecuTorch lowers it, the runtime runs
|
| 119 |
+
it, and the sequence axis stays dynamic. On this model's own LSTM shape β 640 in, 256
|
| 120 |
+
hidden each way, 128 steps:
|
| 121 |
+
|
| 122 |
+
| | build | edge nodes | delegates | 128 steps |
|
| 123 |
+
|---|---|---|---|---|
|
| 124 |
+
| `nn.LSTM`, one fixed length | 59.2 s | 3366 | 131 | 5.72 ms |
|
| 125 |
+
| rolled `scan`, any length | **3.1 s** | **49** | 3 | 16.65 ms |
|
| 126 |
+
|
| 127 |
+
Three times the runtime for one LSTM, against a build that finishes and a file that
|
| 128 |
+
takes any length. Kokoro has six of them. The whole file now builds in about two
|
| 129 |
+
minutes.
|
| 130 |
+
|
| 131 |
+
## Verification
|
| 132 |
+
|
| 133 |
+
Five English sentences through `misaki`, each one synthesised by the `.pte` and by the
|
| 134 |
+
unmodified upstream model in eager. Three gates, because no single one works here:
|
| 135 |
+
|
| 136 |
+
**Durations must match exactly** β they are integers, they decide the rhythm, and they
|
| 137 |
+
come out of the half of the model that has no noise in it. **5 of 5 exact.**
|
| 138 |
+
|
| 139 |
+
**Waveform correlation is not usable.** The vocoder's excitation carries Gaussian noise
|
| 140 |
+
and a random initial phase, so the eager model does not reproduce itself: two runs of
|
| 141 |
+
the same input correlate 0.9948. A correlation gate here measures the noise.
|
| 142 |
+
|
| 143 |
+
**Log-mel distance against the model's own floor** is the gate that works. Measure the
|
| 144 |
+
distance between two eager runs, then between eager and the `.pte`, and ask whether the
|
| 145 |
+
second is the first:
|
| 146 |
+
|
| 147 |
+
| | log-mel vs eager | eager's own floor | ratio |
|
| 148 |
+
|---|---|---|---|
|
| 149 |
+
| worst of five | 0.0427 | 0.0412 | **1.04x** |
|
| 150 |
+
| best of five | 0.0442 | 0.0449 | 0.98x |
|
| 151 |
+
|
| 152 |
+
Below and above 1.0 across the five, which is what "indistinguishable from running it
|
| 153 |
+
again" looks like. For scale, one extra frame of padding shows up at 4.4x.
|
| 154 |
+
|
| 155 |
+
**Transcripts**, through Qwen3-ASR: **CER 0.0000** on all five. Synthetic speech read
|
| 156 |
+
by a recogniser is a control, not a benchmark β it says the file still says the words.
|
| 157 |
+
|
| 158 |
+
## Speed
|
| 159 |
+
|
| 160 |
+
3.25 s of audio (50 phonemes, 130 frames) on an M-series laptop, host CPU:
|
| 161 |
+
|
| 162 |
+
| | ms |
|
| 163 |
+
|---|---|
|
| 164 |
+
| `predict` | 56.6 |
|
| 165 |
+
| `vocode` | 443.7 |
|
| 166 |
+
| **total** | **500.3** β 6.5x faster than real time |
|
| 167 |
+
| the same utterance in eager PyTorch | 227.5 |
|
| 168 |
+
|
| 169 |
+
Measured on a machine that was busy, so read these as a floor rather than a number to
|
| 170 |
+
quote. Eager is faster here and that is expected: it reaches Accelerate's LSTM and
|
| 171 |
+
convolution kernels, while about half the graph runs on portable kernels
|
| 172 |
+
(`predict` 49.6% of ops delegated, `vocode` 54.8%). No device numbers yet.
|
| 173 |
+
|
| 174 |
+
## What is not in this file
|
| 175 |
+
|
| 176 |
+
**No fp16 or int8 build.** The int8 decision is measured, not assumed: dynamic int8
|
| 177 |
+
quantises `nn.Linear` only, and Kokoro is **66.9% Conv1d** with just **15.9%** of its
|
| 178 |
+
weights in Linear layers, so it would touch a sixth of the file. Static int8 would
|
| 179 |
+
reach the convolutions, but a vocoder's quality under quantisation has to be measured
|
| 180 |
+
per task rather than declared, and that has not been done here.
|
| 181 |
+
|
| 182 |
+
**The vocoder is partitioned without XNNPACK's `PermuteConfig`.** A permute inside the
|
| 183 |
+
decoder feeds two consumers outside its partition, and the delegate's output list then
|
| 184 |
+
carries that one node twice, which XNNPACK rejects with "Output node ... is already in
|
| 185 |
+
the inputs ... pass through arguments". It is the partition boundary that is wrong, not
|
| 186 |
+
the graph: the same graph lowers the moment permutes are not eligible to be one.
|