mlboydaisuke commited on
Commit
cb3a97c
Β·
verified Β·
1 Parent(s): 881ba59

Upload README.md with huggingface_hub

Browse files
Files changed (1) hide show
  1. README.md +186 -0
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.