arthurblg1802 commited on
Commit
76a9daf
·
verified ·
1 Parent(s): 1a776c8

Upload folder using huggingface_hub

Browse files
.gitattributes CHANGED
@@ -33,3 +33,11 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ samples/sample_0.wav filter=lfs diff=lfs merge=lfs -text
37
+ samples/sample_1.wav filter=lfs diff=lfs merge=lfs -text
38
+ samples/sample_2.wav filter=lfs diff=lfs merge=lfs -text
39
+ samples/sample_3.wav filter=lfs diff=lfs merge=lfs -text
40
+ samples/sample_4.wav filter=lfs diff=lfs merge=lfs -text
41
+ samples/sample_5.wav filter=lfs diff=lfs merge=lfs -text
42
+ samples/sample_6.wav filter=lfs diff=lfs merge=lfs -text
43
+ samples/sample_7.wav filter=lfs diff=lfs merge=lfs -text
LICENSE ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2026 MicroTTS contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
23
+ ---
24
+
25
+ The files under microtts/g2p/g2p_data/ are third-party assets under the
26
+ Apache License 2.0 (the bundled US English pronunciation dictionaries and the
27
+ graphemes-to-phonemes fallback model). See microtts/g2p/g2p_data/NOTICE.md
28
+ for details. The Apache-2.0 license text for those assets is included as
29
+ microtts/g2p/g2p_data/LICENSE.apache-2.0.txt.
README.md ADDED
@@ -0,0 +1,403 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: mit
3
+ library_name: pytorch
4
+ pipeline_tag: text-to-speech
5
+ tags:
6
+ - tts
7
+ - text-to-speech
8
+ - speech
9
+ - speech-synthesis
10
+ - tiny
11
+ - tiny-model
12
+ - low-resource
13
+ - distillation
14
+ - english
15
+ - pytorch
16
+ - cpu
17
+ - real-time
18
+ language: en
19
+ ---
20
+
21
+ # Vocetta2 1M
22
+
23
+ A complete English text-to-speech system in **1,031,754 parameters** that
24
+ scores **SCOREQ 4.04** on the standard 24-sentence diverse held-out set, runs
25
+ at **~90x faster than real time on a CPU** (RTF 0.011), and needs nothing but
26
+ Python and three pip packages to use.
27
+
28
+ | | |
29
+ |---|---|
30
+ | **Params** | 1,031,754 (duration 5,344 + acoustic 438,516 + decoder 587,894) |
31
+ | **Audio** | 24 kHz mono |
32
+ | **Speed** | ~90x real time on CPU (RTF 0.011); no GPU required |
33
+ | **Intelligibility** | WER 0.083 on a 24-sentence diverse held-out set (12/24 exact) |
34
+ | **Naturalness** | SCOREQ 4.04, DNSMOS-OVRL 3.44, DNSMOS-SIG 3.64, UTMOS 3.97 |
35
+ | **License** | MIT (runtime and weights); bundled G2P data is Apache-2.0 |
36
+
37
+ Listen to `samples/` first. Those eight files were rendered by this exact
38
+ checkpoint.
39
+
40
+ This is the largest release in the Vocetta2 family: 147K, 276K, 500K, and now
41
+ 1M. Each step up doubles the budget and distills from a larger teacher TTS
42
+ through the same three-stage design. This release is the one that crossed
43
+ SCOREQ 4.0, which this family's earlier trajectory (1.48 at 147K, 2.04 at
44
+ 276K, 2.70 at 500K) had suggested would land near 3.0 to 3.3 at this size. The
45
+ section "How it reached 4.0" explains what actually made that possible, and
46
+ most of it was procedure, not size.
47
+
48
+ ## How it works
49
+
50
+ Text becomes audio through four stages:
51
+
52
+ ```
53
+ text ──► G2P ──► phoneme ids
54
+ │
55
+ duration.pt │ ids ──────────────► frame count per phoneme
56
+ │
57
+ acoustic.pt │ ids + durations ───► mel spectrogram [100 bands, T frames]
58
+ │
59
+ decoder.pt │ mel + noise ───────► complex spectrum ──► iSTFT ──► audio
60
+ ```
61
+
62
+ **G2P** (`microtts/g2p/`). A dictionary-first English front end: words are
63
+ looked up in bundled pronunciation dictionaries (gold and silver), and words
64
+ the dictionaries miss go through a small bundled neural fallback model. NumPy
65
+ only, no espeak, no torch, no network access. The output is a string of IPA
66
+ phonemes, mapped to a frozen 62-symbol vocabulary (`<bos>` and `<eos>` bracket
67
+ each utterance).
68
+
69
+ **Duration student** (`duration.pt`, 5,344 params). A 3-layer 1D convolutional
70
+ network over the phoneme sequence. It predicts how many mel frames each phoneme
71
+ occupies, using learned position, sequence-length and duration features. The
72
+ shipped checkpoint is the third stage of a short pacing chain: a base run
73
+ followed by two fine-tunes at lower learning rates. Pacing is a real quality
74
+ lever on the full system: at the champion stage the two fine-tunes moved the
75
+ system from 3.886 to 3.940 (+0.02, then +0.03) with nothing else changed.
76
+
77
+ **Acoustic student** (`acoustic.pt`, 438,516 params). Embed phoneme ids,
78
+ refine them with token-context convolutions, expand them to the frame grid by
79
+ repeating each phoneme its predicted number of frames, then run a second stack
80
+ of convolutions over frames and project to 100 mel bands. Width 72 and depth 5
81
+ for this model. Trained against teacher mels with an L1 + spectral-convergence
82
+ objective plus anti-smoothing (normalized-L1, temporal-delta,
83
+ channel-statistics) terms and a hinge PatchGAN critic.
84
+
85
+ **Decoder** (`decoder.pt`, 587,894 params). Mel to waveform. A ConvNeXt1D stack
86
+ (depthwise conv, LayerNorm, two pointwise layers with a GELU between them,
87
+ residual) maps the mel to a complex spectrum of 513 bins, which the iSTFT turns
88
+ into audio. The magnitude head is exponential with bin 0 and the Nyquist bin
89
+ zeroed, and a DC-blocking filter removes the remaining offset. The decoder is
90
+ noise-fed: a 4-channel noise input is projected and added to the mel embedding.
91
+ **At inference, zero noise is the best choice**, measured on every metric.
92
+
93
+ A 100-value `tilt.npy` ships alongside the weights as a per-band mel
94
+ correction. For this checkpoint the shipped vector is all zeros: zero tilt
95
+ measured best for this acoustic, so applying it is a no-op. The file and the
96
+ tool that computes it (`train/compute_tilt.py`) are kept so the convention
97
+ stays explicit and a retrained acoustic has a defined home for its own vector.
98
+
99
+ ## The budget choice
100
+
101
+ | component | params | share |
102
+ |---|---:|---:|
103
+ | duration | 5,344 | 0.5% |
104
+ | acoustic | 438,516 | 42.5% |
105
+ | decoder | 587,894 | 57% |
106
+
107
+ Under a 1M total, the split went mostly to the decoder, which matches the
108
+ family's pattern: decoder capacity dominated every comparison made while
109
+ building these models. The acoustic side earned its share too. Two measured
110
+ levers shaped it:
111
+
112
+ **Depth 5 on the acoustic was the single biggest architecture find in this
113
+ project: about +0.42 gate SCOREQ** over depth 3 at matched width. The value
114
+ came from the reference recipe this family distills from; earlier work on this
115
+ line had inherited depth 3 and never tested it. Depth 7 lost, and raising the
116
+ token-stage depth also lost, so 5 is a measured optimum, not a default.
117
+
118
+ **Ablations settled the shapes.** The decoder depth was saturated at 4 blocks
119
+ (deeper cost 100K parameters for a WER tie and a worse metallic profile),
120
+ h72 was chosen over h80/h96 by full-system comparison, and the deployment-pack
121
+ recipe (below) made the decoder's job well defined.
122
+
123
+ Family line: 147,532 params reaches SCOREQ 1.48, 276,765 reaches 2.04, 499,528
124
+ reaches 2.70, and this release reaches 4.04. The largest single jump in that
125
+ line (500K to 1M: +1.34) came from a stack of measured changes rather than
126
+ from size alone: depth-5 acoustics (about +0.42 gate), full schedules on
127
+ rented compute after years of undersized local ones, deployment-pack decoder
128
+ training, duration pacing, and checkpoint averaging. Size alone would not
129
+ have gotten there.
130
+
131
+ ## How it reached 4.0
132
+
133
+ The line's first full system scored 3.49, and it climbed to 4.04 in two very
134
+ different phases. The first phase was ordinary training work: longer decoder
135
+ schedules (115k to 410k steps, +0.26), an acoustic polish and a
136
+ spectral-convergence adjustment (system 3.75 to 3.89), and duration pacing
137
+ (3.89 to 3.94). The second phase, the final night, is where the last 0.09 came
138
+ from, and it used two ideas that were not training at all.
139
+
140
+ ### The gate: measure the ceiling, not the system
141
+
142
+ Rendering the acoustic student's own mels through the full teacher vocoder and
143
+ scoring that audio (the "gate") measures how much quality the acoustic is
144
+ leaving on the table, independent of the small decoder. For this line the
145
+ system score tracks roughly **0.92 x gate**, and that ratio stayed remarkably
146
+ stable across configs. Gate evaluations take minutes at the 24-sentence set
147
+ while full system evaluations take longer and depend on the decoder, so the
148
+ gate became the fast signal for where to spend effort.
149
+
150
+ Gate history on this model:
151
+
152
+ | stage | gate SCOREQ |
153
+ |---|---:|
154
+ | base acoustic, depth 5 | 4.083 |
155
+ | + polish fine-tune | 4.123 |
156
+ | + sc-weight bite | 4.239 |
157
+ | + averaging with one polish sibling | 4.333 |
158
+ | + five-way blend with three fresh twins | **4.372** |
159
+
160
+ ### Averaging, the free lever
161
+
162
+ The biggest discovery of the final session: same-recipe checkpoints, diverged
163
+ by nothing but training noise and a short continuation, average into a model
164
+ that beats every member. Every averaging move paid, and every training move
165
+ past that point regressed. The final night's ladder, in which the only trained
166
+ artifacts were six short acoustic twins (about 10 GPU-minutes each):
167
+
168
+ ```
169
+ 3.9462 champion decoder + base acoustic
170
+ 3.9528 + 12k-step decoder continuation on a rebuilt pack
171
+ 3.9709 + decoder checkpoint soup (average of two continuations)
172
+ 3.9931 + acoustic soup (gate raised to 4.333)
173
+ 3.9954 + cross-regime decoder soup
174
+ 4.0286 + three fresh acoustic twins in the gate blend (gate 4.372)
175
+ 4.0377 + four-member decoder soup
176
+ 4.0384 + soup of the two best soups (the shipped decoder)
177
+ ```
178
+
179
+ Twelve distinct configurations in this budget score 4.0 or better; the shipped
180
+ one is the best measured. The shipped weights are weight averages all the way
181
+ down (see `train/README.md` for the full composition), which is why the
182
+ `train/average_checkpoints.py` script is part of this release.
183
+
184
+ ### What did not work (all measured, do not retry)
185
+
186
+ - **Retraining the decoder on the new acoustic** (capture re-adaptation):
187
+ 3.9714 against 3.9931 for the un-adapted soup. The decoder transfers to an
188
+ improved acoustic without retraining.
189
+ - **Larger training pools**: 10k rows scored 3.9328, 20k scored 3.9528, and
190
+ 41.8k scored 3.9223 at matched settings. 20k is a genuine sweet spot.
191
+ - **Training longer**: past about 600k total decoder steps the score regresses.
192
+ - **Every single-knob ablation**: adversarial weight, quiet-ceiling weight,
193
+ high-band weight, hint weight, crop length, discriminator learning rate,
194
+ betas, and start-step variants were all run as isolated arms, and all landed
195
+ at or below the incumbent. The recipe values were already at their optimum.
196
+
197
+ ### Honest notes on the numbers
198
+
199
+ - The campaign's SCOREQ for this configuration is 4.0384. The released files
200
+ re-measure at 4.0375 with a locally installed scorer build. The renders are
201
+ bit-identical; the difference is scorer version noise. Both are above 4.0.
202
+ - SCOREQ, DNSMOS and UTMOS are automatic predictors. They were used alongside
203
+ WER and listening at every decision point, and a checkpoint once gamed
204
+ SCOREQ while being unintelligible (WER 1.0), which is why no single metric
205
+ was trusted.
206
+ - 4.0 was reached at this scale. The margin is thin (about 0.04), and the next
207
+ ceiling question (what would a 2M or 4M version of this recipe reach) is
208
+ untested.
209
+
210
+ ## Use it from scratch
211
+
212
+ Download this repository, install the runtime dependencies, and run the
213
+ script below. Two commands do the download; pick whichever tool you have:
214
+
215
+ ```bash
216
+ # either the Hugging Face CLI (install it if you don't have it)
217
+ pip install -U huggingface_hub
218
+ hf download VantoraLabs/Vocetta2-1m --local-dir vocetta2-1m
219
+
220
+ # or git-lfs (install git-lfs first: https://git-lfs.com)
221
+ git lfs install && git clone https://huggingface.co/VantoraLabs/Vocetta2-1m
222
+
223
+ cd vocetta2-1m
224
+ pip install -r requirements.txt # numpy, torch, soundfile
225
+ ```
226
+
227
+ Then, from inside that folder:
228
+
229
+ ```python
230
+ from microtts import MicroTTS
231
+
232
+ tts = MicroTTS.load(".") # reads duration.pt / acoustic.pt / decoder.pt / tilt.npy
233
+ wav = tts.synthesize("Hello world.") # float32 numpy array, 24 kHz
234
+ tts.save("out.wav", wav) # saves RMS-normalized to -26 dBFS
235
+ ```
236
+
237
+ `MicroTTS.load` accepts `device="cpu"` (default) or `"cuda"`. The full
238
+ pipeline loads in well under a second and needs no network access. If you
239
+ already have phoneme ids, call `tts.synthesize_ids(ids)` directly and skip the
240
+ G2P.
241
+
242
+ Two practical notes:
243
+
244
+ - **Loudness.** `synthesize` returns the raw output; its loudness is not
245
+ normalized. `MicroTTS.normalize(wav)` applies RMS normalization (0.05, about
246
+ -26 dBFS), and `MicroTTS.save` does it for you.
247
+ - **Noise.** `synthesize(..., noise_scale=0.0)` is the default and gives the
248
+ best measured quality; nonzero noise costs quality on every metric.
249
+
250
+ Python 3.10+, CPU is enough. Everything in `benchmark/` runs on the same
251
+ machine with the dependencies from `requirements.txt` (the WER script also
252
+ needs `transformers` and `jiwer`, installed on first use in the same
253
+ environment).
254
+
255
+ ## Benchmarks
256
+
257
+ Measured on this exact checkpoint through the shipped runtime. Word errors use
258
+ Whisper small as the judge; naturalness scores use the standard open scorers
259
+ (SCOREQ, DNSMOS, UTMOS) on the raw rendered audio, normalized to RMS 0.05.
260
+
261
+ **Intelligibility (word error rate, lower is better)**
262
+
263
+ | set | WER | sentences exactly right |
264
+ |---|---|---|
265
+ | 24-sentence diverse held-out set | **0.083** | 12 / 24 |
266
+
267
+ **Naturalness (higher is better, 24-sentence diverse set)**
268
+
269
+ | setting | SCOREQ | DNSMOS-OVRL | DNSMOS-SIG | UTMOS |
270
+ |---|---|---|---|---|
271
+ | noise_scale = 0.0, shipped tilt (recommended) | **4.04** | **3.44** | **3.64** | **3.97** |
272
+
273
+ DNSMOS-SIG catches metallic distortion; 3.64 says the output is not buzzy.
274
+ UTMOS 3.97 is the same automatic MOS estimator several other released TTS
275
+ systems report, which makes it the most portable single number here.
276
+
277
+ **Speed** (24 diverse sentences, warm-up excluded, CPU)
278
+
279
+ | device | RTF | real-time factor |
280
+ |---|---|---|
281
+ | CPU | 0.0098 to 0.0121 across runs | 83x to 102x (~90x typical) |
282
+ | GPU (GTX 750, legacy card) | 0.012 | 83x |
283
+
284
+ RTF includes the duration, acoustic and decoder forwards; the G2P adds a few
285
+ milliseconds per sentence on top. A GPU does not meaningfully help this model:
286
+ at this size the per-operation launch overhead dominates the real arithmetic,
287
+ which is why both rows land within noise of each other. Ship it on CPU.
288
+
289
+ **Reproducing the scores.** `benchmark/rtf.py` and `benchmark/wer.py` are
290
+ included and reproduce the tables above (`wer.py` needs `transformers` and
291
+ `jiwer`). Naturalness: `pip install scoreq speechmos` and score each rendered
292
+ wav with the libraries' own defaults, plus UTMOS from
293
+ `torch.hub ("tarepan/SpeechMOS:v1.2.0")` in a separate interpreter (the two
294
+ packages disagree about the module name `speechmos`, so they cannot share a
295
+ process). The 24 sentences are in `benchmark/eval_sentences.txt`.
296
+
297
+ ## How it compares
298
+
299
+ Parameters are inference-time and exclude the grapheme-to-phoneme front end,
300
+ matching the convention of the tables below. This model is 1.03M in that
301
+ count.
302
+
303
+ **Against the small-TTS field, on the same metric suite.** The following rows
304
+ come from the published comparison table of the sanoTTS release
305
+ ([huggingface.co/ampixa/sanoTTS](https://huggingface.co/ampixa/sanoTTS)),
306
+ which scores a diverse 24-sentence set with SCOREQ / UTMOS / DNSMOS-SIG. This
307
+ model was evaluated with the same scorer suite on the same 24 sentences, so
308
+ the columns are directly comparable even though each system renders with its
309
+ own runtime:
310
+
311
+ | System | Params | SCOREQ | UTMOS | DNS-SIG |
312
+ |---|---:|---:|---:|---:|
313
+ | sanoTTS (amy) | 1.46 M | **4.13** | **4.10** | 3.61 |
314
+ | **Vocetta2 1M (this model)** | **1.03 M** | 4.04 | 3.97 | **3.64** |
315
+ | TinyTTS | 1.62 M | 3.94 | 3.65 | 3.62 |
316
+ | Inflect Nano | 4.63 M | 3.81 | 3.65 | 3.58 |
317
+ | Kitten TTS nano | 15 M | 3.02 | 3.58 | 3.43 |
318
+ | sanoTTS (heart-nano) | 0.29 M | 2.29 | 2.45 | 3.35 |
319
+ | Piper (large teacher) | ~15 M | 4.71 | 4.47 | 3.65 |
320
+ | Kokoro (large teacher) | 82 M | 4.89 | 4.52 | 3.69 |
321
+
322
+ Among sub-2M systems in that table this model is second on SCOREQ and UTMOS
323
+ (behind a system 1.4x its size) and first on DNSMOS-SIG. The gap to the large
324
+ teachers (Piper at about 15x the size, Kokoro at about 80x) remains wide and
325
+ this release does not claim to close it.
326
+
327
+ **Against a mid-size classic architecture.** SupraTTS-0.1-Beta
328
+ ([huggingface.co/SupraLabs/SupraTTS-0.1-Beta](https://huggingface.co/SupraLabs/SupraTTS-0.1-Beta))
329
+ is a Glow-TTS + HiFi-GAN system, about 44M parameters across its acoustic
330
+ model and vocoder, trained on LJSpeech. On its own evaluation protocol
331
+ (10 out-of-domain plus 10 held-out LJSpeech sentences, 5 seeds, UTMOS as the
332
+ automatic MOS estimate) it reports UTMOS 3.59 +/- 0.07 and WER 3.64%. This
333
+ model's UTMOS on its own set is 3.97 at about 1/43rd of the parameters. The
334
+ protocols differ (different sets, different seeds, different language
335
+ material), so read that as a directional comparison, not a controlled one. The
336
+ parameter ratio is the robust part.
337
+
338
+ **Within the family.** Each row measured on its own release, same scorer
339
+ suite:
340
+
341
+ | model | params | SCOREQ | WER |
342
+ |---|---:|---:|---:|
343
+ | Vocetta2-147k | 147,532 | 1.48 | 0.105 |
344
+ | Vocetta2-276K | 276,765 | 2.04 | 0.101 |
345
+ | Vocetta2-500k | 499,528 | 2.70 | 0.117 |
346
+ | **Vocetta2 1M (this)** | **1,031,754** | **4.04** | **0.083** |
347
+
348
+ The family's word accuracy was flat between 147K and 500K (0.101 to 0.117)
349
+ while naturalness climbed; this release is the first to improve both at once.
350
+
351
+ ## What is in this folder
352
+
353
+ ```
354
+ duration.pt, acoustic.pt, decoder.pt the weights (~4.0 MB total, fp32)
355
+ tilt.npy per-band mel correction (all zeros for this model)
356
+ model.safetensors same weights, prefixed keys (dur./ac./dec.),
357
+ fp32 (auto-detected by HF Hub so the params
358
+ count shows on the repo card)
359
+ config.json model metadata + architecture sizes; also the
360
+ file that makes download counting work
361
+ microtts/ runtime package (frontend, models, g2p)
362
+ g2p/g2p_data/ dictionaries + fallback model, Apache-2.0
363
+ samples/ eight rendered examples
364
+ train/ the training scripts + the full recipe table
365
+ benchmark/ RTF + WER scripts, eval sentences, results
366
+ README.md, LICENSE, requirements.txt
367
+ ```
368
+
369
+ ## Reproduction on your own machine
370
+
371
+ Two levels, both spelled out in `train/README.md`:
372
+
373
+ - **Inference**: a laptop CPU, Python 3.10+, three pip packages. About 90x
374
+ real time. This is what the "Use it from scratch" section above is.
375
+ - **Full retrain**: the `train/` scripts are teacher-agnostic (you supply a
376
+ text corpus and a teacher TTS that reports phonemes, audio and durations)
377
+ and reproduce every stage: pack build, duration chain, acoustic chain,
378
+ deployment pack, decoder, and the checkpoint averaging that produced the
379
+ final weights. Total GPU time was roughly a GPU-day on an RTX 3090-class
380
+ card, decoder stages dominating. The recipe table lists the exact commands,
381
+ steps, batch sizes and learning rates per stage.
382
+
383
+ ## Limits, stated plainly
384
+
385
+ - English only. The G2P dictionaries are US English.
386
+ - One voice. This is a single-voice model; there is no speaker conditioning.
387
+ - Utterances cap at ~207 phoneme tokens and 2400 mel frames (~25 s).
388
+ - Free-form conversational text is harder than templated text. On the diverse
389
+ set above, WER is 0.083 and 12 of 24 sentences come out perfect; expect a
390
+ few word errors on unusual names and rare words.
391
+ - No text normalization beyond the front end's number handling. Unusual
392
+ punctuation or markup should be stripped before synthesis.
393
+ - The bundled G2P fallback (used only for words missing from the dictionaries)
394
+ is shared family infrastructure and is not counted in the 1,031,754, same
395
+ convention as the comparison tables above.
396
+ - The 4.0 margin is thin and the number comes from automatic predictors. Listen
397
+ before trusting any single figure, including these.
398
+
399
+ ## Credits
400
+
401
+ Runtime, weights and training scripts: MIT (this release). The bundled
402
+ grapheme-to-phoneme dictionaries and the fallback model are Apache-2.0; see
403
+ `microtts/g2p/g2p_data/NOTICE.md`.
acoustic.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:3ae454be917f0631e0f88c5e2e33730d55128210e8747754a68cffd4d835f629
3
+ size 1765923
benchmark/eval_sentences.txt ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ From those willing to join in establishing this hoped-for period of peace,
2
+ the fatal consequences whereof might be prevented if the justices of the peace were duly authorized
3
+ Hill had both feet on the car and was climbing aboard to assist President and Mrs. Kennedy.
4
+ yet he could not overcome the strange fascination it had for him, and remained by the side of the corpse till the stretcher came.
5
+ Roger D. Craig, a deputy sheriff of Dallas County,
6
+ Even the Caslon type when enlarged shows great shortcomings in this respect:
7
+ that he could only testify with certainty that the print was less than three days old.
8
+ who came from his room ready dressed, a suspicious circumstance, as he was always late in the morning.
9
+ and improve the morals of the prisoners, and shall insure the proper measure of punishment to convicted offenders.
10
+ On the seventh July, eighteen thirty-seven,
11
+ The first physician to see the President at Parkland Hospital was Dr. Charles J. Carrico, a resident in general surgery.
12
+ Mr. Wakefield winds up his graphic but somewhat sensational account by describing another religious service, which may appropriately be inserted here.
13
+ At last, in the twentieth month,
14
+ seeing that since the establishment of the Central Criminal Court, Newgate received prisoners for trial from several counties,
15
+ reached the same conclusion as Latona that the prints found on the cartons were those of Lee Harvey Oswald.
16
+ This is the approximate time he entered the roominghouse, according to Earlene Roberts, the housekeeper there.
17
+ This plan of mine is no attack on the Court;
18
+ is closely reproduced in the life-history of existing deer. Or, in other words,
19
+ he was reported to have fallen away to a shadow.
20
+ long narrow rooms -- one thirty-six feet, six twenty-three feet, and the eighth eighteen,
21
+ during the morning of November twenty-two prior to the motorcade.
22
+ together with a great increase in the payrolls, there has come a substantial rise in the total of industrial profits
23
+ contracted with sheriffs and conveners to work by the job.
24
+ The inadequacy of the jail was noticed and reported upon again and again by the grand juries of the city of London,
benchmark/results.json ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "model": "Vocetta2-1M",
3
+ "params": 1031754,
4
+ "params_breakdown": {
5
+ "duration": 5344,
6
+ "acoustic": 438516,
7
+ "decoder": 587894
8
+ },
9
+ "sample_rate": 24000,
10
+ "wer": {
11
+ "diverse24": {
12
+ "wer": 0.0826,
13
+ "perfect": 12,
14
+ "n": 24
15
+ }
16
+ },
17
+ "naturalness_diverse24": {
18
+ "scoreq": 4.0375,
19
+ "scoreq_source_evaluation": 4.0384,
20
+ "dnsmos_ovrl": 3.4438,
21
+ "dnsmos_sig": 3.6430,
22
+ "utmos": 3.9651,
23
+ "noise_scale": 0.0,
24
+ "tilt": "zeros (the shipped vector; zero tilt measured best for this acoustic)"
25
+ },
26
+ "speed": {
27
+ "cpu_desktop": {
28
+ "rtf": 0.0108,
29
+ "realtime_factor": 93,
30
+ "note": "benchmark/rtf.py runs ranged 0.0098 to 0.0121 (102x to 83x); typical ~0.011"
31
+ },
32
+ "gpu_gtx750_legacy": {
33
+ "rtf": 0.0121,
34
+ "realtime_factor": 83,
35
+ "note": "CPU is competitive at this size; per-op launch overhead dominates on small GPUs"
36
+ }
37
+ },
38
+ "notes": [
39
+ "Params and WER re-measured on this exact release folder; WER reproduced bit-exact against the source evaluation (0.0826, 12/24 perfect).",
40
+ "Naturalness re-measured on this release's renders (24 sentences, RMS-normalized to 0.05, 24 kHz). Two SCOREQ measurement runs are listed: 4.0384 on the source instance, 4.0375 on the released files; the difference is scorer build noise.",
41
+ "WER uses Whisper small as the judge on benchmark/eval_sentences.txt.",
42
+ "Naturalness uses the standard open scorers (SCOREQ, DNSMOS, UTMOS) on the raw renders."
43
+ ]
44
+ }
benchmark/rtf.py ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Measure real-time factor (RTF) for the shipped model.
2
+
3
+ python benchmark/rtf.py --model . [--device cpu]
4
+
5
+ RTF = compute seconds / audio seconds. Lower is better; 0.01 means 100x faster
6
+ than real time. Warm-up renders are excluded. The measurement includes the
7
+ tilt correction, since that is part of the shipped inference path.
8
+ """
9
+ import argparse
10
+ import json
11
+ import os
12
+ import sys
13
+ import time
14
+
15
+ import numpy as np
16
+
17
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
18
+
19
+
20
+ def main():
21
+ ap = argparse.ArgumentParser()
22
+ ap.add_argument("--model", default=".")
23
+ ap.add_argument("--device", default="cpu")
24
+ ap.add_argument("--sentences", default=os.path.join(os.path.dirname(__file__), "eval_sentences.txt"))
25
+ args = ap.parse_args()
26
+
27
+ from microtts import MicroTTS
28
+
29
+ texts = [l.strip() for l in open(args.sentences, encoding="utf-8") if l.strip()]
30
+ tts = MicroTTS.load(args.model, device=args.device)
31
+
32
+ # warm-up
33
+ for s in texts[:2]:
34
+ tts.synthesize(s)
35
+
36
+ total_audio = 0.0
37
+ total_time = 0.0
38
+ for s in texts:
39
+ t0 = time.time()
40
+ wav = tts.synthesize(s)
41
+ total_time += time.time() - t0
42
+ total_audio += len(wav) / 24000
43
+
44
+ rtf = total_time / total_audio
45
+ print(f"device {args.device}: {total_audio:.1f}s audio in {total_time:.2f}s "
46
+ f"-> RTF {rtf:.4f} ({1/rtf:.0f}x realtime)")
47
+ print(json.dumps({"device": args.device, "rtf": round(rtf, 4),
48
+ "realtime_factor": round(1 / rtf, 1)}))
49
+
50
+
51
+ if __name__ == "__main__":
52
+ main()
benchmark/scores_release.json ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "vocetta2-1m": {
3
+ "n": 24,
4
+ "scoreq": 4.0375,
5
+ "dnsmos_ovrl": 3.4438,
6
+ "dnsmos_sig": 3.6430,
7
+ "utmos": 3.9651
8
+ }
9
+ }
benchmark/wer.py ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Measure word error rate: render sentences, transcribe with Whisper small.
2
+
3
+ python benchmark/wer.py --model . --sentences benchmark/eval_sentences.txt
4
+
5
+ Requires `transformers` and (for the first run) network access to fetch the
6
+ Whisper checkpoint. WER is computed against the input text after lower-casing
7
+ and stripping punctuation. ASR runs on CUDA when available, else CPU.
8
+ """
9
+ import argparse
10
+ import os
11
+ import re
12
+ import sys
13
+
14
+ import numpy as np
15
+
16
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
17
+
18
+
19
+ def norm(s):
20
+ return re.sub(r"[^a-z0-9 ]", "", s.lower()).strip()
21
+
22
+
23
+ def main():
24
+ ap = argparse.ArgumentParser()
25
+ ap.add_argument("--model", default=".")
26
+ ap.add_argument("--device", default="cpu")
27
+ ap.add_argument("--sentences", default=os.path.join(os.path.dirname(__file__), "eval_sentences.txt"))
28
+ args = ap.parse_args()
29
+
30
+ from microtts import MicroTTS
31
+ import soundfile as sf
32
+ import torch
33
+ from transformers import pipeline
34
+ import jiwer
35
+
36
+ texts = [l.strip() for l in open(args.sentences, encoding="utf-8") if l.strip()]
37
+ tts = MicroTTS.load(args.model, device=args.device)
38
+ asr = pipeline("automatic-speech-recognition", model="openai/whisper-small",
39
+ device=0 if torch.cuda.is_available() else -1)
40
+
41
+ errs = []
42
+ for i, s in enumerate(texts):
43
+ wav = tts.synthesize(s)
44
+ sf.write("_wer_tmp.wav", MicroTTS.normalize(wav), 24000)
45
+ hyp = asr("_wer_tmp.wav")["text"].strip()
46
+ errs.append(jiwer.wer(norm(s), norm(hyp)))
47
+ print(f"[{i:3d}] WER {errs[-1]:.4f} | {s[:60]!r}", flush=True)
48
+ try:
49
+ os.remove("_wer_tmp.wav")
50
+ except OSError:
51
+ pass
52
+
53
+ print(f"\nmean WER {np.mean(errs):.4f} | perfect {sum(1 for e in errs if e == 0)}/{len(errs)}")
54
+
55
+
56
+ if __name__ == "__main__":
57
+ main()
config.json ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "model_type": "vocetta2",
3
+ "sample_rate": 24000,
4
+ "params": 1031754,
5
+ "architecture": [
6
+ "DurationStudent",
7
+ "AcousticStudent",
8
+ "DecoderStudent"
9
+ ],
10
+ "architecture_dims": {
11
+ "duration_hidden": 12,
12
+ "duration_depth": 3,
13
+ "acoustic_hidden": 72,
14
+ "acoustic_depth": 5,
15
+ "acoustic_token_depth": 3,
16
+ "decoder_dim": 130,
17
+ "decoder_pw": 337,
18
+ "decoder_blocks": 4,
19
+ "noise_channels": 4,
20
+ "n_mels": 100
21
+ },
22
+ "library_name": "pytorch"
23
+ }
decoder.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:b27e12c433c6bab9b3e21715c489bcc72232477e3539ce2325e7306fd26820ea
3
+ size 2364164
duration.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:a932662ae68f8a35be0b1cd7ef13348cb0f5cc6b3d1f5eb3aea20f31602b979b
3
+ size 27242
microtts/__init__.py ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Vocetta2 1M — a 1,031,754-parameter English text-to-speech system.
2
+
3
+ from microtts import MicroTTS
4
+ tts = MicroTTS.load(".") # dir with duration.pt / acoustic.pt / decoder.pt
5
+ wav = tts.synthesize("Hello world.")
6
+ # wav: float32 numpy array, 24 kHz mono
7
+
8
+ Three tiny networks run in sequence:
9
+
10
+ text -> english G2P -> phoneme ids
11
+ duration.pt (5,344 params) ids -> per-phoneme frame counts
12
+ acoustic.pt (438,516 params) ids + durations -> mel [100, T]
13
+ decoder.pt (587,894 params) mel + noise -> waveform
14
+
15
+ The grapheme-to-phoneme front end is numpy-only; the three students need
16
+ PyTorch (they are small, CPU inference is fine). Architecture sizes come from
17
+ the shipped config.json, so this runtime also loads the smaller siblings in
18
+ the family without edits.
19
+ """
20
+
21
+ from .synthesize import MicroTTS
22
+
23
+ __all__ = ["MicroTTS"]
24
+ __version__ = "1.0.0"
microtts/frontend.py ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The frozen 62-symbol vocabulary and the phoneme-string -> id helper.
2
+
3
+ The vocabulary maps IPA symbols (as produced by the G2P in `g2p/`) to the ids
4
+ the three student networks were trained on. `<bos>` and `<eos>` bracket each
5
+ sequence; `<pad>` is reserved.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+ DEFAULT_VOCABULARY: dict[str, int] = {
13
+ "<pad>": 0, "<bos>": 1, "<eos>": 2,
14
+ " ": 3, "!": 4, '"': 5, "(": 6, ")": 7, ",": 8, ".": 9, ":": 10, ";": 11,
15
+ "?": 12, "A": 13, "I": 14, "O": 15, "T": 16, "W": 17, "Y": 18, "b": 19,
16
+ "d": 20, "f": 21, "h": 22, "i": 23, "j": 24, "k": 25, "l": 26, "m": 27,
17
+ "n": 28, "p": 29, "s": 30, "t": 31, "u": 32, "v": 33, "w": 34, "z": 35,
18
+ "æ": 36, "ð": 37, "ŋ": 38, "ɐ": 39, "ɑ": 40,
19
+ "ɔ": 41, "ə": 42, "ɛ": 43, "ɜ": 44, "ɡ": 45,
20
+ "ɪ": 46, "ɹ": 47, "ʃ": 48, "ʊ": 49, "ʌ": 50,
21
+ "ʒ": 51, "ʤ": 52, "ʧ": 53, "ˈ": 54, "ˌ": 55,
22
+ "θ": 56, "ᵊ": 57, "ᵻ": 58, "—": 59, "“": 60,
23
+ "”": 61,
24
+ }
25
+
26
+ SPECIAL_IDS = {"<pad>": 0, "<bos>": 1, "<eos>": 2}
27
+ DEFAULT_MAX_TOKENS = 207 # the sequence length the students were trained on
28
+
29
+
30
+ class FrontendError(RuntimeError):
31
+ """`kind` separates an expected rejection (empty / too_long) from a bug."""
32
+
33
+ def __init__(self, kind: str, message: str) -> None:
34
+ super().__init__(message)
35
+ self.kind = kind
36
+
37
+
38
+ def phonemes_to_token_ids(phonemes: str, vocabulary: dict[str, int] | None = None,
39
+ max_tokens: int = DEFAULT_MAX_TOKENS) -> tuple[list[int], str]:
40
+ """IPA string -> (ids, dropped). Unknown symbols are dropped, not fatal."""
41
+ vocab = vocabulary or DEFAULT_VOCABULARY
42
+ if max_tokens < 2:
43
+ raise FrontendError("config", "max_tokens must allow BOS and EOS")
44
+ kept: list[str] = []
45
+ dropped: list[str] = []
46
+ for ch in phonemes:
47
+ if ch in vocab:
48
+ if ch not in SPECIAL_IDS:
49
+ kept.append(ch)
50
+ else:
51
+ dropped.append(ch)
52
+ if not kept:
53
+ raise FrontendError("empty", "phonemization produced no symbols in the packaged vocabulary")
54
+ ids = [SPECIAL_IDS["<bos>"], *(int(vocab[c]) for c in kept), SPECIAL_IDS["<eos>"]]
55
+ if len(ids) > max_tokens:
56
+ raise FrontendError(
57
+ "too_long",
58
+ f"phoneme sequence has {len(ids)} tokens including BOS/EOS; maximum is {max_tokens}",
59
+ )
60
+ return ids, "".join(dropped)
61
+
62
+
63
+ def vocabulary_for(meta: dict[str, Any]) -> dict[str, int]: # pragma: no cover
64
+ """Compatibility shim: the packaged vocabulary is the only one this model uses."""
65
+ expected = int(meta.get("vocab_size", len(DEFAULT_VOCABULARY)))
66
+ if expected != len(DEFAULT_VOCABULARY):
67
+ raise FrontendError(
68
+ "frontend",
69
+ f"package declares vocab_size {expected} but the built-in table has "
70
+ f"{len(DEFAULT_VOCABULARY)} symbols",
71
+ )
72
+ return dict(DEFAULT_VOCABULARY)
microtts/g2p/__init__.py ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """English grapheme-to-phoneme for MicroTTS.
2
+
3
+ from microtts.g2p import phonemize
4
+ ids, dropped = phonemize("Hello world")
5
+
6
+ Needs numpy only: no espeak-ng, no torch, no network.
7
+ """
8
+
9
+ from .english import EnglishG2P, phonemize, phonemize_to_string, shared_g2p
10
+
11
+ __all__ = ["phonemize", "phonemize_to_string", "shared_g2p", "EnglishG2P"]
microtts/g2p/english.py ADDED
@@ -0,0 +1,1394 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """English text -> phoneme ids.
2
+
3
+ The front end is dictionary-first: words are looked up in the bundled US gold
4
+ and silver pronunciation dictionaries, and words the dictionaries do not cover
5
+ are phonemized by a small bundled neural fallback model. Inference needs no
6
+ espeak-ng and no torch: numpy only, like the rest of the runtime.
7
+
8
+ text -> tokenise + tag -> dictionary lookup
9
+ -> neural fallback for out-of-vocabulary words
10
+ -> the 62-symbol tokenisation the model vocabulary defines
11
+
12
+ The bundled data files are third-party assets redistributed under the Apache
13
+ License 2.0; see g2p_data/NOTICE.md.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import re
20
+ import unicodedata
21
+ from dataclasses import dataclass, replace
22
+ from pathlib import Path
23
+ from typing import Any, Callable
24
+
25
+ from ..frontend import (
26
+ DEFAULT_MAX_TOKENS,
27
+ FrontendError,
28
+ phonemes_to_token_ids,
29
+ )
30
+ from .oov import OOVModelError, OOVPhonemizer, shared_phonemizer
31
+
32
+ DATA_DIR = Path(__file__).resolve().parent / "g2p_data"
33
+ GOLD_PATH = DATA_DIR / "us_gold.json"
34
+ SILVER_PATH = DATA_DIR / "us_silver.json"
35
+
36
+ # --------------------------------------------------------------------------
37
+ # Constants ported verbatim from the reference lexicon/en.py . Names are kept so
38
+ # the two files can be diffed line by line when the reference lexicon moves.
39
+ # --------------------------------------------------------------------------
40
+
41
+ DIPHTHONGS = frozenset("AIOQWYʤʧ")
42
+ SUBTOKEN_JUNKS = frozenset("',-._‘’/")
43
+ PUNCTS = frozenset(';:,.!?—…"“”')
44
+ NON_QUOTE_PUNCTS = frozenset(p for p in PUNCTS if p not in '"“”')
45
+
46
+ PUNCT_TAGS = frozenset([".", ",", "-LRB-", "-RRB-", "``", '""', "''", ":", "$", "#", "NFP"])
47
+ PUNCT_TAG_PHONEMES = {"-LRB-": "(", "-RRB-": ")", "``": chr(8220), '""': chr(8221),
48
+ "''": chr(8221)}
49
+
50
+ LEXICON_ORDS = frozenset([39, 45, *range(65, 91), *range(97, 123)])
51
+ CONSONANTS = frozenset("bdfhjklmnpstvwzðŋɡɹɾʃʒʤʧθ")
52
+ US_TAUS = frozenset("AIOWYiuæɑəɛɪɹʊʌ")
53
+
54
+ CURRENCIES = {"$": ("dollar", "cent"), "£": ("pound", "pence"), "€": ("euro", "cent")}
55
+ ORDINALS = frozenset(["st", "nd", "rd", "th"])
56
+ ADD_SYMBOLS = {".": "dot", "/": "slash"}
57
+ SYMBOLS = {"%": "percent", "&": "and", "+": "plus", "@": "at"}
58
+
59
+ US_VOCAB = frozenset("AIOWYbdfhijklmnpstuvwzæðŋɑɔəɛɜɡɪɹɾʃʊʌʒʤʧˈˌθᵊᵻʔ")
60
+
61
+ STRESSES = "ˌˈ"
62
+ SECONDARY_STRESS = STRESSES[0]
63
+ PRIMARY_STRESS = STRESSES[1]
64
+ VOWELS = frozenset("AIOQWYaiuæɑɒɔəɛɜɪʊʌᵻ")
65
+
66
+ # the chunk cap KModel enforces, and the punctuation
67
+ # classes en_tokenize falls back through when it has to split a long input.
68
+ MAX_PS_CHARS = 510
69
+ WATERFALL = ["!.?…", ":;", ",—"]
70
+ WATERFALL_BUMPS = [")", "”"]
71
+
72
+
73
+ # --------------------------------------------------------------------------
74
+ # Token
75
+ # --------------------------------------------------------------------------
76
+
77
+
78
+ @dataclass
79
+ class Token:
80
+ """The fields of the reference lexicon's `MToken` that the English pipeline actually reads.
81
+
82
+ the reference lexicon stores the second group on an `addict.Dict` under `_`; flattening
83
+ them here drops a dependency and changes nothing about the algorithm.
84
+ """
85
+
86
+ text: str
87
+ tag: str
88
+ whitespace: str
89
+ phonemes: str | None = None
90
+ is_head: bool = True
91
+ alias: str | None = None
92
+ stress: float | None = None
93
+ currency: str | None = None
94
+ num_flags: str = ""
95
+ prespace: bool = False
96
+ rating: int | None = None
97
+
98
+
99
+ def stress_weight(ps: str | None) -> int:
100
+ """stress_weight."""
101
+ return sum(2 if c in DIPHTHONGS else 1 for c in ps) if ps else 0
102
+
103
+
104
+ def merge_tokens(tokens: list[Token], unk: str | None = None) -> Token:
105
+ """merge_tokens."""
106
+ if not tokens:
107
+ raise ValueError("merge_tokens needs at least one token")
108
+ stresses = {tk.stress for tk in tokens if tk.stress is not None}
109
+ currencies = {tk.currency for tk in tokens if tk.currency is not None}
110
+ ratings = {tk.rating for tk in tokens}
111
+ if unk is None:
112
+ phonemes = None
113
+ else:
114
+ phonemes = ""
115
+ for tk in tokens:
116
+ if tk.prespace and phonemes and not phonemes[-1].isspace() and tk.phonemes:
117
+ phonemes += " "
118
+ phonemes += unk if tk.phonemes is None else tk.phonemes
119
+ return Token(
120
+ text="".join(tk.text + tk.whitespace for tk in tokens[:-1]) + tokens[-1].text,
121
+ tag=max(tokens, key=lambda tk: sum(1 if c == c.lower() else 2 for c in tk.text)).tag,
122
+ whitespace=tokens[-1].whitespace,
123
+ phonemes=phonemes,
124
+ is_head=tokens[0].is_head,
125
+ alias=None,
126
+ stress=next(iter(stresses)) if len(stresses) == 1 else None,
127
+ currency=max(currencies) if currencies else None,
128
+ num_flags="".join(sorted({c for tk in tokens for c in tk.num_flags})),
129
+ prespace=tokens[0].prespace,
130
+ rating=None if None in ratings else min(r for r in ratings if r is not None),
131
+ )
132
+
133
+
134
+ def apply_stress(ps: str | None, stress: float | None) -> str | None:
135
+ """apply_stress."""
136
+
137
+ def restress(text: str) -> str:
138
+ indexed: list[tuple[float, str]] = list(enumerate(text))
139
+ moved = {
140
+ i: next(j for j, v in indexed[int(i):] if v in VOWELS)
141
+ for i, p in indexed if p in STRESSES
142
+ }
143
+ for i, j in moved.items():
144
+ indexed[int(i)] = (j - 0.5, indexed[int(i)][1])
145
+ return "".join(p for _, p in sorted(indexed, key=lambda pair: pair[0]))
146
+
147
+ if ps is None or stress is None:
148
+ return ps
149
+ if stress < -1:
150
+ return ps.replace(PRIMARY_STRESS, "").replace(SECONDARY_STRESS, "")
151
+ if stress == -1 or (stress in (0, -0.5) and PRIMARY_STRESS in ps):
152
+ return ps.replace(SECONDARY_STRESS, "").replace(PRIMARY_STRESS, SECONDARY_STRESS)
153
+ if stress in (0, 0.5, 1) and all(s not in ps for s in STRESSES):
154
+ if all(v not in ps for v in VOWELS):
155
+ return ps
156
+ return restress(SECONDARY_STRESS + ps)
157
+ if stress >= 1 and PRIMARY_STRESS not in ps and SECONDARY_STRESS in ps:
158
+ return ps.replace(SECONDARY_STRESS, PRIMARY_STRESS)
159
+ if stress > 1 and all(s not in ps for s in STRESSES):
160
+ if all(v not in ps for v in VOWELS):
161
+ return ps
162
+ return restress(PRIMARY_STRESS + ps)
163
+ return ps
164
+
165
+
166
+ def is_digit(text: str) -> bool:
167
+ """is_digit."""
168
+ return bool(re.match(r"^[0-9]+$", text))
169
+
170
+
171
+ # --------------------------------------------------------------------------
172
+ # Sub-tokeniser
173
+ # --------------------------------------------------------------------------
174
+
175
+ # the reference lexicon/en.py builds this with the `regex` module so it can use \p{L}, \p{Lu}
176
+ # and \p{Ll}. `re` has no Unicode property classes, so: \p{L} becomes
177
+ # [^\W\d_], which is exactly "word character that is neither digit nor
178
+ # underscore", i.e. any Unicode letter; the two case-sensitive alternatives
179
+ # become ASCII ranges. That narrowing only affects CamelCase splitting of
180
+ # non-ASCII words, and a non-ASCII word cannot be in the lexicon anyway
181
+ # (LEXICON_ORDS is ASCII), so it goes to the neural fallback either way.
182
+ _LETTER = r"[^\W\d_]"
183
+ _QUOTES = "'‘’"
184
+ _SUBTOKEN_RE = re.compile(
185
+ rf"^[{_QUOTES}]+"
186
+ rf"|[A-Z](?=[A-Z][a-z])"
187
+ rf"|(?:^-)?(?:\d?[,.]?\d)+"
188
+ rf"|[-_]+"
189
+ rf"|[{_QUOTES}]{{2,}}"
190
+ rf"|{_LETTER}*?(?:[{_QUOTES}]{_LETTER})*?[a-z](?=[A-Z])"
191
+ rf"|{_LETTER}+(?:[{_QUOTES}]{_LETTER})*"
192
+ rf"|[^\w{_QUOTES}-]"
193
+ rf"|[{_QUOTES}]+$"
194
+ )
195
+
196
+
197
+ def subtokenize(word: str) -> list[str]:
198
+ """subtokenize (the SUBTOKEN_REGEX findall)."""
199
+ return _SUBTOKEN_RE.findall(word)
200
+
201
+
202
+ # --------------------------------------------------------------------------
203
+ # Numbers: the English subset of the number library that the reference lexicon asks for
204
+ # --------------------------------------------------------------------------
205
+
206
+ _ONES = ("zero", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine",
207
+ "ten", "eleven", "twelve", "thirteen", "fourteen", "fifteen", "sixteen",
208
+ "seventeen", "eighteen", "nineteen")
209
+ _TENS = ("", "", "twenty", "thirty", "forty", "fifty", "sixty", "seventy", "eighty", "ninety")
210
+ _SCALES = ("", "thousand", "million", "billion", "trillion", "quadrillion")
211
+
212
+ _ORDINAL_WORDS = {"one": "first", "two": "second", "three": "third", "five": "fifth",
213
+ "eight": "eighth", "nine": "ninth", "twelve": "twelfth"}
214
+
215
+
216
+ class NumberError(ValueError):
217
+ """A number this reimplementation cannot spell; never silently ignored."""
218
+
219
+
220
+ def _under_hundred(value: int) -> str:
221
+ if value < 20:
222
+ return _ONES[value]
223
+ tens, ones = divmod(value, 10)
224
+ return _TENS[tens] if ones == 0 else f"{_TENS[tens]}-{_ONES[ones]}"
225
+
226
+
227
+ def _under_thousand(value: int) -> str:
228
+ hundreds, rest = divmod(value, 100)
229
+ if hundreds == 0:
230
+ return _under_hundred(rest)
231
+ head = f"{_ONES[hundreds]} hundred"
232
+ return head if rest == 0 else f"{head} and {_under_hundred(rest)}"
233
+
234
+
235
+ def cardinal_words(value: int) -> str:
236
+ """the number library(value) for English integers, including its comma/'and' rules.
237
+
238
+ Groups of three are joined with ", ", except that a final group below one
239
+ hundred is joined with " and " -- which is why the number library gives "one
240
+ thousand, one hundred" but "one thousand and one".
241
+ """
242
+ if value < 0:
243
+ return f"minus {cardinal_words(-value)}"
244
+ if value < 1000:
245
+ return _under_thousand(value)
246
+ groups: list[int] = []
247
+ rest = value
248
+ while rest:
249
+ rest, group = divmod(rest, 1000)
250
+ groups.append(group)
251
+ if len(groups) > len(_SCALES):
252
+ raise NumberError(f"{value} is larger than this spell-out table covers")
253
+ parts = [
254
+ f"{_under_thousand(group)} {_SCALES[power]}".strip()
255
+ for power, group in reversed(list(enumerate(groups))) if group
256
+ ]
257
+ if len(parts) == 1:
258
+ return parts[0]
259
+ tail = groups[0]
260
+ if 0 < tail < 100:
261
+ return ", ".join(parts[:-1]) + " and " + parts[-1]
262
+ return ", ".join(parts)
263
+
264
+
265
+ def ordinal_words(value: int) -> str:
266
+ """the number library(value, to='ordinal'): the cardinal with its last word ordinalised."""
267
+ words = cardinal_words(value)
268
+ match = re.search(r"[a-z]+$", words)
269
+ if match is None:
270
+ raise NumberError(f"cannot ordinalise the spelling of {value}: {words!r}")
271
+ return words[: match.start()] + _ordinalise(match.group())
272
+
273
+
274
+ def _ordinalise(word: str) -> str:
275
+ if word in _ORDINAL_WORDS:
276
+ return _ORDINAL_WORDS[word]
277
+ if word.endswith("y"):
278
+ return word[:-1] + "ieth"
279
+ return word + "th"
280
+
281
+
282
+ def year_words(value: int) -> str:
283
+ """the number library(value, to='year'): two-digit pairs unless the pattern is 00XX/X00X."""
284
+ if value < 0:
285
+ raise NumberError("negative years are not supported")
286
+ high, low = divmod(value, 100)
287
+ if high == 0 or (high % 10 == 0 and low < 10) or high >= 100:
288
+ return cardinal_words(value)
289
+ if low == 0:
290
+ return f"{cardinal_words(high)} hundred"
291
+ if low < 10:
292
+ return f"{cardinal_words(high)} oh-{cardinal_words(low)}"
293
+ return f"{cardinal_words(high)} {cardinal_words(low)}"
294
+
295
+
296
+ def decimal_words(text: str) -> str:
297
+ """the number library(float(text)): integer part, then 'point', then digit by digit."""
298
+ whole, _, frac = text.partition(".")
299
+ head = cardinal_words(int(whole)) if whole else "zero"
300
+ if not frac:
301
+ return head
302
+ return head + " point " + " ".join(_ONES[int(d)] for d in frac)
303
+
304
+
305
+ # --------------------------------------------------------------------------
306
+ # Lexicon
307
+ # --------------------------------------------------------------------------
308
+
309
+
310
+ @dataclass(frozen=True)
311
+ class TokenContext:
312
+ """TokenContext."""
313
+
314
+ future_vowel: bool | None = None
315
+ future_to: bool = False
316
+
317
+
318
+ class Lexicon:
319
+ """Lexicon, for American English only (british=False).
320
+
321
+ The British tables are not vendored: this model is American English
322
+ and shipping gb_gold/gb_silver would add 6.5 MB nothing reads.
323
+ """
324
+
325
+ cap_stresses = (0.5, 2)
326
+
327
+ def __init__(self, gold_path: Path = GOLD_PATH, silver_path: Path = SILVER_PATH) -> None:
328
+ self.golds = Lexicon.grow_dictionary(_load_dictionary(gold_path))
329
+ self.silvers = Lexicon.grow_dictionary(_load_dictionary(silver_path))
330
+ for word, value in self.golds.items():
331
+ if isinstance(value, dict):
332
+ if "DEFAULT" not in value:
333
+ raise FrontendError(
334
+ "lexicon", f"gold entry {word!r} is tag-keyed but has no DEFAULT"
335
+ )
336
+ for variant in value.values():
337
+ _check_vocab(word, variant)
338
+ else:
339
+ _check_vocab(word, value)
340
+
341
+ @staticmethod
342
+ def grow_dictionary(entries: dict[str, Any]) -> dict[str, Any]:
343
+ """Lexicon.grow_dictionary."""
344
+ grown: dict[str, Any] = {}
345
+ for key, value in entries.items():
346
+ if len(key) < 2:
347
+ continue
348
+ if key == key.lower():
349
+ if key != key.capitalize():
350
+ grown[key.capitalize()] = value
351
+ elif key == key.lower().capitalize():
352
+ grown[key.lower()] = value
353
+ grown.update(entries)
354
+ return grown
355
+
356
+ def get_NNP(self, word: str) -> tuple[str | None, int | None]:
357
+ """Lexicon.get_NNP -- spell the word out letter by letter."""
358
+ letters = [self.golds.get(c.upper()) for c in word if c.isalpha()]
359
+ if not letters or None in letters:
360
+ return None, None
361
+ spelled = apply_stress("".join(str(p) for p in letters), 0)
362
+ if spelled is None:
363
+ return None, None
364
+ head, _, tail = spelled.rpartition(SECONDARY_STRESS)
365
+ if not head:
366
+ return spelled, 3
367
+ return head + PRIMARY_STRESS + tail, 3
368
+
369
+ def get_special_case(self, word: str, tag: str, stress: float | None,
370
+ ctx: TokenContext) -> tuple[str | None, int | None]:
371
+ """Lexicon.get_special_case."""
372
+ if tag == "ADD" and word in ADD_SYMBOLS:
373
+ return self.lookup(ADD_SYMBOLS[word], None, -0.5, ctx)
374
+ if word in SYMBOLS:
375
+ return self.lookup(SYMBOLS[word], None, None, ctx)
376
+ if ("." in word.strip(".") and word.replace(".", "").isalpha()
377
+ and len(max(word.split("."), key=len)) < 3):
378
+ return self.get_NNP(word)
379
+ if word in ("a", "A"):
380
+ return ("ɐ" if tag == "DT" else "ˈA"), 4
381
+ if word in ("am", "Am", "AM"):
382
+ if tag.startswith("NN"):
383
+ return self.get_NNP(word)
384
+ if ctx.future_vowel is None or word != "am" or (stress is not None and stress > 0):
385
+ return self.golds["am"], 4
386
+ return "ɐm", 4
387
+ if word in ("an", "An", "AN"):
388
+ if word == "AN" and tag.startswith("NN"):
389
+ return self.get_NNP(word)
390
+ return "ɐn", 4
391
+ if word == "I" and tag == "PRP":
392
+ return SECONDARY_STRESS + "I", 4
393
+ if word in ("by", "By", "BY") and Lexicon.get_parent_tag(tag) == "ADV":
394
+ return "bˈI", 4
395
+ if word in ("to", "To") or (word == "TO" and tag in ("TO", "IN")):
396
+ return {None: self.golds["to"], False: "tə", True: "tʊ"}[ctx.future_vowel], 4
397
+ if word in ("in", "In") or (word == "IN" and tag != "NNP"):
398
+ prefix = PRIMARY_STRESS if ctx.future_vowel is None or tag != "IN" else ""
399
+ return prefix + "ɪn", 4
400
+ if word in ("the", "The") or (word == "THE" and tag == "DT"):
401
+ return ("ði" if ctx.future_vowel is True else "ðə"), 4
402
+ if tag == "IN" and re.match(r"(?i)vs\.?$", word):
403
+ return self.lookup("versus", None, None, ctx)
404
+ if word in ("used", "Used", "USED"):
405
+ if tag in ("VBD", "JJ") and ctx.future_to:
406
+ return self.golds["used"]["VBD"], 4
407
+ return self.golds["used"]["DEFAULT"], 4
408
+ return None, None
409
+
410
+ @staticmethod
411
+ def get_parent_tag(tag: str | None) -> str | None:
412
+ """Lexicon.get_parent_tag."""
413
+ if tag is None:
414
+ return tag
415
+ if tag.startswith("VB"):
416
+ return "VERB"
417
+ if tag.startswith("NN"):
418
+ return "NOUN"
419
+ if tag.startswith("ADV") or tag.startswith("RB"):
420
+ return "ADV"
421
+ if tag.startswith("ADJ") or tag.startswith("JJ"):
422
+ return "ADJ"
423
+ return tag
424
+
425
+ def is_known(self, word: str, tag: str | None) -> bool:
426
+ """Lexicon.is_known."""
427
+ if word in self.golds or word in SYMBOLS or word in self.silvers:
428
+ return True
429
+ if not word.isalpha() or not all(ord(c) in LEXICON_ORDS for c in word):
430
+ return False
431
+ if len(word) == 1:
432
+ return True
433
+ if word == word.upper() and word.lower() in self.golds:
434
+ return True
435
+ return word[1:] == word[1:].upper()
436
+
437
+ def lookup(self, word: str, tag: str | None, stress: float | None,
438
+ ctx: TokenContext | None) -> tuple[str | None, int | None]:
439
+ """Lexicon.lookup."""
440
+ is_NNP = None
441
+ if word == word.upper() and word not in self.golds:
442
+ word = word.lower()
443
+ is_NNP = tag == "NNP"
444
+ entry: Any = self.golds.get(word)
445
+ rating: int | None = 4
446
+ if entry is None and not is_NNP:
447
+ entry, rating = self.silvers.get(word), 3
448
+ if isinstance(entry, dict):
449
+ if ctx is not None and ctx.future_vowel is None and "None" in entry:
450
+ tag = "None"
451
+ elif tag not in entry:
452
+ tag = Lexicon.get_parent_tag(tag)
453
+ entry = entry.get(tag, entry["DEFAULT"])
454
+ if entry is None or (is_NNP and PRIMARY_STRESS not in entry):
455
+ spelled, spelled_rating = self.get_NNP(word)
456
+ if spelled is not None:
457
+ return spelled, spelled_rating
458
+ return apply_stress(entry, stress), rating
459
+
460
+ def _s(self, stem: str | None) -> str | None:
461
+ """Lexicon._s (the -s suffix)."""
462
+ if not stem:
463
+ return None
464
+ if stem[-1] in "ptkfθ":
465
+ return stem + "s"
466
+ if stem[-1] in "szʃʒʧʤ":
467
+ return stem + "ᵻz"
468
+ return stem + "z"
469
+
470
+ def stem_s(self, word: str, tag: str | None, stress: float | None,
471
+ ctx: TokenContext | None) -> tuple[str | None, int | None]:
472
+ """Lexicon.stem_s."""
473
+ if len(word) < 3 or not word.endswith("s"):
474
+ return None, None
475
+ if not word.endswith("ss") and self.is_known(word[:-1], tag):
476
+ stem = word[:-1]
477
+ elif ((word.endswith("'s") or (len(word) > 4 and word.endswith("es")
478
+ and not word.endswith("ies")))
479
+ and self.is_known(word[:-2], tag)):
480
+ stem = word[:-2]
481
+ elif len(word) > 4 and word.endswith("ies") and self.is_known(word[:-3] + "y", tag):
482
+ stem = word[:-3] + "y"
483
+ else:
484
+ return None, None
485
+ stem_ps, rating = self.lookup(stem, tag, stress, ctx)
486
+ return self._s(stem_ps), rating
487
+
488
+ def _ed(self, stem: str | None) -> str | None:
489
+ """Lexicon._ed (the -ed suffix)."""
490
+ if not stem:
491
+ return None
492
+ if stem[-1] in "pkfθʃsʧ":
493
+ return stem + "t"
494
+ if stem[-1] == "d":
495
+ return stem + "ᵻd"
496
+ if stem[-1] != "t":
497
+ return stem + "d"
498
+ if len(stem) < 2:
499
+ return stem + "ɪd"
500
+ if stem[-2] in US_TAUS:
501
+ return stem[:-1] + "ɾᵻd"
502
+ return stem + "ᵻd"
503
+
504
+ def stem_ed(self, word: str, tag: str | None, stress: float | None,
505
+ ctx: TokenContext | None) -> tuple[str | None, int | None]:
506
+ """Lexicon.stem_ed."""
507
+ if len(word) < 4 or not word.endswith("d"):
508
+ return None, None
509
+ if not word.endswith("dd") and self.is_known(word[:-1], tag):
510
+ stem = word[:-1]
511
+ elif (len(word) > 4 and word.endswith("ed") and not word.endswith("eed")
512
+ and self.is_known(word[:-2], tag)):
513
+ stem = word[:-2]
514
+ else:
515
+ return None, None
516
+ stem_ps, rating = self.lookup(stem, tag, stress, ctx)
517
+ return self._ed(stem_ps), rating
518
+
519
+ def _ing(self, stem: str | None) -> str | None:
520
+ """Lexicon._ing (the -ing suffix)."""
521
+ if not stem:
522
+ return None
523
+ if len(stem) > 1 and stem[-1] == "t" and stem[-2] in US_TAUS:
524
+ return stem[:-1] + "ɾɪŋ"
525
+ return stem + "ɪŋ"
526
+
527
+ def stem_ing(self, word: str, tag: str | None, stress: float | None,
528
+ ctx: TokenContext | None) -> tuple[str | None, int | None]:
529
+ """Lexicon.stem_ing."""
530
+ if len(word) < 5 or not word.endswith("ing"):
531
+ return None, None
532
+ if len(word) > 5 and self.is_known(word[:-3], tag):
533
+ stem = word[:-3]
534
+ elif self.is_known(word[:-3] + "e", tag):
535
+ stem = word[:-3] + "e"
536
+ elif (len(word) > 5 and re.search(r"([bcdgklmnprstvxz])\1ing$|cking$", word)
537
+ and self.is_known(word[:-4], tag)):
538
+ stem = word[:-4]
539
+ else:
540
+ return None, None
541
+ stem_ps, rating = self.lookup(stem, tag, stress, ctx)
542
+ return self._ing(stem_ps), rating
543
+
544
+ def get_word(self, word: str, tag: str | None, stress: float | None,
545
+ ctx: TokenContext) -> tuple[str | None, int | None]:
546
+ """Lexicon.get_word."""
547
+ ps, rating = self.get_special_case(word, tag or "", stress, ctx)
548
+ if ps is not None:
549
+ return ps, rating
550
+ lowered = word.lower()
551
+ if (len(word) > 1 and word.replace("'", "").isalpha() and word != lowered
552
+ and (tag != "NNP" or len(word) > 7)
553
+ and word not in self.golds and word not in self.silvers
554
+ and (word == word.upper() or word[1:] == word[1:].lower())
555
+ and (lowered in self.golds or lowered in self.silvers
556
+ or any(fn(lowered, tag, stress, ctx)[0]
557
+ for fn in (self.stem_s, self.stem_ed, self.stem_ing)))):
558
+ word = lowered
559
+ if self.is_known(word, tag):
560
+ return self.lookup(word, tag, stress, ctx)
561
+ if word.endswith("s'") and self.is_known(word[:-2] + "'s", tag):
562
+ return self.lookup(word[:-2] + "'s", tag, stress, ctx)
563
+ if word.endswith("'") and self.is_known(word[:-1], tag):
564
+ return self.lookup(word[:-1], tag, stress, ctx)
565
+ for stemmer, stem_stress in ((self.stem_s, stress), (self.stem_ed, stress),
566
+ (self.stem_ing, 0.5 if stress is None else stress)):
567
+ stemmed, rating = stemmer(word, tag, stem_stress, ctx)
568
+ if stemmed is not None:
569
+ return stemmed, rating
570
+ return None, None
571
+
572
+ @staticmethod
573
+ def is_currency(word: str) -> bool:
574
+ """Lexicon.is_currency."""
575
+ if "." not in word:
576
+ return True
577
+ if word.count(".") > 1:
578
+ return False
579
+ cents = word.split(".")[1]
580
+ return len(cents) < 3 or set(cents) == {"0"}
581
+
582
+ @staticmethod
583
+ def is_number(word: str, is_head: bool) -> bool:
584
+ """Lexicon.is_number."""
585
+ if all(not is_digit(c) for c in word):
586
+ return False
587
+ for suffix in ("ing", "'d", "ed", "'s", *ORDINALS, "s"):
588
+ if word.endswith(suffix):
589
+ word = word[: -len(suffix)]
590
+ break
591
+ return all(is_digit(c) or c in ",." or (is_head and i == 0 and c == "-")
592
+ for i, c in enumerate(word))
593
+
594
+ def get_number(self, word: str, currency: str | None, is_head: bool,
595
+ num_flags: str) -> tuple[str | None, int | None]:
596
+ """Lexicon.get_number, with cardinal_words for the number library."""
597
+ match = re.search(r"[a-z']+$", word)
598
+ suffix = match.group() if match else None
599
+ if suffix:
600
+ word = word[: -len(suffix)]
601
+ result: list[tuple[str | None, int | None]] = []
602
+ if word.startswith("-"):
603
+ result.append(self.lookup("minus", None, None, None))
604
+ word = word[1:]
605
+
606
+ def extend_num(num: str, first: bool = True, escape: bool = False) -> None:
607
+ splits = re.split(r"[^a-z]+", num if escape else cardinal_words(int(num)))
608
+ for i, part in enumerate(splits):
609
+ if not part:
610
+ continue
611
+ if part != "and" or "&" in num_flags:
612
+ if first and i == 0 and len(splits) > 1 and part == "one" and "a" in num_flags:
613
+ result.append(("ə", 4))
614
+ else:
615
+ result.append(
616
+ self.lookup(part, None, -2 if part == "point" else None, None)
617
+ )
618
+ elif part == "and" and "n" in num_flags and result:
619
+ result[-1] = ((result[-1][0] or "") + "ən", result[-1][1])
620
+
621
+ if is_digit(word) and suffix in ORDINALS:
622
+ extend_num(ordinal_words(int(word)), escape=True)
623
+ elif not result and len(word) == 4 and currency not in CURRENCIES and is_digit(word):
624
+ extend_num(year_words(int(word)), escape=True)
625
+ elif not is_head and "." not in word:
626
+ num = word.replace(",", "")
627
+ if num[0] == "0" or len(num) > 3:
628
+ for digit in num:
629
+ extend_num(digit, first=False)
630
+ elif len(num) == 3 and not num.endswith("00"):
631
+ extend_num(num[0])
632
+ if num[1] == "0":
633
+ result.append(self.lookup("O", None, -2, None))
634
+ extend_num(num[2], first=False)
635
+ else:
636
+ extend_num(num[1:], first=False)
637
+ else:
638
+ extend_num(num)
639
+ elif word.count(".") > 1 or not is_head:
640
+ first = True
641
+ for num in word.replace(",", "").split("."):
642
+ if not num:
643
+ pass
644
+ elif num[0] == "0" or (len(num) != 2 and any(n != "0" for n in num[1:])):
645
+ for digit in num:
646
+ extend_num(digit, first=False)
647
+ else:
648
+ extend_num(num, first=first)
649
+ first = False
650
+ elif currency in CURRENCIES and Lexicon.is_currency(word):
651
+ pairs = [(int(num) if num else 0, unit) for num, unit
652
+ in zip(word.replace(",", "").split("."), CURRENCIES[currency])]
653
+ if len(pairs) > 1:
654
+ if pairs[1][0] == 0:
655
+ pairs = pairs[:1]
656
+ elif pairs[0][0] == 0:
657
+ pairs = pairs[1:]
658
+ for i, (num, unit) in enumerate(pairs):
659
+ if i > 0:
660
+ result.append(self.lookup("and", None, None, None))
661
+ extend_num(str(num), first=i == 0)
662
+ result.append(
663
+ self.stem_s(unit + "s", None, None, None)
664
+ if abs(num) != 1 and unit != "pence"
665
+ else self.lookup(unit, None, None, None)
666
+ )
667
+ else:
668
+ cleaned = word.replace(",", "")
669
+ if is_digit(cleaned):
670
+ spelled = (ordinal_words(int(cleaned)) if suffix in ORDINALS
671
+ else cardinal_words(int(cleaned)))
672
+ elif "." not in cleaned:
673
+ raise NumberError(f"cannot spell {word!r} as a number")
674
+ elif cleaned[0] == ".":
675
+ spelled = "point " + " ".join(_ONES[int(n)] for n in cleaned[1:])
676
+ else:
677
+ spelled = decimal_words(cleaned)
678
+ extend_num(spelled, escape=True)
679
+
680
+ if not result:
681
+ return None, None
682
+ joined = " ".join(p for p, _ in result if p)
683
+ ratings = [r for _, r in result if r is not None]
684
+ rating = min(ratings) if ratings else None
685
+ if suffix in ("s", "'s"):
686
+ return self._s(joined), rating
687
+ if suffix in ("ed", "'d"):
688
+ return self._ed(joined), rating
689
+ if suffix == "ing":
690
+ return self._ing(joined), rating
691
+ return joined, rating
692
+
693
+ def append_currency(self, ps: str | None, currency: str | None) -> str | None:
694
+ """Lexicon.append_currency."""
695
+ if not currency:
696
+ return ps
697
+ pair = CURRENCIES.get(currency)
698
+ if pair is None:
699
+ return ps
700
+ unit = self.stem_s(pair[0] + "s", None, None, None)[0]
701
+ return f"{ps} {unit}" if unit else ps
702
+
703
+ @staticmethod
704
+ def numeric_if_needed(char: str) -> str:
705
+ """Lexicon.numeric_if_needed."""
706
+ if not char.isdigit():
707
+ return char
708
+ value = unicodedata.numeric(char)
709
+ return str(int(value)) if value == int(value) else char
710
+
711
+ def __call__(self, token: Token, ctx: TokenContext) -> tuple[str | None, int | None]:
712
+ """Lexicon.__call__."""
713
+ word = (token.text if token.alias is None else token.alias)
714
+ word = word.replace(chr(8216), "'").replace(chr(8217), "'")
715
+ word = unicodedata.normalize("NFKC", word)
716
+ word = "".join(Lexicon.numeric_if_needed(c) for c in word)
717
+ stress = None if word == word.lower() else Lexicon.cap_stresses[int(word == word.upper())]
718
+ ps, rating = self.get_word(word, token.tag, stress, ctx)
719
+ if ps is not None:
720
+ return apply_stress(self.append_currency(ps, token.currency), token.stress), rating
721
+ if Lexicon.is_number(word, token.is_head):
722
+ ps, rating = self.get_number(word, token.currency, token.is_head, token.num_flags)
723
+ return apply_stress(ps, token.stress), rating
724
+ # the reference lexicon distinguishes "outside the lexicon alphabet" from "inside it but
725
+ # unknown" here and returns the same thing for both; the caller's next
726
+ # step is the fallback either way.
727
+ return None, None
728
+
729
+
730
+ def _load_dictionary(path: Path) -> dict[str, Any]:
731
+ if not path.is_file():
732
+ raise FrontendError(
733
+ "lexicon",
734
+ f"dictionary not found at {path}; the bundled assets appear incomplete",
735
+ )
736
+ try:
737
+ entries = json.loads(path.read_text(encoding="utf-8"))
738
+ except (OSError, json.JSONDecodeError) as exc:
739
+ raise FrontendError("lexicon", f"could not read {path}: {exc}") from exc
740
+ if not isinstance(entries, dict) or not entries:
741
+ raise FrontendError("lexicon", f"{path} is not a non-empty JSON object")
742
+ return entries
743
+
744
+
745
+ def _check_vocab(word: str, value: Any) -> None:
746
+ if value is None:
747
+ return
748
+ if not isinstance(value, str):
749
+ raise FrontendError("lexicon", f"entry {word!r} maps to {type(value).__name__}, not str")
750
+ unknown = [c for c in value if c not in US_VOCAB]
751
+ if unknown:
752
+ raise FrontendError(
753
+ "lexicon", f"entry {word!r} uses symbols outside the American vocab: {unknown}"
754
+ )
755
+
756
+
757
+ # --------------------------------------------------------------------------
758
+ # Tokeniser and tagger (the an external parser replacements)
759
+ # --------------------------------------------------------------------------
760
+
761
+ # Words whose part of speech is fixed enough to hard-code. Only tags that can
762
+ # change the phonemes are worth listing: the punctuation tags, 'DT' for "a" and
763
+ # "the", 'IN' for "in", 'PRP' for "I", 'TO', and the coarse NOUN/VERB/ADJ that
764
+ # picks a variant for the 790 tag-keyed gold entries.
765
+ _CLOSED_CLASS: dict[str, str] = {
766
+ "a": "DT", "an": "DT", "the": "DT", "this": "DT", "that": "IN", "these": "DT",
767
+ "those": "DT", "every": "DT", "each": "DT", "another": "DT", "any": "DT",
768
+ "some": "DT", "no": "DT", "both": "DT", "either": "DT", "neither": "DT",
769
+ "all": "PDT", "such": "JJ",
770
+ "to": "TO",
771
+ "of": "IN", "in": "IN", "on": "IN", "at": "IN", "by": "IN", "for": "IN",
772
+ "with": "IN", "from": "IN", "into": "IN", "about": "IN", "as": "IN",
773
+ "than": "IN", "if": "IN", "while": "IN", "though": "IN", "although": "IN",
774
+ "because": "IN", "since": "IN", "until": "IN", "unless": "IN", "upon": "IN",
775
+ "over": "IN", "under": "IN", "between": "IN", "among": "IN", "through": "IN",
776
+ "during": "IN", "without": "IN", "within": "IN", "against": "IN",
777
+ "toward": "IN", "towards": "IN", "before": "IN", "after": "IN", "behind": "IN",
778
+ "beyond": "IN", "near": "IN", "per": "IN", "via": "IN", "despite": "IN",
779
+ "above": "IN", "below": "IN", "across": "IN", "along": "IN", "around": "IN",
780
+ "beside": "IN", "besides": "IN", "beneath": "IN", "throughout": "IN",
781
+ "and": "CC", "or": "CC", "but": "CC", "nor": "CC", "yet": "CC", "so": "RB",
782
+ "i": "PRP", "you": "PRP", "he": "PRP", "she": "PRP", "it": "PRP", "we": "PRP",
783
+ "they": "PRP", "me": "PRP", "him": "PRP", "us": "PRP", "them": "PRP",
784
+ "myself": "PRP", "yourself": "PRP", "himself": "PRP", "herself": "PRP",
785
+ "itself": "PRP", "ourselves": "PRP", "themselves": "PRP",
786
+ "my": "PRP$", "your": "PRP$", "his": "PRP$", "its": "PRP$", "our": "PRP$",
787
+ "their": "PRP$", "her": "PRP$",
788
+ "can": "MD", "could": "MD", "will": "MD", "would": "MD", "shall": "MD",
789
+ "should": "MD", "may": "MD", "might": "MD", "must": "MD",
790
+ "am": "VBP", "are": "VBP", "is": "VBZ", "was": "VBD", "were": "VBD",
791
+ "be": "VB", "been": "VBN", "being": "VBG",
792
+ "have": "VBP", "has": "VBZ", "had": "VBD", "having": "VBG",
793
+ "do": "VBP", "does": "VBZ", "did": "VBD", "done": "VBN", "doing": "VBG",
794
+ "not": "RB", "never": "RB", "very": "RB", "too": "RB", "also": "RB",
795
+ "just": "RB", "only": "RB", "then": "RB", "now": "RB", "again": "RB",
796
+ "still": "RB", "quite": "RB", "rather": "RB", "almost": "RB", "always": "RB",
797
+ "often": "RB", "here": "RB", "well": "RB", "even": "RB", "much": "RB",
798
+ "more": "RBR", "most": "RBS", "less": "RBR", "least": "RBS",
799
+ "there": "EX", "when": "WRB", "where": "WRB", "why": "WRB", "how": "WRB",
800
+ "who": "WP", "whom": "WP", "what": "WP", "whose": "WP$", "which": "WDT",
801
+ }
802
+
803
+ # Punctuation character -> Penn tag. the reference lexicon reads the tag, not the character:
804
+ # ';' has to arrive as ':' and '!' as '.' or the phoneme lookup misses.
805
+ _PUNCT_TAG_BY_CHAR: dict[str, str] = {
806
+ ",": ",", ".": ".", "!": ".", "?": ".", ";": ":", ":": ":",
807
+ # A bare ASCII hyphen is HYPH, not ':': an external parser calls it that even when it is
808
+ # standing in for a dash, and HYPH is outside PUNCT_TAGS, so the reference lexicon voices
809
+ # nothing for it. Real en/em dashes do get ':' and become the "—" symbol.
810
+ "-": "HYPH", "–": ":", "—": ":", "…": ":",
811
+ "(": "-LRB-", "[": "-LRB-", "{": "-LRB-",
812
+ ")": "-RRB-", "]": "-RRB-", "}": "-RRB-",
813
+ "$": "$", "£": "$", "€": "$", "#": "#",
814
+ "“": "``", "‘": "``", "”": "''", "’": "''",
815
+ }
816
+
817
+ _PREFIX_CHARS = "\"`([{“‘«¿¡$£€#*…"
818
+ _SUFFIX_CHARS = ".,;:!?\")]}”»…%"
819
+
820
+ # Abbreviations whose trailing period belongs to the word, hand-written here
821
+ # rather than taken from an external parser's exception table. Only the ones the gold
822
+ # dictionary can actually pronounce, plus the common titles and Latin
823
+ # abbreviations, are listed; anything else keeps the period as its own token,
824
+ # which is also what the reference lexicon does when an external parser does not recognise the form.
825
+ _ABBREVIATIONS = frozenset([
826
+ "Mr.", "Mrs.", "Ms.", "Dr.", "Prof.", "St.", "Jr.", "Sr.", "Esq.", "Rev.",
827
+ "Gen.", "Gov.", "Sen.", "Rep.", "Capt.", "Lt.", "Col.", "Sgt.", "Adm.",
828
+ "Mt.", "Ft.", "Co.", "Corp.", "Inc.", "Ltd.", "Bros.",
829
+ "Jan.", "Feb.", "Mar.", "Apr.", "Jun.", "Jul.", "Aug.", "Sep.", "Sept.",
830
+ "Oct.", "Nov.", "Dec.",
831
+ "etc.", "vs.", "e.g.", "i.e.", "a.m.", "p.m.",
832
+ ])
833
+
834
+ _QUOTE_CHARS = "\"“”"
835
+
836
+ # an external parser splits a double hyphen that sits between two word characters into its
837
+ # own token ("saw--and" -> "saw", "--", "and"); the reference lexicon then reads the ':' tag
838
+ # and voices nothing for it. Without this the whole run reaches the fallback as
839
+ # one unpronounceable string.
840
+ _DOUBLE_HYPHEN_RE = re.compile(r"(?<=\w)(-{2,})(?=\w)")
841
+
842
+
843
+ def _punct_tag(text: str, position: str, quote_open: bool) -> str | None:
844
+ """Penn tag for a punctuation-only token, or None if it is not punctuation."""
845
+ if any(c.isalnum() for c in text):
846
+ return None
847
+ if text in SYMBOLS:
848
+ # "%", "&", "+", "@" are words, not punctuation: the reference lexicon's
849
+ # get_special_case spells them out. Tagging them here would put them in
850
+ # PUNCT_TAGS, where they phonemize to nothing at all.
851
+ return "NN"
852
+ if text and set(text) <= set(_QUOTE_CHARS):
853
+ if text in ("“", "”"):
854
+ return "``" if text == "“" else "''"
855
+ if position == "prefix":
856
+ return "``"
857
+ if position == "suffix":
858
+ return "''"
859
+ return "``" if quote_open else "''"
860
+ if set(text) == {"'"}:
861
+ return "''"
862
+ tags = {_PUNCT_TAG_BY_CHAR.get(c) for c in text}
863
+ if len(tags) == 1:
864
+ only = tags.pop()
865
+ if only is not None:
866
+ return only
867
+ return "NFP"
868
+
869
+
870
+ def _tag(text: str, position: str, sentence_start: bool, quote_open: bool) -> str:
871
+ """A Penn tag for one token; the stand-in for an external parser's statistical tagger.
872
+
873
+ See the module docstring for what this costs. It is deliberately blunt: a
874
+ wrong tag on an ordinary content word changes nothing, because the lexicon
875
+ only consults the tag for punctuation, a dozen function words, and the gold
876
+ entries that carry per-tag variants.
877
+ """
878
+ punct = _punct_tag(text, position, quote_open)
879
+ if punct is not None:
880
+ return punct
881
+ if Lexicon.is_number(text, True):
882
+ return "CD"
883
+ if text == "I":
884
+ return "PRP"
885
+ lowered = text.lower()
886
+ closed = _CLOSED_CLASS.get(lowered)
887
+ if closed is not None:
888
+ return closed
889
+ if text[:1].isupper() and not sentence_start:
890
+ return "NNP"
891
+ return "NN"
892
+
893
+
894
+ def _split_chunk(chunk: str) -> list[tuple[str, str]]:
895
+ """One whitespace-delimited chunk -> [(token text, position)] as an external parser splits it.
896
+
897
+ Position is "prefix", "core" or "suffix" and exists only so a straight
898
+ double quote can be called opening or closing by where it sits: a quote
899
+ peeled off the front of a chunk opens, one peeled off the back closes. That
900
+ beats alternating from the start of the string, which gets the direction
901
+ wrong for every sentence that begins inside a quotation.
902
+
903
+ Prefix and suffix punctuation is peeled one character at a time. Apostrophes
904
+ are never split: the reference lexicon's own sub-tokeniser keeps "don't" and "boys'" whole
905
+ and `get_word` has the rules for both, whereas a split apostrophe would be
906
+ tagged as a closing quote and voiced.
907
+ """
908
+ if not chunk:
909
+ return []
910
+ prefixes: list[str] = []
911
+ suffixes: list[str] = []
912
+ core = chunk
913
+ changed = True
914
+ while changed and len(core) > 1:
915
+ changed = False
916
+ if core[0] in _PREFIX_CHARS:
917
+ prefixes.append(core[0])
918
+ core = core[1:]
919
+ changed = True
920
+ continue
921
+ if core[-1] in _SUFFIX_CHARS and not _keeps_final_period(core):
922
+ suffixes.insert(0, core[-1])
923
+ core = core[:-1]
924
+ changed = True
925
+ cores = [part for part in _DOUBLE_HYPHEN_RE.split(core) if part] if core else []
926
+ return [*((p, "prefix") for p in prefixes),
927
+ *((c, "core") for c in cores),
928
+ *((x, "suffix") for x in suffixes)]
929
+
930
+
931
+ def _keeps_final_period(word: str) -> bool:
932
+ """True when a trailing '.' is part of the token, not sentence punctuation."""
933
+ if not word.endswith("."):
934
+ return False
935
+ if word in _ABBREVIATIONS or word.lower() in _ABBREVIATIONS:
936
+ return True
937
+ # Dotted initialisms ("U.S.", "a.m.") -- the same shape the reference lexicon's
938
+ # get_special_case spells out letter by letter.
939
+ stripped = word.strip(".")
940
+ return ("." in stripped and word.replace(".", "").isalpha()
941
+ and len(max(word.split("."), key=len)) < 3)
942
+
943
+
944
+ def tokenize(text: str) -> list[Token]:
945
+ """Text -> tagged tokens (rule tokeniser + closed-class lookup)."""
946
+ tokens: list[Token] = []
947
+ quote_open = True
948
+ sentence_start = True
949
+ for chunk in text.split():
950
+ pieces = _split_chunk(chunk)
951
+ for index, (piece, position) in enumerate(pieces):
952
+ tag = _tag(piece, position, sentence_start, quote_open)
953
+ if tag in ("``", "''"):
954
+ quote_open = tag == "''"
955
+ sentence_start = tag == "." or (sentence_start and tag in ("``", "-LRB-"))
956
+ tokens.append(Token(text=piece, tag=tag,
957
+ whitespace=" " if index == len(pieces) - 1 else ""))
958
+ if tokens:
959
+ tokens[-1].whitespace = ""
960
+ _retag_verbs(tokens)
961
+ _retag_that(tokens)
962
+ return tokens
963
+
964
+
965
+ def _retag_that(tokens: list[Token]) -> None:
966
+ """"that" is the one homograph a single lookahead resolves.
967
+
968
+ us_gold keys it as {'DEFAULT': 'ðæt', 'DT': 'ðˈæt'}, so only the
969
+ determiner reading is stressed. Demonstrative "that" is followed by a noun
970
+ or an adjective ("that cup", "that lively air"); the complementiser and
971
+ relative readings are followed by a pronoun, a determiner or a verb. On the
972
+ measured set this rule is right 11 times and wrong twice.
973
+ """
974
+ for i, token in enumerate(tokens):
975
+ if token.text.lower() != "that" or token.tag != "IN":
976
+ continue
977
+ following = tokens[i + 1].tag if i + 1 < len(tokens) else None
978
+ if following is not None and (following.startswith("NN") or following.startswith("JJ")):
979
+ token.tag = "DT"
980
+
981
+
982
+ # Auxiliaries after which an open-class word is a past participle rather than a
983
+ # bare verb; "read" is keyed on VBN and VBD separately, so VB alone is no use.
984
+ _PERFECT_AUXILIARIES = frozenset(["have", "has", "had", "having",
985
+ "is", "are", "was", "were", "be", "been", "being"])
986
+
987
+
988
+ def _retag_verbs(tokens: list[Token]) -> None:
989
+ """Promote a following open-class word to a verb after "to" or an auxiliary.
990
+
991
+ The 790 tag-keyed gold entries include a few dozen noun/verb pairs whose
992
+ vowel changes ("live", "tear", "use", "close"), and without an external parser the only
993
+ signal available is the word in front. "to X" and "will X" are verbs;
994
+ "had X" and "was X" are past participles. Both are safe enough to apply
995
+ unconditionally -- an open-class word in those slots is a verb in ordinary
996
+ English -- and neither fires on a word the closed-class table already tagged.
997
+ """
998
+ previous_tag = None
999
+ previous_text = ""
1000
+ for token in tokens:
1001
+ if token.tag in ("NN", "JJ"):
1002
+ if previous_tag in ("TO", "MD"):
1003
+ token.tag = "VB"
1004
+ elif previous_text in _PERFECT_AUXILIARIES:
1005
+ token.tag = "VBN"
1006
+ if token.tag not in ("RB", "RBR", "RBS"):
1007
+ previous_tag = token.tag
1008
+ previous_text = token.text.lower()
1009
+
1010
+
1011
+ # --------------------------------------------------------------------------
1012
+ # G2P
1013
+ # --------------------------------------------------------------------------
1014
+
1015
+
1016
+ class EnglishG2P:
1017
+ """G2P, with the an external parser front and the espeak fallback replaced."""
1018
+
1019
+ def __init__(self, fallback: Callable[[Token], tuple[str | None, int | None]] | None = None,
1020
+ unk: str = "") -> None:
1021
+ self.lexicon = Lexicon()
1022
+ self.unk = unk
1023
+ self.fallback = fallback if fallback is not None else _NeuralFallback()
1024
+
1025
+ @staticmethod
1026
+ def fold_left(tokens: list[Token]) -> list[Token]:
1027
+ """G2P.fold_left."""
1028
+ result: list[Token] = []
1029
+ for token in tokens:
1030
+ if result and not token.is_head:
1031
+ token = merge_tokens([result.pop(), token], unk="")
1032
+ result.append(token)
1033
+ return result
1034
+
1035
+ @staticmethod
1036
+ def retokenize(tokens: list[Token]) -> list[Token | list[Token]]:
1037
+ """G2P.retokenize."""
1038
+ words: list[Token | list[Token]] = []
1039
+ currency: str | None = None
1040
+ for i, token in enumerate(tokens):
1041
+ if token.alias is None and token.phonemes is None:
1042
+ pieces = subtokenize(token.text)
1043
+ if not pieces:
1044
+ pieces = [token.text]
1045
+ tks = [replace(token, text=piece, whitespace="", is_head=True,
1046
+ num_flags=token.num_flags, stress=token.stress,
1047
+ prespace=False, phonemes=None, alias=None, currency=None,
1048
+ rating=None)
1049
+ for piece in pieces]
1050
+ else:
1051
+ tks = [token]
1052
+ tks[-1].whitespace = token.whitespace
1053
+ for j, tk in enumerate(tks):
1054
+ if tk.alias is not None or tk.phonemes is not None:
1055
+ pass
1056
+ elif tk.tag == "$" and tk.text in CURRENCIES:
1057
+ currency = tk.text
1058
+ tk.phonemes = ""
1059
+ tk.rating = 4
1060
+ elif tk.tag == ":" and tk.text in ("-", "–"):
1061
+ tk.phonemes = "—"
1062
+ tk.rating = 3
1063
+ elif (tk.tag in PUNCT_TAGS
1064
+ and not all(97 <= ord(c.lower()) <= 122 for c in tk.text)):
1065
+ tk.phonemes = PUNCT_TAG_PHONEMES.get(
1066
+ tk.tag, "".join(c for c in tk.text if c in PUNCTS)
1067
+ )
1068
+ tk.rating = 4
1069
+ elif currency is not None:
1070
+ if tk.tag != "CD":
1071
+ currency = None
1072
+ elif (j + 1 == len(tks)
1073
+ and (i + 1 == len(tokens) or tokens[i + 1].tag != "CD")):
1074
+ tk.currency = currency
1075
+ elif (0 < j < len(tks) - 1 and tk.text == "2"
1076
+ and (tks[j - 1].text[-1] + tks[j + 1].text[0]).isalpha()):
1077
+ tk.alias = "to"
1078
+ if tk.alias is not None or tk.phonemes is not None:
1079
+ words.append(tk)
1080
+ elif words and isinstance(words[-1], list) and not words[-1][-1].whitespace:
1081
+ tk.is_head = False
1082
+ words[-1].append(tk)
1083
+ else:
1084
+ words.append(tk if tk.whitespace else [tk])
1085
+ return [w[0] if isinstance(w, list) and len(w) == 1 else w for w in words]
1086
+
1087
+ @staticmethod
1088
+ def token_context(ctx: TokenContext, ps: str | None, token: Token) -> TokenContext:
1089
+ """G2P.token_context."""
1090
+ vowel = ctx.future_vowel
1091
+ if ps:
1092
+ vowel = next(
1093
+ (None if c in NON_QUOTE_PUNCTS else (c in VOWELS)
1094
+ for c in ps
1095
+ if any(c in group for group in (VOWELS, CONSONANTS, NON_QUOTE_PUNCTS))),
1096
+ vowel,
1097
+ )
1098
+ future_to = token.text in ("to", "To") or (token.text == "TO"
1099
+ and token.tag in ("TO", "IN"))
1100
+ return TokenContext(future_vowel=vowel, future_to=future_to)
1101
+
1102
+ @staticmethod
1103
+ def resolve_tokens(tokens: list[Token]) -> None:
1104
+ """G2P.resolve_tokens."""
1105
+ text = "".join(tk.text + tk.whitespace for tk in tokens[:-1]) + tokens[-1].text
1106
+ prespace = (" " in text or "/" in text
1107
+ or len({0 if c.isalpha() else (1 if is_digit(c) else 2)
1108
+ for c in text if c not in SUBTOKEN_JUNKS}) > 1)
1109
+ for i, tk in enumerate(tokens):
1110
+ if tk.phonemes is None:
1111
+ if i == len(tokens) - 1 and tk.text in NON_QUOTE_PUNCTS:
1112
+ tk.phonemes = tk.text
1113
+ tk.rating = 3
1114
+ elif all(c in SUBTOKEN_JUNKS for c in tk.text):
1115
+ tk.phonemes = ""
1116
+ tk.rating = 3
1117
+ elif i > 0:
1118
+ tk.prespace = prespace
1119
+ if prespace:
1120
+ return
1121
+ indices = [(PRIMARY_STRESS in tk.phonemes, stress_weight(tk.phonemes), i)
1122
+ for i, tk in enumerate(tokens) if tk.phonemes]
1123
+ if len(indices) == 2 and len(tokens[indices[0][2]].text) == 1:
1124
+ i = indices[1][2]
1125
+ tokens[i].phonemes = apply_stress(tokens[i].phonemes, -0.5)
1126
+ return
1127
+ if len(indices) < 2 or sum(b for b, _, _ in indices) <= (len(indices) + 1) // 2:
1128
+ return
1129
+ for _, _, i in sorted(indices)[: len(indices) // 2]:
1130
+ tokens[i].phonemes = apply_stress(tokens[i].phonemes, -0.5)
1131
+
1132
+ def __call__(self, text: str) -> tuple[str, list[Token]]:
1133
+ """G2P.__call__ (preprocess reduced to a left strip)."""
1134
+ tokens: list[Token | list[Token]] = EnglishG2P.retokenize(
1135
+ EnglishG2P.fold_left(tokenize(text.lstrip()))
1136
+ )
1137
+ ctx = TokenContext()
1138
+ for _i, word in reversed(list(enumerate(tokens))):
1139
+ if not isinstance(word, list):
1140
+ if word.phonemes is None:
1141
+ word.phonemes, word.rating = self.lexicon(word, ctx)
1142
+ if word.phonemes is None and self.fallback is not None:
1143
+ word.phonemes, word.rating = self.fallback(word)
1144
+ ctx = EnglishG2P.token_context(ctx, word.phonemes, word)
1145
+ continue
1146
+ left, right = 0, len(word)
1147
+ should_fallback = False
1148
+ while left < right:
1149
+ if any(tk.alias is not None or tk.phonemes is not None
1150
+ for tk in word[left:right]):
1151
+ merged = None
1152
+ else:
1153
+ merged = merge_tokens(word[left:right])
1154
+ ps, rating = (None, None) if merged is None else self.lexicon(merged, ctx)
1155
+ # `ps` is None whenever `merged` is, but only the reader knows
1156
+ # that; naming both keeps the invariant checkable.
1157
+ if merged is not None and ps is not None:
1158
+ word[left].phonemes = ps
1159
+ word[left].rating = rating
1160
+ for other in word[left + 1:right]:
1161
+ other.phonemes = ""
1162
+ other.rating = rating
1163
+ ctx = EnglishG2P.token_context(ctx, ps, merged)
1164
+ right = left
1165
+ left = 0
1166
+ elif left + 1 < right:
1167
+ left += 1
1168
+ else:
1169
+ right -= 1
1170
+ tk = word[right]
1171
+ if tk.phonemes is None:
1172
+ if all(c in SUBTOKEN_JUNKS for c in tk.text):
1173
+ tk.phonemes = ""
1174
+ tk.rating = 3
1175
+ elif self.fallback is not None:
1176
+ should_fallback = True
1177
+ break
1178
+ left = 0
1179
+ if should_fallback:
1180
+ merged = merge_tokens(word)
1181
+ word[0].phonemes, word[0].rating = self.fallback(merged)
1182
+ for other in word[1:]:
1183
+ other.phonemes = ""
1184
+ other.rating = word[0].rating
1185
+ else:
1186
+ EnglishG2P.resolve_tokens(word)
1187
+ flat = [merge_tokens(tk, unk=self.unk) if isinstance(tk, list) else tk for tk in tokens]
1188
+ # the reference lexicon applies this whenever version != '2.0', which is the text pipeline's setting.
1189
+ for tk in flat:
1190
+ if tk.phonemes:
1191
+ tk.phonemes = tk.phonemes.replace("ɾ", "T").replace("ʔ", "t")
1192
+ result = "".join((self.unk if tk.phonemes is None else tk.phonemes) + tk.whitespace
1193
+ for tk in flat)
1194
+ return result, flat
1195
+
1196
+
1197
+ # A word-like run for the fallback: letters, plus the hyphens, apostrophes and
1198
+ # periods that sit between letters. The model's grapheme vocabulary contains
1199
+ # "-", "'" and ".", because the reference lexicon's dictionary keys do, so hyphenated names
1200
+ # stay in-domain instead of being chopped into pieces it never saw.
1201
+ _FALLBACK_PIECE_RE = re.compile(
1202
+ r"[^\W\d_]+(?:['’\-.][^\W\d_]+)*|\s+|.", re.DOTALL
1203
+ )
1204
+
1205
+
1206
+ def _fold_to_model_alphabet(word: str) -> str:
1207
+ """Drop the diacritics the model has no grapheme token for.
1208
+
1209
+ It was trained on the reference lexicon's dictionary keys, whose alphabet is ASCII letters
1210
+ plus "'", "-" and "."; an accented letter would become <unk> and take the
1211
+ surrounding phonemes down with it. NFD-decomposing and dropping the
1212
+ combining marks turns "café" into "cafe", which the model can read.
1213
+ """
1214
+ decomposed = unicodedata.normalize("NFD", word.replace(chr(8217), "'"))
1215
+ stripped = "".join(c for c in decomposed if not unicodedata.combining(c))
1216
+ return unicodedata.normalize("NFC", stripped).strip("-.'")
1217
+
1218
+
1219
+ def _split_for_model(word: str, max_positions: int) -> list[str]:
1220
+ """Cut a word that will not fit the model's 64-position table.
1221
+
1222
+ Ordinary English words never reach this; runs of letters this long are URLs
1223
+ with the separators already stripped, or someone leaning on a key. Cutting
1224
+ is better than raising, because raising loses the whole utterance over one
1225
+ pathological token.
1226
+ """
1227
+ limit = max_positions - 2 # the model's own bos and eos
1228
+ if len(word) <= limit:
1229
+ return [word]
1230
+ return [word[i:i + limit] for i in range(0, len(word), limit)]
1231
+
1232
+
1233
+ class _NeuralFallback:
1234
+ """The OOV path: the bundled neural fallback for out-of-vocabulary words.
1235
+
1236
+ the reference lexicon hands the fallback whatever the back-off loop could not resolve,
1237
+ which is not always a single clean word: a token like "forgave;--that" gets
1238
+ here whole because an external parser never split it and no prefix of it is in the
1239
+ dictionary. espeak phonemizes the word runs and keeps the punctuation. This
1240
+ does the same -- word runs through the model, characters that are in PUNCTS
1241
+ passed through, everything else dropped -- rather than handing the model a
1242
+ string full of characters it has no token for.
1243
+ """
1244
+
1245
+ def __init__(self, model: OOVPhonemizer | None = None) -> None:
1246
+ self._model = model
1247
+
1248
+ def __call__(self, token: Token) -> tuple[str | None, int | None]:
1249
+ text = token.text.strip()
1250
+ if not text:
1251
+ return "", 2
1252
+ if self._model is None:
1253
+ self._model = shared_phonemizer()
1254
+ out: list[str] = []
1255
+ for piece in _FALLBACK_PIECE_RE.findall(text):
1256
+ if piece.isspace():
1257
+ if out and not out[-1].endswith(" "):
1258
+ out.append(" ")
1259
+ continue
1260
+ if piece[0].isalpha():
1261
+ cleaned = _fold_to_model_alphabet(piece)
1262
+ if not cleaned:
1263
+ continue
1264
+ for part in _split_for_model(cleaned, self._model.max_positions):
1265
+ try:
1266
+ phonemes = self._model(part)
1267
+ except OOVModelError as exc:
1268
+ raise FrontendError(
1269
+ "oov", f"the fallback model could not phonemize {part!r}: {exc}"
1270
+ ) from exc
1271
+ if phonemes:
1272
+ out.append(phonemes)
1273
+ elif piece in PUNCTS:
1274
+ out.append(piece)
1275
+ joined = "".join(out).strip()
1276
+ return joined, 2
1277
+
1278
+
1279
+ # --------------------------------------------------------------------------
1280
+ # Chunking and the public entry point
1281
+ # --------------------------------------------------------------------------
1282
+
1283
+
1284
+ def _tokens_to_ps(tokens: list[Token]) -> str:
1285
+ """tokens_to_ps: phoneme string -> the 62-symbol token ids."""
1286
+ return "".join((tk.phonemes or "") + (" " if tk.whitespace else "")
1287
+ for tk in tokens).strip()
1288
+
1289
+
1290
+ def _waterfall_last(tokens: list[Token], next_count: int) -> int:
1291
+ """waterfall_last: the word-level fallback chain."""
1292
+ for group in WATERFALL:
1293
+ z = next((i for i, t in reversed(list(enumerate(tokens))) if t.phonemes in set(group)),
1294
+ None)
1295
+ if z is None:
1296
+ continue
1297
+ z += 1
1298
+ if z < len(tokens) and tokens[z].phonemes in WATERFALL_BUMPS:
1299
+ z += 1
1300
+ if next_count - len(_tokens_to_ps(tokens[:z])) <= MAX_PS_CHARS:
1301
+ return z
1302
+ return len(tokens)
1303
+
1304
+
1305
+ def en_chunks(tokens: list[Token]) -> list[str]:
1306
+ """en_tokenize, reduced to the phoneme strings.
1307
+
1308
+ The chunk boundaries matter beyond length: each chunk is wrapped in its own
1309
+ <bos>/<eos> when the ids are built, which is how the training packs were
1310
+ laid out.
1311
+ """
1312
+ chunks: list[str] = []
1313
+ held: list[Token] = []
1314
+ pcount = 0
1315
+ for token in tokens:
1316
+ if token.phonemes is None:
1317
+ token.phonemes = ""
1318
+ next_ps = token.phonemes + (" " if token.whitespace else "")
1319
+ next_pcount = pcount + len(next_ps.rstrip())
1320
+ if next_pcount > MAX_PS_CHARS:
1321
+ z = _waterfall_last(held, next_pcount)
1322
+ ps = _tokens_to_ps(held[:z])
1323
+ if ps:
1324
+ chunks.append(ps)
1325
+ held = held[z:]
1326
+ pcount = len(_tokens_to_ps(held))
1327
+ if not held:
1328
+ next_ps = next_ps.lstrip()
1329
+ held.append(token)
1330
+ pcount += len(next_ps)
1331
+ if held:
1332
+ ps = _tokens_to_ps(held)
1333
+ if ps:
1334
+ chunks.append(ps)
1335
+ return chunks
1336
+
1337
+
1338
+ _SHARED_G2P: EnglishG2P | None = None
1339
+
1340
+
1341
+ def shared_g2p() -> EnglishG2P:
1342
+ """One process-wide G2P.
1343
+
1344
+ Building it reads 6.1 MB of JSON into 365,368 dict entries (the reference lexicon's
1345
+ `grow_dictionary` adds the capitalised and lower-cased variants), which
1346
+ takes about 0.2 s and 68 MB of resident memory. Worth doing once.
1347
+ """
1348
+ global _SHARED_G2P
1349
+ if _SHARED_G2P is None:
1350
+ _SHARED_G2P = EnglishG2P()
1351
+ return _SHARED_G2P
1352
+
1353
+
1354
+ def phonemize(text: str, *, vocabulary: dict[str, int] | None = None,
1355
+ max_tokens: int = DEFAULT_MAX_TOKENS,
1356
+ g2p: EnglishG2P | None = None) -> tuple[list[int], str]:
1357
+ """Text -> (phoneme ids, dropped symbols).
1358
+
1359
+ American English: the dictionaries bundled in g2p_data/ are US gold/silver.
1360
+ """
1361
+ if not isinstance(text, str) or not text.strip():
1362
+ raise FrontendError("empty", "text must be a non-empty string")
1363
+ engine = g2p if g2p is not None else shared_g2p()
1364
+ _result, tokens = engine(text)
1365
+ chunks = en_chunks(tokens)
1366
+ if not chunks:
1367
+ raise FrontendError("empty", f"phonemization produced no symbols for text: {text!r}")
1368
+ ids: list[int] = []
1369
+ dropped: list[str] = []
1370
+ for chunk in chunks:
1371
+ if len(chunk) > MAX_PS_CHARS:
1372
+ chunk = chunk[:MAX_PS_CHARS]
1373
+ # The per-chunk cap is disabled (a chunk can never exceed its own length
1374
+ # plus BOS/EOS) so that the real limit is checked once, on the whole
1375
+ # sequence, and reports the number the caller cares about.
1376
+ chunk_ids, chunk_dropped = phonemes_to_token_ids(
1377
+ chunk, vocabulary, max_tokens=len(chunk) + 2
1378
+ )
1379
+ ids.extend(chunk_ids)
1380
+ dropped.append(chunk_dropped)
1381
+ if len(ids) > max_tokens:
1382
+ raise FrontendError(
1383
+ "too_long",
1384
+ f"phoneme sequence has {len(ids)} tokens including BOS/EOS; maximum is {max_tokens}",
1385
+ )
1386
+ return ids, "".join(dropped)
1387
+
1388
+
1389
+ def phonemize_to_string(text: str, *, g2p: EnglishG2P | None = None) -> str:
1390
+ """The phoneme string alone, for comparing against the reference lexicon without the ids."""
1391
+ if not isinstance(text, str) or not text.strip():
1392
+ raise FrontendError("empty", "text must be a non-empty string")
1393
+ engine = g2p if g2p is not None else shared_g2p()
1394
+ return "".join(en_chunks(engine(text)[1]))
microtts/g2p/g2p_data/LICENSE.apache-2.0.txt ADDED
@@ -0,0 +1,201 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright [yyyy] [name of copyright owner]
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
microtts/g2p/g2p_data/NOTICE.md ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Bundled data — `microtts/g2p/g2p_data/`
2
+
3
+ The grapheme-to-phoneme front end runs on three data files that are
4
+ redistributed here under the Apache License 2.0 (full text in
5
+ `LICENSE.apache-2.0.txt`):
6
+
7
+ | file | what it is |
8
+ |---|---|
9
+ | `us_gold.json` | US English pronunciation dictionary (gold subset) |
10
+ | `us_silver.json` | US English pronunciation dictionary (silver subset) |
11
+ | `oov_bart_en_us.npz` | neural fallback model for out-of-vocabulary words (weights only) |
12
+
13
+ `microtts/g2p/oov.py` is a numpy-only transcription of the fallback model's
14
+ forward pass; `microtts/g2p/english.py` is the dictionary-first front end.
15
+
16
+ No GPL code or data is included.
microtts/g2p/g2p_data/oov_bart_en_us.npz ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:f14370fca67996500724d2ecfb73c5ef170ff3bbc5814d613943f75730270ff2
3
+ size 2806853
microtts/g2p/g2p_data/us_gold.json ADDED
The diff for this file is too large to render. See raw diff
 
microtts/g2p/g2p_data/us_silver.json ADDED
The diff for this file is too large to render. See raw diff
 
microtts/g2p/oov.py ADDED
@@ -0,0 +1,244 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Numpy-only grapheme -> phoneme model for words the dictionary does not have.
2
+
3
+ The checkpoint is the bundled fallback model, a
4
+ 751,551-parameter transformer with one encoder layer, one decoder layer, one attention
5
+ head and d_model 128, trained on the reference lexicon's own us_gold/us_silver dictionaries.
6
+ It was not distilled from espeak-ng, which is the whole point: it is the OOV
7
+ path for a front end that must not carry a GPL dependency.
8
+
9
+ The published safetensors were converted once to `g2p_data/oov_bart_en_us.npz`;
10
+ this module is the forward pass, transcribed
11
+ from transformers' `BartModel` so it can run under numpy alone:
12
+
13
+ * post-norm blocks (residual add, then LayerNorm), which is transformer, not the
14
+ pre-norm arrangement most later decoders use;
15
+ * learned positional embeddings with the offset of 2 that transformer reserves for
16
+ pad and bos, hence the 66-row table for 64 positions;
17
+ * `scale_embedding=false`, so token embeddings are used unscaled;
18
+ * exact erf gelu, not the tanh approximation;
19
+ * greedy decoding from `decoder_start_token_id`, which is what the upstream
20
+ reference script gets from its generation config (no beams, no sampling).
21
+
22
+ Decoding is per word and words are short, so there is no KV cache: the decoder
23
+ is re-run over the whole prefix each step. A cache would save microseconds on
24
+ sequences that are at most a few dozen tokens and is not worth the surface area.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import json
30
+ import math
31
+ from pathlib import Path
32
+
33
+ import numpy as np
34
+
35
+ DATA_DIR = Path(__file__).resolve().parent / "g2p_data"
36
+ MODEL_PATH = DATA_DIR / "oov_bart_en_us.npz"
37
+
38
+ # The tokenizer reserves 0..3 for <pad>/<s>/</s>/<unk> and then indexes the
39
+ # character lists; the shipped config records both lists already padded with
40
+ # four underscores so the character at index i is simply chars[i].
41
+ SPECIAL_TOKEN_COUNT = 4
42
+ UNK_ID = 3
43
+
44
+
45
+ class OOVModelError(RuntimeError):
46
+ """Raised when the fallback model is unusable; never swallowed by callers."""
47
+
48
+
49
+ def _layer_norm(x: np.ndarray, weight: np.ndarray, bias: np.ndarray, eps: float) -> np.ndarray:
50
+ mean = x.mean(axis=-1, keepdims=True)
51
+ var = x.var(axis=-1, keepdims=True)
52
+ return (x - mean) / np.sqrt(var + eps) * weight + bias
53
+
54
+
55
+ def _erf(x: np.ndarray) -> np.ndarray:
56
+ """Abramowitz-Stegun 7.1.26 erf, max abs error 1.5e-7.
57
+
58
+ transformers maps the "gelu" activation to the erf form, not the tanh
59
+ approximation. numpy has no erf and scipy is not a dependency of this
60
+ package, so the approximation is inlined. 1.5e-7 is far below the margin between competing
61
+ characters in a 63-way argmax; an output-parity check compares this port's
62
+ greedy output against the transformers implementation on the dictionary and
63
+ finds no disagreement.
64
+ """
65
+ sign = np.sign(x)
66
+ ax = np.abs(x)
67
+ t = 1.0 / (1.0 + 0.3275911 * ax)
68
+ poly = t * (0.254829592 + t * (-0.284496736 + t * (1.421413741
69
+ + t * (-1.453152027 + t * 1.061405429))))
70
+ return sign * (1.0 - poly * np.exp(-ax * ax))
71
+
72
+
73
+ def _gelu_exact(x: np.ndarray) -> np.ndarray:
74
+ return 0.5 * x * (1.0 + _erf(x / math.sqrt(2.0)))
75
+
76
+
77
+ def _softmax(x: np.ndarray) -> np.ndarray:
78
+ shifted = x - x.max(axis=-1, keepdims=True)
79
+ exp = np.exp(shifted)
80
+ return exp / exp.sum(axis=-1, keepdims=True)
81
+
82
+
83
+ class _Attention:
84
+ """Single-head BartAttention. num_heads is 1 in this checkpoint, asserted
85
+ by the asset builder, so the head reshape collapses to a plain matmul."""
86
+
87
+ def __init__(self, weights: dict[str, np.ndarray], prefix: str, scaling: float) -> None:
88
+ self.q_w = weights[f"{prefix}.q_proj.weight"].T
89
+ self.q_b = weights[f"{prefix}.q_proj.bias"]
90
+ self.k_w = weights[f"{prefix}.k_proj.weight"].T
91
+ self.k_b = weights[f"{prefix}.k_proj.bias"]
92
+ self.v_w = weights[f"{prefix}.v_proj.weight"].T
93
+ self.v_b = weights[f"{prefix}.v_proj.bias"]
94
+ self.o_w = weights[f"{prefix}.out_proj.weight"].T
95
+ self.o_b = weights[f"{prefix}.out_proj.bias"]
96
+ self.scaling = scaling
97
+
98
+ def __call__(self, query: np.ndarray, memory: np.ndarray,
99
+ causal: bool = False) -> np.ndarray:
100
+ q = (query @ self.q_w + self.q_b) * self.scaling
101
+ k = memory @ self.k_w + self.k_b
102
+ v = memory @ self.v_w + self.v_b
103
+ scores = q @ k.T
104
+ if causal:
105
+ mask = np.triu(np.ones((scores.shape[0], scores.shape[1]), dtype=bool), k=1)
106
+ scores = np.where(mask, np.float32(-np.inf), scores)
107
+ return _softmax(scores) @ v @ self.o_w + self.o_b
108
+
109
+
110
+ class _Layer:
111
+ """One BartEncoderLayer, or one BartDecoderLayer when `cross` is present."""
112
+
113
+ def __init__(self, weights: dict[str, np.ndarray], prefix: str,
114
+ scaling: float, eps: float, cross: bool) -> None:
115
+ self.eps = eps
116
+ self.self_attn = _Attention(weights, f"{prefix}.self_attn", scaling)
117
+ self.self_ln = (weights[f"{prefix}.self_attn_layer_norm.weight"],
118
+ weights[f"{prefix}.self_attn_layer_norm.bias"])
119
+ if cross:
120
+ self.cross_attn = _Attention(weights, f"{prefix}.encoder_attn", scaling)
121
+ self.cross_ln = (weights[f"{prefix}.encoder_attn_layer_norm.weight"],
122
+ weights[f"{prefix}.encoder_attn_layer_norm.bias"])
123
+ else:
124
+ self.cross_attn = None
125
+ self.cross_ln = None
126
+ self.fc1_w = weights[f"{prefix}.fc1.weight"].T
127
+ self.fc1_b = weights[f"{prefix}.fc1.bias"]
128
+ self.fc2_w = weights[f"{prefix}.fc2.weight"].T
129
+ self.fc2_b = weights[f"{prefix}.fc2.bias"]
130
+ self.final_ln = (weights[f"{prefix}.final_layer_norm.weight"],
131
+ weights[f"{prefix}.final_layer_norm.bias"])
132
+
133
+ def __call__(self, hidden: np.ndarray, memory: np.ndarray | None,
134
+ causal: bool) -> np.ndarray:
135
+ hidden = _layer_norm(hidden + self.self_attn(hidden, hidden, causal=causal),
136
+ *self.self_ln, self.eps)
137
+ if self.cross_attn is not None:
138
+ if memory is None:
139
+ raise OOVModelError("decoder layer reached with no encoder memory")
140
+ hidden = _layer_norm(hidden + self.cross_attn(hidden, memory),
141
+ *self.cross_ln, self.eps)
142
+ ffn = _gelu_exact(hidden @ self.fc1_w + self.fc1_b) @ self.fc2_w + self.fc2_b
143
+ return _layer_norm(hidden + ffn, *self.final_ln, self.eps)
144
+
145
+
146
+ class OOVPhonemizer:
147
+ """Greedy grapheme -> phoneme decoding for one word at a time."""
148
+
149
+ def __init__(self, path: Path | str = MODEL_PATH) -> None:
150
+ path = Path(path)
151
+ if not path.is_file():
152
+ raise OOVModelError(
153
+ f"fallback model not found at {path}; run "
154
+ "the bundled asset appears incomplete"
155
+ )
156
+ try:
157
+ with np.load(path, allow_pickle=False) as bundle:
158
+ weights = {name: np.asarray(bundle[name], dtype=np.float32)
159
+ for name in bundle.files if name != "meta_json"}
160
+ meta_raw = str(bundle["meta_json"])
161
+ except (OSError, ValueError, KeyError) as exc:
162
+ raise OOVModelError(f"could not read {path}: {exc}") from exc
163
+ try:
164
+ meta = json.loads(meta_raw)
165
+ except json.JSONDecodeError as exc:
166
+ raise OOVModelError(f"{path}: meta_json is not valid JSON: {exc}") from exc
167
+
168
+ self.grapheme_chars: str = meta["grapheme_chars"]
169
+ self.phoneme_chars: str = meta["phoneme_chars"]
170
+ self.max_positions = int(meta["max_position_embeddings"])
171
+ self.bos_id = int(meta["bos_token_id"])
172
+ self.eos_id = int(meta["eos_token_id"])
173
+ self.start_id = int(meta["decoder_start_token_id"])
174
+ self.offset = int(meta["position_offset"])
175
+ eps = float(meta["layer_norm_eps"])
176
+ heads = int(meta["num_heads"])
177
+ d_model = int(meta["d_model"])
178
+ if heads != 1:
179
+ raise OOVModelError(f"this port assumes one attention head, checkpoint has {heads}")
180
+ scaling = (d_model // heads) ** -0.5
181
+
182
+ self.grapheme_to_id = {
183
+ ch: i for i, ch in enumerate(self.grapheme_chars) if i >= SPECIAL_TOKEN_COUNT
184
+ }
185
+ self.embed = weights["model.shared.weight"]
186
+ self.logit_bias = weights["final_logits_bias"][0]
187
+ self.enc_pos = weights["model.encoder.embed_positions.weight"]
188
+ self.dec_pos = weights["model.decoder.embed_positions.weight"]
189
+ self.enc_ln = (weights["model.encoder.layernorm_embedding.weight"],
190
+ weights["model.encoder.layernorm_embedding.bias"])
191
+ self.dec_ln = (weights["model.decoder.layernorm_embedding.weight"],
192
+ weights["model.decoder.layernorm_embedding.bias"])
193
+ self.encoder = _Layer(weights, "model.encoder.layers.0", scaling, eps, cross=False)
194
+ self.decoder = _Layer(weights, "model.decoder.layers.0", scaling, eps, cross=True)
195
+ self.eps = eps
196
+
197
+ def encode_word(self, word: str) -> list[int]:
198
+ """Character ids with bos/eos, unknown characters mapped to <unk>."""
199
+ return [self.bos_id, *(self.grapheme_to_id.get(c, UNK_ID) for c in word), self.eos_id]
200
+
201
+ def __call__(self, word: str) -> str:
202
+ """Phonemes for `word`, or '' if the model emits nothing usable."""
203
+ if not word:
204
+ raise OOVModelError("cannot phonemize an empty word")
205
+ ids = self.encode_word(word)
206
+ if len(ids) > self.max_positions:
207
+ raise OOVModelError(
208
+ f"word {word!r} needs {len(ids)} positions; the model has {self.max_positions}"
209
+ )
210
+ memory = self._encode(np.asarray(ids, dtype=np.int64))
211
+
212
+ out: list[int] = [self.start_id]
213
+ for _ in range(self.max_positions - 1):
214
+ logits = self._decode_step(np.asarray(out, dtype=np.int64), memory)
215
+ nxt = int(np.argmax(logits))
216
+ if nxt == self.eos_id:
217
+ break
218
+ out.append(nxt)
219
+ return "".join(
220
+ self.phoneme_chars[i] for i in out[1:]
221
+ if SPECIAL_TOKEN_COUNT <= i < len(self.phoneme_chars)
222
+ )
223
+
224
+ def _encode(self, ids: np.ndarray) -> np.ndarray:
225
+ hidden = self.embed[ids] + self.enc_pos[self.offset : self.offset + len(ids)]
226
+ hidden = _layer_norm(hidden, *self.enc_ln, self.eps)
227
+ return self.encoder(hidden, None, causal=False)
228
+
229
+ def _decode_step(self, ids: np.ndarray, memory: np.ndarray) -> np.ndarray:
230
+ hidden = self.embed[ids] + self.dec_pos[self.offset : self.offset + len(ids)]
231
+ hidden = _layer_norm(hidden, *self.dec_ln, self.eps)
232
+ hidden = self.decoder(hidden, memory, causal=True)
233
+ return hidden[-1] @ self.embed.T + self.logit_bias
234
+
235
+
236
+ _SHARED: OOVPhonemizer | None = None
237
+
238
+
239
+ def shared_phonemizer() -> OOVPhonemizer:
240
+ """One process-wide instance; loading the npz costs ~3 MB and a few ms."""
241
+ global _SHARED
242
+ if _SHARED is None:
243
+ _SHARED = OOVPhonemizer()
244
+ return _SHARED
microtts/models.py ADDED
@@ -0,0 +1,225 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Vocetta2 student networks: three small models over a mel-100 interface.
2
+
3
+ duration: phoneme ids -> per-phoneme frame counts
4
+ acoustic: ids + durations -> mel spectrogram [100, T]
5
+ decoder: mel + noise [4, T] -> complex spectrum [513, T] -> iSTFT -> audio
6
+
7
+ Residual Conv1d blocks, ConvNeXt1D decoder, LayerNorm eps=1e-6, an
8
+ exp-magnitude head with bin 0 and the Nyquist bin zeroed, Hann-windowed
9
+ overlap-add iSTFT and a DC-blocking filter.
10
+ """
11
+ import math
12
+
13
+ import numpy as np
14
+ import torch
15
+ import torch.nn as nn
16
+ import torch.nn.functional as F
17
+
18
+
19
+ # ---------------------------------------------------------------------------
20
+ # Duration student
21
+ # ---------------------------------------------------------------------------
22
+ class DurationStudent(nn.Module):
23
+ def __init__(self, vocab=62, hidden=20, depth=3, kernel=5,
24
+ max_tokens=207, max_duration=80):
25
+ super().__init__()
26
+ self.hidden = hidden
27
+ self.kernel = kernel
28
+ self.max_tokens = max_tokens
29
+ self.max_duration = max_duration
30
+ self.embed = nn.Embedding(vocab, hidden)
31
+ self.proj = nn.Conv1d(hidden + 3, hidden, 1)
32
+ self.blocks = nn.ModuleList()
33
+ self.scales = nn.ParameterList()
34
+ for _ in range(depth):
35
+ self.blocks.append(nn.ModuleList([
36
+ nn.Conv1d(hidden, hidden, kernel, padding=kernel // 2),
37
+ nn.Conv1d(hidden, hidden, kernel, padding=kernel // 2),
38
+ ]))
39
+ self.scales.append(nn.Parameter(torch.tensor(0.5)))
40
+ self.out = nn.Conv1d(hidden, 1, 1)
41
+
42
+ def forward(self, ids, round_out=True):
43
+ n = ids.shape[0]
44
+ x = self.embed(ids).T # [H, n]
45
+ positions = torch.linspace(0.0, 1.0, n, device=ids.device) if n > 1 else torch.zeros(1, device=ids.device)
46
+ length_hint = math.log1p(max(n, 1)) / math.log1p(float(self.max_tokens))
47
+ feats = torch.stack([positions,
48
+ torch.full((n,), length_hint, device=ids.device),
49
+ torch.ones(n, device=ids.device)])
50
+ x = torch.cat([x, feats], dim=0) # [H+3, n]
51
+ x = self.proj(x)
52
+ for (c0, c1), scale in zip(self.blocks, self.scales):
53
+ h = F.silu(c0(x))
54
+ h = c1(h)
55
+ x = x + scale * h
56
+ log_d = self.out(x)[0] # [n] log-duration (differentiable)
57
+ if not round_out:
58
+ return log_d
59
+ d = torch.round(torch.clamp(torch.exp(log_d), min=1.0))
60
+ return torch.clamp(d, 1, self.max_duration).long()
61
+
62
+
63
+ # ---------------------------------------------------------------------------
64
+ # Acoustic student (token-context)
65
+ # ---------------------------------------------------------------------------
66
+ class AcousticStudent(nn.Module):
67
+ def __init__(self, vocab=62, hidden=24, token_depth=3, depth=3, kernel=5, n_mels=100):
68
+ super().__init__()
69
+ self.hidden = hidden
70
+ self.kernel = kernel
71
+ self.embed = nn.Embedding(vocab, hidden)
72
+ self.tproj = nn.Conv1d(hidden + 2, hidden, 1)
73
+ self.token_blocks = nn.ModuleList()
74
+ self.token_scales = nn.ParameterList()
75
+ for _ in range(token_depth):
76
+ self.token_blocks.append(nn.ModuleList([
77
+ nn.Conv1d(hidden, hidden, kernel, padding=kernel // 2),
78
+ nn.Conv1d(hidden, hidden, kernel, padding=kernel // 2),
79
+ ]))
80
+ self.token_scales.append(nn.Parameter(torch.tensor(0.5)))
81
+ self.fproj = nn.Conv1d(hidden + 3, hidden, 1)
82
+ self.frame_blocks = nn.ModuleList()
83
+ self.frame_scales = nn.ParameterList()
84
+ for _ in range(depth):
85
+ self.frame_blocks.append(nn.ModuleList([
86
+ nn.Conv1d(hidden, hidden, kernel, padding=kernel // 2),
87
+ nn.Conv1d(hidden, hidden, kernel, padding=kernel // 2),
88
+ ]))
89
+ self.frame_scales.append(nn.Parameter(torch.tensor(0.5)))
90
+ self.out = nn.Conv1d(hidden, n_mels, 1)
91
+
92
+ def forward(self, ids, durations):
93
+ n = ids.shape[0]
94
+ dev = ids.device
95
+ x = self.embed(ids).T # [H, n]
96
+ token_pos = torch.linspace(0.0, 1.0, n, device=dev) if n > 1 else torch.zeros(1, device=dev)
97
+ dur_f = durations.float().to(dev)
98
+ max_dur = max(float(dur_f.max()), 1.0)
99
+ duration_hint = torch.log1p(dur_f) / math.log1p(max_dur)
100
+ x = torch.cat([x, torch.stack([token_pos, duration_hint])], dim=0) # [H+2, n]
101
+ x = self.tproj(x)
102
+ for (c0, c1), scale in zip(self.token_blocks, self.token_scales):
103
+ h = F.silu(c0(x))
104
+ h = c1(h)
105
+ x = x + scale * h
106
+ # repeat by durations
107
+ x = torch.repeat_interleave(x, durations.to(dev), dim=1) # [H, T]
108
+ frames = x.shape[1]
109
+ frame_pos = torch.linspace(0.0, 1.0, frames, device=dev) if frames > 1 else torch.zeros(1, device=dev)
110
+ token_pos_f = torch.repeat_interleave(token_pos, durations.to(dev))
111
+ dur_pos_f = torch.cat([torch.linspace(0.0, 1.0, int(d), device=dev) if d > 1
112
+ else torch.zeros(1, device=dev) for d in durations])
113
+ x = torch.cat([x, torch.stack([frame_pos, token_pos_f, dur_pos_f])], dim=0) # [H+3, T]
114
+ x = self.fproj(x)
115
+ for (c0, c1), scale in zip(self.frame_blocks, self.frame_scales):
116
+ h = F.silu(c0(x))
117
+ h = c1(h)
118
+ x = x + scale * h
119
+ return self.out(x) # [100, T]
120
+
121
+
122
+ # ---------------------------------------------------------------------------
123
+ # Decoder student (ConvNeXt1D -> iSTFT)
124
+ # ---------------------------------------------------------------------------
125
+ class ConvNeXtBlock(nn.Module):
126
+ def __init__(self, dim, pw_hidden, dw_kernel=7):
127
+ super().__init__()
128
+ self.dw = nn.Conv1d(dim, dim, dw_kernel, padding=dw_kernel // 2, groups=dim)
129
+ self.norm = nn.LayerNorm(dim, eps=1e-6)
130
+ self.pw0 = nn.Linear(dim, pw_hidden)
131
+ self.pw1 = nn.Linear(pw_hidden, dim)
132
+ self.gamma = nn.Parameter(torch.ones(dim))
133
+
134
+ def forward(self, x):
135
+ # x: [C, T]
136
+ h = self.dw(x)
137
+ h = h.transpose(0, 1) # [T, C]
138
+ h = self.norm(h)
139
+ h = F.gelu(self.pw0(h))
140
+ h = self.pw1(h)
141
+ h = h.transpose(0, 1) * self.gamma[:, None] # [C, T]
142
+ return x + h
143
+
144
+
145
+ class DecoderStudent(nn.Module):
146
+ def __init__(self, n_mels=100, dim=48, blocks=3, pw_hidden=144,
147
+ dw_kernel=7, embed_kernel=7, noise_ch=4,
148
+ n_fft=1024, hop=256, bins=513):
149
+ super().__init__()
150
+ self.n_fft = n_fft
151
+ self.hop = hop
152
+ self.bins = bins
153
+ self.embed = nn.Conv1d(n_mels, dim, embed_kernel, padding=embed_kernel // 2)
154
+ self.noise_adapter = nn.Conv1d(noise_ch, dim, embed_kernel, padding=embed_kernel // 2)
155
+ self.norm = nn.LayerNorm(dim, eps=1e-6)
156
+ self.blocks = nn.ModuleList([ConvNeXtBlock(dim, pw_hidden, dw_kernel) for _ in range(blocks)])
157
+ self.fnorm = nn.LayerNorm(dim, eps=1e-6)
158
+ self.head = nn.Linear(dim, bins * 2)
159
+
160
+ def forward(self, mel, noise):
161
+ # mel: [100, T], noise: [4, T]
162
+ x = self.embed(mel) + self.noise_adapter(noise) # [C, T]
163
+ x = self.norm(x.transpose(0, 1)).transpose(0, 1) # LayerNorm over channels
164
+ for b in self.blocks:
165
+ x = b(x)
166
+ x = self.fnorm(x.transpose(0, 1)).transpose(0, 1)
167
+ out = self.head(x.transpose(0, 1)).transpose(0, 1) # [2*bins, T]
168
+ mag, phase = out[:self.bins], out[self.bins:]
169
+ mag = torch.exp(mag)
170
+ mag = mag.clone()
171
+ mag[0] = 0.0
172
+ mag[-1] = 0.0
173
+ mag = torch.clamp(mag, max=1e2)
174
+ return mag.unsqueeze(-1) * torch.stack([torch.cos(phase), torch.sin(phase)], dim=-1) # [bins, T, 2]
175
+
176
+ def istft(self, spec):
177
+ """spec: [bins, T, 2] complex -> audio via Hann-windowed overlap-add."""
178
+ bins, frames, _ = spec.shape
179
+ window = torch.hann_window(self.n_fft, periodic=True, device=spec.device).double()
180
+ length = (frames - 1) * self.hop + self.n_fft
181
+ out = torch.zeros(length, device=spec.device, dtype=torch.float64)
182
+ norm = torch.zeros(length, device=spec.device, dtype=torch.float64)
183
+ spec_c = torch.complex(spec[..., 0], spec[..., 1]) # [bins, T]
184
+ frames_td = torch.fft.irfft(spec_c, n=self.n_fft, dim=0) # [n_fft, T]
185
+ wsq = window * window
186
+ for i in range(frames):
187
+ start = i * self.hop
188
+ out[start:start + self.n_fft] += frames_td[:, i] * window
189
+ norm[start:start + self.n_fft] += wsq
190
+ pad = self.n_fft // 2
191
+ out = out[pad:length - pad]
192
+ norm = norm[pad:length - pad]
193
+ nonzero = norm > 1e-11
194
+ out[nonzero] /= norm[nonzero]
195
+ return out.float()
196
+
197
+ def dc_block(self, wav, r=0.9973, taps=4096):
198
+ impulse = torch.zeros(taps, device=wav.device, dtype=torch.float64)
199
+ impulse[0] = 1.0
200
+ n = torch.arange(1, taps, device=wav.device, dtype=torch.float64)
201
+ impulse[1:] = (r - 1.0) * r ** (n - 1.0)
202
+ samples = wav.shape[0]
203
+ n_lin = samples + taps - 1
204
+ n_fft = 1 << (n_lin - 1).bit_length()
205
+ filtered = torch.fft.irfft(torch.fft.rfft(wav.double(), n_fft) * torch.fft.rfft(impulse, n_fft), n_fft)
206
+ return filtered[:samples].float()
207
+
208
+ def synthesize(self, mel, noise):
209
+ spec = self.forward(mel, noise)
210
+ return self.dc_block(self.istft(spec))
211
+
212
+
213
+ def count_params(m):
214
+ return sum(p.numel() for p in m.parameters())
215
+
216
+
217
+ if __name__ == "__main__":
218
+ dur = DurationStudent()
219
+ ac = AcousticStudent()
220
+ dec = DecoderStudent()
221
+ n_dur, n_ac, n_dec = count_params(dur), count_params(ac), count_params(dec)
222
+ print(f"duration: {n_dur:,}")
223
+ print(f"acoustic: {n_ac:,}")
224
+ print(f"decoder: {n_dec:,}")
225
+ print(f"TOTAL: {n_dur + n_ac + n_dec:,}")
microtts/synthesize.py ADDED
@@ -0,0 +1,134 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The end-to-end Vocetta2 runtime: text -> waveform.
2
+
3
+ from microtts import MicroTTS
4
+ tts = MicroTTS.load(".")
5
+ wav = tts.synthesize("Hello world.") # float32 numpy, 24 kHz
6
+
7
+ Pipeline: english G2P -> duration -> acoustic -> (+ mel tilt) -> decoder.
8
+
9
+ This model ships a mel-level tilt correction (`tilt.npy`, a 100-value per-band
10
+ offset) and the runtime applies it by default, exactly as the published
11
+ benchmark numbers were measured. For this checkpoint the shipped vector is all
12
+ zeros: zero tilt measured best for this acoustic, so applying it changes
13
+ nothing. The file is kept so the inference path carries the same explicit,
14
+ inspectable convention as the smaller releases in the family, and so a tilt
15
+ recomputed for a different acoustic has a defined home.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import os
22
+ from pathlib import Path
23
+
24
+ import numpy as np
25
+ import torch
26
+
27
+ from .g2p import phonemize
28
+ from .models import AcousticStudent, DecoderStudent, DurationStudent
29
+
30
+ SAMPLE_RATE = 24000
31
+ MAX_FRAMES = 2400 # ~25 s at hop 256 / 24 kHz; the training cap
32
+ # Architecture dims. The shipped config.json overrides these at load time, so
33
+ # one runtime serves every model in the family (500k / 1M / ...); the values
34
+ # below are this model's shipped configuration.
35
+ ACOUSTIC_HIDDEN = 72
36
+ ACOUSTIC_DEPTH = 5
37
+ ACOUSTIC_TOKEN_DEPTH = 3
38
+ DECODER_DIM = 130
39
+ DECODER_PW = 337
40
+ DECODER_BLOCKS = 4
41
+ DURATION_HIDDEN = 12
42
+ DURATION_DEPTH = 3
43
+
44
+
45
+ class MicroTTS:
46
+ """Loaded once, reused for many sentences."""
47
+
48
+ def __init__(self, duration, acoustic, decoder, tilt=None, device: str = "cpu"):
49
+ self.duration = duration.eval()
50
+ self.acoustic = acoustic.eval()
51
+ self.decoder = decoder.eval()
52
+ self.tilt = tilt # torch [100] or None
53
+ self.device = torch.device(device)
54
+
55
+ # ------------------------------------------------------------------ load
56
+ @classmethod
57
+ def load(cls, model_dir: str | os.PathLike = ".", device: str = "cpu") -> "MicroTTS":
58
+ """Load duration.pt / acoustic.pt / decoder.pt (and tilt.npy) from `model_dir`."""
59
+ d = Path(model_dir)
60
+ cfg = {}
61
+ cfg_path = d / "config.json"
62
+ if cfg_path.exists():
63
+ try:
64
+ cfg = json.loads(cfg_path.read_text(encoding="utf-8"))
65
+ except Exception:
66
+ cfg = {}
67
+ dims = cfg.get("architecture_dims", {})
68
+ dur = DurationStudent(hidden=dims.get("duration_hidden", DURATION_HIDDEN),
69
+ depth=dims.get("duration_depth", DURATION_DEPTH))
70
+ ac = AcousticStudent(hidden=dims.get("acoustic_hidden", ACOUSTIC_HIDDEN),
71
+ depth=dims.get("acoustic_depth", ACOUSTIC_DEPTH),
72
+ token_depth=dims.get("acoustic_token_depth", ACOUSTIC_TOKEN_DEPTH))
73
+ dec = DecoderStudent(dim=dims.get("decoder_dim", DECODER_DIM),
74
+ pw_hidden=dims.get("decoder_pw", DECODER_PW),
75
+ blocks=dims.get("decoder_blocks", DECODER_BLOCKS))
76
+
77
+ dur.load_state_dict(torch.load(d / "duration.pt", map_location="cpu", weights_only=True))
78
+ ac.load_state_dict(torch.load(d / "acoustic.pt", map_location="cpu", weights_only=True))
79
+ dec.load_state_dict(torch.load(d / "decoder.pt", map_location="cpu", weights_only=True))
80
+
81
+ tilt = None
82
+ tilt_path = d / "tilt.npy"
83
+ if tilt_path.exists():
84
+ tilt = torch.from_numpy(np.load(tilt_path).astype(np.float32)).reshape(-1)
85
+
86
+ dev = torch.device(device)
87
+ return cls(dur.to(dev), ac.to(dev), dec.to(dev),
88
+ tilt=None if tilt is None else tilt.to(dev), device=device)
89
+
90
+ @staticmethod
91
+ def total_params(model_dir: str | os.PathLike = ".") -> int:
92
+ n = 0
93
+ for f in ("duration.pt", "acoustic.pt", "decoder.pt"):
94
+ sd = torch.load(Path(model_dir) / f, map_location="cpu", weights_only=True)
95
+ n += sum(v.numel() for v in sd.values() if hasattr(v, "numel"))
96
+ return n
97
+
98
+ # -------------------------------------------------------------- synthesis
99
+ def synthesize_ids(self, ids: list[int], noise_scale: float = 0.0,
100
+ use_tilt: bool = True) -> np.ndarray:
101
+ """Phoneme ids -> waveform. noise_scale=0.0 is the recommended default;
102
+ use_tilt applies the shipped per-band mel correction (recommended)."""
103
+ t = torch.tensor(ids, device=self.device)
104
+ with torch.no_grad():
105
+ log_d = self.duration(t, round_out=False)
106
+ durs = torch.exp(log_d).round().clamp_min(1).long()
107
+ mel = self.acoustic(t, durs)
108
+ T = int(min(durs.sum().item(), MAX_FRAMES))
109
+ mel = mel[:, :T]
110
+ if use_tilt and self.tilt is not None:
111
+ mel = mel + self.tilt.unsqueeze(1)
112
+ if noise_scale == 0.0:
113
+ noise = torch.zeros(4, mel.shape[1], device=self.device)
114
+ else:
115
+ noise = torch.randn(4, mel.shape[1], device=self.device) * noise_scale
116
+ wav = self.decoder.synthesize(mel, noise)
117
+ return wav.cpu().numpy().reshape(-1).astype(np.float32)
118
+
119
+ def synthesize(self, text: str, noise_scale: float = 0.0,
120
+ use_tilt: bool = True) -> np.ndarray:
121
+ """Text -> waveform. Loudness is unnormalized; see normalize() below."""
122
+ ids, _dropped = phonemize(text)
123
+ return self.synthesize_ids(ids, noise_scale=noise_scale, use_tilt=use_tilt)
124
+
125
+ # ---------------------------------------------------------------- helpers
126
+ @staticmethod
127
+ def normalize(wav: np.ndarray, rms: float = 0.05) -> np.ndarray:
128
+ """RMS-normalize to `rms` (0.05 is about -26 dBFS, a comfortable speech level)."""
129
+ return (wav / (np.sqrt(np.mean(wav ** 2)) + 1e-9) * rms).astype(np.float32)
130
+
131
+ @staticmethod
132
+ def save(path: str | os.PathLike, wav: np.ndarray) -> None:
133
+ import soundfile as sf
134
+ sf.write(str(path), MicroTTS.normalize(wav), SAMPLE_RATE)
model.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:efafc7bb114d44a33f89ac8955c201d3c6993069fe4694decf69e3f93c6a1eed
3
+ size 4136872
requirements.txt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ numpy>=1.24
2
+ torch>=2.0
3
+ soundfile>=0.12
samples/sample_0.wav ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:8644685babb8fcb22fcc1b8c9700094920e9ac477b7ff310cd1f9faf469a6339
3
+ size 176684
samples/sample_1.wav ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:923324b2a990991f18d773a6e9cbf7c33be9001e716d521e14bdc8fa385a894d
3
+ size 111148
samples/sample_2.wav ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:bd4b6033651edd06ce93c25be67dc80bbaf9777677659a147d66cb2c6deafa6b
3
+ size 174636
samples/sample_3.wav ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:9f17e362981e1053d01c6bb145daee8492b8f5e1449eb98e95c8794bfdbfa0a3
3
+ size 168492
samples/sample_4.wav ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:b2f3fa987968f5ad7609052d31656d45524210b578716a19db2d5a1ad88868a3
3
+ size 146476
samples/sample_5.wav ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:e66cb319ee441680c450b36549f7d30301ba069444cd8d2068032ac1a13474b9
3
+ size 178220
samples/sample_6.wav ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:1c7d20317613c797f34e40f66de64f47cfe12f3068d111f2bbcec0d66578a9c8
3
+ size 122412
samples/sample_7.wav ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:36583c66d4e7ee88e9d4f30c5a99b3780588bd2aa8308e54aae0e9e09484b936
3
+ size 206892
tilt.npy ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:4e6e224a262c9b6cf055a59b8f994b1c02654003d0b5025843538efe6e2c5275
3
+ size 528
train/README.md ADDED
@@ -0,0 +1,130 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Training scripts
2
+
3
+ Eight scripts. Together they reproduce the recipe behind the shipped
4
+ checkpoint, starting from a text corpus and a teacher TTS of your own.
5
+
6
+ ```
7
+ build_pack.py text corpus -> training pack (.npz per line)
8
+ build_deploy_pack.py teacher pack -> deployment pack (acoustic mels + vocoder renders)
9
+ train_duration.py ids -> frame counts (base run and pacing fine-tunes)
10
+ train_acoustic.py ids + durations -> mel
11
+ init_decoder.py slice a pretrained vocoder's decoder -> decoder_init.pt
12
+ train_decoder.py deployment-distribution decoder training and continuations
13
+ compute_tilt.py per-band mel correction between acoustic mel and teacher mel
14
+ average_checkpoints.py checkpoint averaging (the final lever; see below)
15
+ ```
16
+
17
+ There is no joint stage here. Co-training the acoustic and decoder together
18
+ was measured to make things worse on this family (twice, with two different
19
+ loss stacks), so the recipe freezes the acoustic after its own training and
20
+ trains the decoder only.
21
+
22
+ ## The configuration behind the released weights
23
+
24
+ Teacher: one fixed-voice neural TTS (bring your own; the scripts are
25
+ teacher-agnostic and mark where it plugs in). Corpus: about 42,000 varied
26
+ sentences. Pack: 100-band mel, n_fft 1024, hop 256, 24 kHz.
27
+
28
+ | stage | command core | steps | batch | lr | wall clock (approx., RTX 3090-class) |
29
+ |---|---|---|---|---|---|
30
+ | pack | `build_pack.py --textset corpus.jsonl --out packs/train` | ~42k rows | - | - | ~1 h |
31
+ | duration base | `train_duration.py --hidden 12 --depth 3 --const-lr` | 4,000 | 32 | 2e-3 | ~6 min |
32
+ | duration pacing 1 | `train_duration.py --init dur/final.pt --const-lr` | 8,000 | 16 | 5e-4 | ~11 min |
33
+ | duration pacing 2 | `train_duration.py --init dur_ft12/final.pt --const-lr` | 12,000 | 16 | 2e-4 | ~17 min |
34
+ | acoustic base | `train_acoustic.py --hidden 72 --depth 5 --token-depth 3 --const-lr` | 50,000 | 8 | 2e-3 | ~2.5 h |
35
+ | acoustic polish | `train_acoustic.py --init 50k/final.pt` | 12,000 | 8 | 2e-4 | ~40 min |
36
+ | acoustic polish 2 | `train_acoustic.py --init snap6k.pt` | 6,000 | 8 | 1e-4 | ~20 min |
37
+ | acoustic bite | `train_acoustic.py --init ft2/final.pt --sc-weight 0.15` | 6,000 | 8 | 1e-4 | ~20 min |
38
+ | acoustic twins | `train_acoustic.py --init bs015/final.pt` (x3, lr 1e-4 / 8e-5 / 1e-4) | 6k/6k/9k | 8 | see left | ~1 h |
39
+ | deploy pack | `build_deploy_pack.py --pack packs/train --acoustic ... --duration ...` | ~20k rows | - | - | ~30 min |
40
+ | decoder init | `init_decoder.py --dim 130 --pw 337 --blocks 4 --vocoder-ckpt <path>` | - | - | - | seconds |
41
+ | decoder | `train_decoder.py --mix-prob 0.0 --const-lr --noise-zeros` | 115,000 | 4 | 2e-4 | ~6 h |
42
+ | continuations | `train_decoder.py --init <prev>/last.pt --resume-step <n>` | 12k each | 4 | 2e-4 | ~40 min each |
43
+ | soup | `average_checkpoints.py ...` | - | - | - | seconds |
44
+
45
+ All stages are resumable: the trainers write `last.pt` plus `last_step.txt`
46
+ every 500 to 1000 steps, and `--resume-step` restores the schedule position.
47
+
48
+ Total shipped parameters: **1,031,754** (duration 5,344 + acoustic 438,516 +
49
+ decoder 587,894).
50
+
51
+ ## What the released weights actually are
52
+
53
+ The shipped checkpoints are not single training runs. They are averages, and
54
+ the averaging is the part that produced the final quality:
55
+
56
+ ```
57
+ decoder fs1 = avg(e5, ss2)
58
+ e5 = avg(avgp1, w3e, w3d, w2ctl)
59
+ ss2 = avg(e2, e5)
60
+ e2 = avg(avgp1, w3e, w3d)
61
+ avgp1 = avg(champion_576k, w2ctl)
62
+
63
+ champion_576k = the best single decoder checkpoint
64
+ w2ctl = +12k steps on a 20k-row deployment pack
65
+ w3e = +12k steps, crop length 256 frames, full pack
66
+ w3d = +12k steps, crop length 128 frames, full pack
67
+
68
+ acoustic G4 = weighted average of (bs015, ft2, t1, t2, t3) with weights
69
+ (2, 2, 1, 1, 1); bs015 is the gate-4.239 base, ft2 the
70
+ 4.216 polish, t1..t3 three divergent fine-tune twins
71
+
72
+ duration = the third stage of the pacing chain (pacing 2 above)
73
+ ```
74
+
75
+ The practice, in plain terms: continue one good run in several short
76
+ same-recipe stretches with one setting varied (crop length, pack subset), then
77
+ average the results, then average the averages. Every member stays in the same
78
+ basin, which is what makes the average better than any member. The script
79
+ refuses to mix incompatible key sets and supports the weighted form used for
80
+ the acoustic.
81
+
82
+ Two measured rules from this line:
83
+ - Averaging paid on every attempt. All of the night's gains (3.9462 to 4.0384,
84
+ +0.09) came from averaging; the only trained artifacts were six short
85
+ acoustic twins (about 10 minutes each). Training past the point of the soup
86
+ regressed on every attempt.
87
+ - Two to six members is the working range. Larger blends diluted (a six-way
88
+ decoder blend and nine-way acoustic blends both lost to smaller ones).
89
+
90
+ ## Stage notes
91
+
92
+ **Constant learning rates.** Every stage above runs with a constant lr. An
93
+ exponential decay measurably starves models this small: the loss looks healthy
94
+ while the output stays muddy. The duration fine-tunes and decoder runs used
95
+ `--const-lr`; keep it.
96
+
97
+ **Acoustic learning rate.** 2e-3 for the base run is the working value, and it
98
+ matters more than anything else in this stage. The polish and bite stages use
99
+ much lower rates (2e-4, 1e-4) for short stretches, and that is where the
100
+ acoustic gate climbed from 4.083 to 4.239. The delta-L1 term (0.10) is the
101
+ anti-smoothing supervision; the hinge PatchGAN critic (weight 0.1 starting
102
+ early) sharpens further.
103
+
104
+ **Acoustic depth is load-bearing.** Depth 5 is the value the family's
105
+ reference recipe used; earlier work on this line had inherited depth 3, and
106
+ switching to depth 5 at matched width was worth about +0.42 gate SCOREQ. Depth
107
+ 7 lost, and token-depth 5 lost. Sweep depth before spending budget elsewhere.
108
+
109
+ **The decoder trains on the deployment distribution.** The deployment pack
110
+ stores the acoustic student's own mels as inputs and full vocoder renders of
111
+ those exact mels as targets. The decoder's objective becomes "reproduce the
112
+ vocoder's output on the mels you will receive at inference", and the usual
113
+ acoustic-to-decoder distribution gap does not exist to begin with. Pass
114
+ `--noise-zeros` to match the inference convention.
115
+
116
+ **Duration pacing is a quiet lever.** Two short fine-tunes of the duration
117
+ student (8k then 12k steps at falling lr) improved the full system by about
118
+ +0.03 SCOREQ with nothing else changed. Rebuild the deployment pack after
119
+ swapping the duration model, since the pack embeds its pacing.
120
+
121
+ **The tilt file ships as zeros.** `compute_tilt.py` measures a per-band
122
+ offset between the acoustic's mel and the teacher's mel. For this acoustic
123
+ zero tilt measured best, so the shipped vector is all zeros and the runtime
124
+ applies it as a no-op; the tool is included for retrained acoustics, where
125
+ the answer can differ (it was worth +0.036 on the 500K sibling).
126
+
127
+ **Do not tune against one metric.** A decoder tuned only against SCOREQ once
128
+ learned to emit muffled, unintelligible audio that scored well on SCOREQ with
129
+ a word error rate of 1.0. Every candidate in this line was checked with WER,
130
+ DNSMOS, and listening.
train/average_checkpoints.py ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Average checkpoints into a soup (the lever that carried this release).
2
+
3
+ Same-basin checkpoints from the same recipe, saved at different points of
4
+ short continuation runs, average into a better model than any member: on this
5
+ line the final soups scored +0.09 SCOREQ above their best single member, for
6
+ zero training.
7
+
8
+ # uniform average
9
+ python average_checkpoints.py --out decoder_soup.pt a.pt b.pt c.pt d.pt
10
+
11
+ # weighted average (the acoustic blend used weights 2,2,1,1,1)
12
+ python average_checkpoints.py --out acoustic_G4.pt \
13
+ --weights 2,2,1,1,1 bs015.pt ft2.pt t1.pt t2.pt t3.pt
14
+
15
+ Rules (measured on this line):
16
+ - Only average checkpoints with IDENTICAL key sets; the script refuses to mix
17
+ different architectures.
18
+ - Float tensors are averaged; non-float tensors are copied from the first
19
+ member.
20
+ - Members must share a basin (same recipe, diverged only by training noise
21
+ and step count). Mixing distant runs loses.
22
+ - 2 to 6 members is the proven range; more members dilute. The shipped
23
+ decoder is the average of its two best soups, which itself beat larger
24
+ blends, and the shipped acoustic is a pair-heavy blend of five
25
+ checkpoints.
26
+ - Averaging can be applied to soups again (nested), e.g. the shipped decoder
27
+ is avg(e5, ss2) where e5 and ss2 are themselves averages.
28
+ """
29
+ import argparse
30
+
31
+ import torch
32
+
33
+
34
+ def main():
35
+ ap = argparse.ArgumentParser()
36
+ ap.add_argument("--out", required=True)
37
+ ap.add_argument("--weights", default=None,
38
+ help="comma-separated per-input weights (default: uniform)")
39
+ ap.add_argument("inputs", nargs="+", help="checkpoint files to average")
40
+ args = ap.parse_args()
41
+
42
+ weights = None
43
+ if args.weights:
44
+ weights = [float(x) for x in args.weights.split(",")]
45
+ if len(weights) != len(args.inputs):
46
+ raise SystemExit(f"{len(weights)} weights for {len(args.inputs)} inputs")
47
+ else:
48
+ weights = [1.0] * len(args.inputs)
49
+ tot = sum(weights)
50
+
51
+ sds = [torch.load(p, map_location="cpu", weights_only=False) for p in args.inputs]
52
+ ks0 = set(sds[0].keys())
53
+ for p, sd in zip(args.inputs[1:], sds[1:]):
54
+ if set(sd.keys()) != ks0:
55
+ raise SystemExit(f"key mismatch: {p} does not match {args.inputs[0]}")
56
+
57
+ out = {}
58
+ for k in sds[0].keys():
59
+ ref = sds[0][k]
60
+ if torch.is_tensor(ref) and ref.is_floating_point():
61
+ acc = None
62
+ for w, sd in zip(weights, sds):
63
+ v = sd[k].float() * (w / tot)
64
+ acc = v if acc is None else acc + v
65
+ out[k] = acc
66
+ else:
67
+ out[k] = ref
68
+ torch.save(out, args.out)
69
+ n = sum(v.numel() for v in out.values() if torch.is_tensor(v))
70
+ print(f"averaged {len(sds)} checkpoints -> {args.out} ({n:,} parameters)")
71
+
72
+
73
+ if __name__ == "__main__":
74
+ main()
train/build_deploy_pack.py ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Build the deployment pack: the decoder's training distribution.
2
+
3
+ For every row of the teacher pack this produces a new row where:
4
+
5
+ mel := acoustic_student(ids, durations) (the exact inference input)
6
+ audio := full vocoder render of THAT mel (the training target)
7
+ ids / durations := unchanged
8
+
9
+ The decoder trained on this pack learns "reproduce the vocoder's output on the
10
+ mels you will actually receive at inference", so the usual acoustic->decoder
11
+ distribution gap does not exist to begin with. This is the single most
12
+ important departure from the classic teacher-mel recipe, and it is what the
13
+ released decoder was trained on.
14
+
15
+ python build_deploy_pack.py --pack packs/train \
16
+ --acoustic acoustic.pt --duration duration.pt --out packs/deploy
17
+
18
+ Requires `vocos` (pip install vocos) for the renders. Uses CUDA when
19
+ available; CPU works but is slower. The acoustic's sizes (width, depth) are
20
+ detected from its state dict, so the script works for any model in the family.
21
+ """
22
+ import argparse
23
+ import glob
24
+ import os
25
+ import re
26
+ import sys
27
+ import time
28
+ from pathlib import Path
29
+
30
+ import numpy as np
31
+ import torch
32
+
33
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
34
+ from microtts.models import AcousticStudent, DurationStudent
35
+
36
+
37
+ def _detect_ac_dims(sd):
38
+ """(hidden, depth, token_depth) from an acoustic state dict."""
39
+ hidden = int(sd["embed.weight"].shape[1])
40
+ idx = [int(m.group(1)) for k in sd for m in [re.match(r"frame_blocks\.(\d+)\.", k)] if m]
41
+ depth = (max(idx) + 1) if idx else 3
42
+ tidx = [int(m.group(1)) for k in sd for m in [re.match(r"token_blocks\.(\d+)\.", k)] if m]
43
+ token_depth = (max(tidx) + 1) if tidx else 3
44
+ return hidden, depth, token_depth
45
+
46
+
47
+ def _detect_dur_dims(sd):
48
+ """(hidden, depth) from a duration state dict."""
49
+ hidden = int(sd["embed.weight"].shape[1])
50
+ idx = [int(m.group(1)) for k in sd for m in [re.match(r"blocks\.(\d+)\.", k)] if m]
51
+ depth = (max(idx) + 1) if idx else 3
52
+ return hidden, depth
53
+
54
+
55
+ def main():
56
+ ap = argparse.ArgumentParser()
57
+ ap.add_argument("--pack", default="packs/train")
58
+ ap.add_argument("--acoustic", required=True)
59
+ ap.add_argument("--duration", required=True)
60
+ ap.add_argument("--out", default="packs/deploy")
61
+ ap.add_argument("--device", default="cuda")
62
+ args = ap.parse_args()
63
+
64
+ device = torch.device(args.device if torch.cuda.is_available() else "cpu")
65
+ os.makedirs(args.out, exist_ok=True)
66
+
67
+ dur_sd = torch.load(args.duration, map_location="cpu", weights_only=False)
68
+ dur_h, dur_d = _detect_dur_dims(dur_sd)
69
+ dur = DurationStudent(hidden=dur_h, depth=dur_d).to(device)
70
+ dur.load_state_dict(dur_sd)
71
+ dur.eval()
72
+
73
+ ac_sd = torch.load(args.acoustic, map_location="cpu", weights_only=False)
74
+ ac_h, ac_d, ac_td = _detect_ac_dims(ac_sd)
75
+ ac = AcousticStudent(hidden=ac_h, depth=ac_d, token_depth=ac_td).to(device)
76
+ ac.load_state_dict(ac_sd)
77
+ ac.eval()
78
+ print(f"acoustic: hidden {ac_h}, depth {ac_d}, token_depth {ac_td} | duration: hidden {dur_h}, depth {dur_d}", flush=True)
79
+
80
+ from vocos import Vocos
81
+ vocos = Vocos.from_pretrained("charactr/vocos-mel-24khz").to(device).eval()
82
+ print("models loaded", flush=True)
83
+
84
+ files = sorted(glob.glob(os.path.join(args.pack, "*.npz")))
85
+ print(f"rows: {len(files)}", flush=True)
86
+ t0 = time.time()
87
+ done = 0
88
+ for i, f in enumerate(files):
89
+ out = os.path.join(args.out, os.path.basename(f))
90
+ if os.path.exists(out):
91
+ done += 1
92
+ continue
93
+ d = np.load(f)
94
+ ids = torch.from_numpy(d["phone_ids"].astype(np.int64)).long().to(device)
95
+ with torch.no_grad():
96
+ fr = torch.round(dur(ids)).long().clamp_min(1)
97
+ mel = ac(ids, fr) # [100, T] exact inference mel
98
+ aud = vocos.decode(mel.unsqueeze(0)).squeeze(0)
99
+ np.savez(out,
100
+ phone_ids=d["phone_ids"].astype(np.int16),
101
+ durations=fr.cpu().numpy().astype(np.int16),
102
+ mel=mel.cpu().numpy().astype(np.float32),
103
+ audio=aud.cpu().numpy().astype(np.float32))
104
+ done += 1
105
+ if done % 500 == 0:
106
+ print(f" {done} done, {time.time()-t0:.0f}s", flush=True)
107
+
108
+ print(f"DEPLOY PACK DONE: {done} rows in {time.time()-t0:.0f}s", flush=True)
109
+
110
+
111
+ if __name__ == "__main__":
112
+ main()
train/build_pack.py ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Build the training pack: render teacher audio for a text corpus.
2
+
3
+ For every line of text this produces one .npz row holding everything the
4
+ students need:
5
+
6
+ text -> teacher G2P -> phoneme string
7
+ -> teacher TTS -> audio (24 kHz) + per-phoneme durations
8
+ -> mel spectrogram (100 bands, n_fft 1024, hop 256) from that audio
9
+
10
+ The teacher stack is a larger neural teacher TTS with a dictionary-first G2P,
11
+ plus a 100-band log-mel extractor. The two classes are imported at the top of
12
+ main() under placeholder names; point them at the teacher you have and the
13
+ rest of the file works unchanged. Any teacher works as long as it reports
14
+ phonemes, audio at 24 kHz, and per-phoneme durations.
15
+
16
+ One subtlety matters: the released teacher reports durations at 40 Hz (600
17
+ samples per unit at 24 kHz), while this pipeline's mel hop is 256 samples, so
18
+ durations are converted with `dur_mel = round(pred_dur * 600 / 256)`.
19
+
20
+ Run with the interpreter that has the teacher TTS and a mel extractor
21
+ installed.
22
+
23
+ python build_pack.py --textset corpus.jsonl --out packs/train
24
+
25
+ `--textset` is a JSONL file with one {"text": "..."} object per line.
26
+
27
+ Note: the decoder stage of this release trains on a *deployment pack*, not on
28
+ this teacher pack directly. See `build_deploy_pack.py` for how the teacher pack
29
+ is converted (acoustic mels + vocoder renders of those mels) once the acoustic
30
+ student exists.
31
+ """
32
+ import argparse
33
+ import json
34
+ import os
35
+ import sys
36
+ import time
37
+ from pathlib import Path
38
+
39
+ import numpy as np
40
+ import torch
41
+
42
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
43
+ from microtts.frontend import DEFAULT_VOCABULARY, phonemes_to_token_ids # noqa: E402
44
+
45
+
46
+ def main():
47
+ ap = argparse.ArgumentParser()
48
+ ap.add_argument("--textset", default="corpus.jsonl",
49
+ help="JSONL with one {'text': ...} object per line")
50
+ ap.add_argument("--out", default="packs/train")
51
+ ap.add_argument("--max-rows", type=int, default=42000)
52
+ ap.add_argument("--max-sec", type=float, default=10.0)
53
+ ap.add_argument("--voice", default=None, help="teacher voice id")
54
+ ap.add_argument("--start", type=int, default=0)
55
+ args = ap.parse_args()
56
+
57
+ from teacher_runtime import TeacherPipeline # your teacher TTS package
58
+ from your_mel_extractor import MelSpectrogramFeatures # any 100-band log-mel extractor
59
+
60
+ pipeline = TeacherPipeline(device="cuda" if torch.cuda.is_available() else "cpu")
61
+ mel_ext = MelSpectrogramFeatures(sample_rate=24000, n_fft=1024, hop_length=256, n_mels=100, padding="center")
62
+
63
+ out_dir = Path(args.out)
64
+ out_dir.mkdir(parents=True, exist_ok=True)
65
+ rows = []
66
+ for line in open(args.textset, encoding="utf-8"):
67
+ line = line.strip()
68
+ if not line:
69
+ continue
70
+ obj = json.loads(line)
71
+ if "text" in obj:
72
+ rows.append((obj.get("id", f"row{len(rows)}"), obj["text"]))
73
+
74
+ rows = rows[args.start:args.start + args.max_rows]
75
+ print(f"rendering {len(rows)} rows to {out_dir}", flush=True)
76
+ t0 = time.time()
77
+ done = 0
78
+ for uid, text in rows:
79
+ try:
80
+ # 1. phonemes via the teacher's dictionary-first G2P
81
+ phonemes = None
82
+ for res in pipeline(text, voice=args.voice, speed=1.0):
83
+ phonemes = res.phonemes
84
+ audio = res.output.audio # (L,) 24kHz
85
+ pred_dur = res.output.pred_dur # (n_phonemes,) frames
86
+ break
87
+ if phonemes is None or pred_dur is None:
88
+ continue
89
+ # 2. tokenize to 62-symbol ids (BOS/EOS included)
90
+ ids, dropped = phonemes_to_token_ids(phonemes, DEFAULT_VOCABULARY)
91
+ if len(ids) > 207:
92
+ continue
93
+ # 3. mel-100 from teacher audio
94
+ audio_t = audio.unsqueeze(0) if audio.ndim == 1 else audio
95
+ mel = mel_ext(audio_t).squeeze(0)
96
+ # 4. IMPORTANT: teacher durations are at 40 Hz (600 samples/unit);
97
+ # this pipeline's hop is 256 samples, so convert to mel-frame
98
+ # units: dur_mel = round(pred_dur * 600 / 256)
99
+ dur_mel = np.round(pred_dur.numpy().astype(np.float64) * 600.0 / 256.0)
100
+ dur_mel = np.clip(dur_mel, 1, 80).astype(np.int16)
101
+ np.savez(
102
+ out_dir / f"{uid}.npz",
103
+ phone_ids=np.asarray(ids, dtype=np.int16),
104
+ durations=dur_mel,
105
+ mel=mel.numpy().astype(np.float32),
106
+ audio=audio.numpy().astype(np.float32),
107
+ )
108
+ done += 1
109
+ except Exception as e:
110
+ print(f" skip {uid}: {type(e).__name__}: {e}", flush=True)
111
+ if done % 100 == 0 and done > 0:
112
+ el = time.time() - t0
113
+ print(f" {done} done, {el:.0f}s ({done/el:.2f}/s)", flush=True)
114
+
115
+ print(f"DONE: {done} rows in {time.time()-t0:.0f}s", flush=True)
116
+
117
+
118
+ if __name__ == "__main__":
119
+ main()
train/compute_tilt.py ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Compute the per-band mel tilt correction applied at inference.
2
+
3
+ tilt[b] = mean over pack rows of (teacher_mel[b] - acoustic_mel[b])
4
+
5
+ The shipped `tilt.npy` is a 100-value float32 vector; `microtts/synthesize.py`
6
+ adds it to the acoustic's mel before decoding when present. For this model the
7
+ shipped vector is all zeros: zero tilt measured best for this acoustic (a
8
+ nonzero tilt computed for a different acoustic actively hurt, and the sweep
9
+ 0.0 / 0.75 / 1.0 / 1.25 / 1.5 was monotonic in the wrong direction). The tool
10
+ is included so the correction can be recomputed for a retrained acoustic; the
11
+ 0.036 SCOREQ gain it gave the 500k line did not transfer here.
12
+
13
+ python compute_tilt.py --pack packs/train \
14
+ --acoustic acoustic.pt --duration duration.pt --out tilt.npy
15
+
16
+ Recompute whenever the acoustic changes; whether tilt helps is acoustic
17
+ specific. Acoustic sizes (width, depth) are detected from the state dicts.
18
+ """
19
+ import argparse
20
+ import glob
21
+ import os
22
+ import re
23
+ import sys
24
+ from pathlib import Path
25
+
26
+ import numpy as np
27
+ import torch
28
+
29
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
30
+ from microtts.models import AcousticStudent, DurationStudent
31
+
32
+
33
+ def _detect_ac_dims(sd):
34
+ hidden = int(sd["embed.weight"].shape[1])
35
+ idx = [int(m.group(1)) for k in sd for m in [re.match(r"frame_blocks\.(\d+)\.", k)] if m]
36
+ depth = (max(idx) + 1) if idx else 3
37
+ tidx = [int(m.group(1)) for k in sd for m in [re.match(r"token_blocks\.(\d+)\.", k)] if m]
38
+ token_depth = (max(tidx) + 1) if tidx else 3
39
+ return hidden, depth, token_depth
40
+
41
+
42
+ def _detect_dur_dims(sd):
43
+ hidden = int(sd["embed.weight"].shape[1])
44
+ idx = [int(m.group(1)) for k in sd for m in [re.match(r"blocks\.(\d+)\.", k)] if m]
45
+ depth = (max(idx) + 1) if idx else 3
46
+ return hidden, depth
47
+
48
+
49
+ def main():
50
+ ap = argparse.ArgumentParser()
51
+ ap.add_argument("--pack", default="packs/train")
52
+ ap.add_argument("--acoustic", default="acoustic.pt")
53
+ ap.add_argument("--duration", default="duration.pt")
54
+ ap.add_argument("--rows", type=int, default=120)
55
+ ap.add_argument("--out", default="tilt.npy")
56
+ ap.add_argument("--device", default="cpu")
57
+ args = ap.parse_args()
58
+
59
+ device = torch.device(args.device if torch.cuda.is_available() else "cpu")
60
+ dur_sd = torch.load(args.duration, map_location="cpu", weights_only=False)
61
+ dur = DurationStudent(hidden=_detect_dur_dims(dur_sd)[0], depth=_detect_dur_dims(dur_sd)[1]).to(device)
62
+ dur.load_state_dict(dur_sd)
63
+ dur.eval()
64
+ ac_sd = torch.load(args.acoustic, map_location="cpu", weights_only=False)
65
+ ac_h, ac_d, ac_td = _detect_ac_dims(ac_sd)
66
+ ac = AcousticStudent(hidden=ac_h, depth=ac_d, token_depth=ac_td).to(device)
67
+ ac.load_state_dict(ac_sd)
68
+ ac.eval()
69
+
70
+ files = sorted(glob.glob(os.path.join(args.pack, "*.npz")))[:args.rows]
71
+ acc = np.zeros(100)
72
+ n = 0
73
+ for f in files:
74
+ d = np.load(f)
75
+ ids = torch.from_numpy(d["phone_ids"].astype(np.int64)).to(device)
76
+ with torch.no_grad():
77
+ fr = torch.round(dur(ids)).long().clamp_min(1)
78
+ mel_s = ac(ids, fr).cpu().numpy()
79
+ mel_t = d["mel"]
80
+ Ts = min(mel_t.shape[1], mel_s.shape[1])
81
+ if Ts < 4:
82
+ continue
83
+ acc += (mel_t[:, :Ts] - mel_s[:, :Ts]).mean(1)
84
+ n += 1
85
+ tilt = (acc / max(n, 1)).astype(np.float32)
86
+ np.save(args.out, tilt)
87
+ print(f"tilt from {n} rows -> {args.out} (mean {tilt.mean():.3f}, std {tilt.std():.3f})")
88
+
89
+
90
+ if __name__ == "__main__":
91
+ main()
train/init_decoder.py ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Initialize the decoder student by channel-slicing a frozen pretrained vocoder.
2
+
3
+ Training a decoder this small from scratch does not converge to intelligible
4
+ audio; slicing the top-left corner of a pretrained neural vocoder's decoder
5
+ weights does. Pass any ConvNeXt-style vocoder state dict with --vocoder-ckpt;
6
+ this takes the first `--dim` channels, the first `--pw` hidden units and the
7
+ first `--blocks` blocks (dim 130 / pw 337 / blocks 4 for the shipped model).
8
+
9
+ The sliced checkpoint still needs decoder training (train_decoder.py) before
10
+ it is usable: a fresh slice renders noise, and only fine-tuning brings it back.
11
+
12
+ python init_decoder.py --dim 130 --pw 337 --blocks 4 \
13
+ --vocoder-ckpt <vocos-mel-24khz state dict> --out decoder_init.pt
14
+ """
15
+ import sys
16
+ from pathlib import Path
17
+
18
+ import torch
19
+
20
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
21
+ from microtts.models import DecoderStudent
22
+
23
+
24
+ def main():
25
+ import argparse
26
+ ap = argparse.ArgumentParser()
27
+ ap.add_argument("--dim", type=int, default=130)
28
+ ap.add_argument("--pw", type=int, default=337)
29
+ ap.add_argument("--blocks", type=int, default=4)
30
+ ap.add_argument("--vocoder-ckpt", default=None,
31
+ help="state dict / checkpoint of a ConvNeXt-style neural vocoder")
32
+ ap.add_argument("--out", default="decoder_init.pt")
33
+ args = ap.parse_args()
34
+
35
+ if args.vocoder_ckpt:
36
+ obj = torch.load(args.vocoder_ckpt, map_location="cpu", weights_only=False)
37
+ sd = obj.state_dict() if hasattr(obj, "state_dict") else obj
38
+ else:
39
+ raise ImportError("pass --vocoder-ckpt pointing at a ConvNeXt-style vocoder state dict")
40
+
41
+ student = DecoderStudent(dim=args.dim, pw_hidden=args.pw, blocks=args.blocks)
42
+ ssd = student.state_dict()
43
+
44
+ # embedding conv: [512, 100, 7] -> [dim, 100, 7]
45
+ ssd["embed.weight"] = sd["backbone.embed.weight"][:args.dim].contiguous()
46
+ ssd["embed.bias"] = sd["backbone.embed.bias"][:args.dim].contiguous()
47
+ ssd["norm.weight"] = sd["backbone.norm.weight"][:args.dim].contiguous()
48
+ ssd["norm.bias"] = sd["backbone.norm.bias"][:args.dim].contiguous()
49
+ for i in range(args.blocks):
50
+ src = f"backbone.convnext.{i}"
51
+ dst = f"blocks.{i}"
52
+ ssd[f"{dst}.dw.weight"] = sd[f"{src}.dwconv.weight"][:args.dim].contiguous()
53
+ ssd[f"{dst}.dw.bias"] = sd[f"{src}.dwconv.bias"][:args.dim].contiguous()
54
+ ssd[f"{dst}.norm.weight"] = sd[f"{src}.norm.weight"][:args.dim].contiguous()
55
+ ssd[f"{dst}.norm.bias"] = sd[f"{src}.norm.bias"][:args.dim].contiguous()
56
+ # pointwise slices: pw0 [pw, dim], pw1 [dim, pw]
57
+ ssd[f"{dst}.pw0.weight"] = sd[f"{src}.pwconv1.weight"][:args.pw, :args.dim].contiguous()
58
+ ssd[f"{dst}.pw0.bias"] = sd[f"{src}.pwconv1.bias"][:args.pw].contiguous()
59
+ ssd[f"{dst}.pw1.weight"] = sd[f"{src}.pwconv2.weight"][:args.dim, :args.pw].contiguous()
60
+ ssd[f"{dst}.pw1.bias"] = sd[f"{src}.pwconv2.bias"][:args.dim].contiguous()
61
+ ssd[f"{dst}.gamma"] = sd[f"{src}.gamma"][:args.dim].contiguous()
62
+ # final norm + head
63
+ ssd["fnorm.weight"] = sd["backbone.final_layer_norm.weight"][:args.dim].contiguous()
64
+ ssd["fnorm.bias"] = sd["backbone.final_layer_norm.bias"][:args.dim].contiguous()
65
+ ssd["head.weight"] = sd["head.out.weight"][:, :args.dim].contiguous()
66
+ ssd["head.bias"] = sd["head.out.bias"].contiguous()
67
+
68
+ student.load_state_dict(ssd)
69
+ torch.save(student.state_dict(), args.out)
70
+ n = sum(p.numel() for p in student.parameters())
71
+ print(f"saved {args.out} | student params: {n:,}")
72
+
73
+
74
+ if __name__ == "__main__":
75
+ main()
train/train_acoustic.py ADDED
@@ -0,0 +1,215 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Train the acoustic student: ids + durations -> mel-100.
2
+
3
+ Targets are the teacher mels from the pack. Loss: L1 + spectral convergence,
4
+ plus norm/delta/channel-statistics terms and a hinge PatchGAN adversarial term
5
+ that fights the over-smoothing a pure L1 regressor produces.
6
+
7
+ Learning rate matters more than anything else here: 2e-3 is the working value
8
+ for the base run. A model trained a few times lower simply underfits, and the
9
+ symptom is a mel that is too smooth in time (frame-to-frame delta well below
10
+ the teacher's) which garbles the output even when the L1 looks fine. The
11
+ polish stages after the base run use lower rates (1e-4) for short stretches;
12
+ see train/README.md for the exact chain that produced the shipped weights.
13
+
14
+ python train_acoustic.py --pack packs/train --hidden 72 --depth 5 \
15
+ --token-depth 3 --batch 8 --steps 50000
16
+ """
17
+ import argparse
18
+ import glob
19
+ import os
20
+ import time
21
+
22
+ import numpy as np
23
+ import torch
24
+
25
+ import sys
26
+ from pathlib import Path
27
+
28
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
29
+ import torch.nn as nn
30
+ import torch.nn.functional as F
31
+
32
+ from microtts.models import AcousticStudent
33
+
34
+
35
+ class MelDisc(nn.Module):
36
+ """PatchGAN 1D critic over time. Training-only, never shipped.
37
+
38
+ Hinge loss, dense patch scores over the time axis.
39
+ """
40
+ def __init__(self, channels=100, hidden=64, layers=3, kernel_size=5):
41
+ super().__init__()
42
+ padding = kernel_size // 2
43
+ widths = [channels] + [hidden] * layers
44
+ net: list[nn.Module] = []
45
+ for i in range(layers):
46
+ net.append(nn.Conv1d(widths[i], widths[i + 1], kernel_size, padding=padding))
47
+ net.append(nn.LeakyReLU(0.2))
48
+ net.append(nn.Conv1d(widths[layers], 1, kernel_size, padding=padding))
49
+ self.net = nn.Sequential(*net)
50
+
51
+ def forward(self, latent):
52
+ # [B, C, T] -> [B, 1, T'] -> [B, T']
53
+ return self.net(latent).squeeze(1)
54
+
55
+
56
+ def main():
57
+ ap = argparse.ArgumentParser()
58
+ ap.add_argument("--pack", default="packs/train")
59
+ ap.add_argument("--steps", type=int, default=50000)
60
+ ap.add_argument("--batch", type=int, default=8)
61
+ ap.add_argument("--lr", type=float, default=2e-3)
62
+ ap.add_argument("--const-lr", action="store_true",
63
+ help="constant lr (the released recipe; no scheduler)")
64
+ ap.add_argument("--norm-l1-weight", type=float, default=0.25)
65
+ ap.add_argument("--delta-l1-weight", type=float, default=0.10,
66
+ help="temporal slope L1, the anti-over-smoothing term")
67
+ ap.add_argument("--channel-stat-weight", type=float, default=0.05)
68
+ ap.add_argument("--sc-weight", type=float, default=0.5,
69
+ help="spectral-convergence weight (the sc-bite stage used 0.15)")
70
+ ap.add_argument("--adv-weight", type=float, default=0.1)
71
+ ap.add_argument("--adv-start-step", type=int, default=500)
72
+ ap.add_argument("--disc-lr-ratio", type=float, default=0.25,
73
+ help="discriminator lr as a fraction of the model lr")
74
+ ap.add_argument("--betas", type=str, default="0.8,0.99")
75
+ ap.add_argument("--init", default=None,
76
+ help="checkpoint to fine-tune from (polish and bite stages)")
77
+ ap.add_argument("--resume-step", type=int, default=0)
78
+ ap.add_argument("--device", default="cuda")
79
+ ap.add_argument("--tag", default="ac")
80
+ ap.add_argument("--hidden", type=int, default=72)
81
+ ap.add_argument("--token-depth", type=int, default=3)
82
+ ap.add_argument("--depth", type=int, default=5)
83
+ args = ap.parse_args()
84
+
85
+ device = torch.device(args.device if torch.cuda.is_available() else "cpu")
86
+ files = sorted(glob.glob(os.path.join(args.pack, "*.npz")))
87
+ print(f"pack: {len(files)} rows, device {device}")
88
+ model = AcousticStudent(hidden=args.hidden, token_depth=args.token_depth,
89
+ depth=args.depth).to(device)
90
+ if args.init:
91
+ model.load_state_dict(torch.load(args.init, map_location=device, weights_only=False))
92
+ print(f"acoustic fine-tune init: {args.init}")
93
+ disc = MelDisc().to(device)
94
+ n = sum(p.numel() for p in model.parameters())
95
+ print(f"acoustic params: {n:,}")
96
+ betas = tuple(float(x) for x in args.betas.split(","))
97
+ opt = torch.optim.AdamW(model.parameters(), lr=args.lr, betas=betas)
98
+ opt_d = torch.optim.AdamW(disc.parameters(), lr=args.lr * args.disc_lr_ratio, betas=betas)
99
+ sched = None if args.const_lr else torch.optim.lr_scheduler.ExponentialLR(opt, gamma=0.9998)
100
+ sched_d = None if args.const_lr else torch.optim.lr_scheduler.ExponentialLR(opt_d, gamma=0.9998)
101
+ if sched is not None and args.resume_step > 0:
102
+ for _ in range(args.resume_step):
103
+ sched.step()
104
+ sched_d.step()
105
+
106
+ RUN = os.path.join("runs", args.tag)
107
+ os.makedirs(RUN, exist_ok=True)
108
+ t0 = time.time()
109
+ for step in range(args.resume_step + 1, args.steps + 1):
110
+ idx = np.random.choice(len(files), min(args.batch, len(files)), replace=False)
111
+ ids_list, dur_list, mel_list = [], [], []
112
+ for i in idx:
113
+ d = np.load(files[i])
114
+ ids_list.append(torch.from_numpy(d["phone_ids"].astype(np.int64)))
115
+ dur_list.append(torch.from_numpy(d["durations"].astype(np.int64)))
116
+ mel_list.append(torch.from_numpy(d["mel"]).float())
117
+ T = max(x.shape[0] for x in ids_list)
118
+ ids = torch.zeros(len(ids_list), T, dtype=torch.long)
119
+ for b, x in enumerate(ids_list):
120
+ ids[b, :x.shape[0]] = x
121
+ ids = ids.to(device)
122
+
123
+ opt.zero_grad()
124
+ opt_d.zero_grad()
125
+ total = 0.0
126
+ cnt = 0
127
+ adv_g_total = 0.0
128
+ use_adv = step >= args.adv_start_step
129
+ if use_adv:
130
+ for prm in disc.parameters():
131
+ prm.requires_grad_(False) # generator path: no disc grads
132
+ for b in range(len(ids_list)):
133
+ n_tok = int((ids[b] > 0).sum())
134
+ if n_tok < 2:
135
+ continue
136
+ xb = ids[b, :n_tok]
137
+ db = dur_list[b][:n_tok]
138
+ mel_t = mel_list[b].to(device) # [100, T]
139
+ Tf = mel_t.shape[1]
140
+ while db.sum() > Tf:
141
+ db = db.clone()
142
+ db[db.argmax()] -= 1
143
+ db = db.clamp_min(1)
144
+ mel_pred = model(xb, db)
145
+ Tp = min(mel_pred.shape[1], Tf)
146
+ p, t = mel_pred[:, :Tp], mel_t[:, :Tp]
147
+ l1 = F.l1_loss(p, t)
148
+ sc = (p - t).norm(dim=0) / (t.norm(dim=0) + 1e-6)
149
+ # anti-smoothing supervision
150
+ l_norm = torch.abs((p - p.mean(dim=1, keepdim=True)) / (p.std(dim=1, keepdim=True).clamp_min(1e-4))
151
+ - (t - t.mean(dim=1, keepdim=True)) / (t.std(dim=1, keepdim=True).clamp_min(1e-4))).mean()
152
+ if Tp > 1:
153
+ l_delta = ((p[:, 1:] - p[:, :-1]) - (t[:, 1:] - t[:, :-1])).abs().mean()
154
+ else:
155
+ l_delta = torch.zeros((), device=device)
156
+ l_cstat = (torch.abs(p.mean(dim=1) - t.mean(dim=1))
157
+ + torch.abs(p.std(dim=1) - t.std(dim=1))).mean()
158
+ total = total + l1 + args.sc_weight * sc.mean() \
159
+ + args.norm_l1_weight * l_norm \
160
+ + args.delta_l1_weight * l_delta \
161
+ + args.channel_stat_weight * l_cstat
162
+ if use_adv:
163
+ adv_g_total = adv_g_total - disc(p.unsqueeze(0)).mean()
164
+ cnt += 1
165
+ if cnt == 0:
166
+ continue
167
+ loss = total / cnt
168
+ if use_adv:
169
+ loss = loss + args.adv_weight * (adv_g_total / cnt)
170
+ loss.backward()
171
+ torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
172
+ opt.step()
173
+ if use_adv:
174
+ for prm in disc.parameters():
175
+ prm.requires_grad_(True)
176
+ # update discriminator on detached inputs (dense patch hinge)
177
+ opt_d.zero_grad()
178
+ dl = 0.0
179
+ for b in range(len(ids_list)):
180
+ n_tok = int((ids[b] > 0).sum())
181
+ if n_tok < 2:
182
+ continue
183
+ xb = ids[b, :n_tok]
184
+ db = dur_list[b][:n_tok]
185
+ mel_t = mel_list[b].to(device)
186
+ Tf = mel_t.shape[1]
187
+ while db.sum() > Tf:
188
+ db = db.clone()
189
+ db[db.argmax()] -= 1
190
+ db = db.clamp_min(1)
191
+ with torch.no_grad():
192
+ mp = model(xb, db)
193
+ p = mp[:, :min(mp.shape[1], Tf)]
194
+ t = mel_t[:, :min(mp.shape[1], Tf)]
195
+ dl = dl + (F.relu(1.0 - disc(t.unsqueeze(0))).mean()
196
+ + F.relu(1.0 + disc(p.unsqueeze(0))).mean())
197
+ (dl / max(cnt, 1)).backward()
198
+ opt_d.step()
199
+ if sched_d is not None:
200
+ sched_d.step()
201
+ if sched is not None:
202
+ sched.step()
203
+
204
+ if step % 100 == 0 or step == 1:
205
+ dt = time.time() - t0
206
+ print(f"step {step}/{args.steps} | mel {loss.item():.4f} | {step/dt:.1f} sps", flush=True)
207
+ if step % 1000 == 0:
208
+ torch.save(model.state_dict(), os.path.join(RUN, "last.pt"))
209
+
210
+ torch.save(model.state_dict(), os.path.join(RUN, "final.pt"))
211
+ print("ACOUSTIC DONE")
212
+
213
+
214
+ if __name__ == "__main__":
215
+ main()
train/train_decoder.py ADDED
@@ -0,0 +1,501 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Train the decoder student on the deployment distribution.
2
+
3
+ The pack holds, per row: the acoustic student's mels (the exact inference
4
+ input), the teacher's phoneme ids/durations, and, as audio targets, full
5
+ vocoder renders of those exact mels. The objective is therefore "reproduce the
6
+ vocoder's output on the mels you will actually receive", and there is no
7
+ acoustic->decoder distribution gap to fix later.
8
+
9
+ This is the full supervision stack used for the released weights:
10
+
11
+ waveform L1 (wav-weight 1.0 for this model)
12
+ multi-resolution spectral loss
13
+ sig-temporal (cosine-gram) (0.4) temporal texture
14
+ feature-hint (channel moments) (0.1) distillation from the vocoder
15
+ quiet-ceiling (0.010) stops quiet-frame hiss
16
+ high-band-excess (0.1) penalizes HF overshoot
17
+ MRD adversarial + feature match (0.075 / 0.75 from step 1500)
18
+
19
+ Requires `vocos` (pip install vocos) for the feature hooks; the pack itself
20
+ needs no vocoder at train time.
21
+
22
+ Stages used for the release (see train/README.md for wall-clock):
23
+ recovery --mix-prob 0.0 --const-lr --noise-zeros
24
+ ... then short continuation arms and an averaging pass over the arms
25
+ (the released weights are a weight average of five same-recipe runs;
26
+ averaging paid +0.09 SCOREQ on this line, see the README).
27
+ """
28
+ import argparse
29
+ import glob
30
+ import os
31
+ import time
32
+
33
+ import numpy as np
34
+ import torch
35
+
36
+ import sys
37
+ from pathlib import Path
38
+
39
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
40
+ import torch.nn as nn
41
+ import torch.nn.functional as F
42
+
43
+ from microtts.models import DecoderStudent
44
+
45
+
46
+ # --------------------------------------------------------------------------
47
+ # Teacher feature hooks (vocoder internals, for the hint + sig-temporal terms)
48
+ # --------------------------------------------------------------------------
49
+ class TeacherFeats:
50
+ """Capture the vocoder's internal activations at chosen stages."""
51
+
52
+ def __init__(self, vocos):
53
+ self.feats = {}
54
+ self.handles = []
55
+ b = vocos.backbone
56
+ self.handles.append(b.embed.register_forward_hook(self._h("embed")))
57
+ self.handles.append(b.convnext[3].register_forward_hook(self._h("cn3")))
58
+ self.handles.append(b.final_layer_norm.register_forward_hook(self._h("final")))
59
+
60
+ def _h(self, key):
61
+ def fn(mod, inp, out):
62
+ self.feats[key] = out
63
+ return fn
64
+
65
+ def clear(self):
66
+ self.feats = {}
67
+
68
+
69
+ def standardized_time_series(v):
70
+ mean = v.mean(dim=1, keepdim=True)
71
+ std = v.std(dim=1, keepdim=True).clamp_min(1e-4)
72
+ return (v - mean) / std
73
+
74
+
75
+ def temporal_cosine_gram(value):
76
+ """Per-timestep channel-vector self-similarity [B,T,T]."""
77
+ if value.ndim != 3:
78
+ raise RuntimeError(f"expected [B, C, T], got {value.shape}")
79
+ series = value.transpose(1, 2)
80
+ series = series - series.mean(dim=-1, keepdim=True)
81
+ series = F.normalize(series, dim=-1, eps=1e-6)
82
+ return torch.matmul(series, series.transpose(1, 2))
83
+
84
+
85
+ def signature_temporal_loss(student_feats, teacher_feats, keys=("embed", "cn3")):
86
+ losses = []
87
+ for key in keys:
88
+ s = student_feats.get(key)
89
+ t = teacher_feats.get(key)
90
+ if s is None or t is None:
91
+ continue
92
+ if s.ndim == 3:
93
+ s = s[0]
94
+ if t.ndim == 3:
95
+ t = t[0]
96
+ T = min(s.shape[-1], t.shape[-1])
97
+ if T < 4:
98
+ continue
99
+ s = s[:, :T].unsqueeze(0).float()
100
+ t = t[:, :T].unsqueeze(0).float()
101
+ losses.append(F.l1_loss(temporal_cosine_gram(s), temporal_cosine_gram(t)))
102
+ if not losses:
103
+ return torch.zeros((), device=next(iter(student_feats.values())).device)
104
+ return sum(losses) / len(losses)
105
+
106
+
107
+ def channel_moment_hint_loss(student_feats, teacher_feats):
108
+ """Standardized channel-mean + log-RMS envelopes."""
109
+ losses = []
110
+ for key in ("embed", "cn3"):
111
+ s = student_feats.get(key)
112
+ t = teacher_feats.get(key)
113
+ if s is None or t is None:
114
+ continue
115
+ if s.ndim == 3:
116
+ s = s[0]
117
+ if t.ndim == 3:
118
+ t = t[0]
119
+ if s.shape[-1] != t.shape[-1]:
120
+ T = min(s.shape[-1], t.shape[-1])
121
+ s, t = s[:, :T], t[:, :T]
122
+ s_mean = standardized_time_series(s.mean(dim=0, keepdim=True))
123
+ t_mean = standardized_time_series(t.mean(dim=0, keepdim=True))
124
+ s_rms = standardized_time_series(torch.log1p(torch.sqrt(s.square().mean(dim=0, keepdim=True) + 1e-6)))
125
+ t_rms = standardized_time_series(torch.log1p(torch.sqrt(t.square().mean(dim=0, keepdim=True) + 1e-6)))
126
+ losses.append(F.l1_loss(s_mean, t_mean))
127
+ losses.append(F.l1_loss(s_rms, t_rms))
128
+ if not losses:
129
+ return torch.zeros((), device=next(iter(student_feats.values())).device)
130
+ return sum(losses) / len(losses)
131
+
132
+
133
+ # --------------------------------------------------------------------------
134
+ # Spectral-resolution discriminator (UnivNet-style MRD)
135
+ # --------------------------------------------------------------------------
136
+ class SpectralResolutionDiscriminator(nn.Module):
137
+ def __init__(self, n_fft, hop_length, win_length, channels=(32, 32, 32, 32)):
138
+ super().__init__()
139
+ self.n_fft, self.hop_length, self.win_length = n_fft, hop_length, win_length
140
+ ks, st, pd = (3, 9), (1, 2), (1, 4)
141
+ layers = []
142
+ in_ch = 1
143
+ for out_ch in channels:
144
+ layers.append(nn.Conv2d(in_ch, out_ch, kernel_size=ks, stride=st, padding=pd))
145
+ in_ch = out_ch
146
+ self.layers = nn.ModuleList(layers)
147
+ self.post = nn.Conv2d(in_ch, 1, kernel_size=ks, padding=pd)
148
+
149
+ def spec(self, x): # x: [1, T]
150
+ if x.shape[-1] < self.n_fft:
151
+ return None
152
+ w = torch.hann_window(self.n_fft, device=x.device, dtype=x.dtype)
153
+ s = torch.stft(x[0], self.n_fft, self.hop_length, win_length=self.win_length,
154
+ window=w, return_complex=True)
155
+ return torch.log1p(s.abs()).unsqueeze(0).unsqueeze(0) # [1,1,F,T]
156
+
157
+ def forward(self, x):
158
+ h = self.spec(x)
159
+ if h is None:
160
+ return None, None
161
+ feats = []
162
+ for layer in self.layers:
163
+ h = F.leaky_relu(layer(h), 0.1)
164
+ feats.append(h)
165
+ score = self.post(h)
166
+ feats.append(score)
167
+ return score, feats
168
+
169
+
170
+ class MRD(nn.Module):
171
+ def __init__(self, configs=((512, 128, 512), (1024, 256, 1024), (2048, 512, 2048))):
172
+ super().__init__()
173
+ self.discs = nn.ModuleList([SpectralResolutionDiscriminator(*c) for c in configs])
174
+
175
+ def forward(self, x):
176
+ scores, feats = [], []
177
+ for d in self.discs:
178
+ s, f = d(x)
179
+ if s is None:
180
+ continue
181
+ scores.append(s)
182
+ feats.append(f)
183
+ return scores, feats
184
+
185
+
186
+ def generator_lsgan_loss(fake_scores):
187
+ if not fake_scores:
188
+ return torch.zeros(())
189
+ total = sum((s - 1.0).square().mean() for s in fake_scores)
190
+ return total / len(fake_scores)
191
+
192
+
193
+ def discriminator_lsgan_loss(real_scores, fake_scores):
194
+ if not real_scores:
195
+ return torch.zeros(())
196
+ total = sum((r - 1.0).square().mean() + f.square().mean()
197
+ for r, f in zip(real_scores, fake_scores))
198
+ return total / len(real_scores)
199
+
200
+
201
+ def feature_matching_loss(real_feats, fake_feats):
202
+ losses = []
203
+ for rf, ff in zip(real_feats, fake_feats):
204
+ for a, b in zip(rf, ff):
205
+ losses.append(F.l1_loss(b, a.detach()))
206
+ if not losses:
207
+ return torch.zeros(())
208
+ return sum(losses) / len(losses)
209
+
210
+
211
+ # --------------------------------------------------------------------------
212
+ # Spectral losses
213
+ # --------------------------------------------------------------------------
214
+ def stft_loss(y, yh, device):
215
+ loss = 0.0
216
+ n = min(y.shape[-1], yh.shape[-1])
217
+ for n_fft, hop in [(512, 128), (1024, 256), (2048, 512)]:
218
+ if n < n_fft:
219
+ continue # skip FFTs larger than the input
220
+ Y = torch.stft(y[..., :n], n_fft, hop, window=torch.hann_window(n_fft, device=device),
221
+ return_complex=True, center=False)
222
+ Yh = torch.stft(yh[..., :n], n_fft, hop, window=torch.hann_window(n_fft, device=device),
223
+ return_complex=True, center=False)
224
+ T = min(Y.shape[-1], Yh.shape[-1])
225
+ mag, mag_r = Y[..., :T].abs(), Yh[..., :T].abs()
226
+ sc = (mag - mag_r).norm(dim=-2) / (mag.norm(dim=-2) + mag_r.norm(dim=-2) + 1e-6)
227
+ lm = (mag.clamp_min(1e-6).log10() - mag_r.clamp_min(1e-6).log10()).abs().mean()
228
+ loss = loss + sc.mean() + lm
229
+ return loss / 3.0
230
+
231
+
232
+ def high_band_excess_loss(prediction, target, sample_rate=24000, high_band_hz=6000.0, margin_db=1.0):
233
+ """Penalize HF energy ratio above the target's ratio times a margin."""
234
+ n_fft, hop = 1024, 256
235
+ window = torch.hann_window(n_fft, device=prediction.device, dtype=prediction.dtype)
236
+ ps = torch.stft(prediction[0], n_fft, hop, win_length=n_fft, window=window, return_complex=True)
237
+ ts = torch.stft(target[0], n_fft, hop, win_length=n_fft, window=window, return_complex=True)
238
+ nyq = sample_rate / 2.0
239
+ freqs = torch.linspace(0.0, nyq, n_fft // 2 + 1, device=prediction.device, dtype=prediction.dtype)
240
+ mask = (freqs >= high_band_hz).to(dtype=prediction.dtype).unsqueeze(-1)
241
+ p_pow = ps.abs().square()
242
+ t_pow = ts.abs().square()
243
+ pr = torch.sum(p_pow * mask) / torch.sum(p_pow).clamp_min(1e-10)
244
+ tr = torch.sum(t_pow * mask) / torch.sum(t_pow).clamp_min(1e-10)
245
+ margin_ratio = 10.0 ** (margin_db / 10.0)
246
+ excess = torch.log(pr.clamp_min(1e-10)) - torch.log((tr * margin_ratio).clamp_min(1e-10))
247
+ return F.relu(excess).square()
248
+
249
+
250
+ def framed_rms(x, frame_size=1024, frame_hop=256):
251
+ if x.ndim == 2:
252
+ x = x[0]
253
+ if x.ndim == 1:
254
+ x = x.unsqueeze(0)
255
+ if x.shape[-1] < frame_size:
256
+ return x.square().mean(dim=-1).sqrt().unsqueeze(-1)
257
+ frames = x.unfold(-1, frame_size, frame_hop)
258
+ return frames.square().mean(dim=-1).sqrt().mean(dim=0, keepdim=True)
259
+
260
+
261
+ def quiet_ceiling_loss(prediction, target, quantile=0.25, frame_size=1024, frame_hop=256, margin_db=0.5):
262
+ """Penalize predictions louder than the target on the target's quiet frames."""
263
+ tr = framed_rms(target, frame_size, frame_hop)
264
+ pr = framed_rms(prediction, frame_size, frame_hop)
265
+ T = min(tr.shape[-1], pr.shape[-1])
266
+ tr, pr = tr[..., :T], pr[..., :T]
267
+ thr = torch.quantile(tr.flatten(), quantile)
268
+ mask = (torch.log1p(tr) <= thr).to(prediction.dtype)
269
+ db = 20.0 / np.log(10.0)
270
+ excess_db = db * (torch.log(pr + 1e-8) - torch.log(tr + 1e-8)) - margin_db
271
+ return torch.sum(F.relu(excess_db) * mask) / mask.sum().clamp_min(1.0) / 20.0
272
+
273
+
274
+ def main():
275
+ ap = argparse.ArgumentParser()
276
+ ap.add_argument("--pack", default="packs/deploy")
277
+ ap.add_argument("--steps", type=int, default=20000)
278
+ ap.add_argument("--batch", type=int, default=4)
279
+ ap.add_argument("--lr", type=float, default=2e-4)
280
+ ap.add_argument("--device", default="cuda")
281
+ ap.add_argument("--tag", default="dec")
282
+ ap.add_argument("--mix-prob", type=float, default=0.0,
283
+ help="fraction of items that use live acoustic mels instead "
284
+ "of the pack's (the deployment pack needs 0.0)")
285
+ ap.add_argument("--init", required=True,
286
+ help="teacher-initialized decoder checkpoint (init_decoder.py) or a "
287
+ "previous decoder checkpoint for continuation runs")
288
+ ap.add_argument("--resume-step", type=int, default=0,
289
+ help="step number the init checkpoint was saved at (continuation runs)")
290
+ ap.add_argument("--dec-dim", type=int, default=130)
291
+ ap.add_argument("--dec-pw", type=int, default=337)
292
+ ap.add_argument("--dec-blocks", type=int, default=4)
293
+ ap.add_argument("--ac-hidden", type=int, default=72)
294
+ ap.add_argument("--ac-depth", type=int, default=5)
295
+ ap.add_argument("--ac-ckpt", default="acoustic.pt")
296
+ ap.add_argument("--dur-hidden", type=int, default=12)
297
+ ap.add_argument("--dur-ckpt", default="duration.pt")
298
+ # loss weights (the released recipe)
299
+ ap.add_argument("--feature-hint-weight", type=float, default=0.1)
300
+ ap.add_argument("--quiet-ceiling-weight", type=float, default=0.010)
301
+ ap.add_argument("--high-band-excess-weight", type=float, default=0.1)
302
+ ap.add_argument("--adv-weight", type=float, default=0.075)
303
+ ap.add_argument("--adv-feature-weight", type=float, default=0.75)
304
+ ap.add_argument("--adv-lr", type=float, default=2e-4)
305
+ ap.add_argument("--adv-start-step", type=int, default=1500)
306
+ ap.add_argument("--const-lr", action="store_true",
307
+ help="constant lr (the released recipe)")
308
+ ap.add_argument("--betas", type=str, default="0.8,0.99")
309
+ ap.add_argument("--wav-weight", type=float, default=1.0)
310
+ ap.add_argument("--sig-temporal-weight", type=float, default=0.4)
311
+ ap.add_argument("--loud-weight", type=float, default=0.0)
312
+ ap.add_argument("--seg", type=int, default=64,
313
+ help="training crop length in mel frames")
314
+ ap.add_argument("--noise-zeros", action="store_true",
315
+ help="zeros instead of randn for the decoder noise input "
316
+ "(matches inference; the released recipe)")
317
+ args = ap.parse_args()
318
+
319
+ device = torch.device(args.device if torch.cuda.is_available() else "cpu")
320
+ files = sorted(glob.glob(os.path.join(args.pack, "*.npz")))
321
+ print(f"pack: {len(files)} rows, device {device}", flush=True)
322
+
323
+ from microtts.models import AcousticStudent, DurationStudent
324
+
325
+ ac = AcousticStudent(hidden=args.ac_hidden, depth=args.ac_depth).to(device)
326
+ ac.load_state_dict(torch.load(args.ac_ckpt, map_location=device, weights_only=False))
327
+ ac.eval()
328
+ dur = DurationStudent(hidden=args.dur_hidden).to(device)
329
+ dur.load_state_dict(torch.load(args.dur_ckpt, map_location=device, weights_only=False))
330
+ dur.eval()
331
+
332
+ model = DecoderStudent(dim=args.dec_dim, pw_hidden=args.dec_pw, blocks=args.dec_blocks).to(device)
333
+ model.load_state_dict(torch.load(args.init, map_location=device, weights_only=False))
334
+ n = sum(p.numel() for p in model.parameters())
335
+ print(f"decoder params: {n:,} (init {args.init})", flush=True)
336
+
337
+ from vocos import Vocos
338
+ teacher = Vocos.from_pretrained("charactr/vocos-mel-24khz").to(device).eval()
339
+ for p in teacher.parameters():
340
+ p.requires_grad_(False)
341
+ tf = TeacherFeats(teacher)
342
+ print("vocoder loaded with hooks", flush=True)
343
+
344
+ mrd = MRD().to(device)
345
+ betas = tuple(float(x) for x in args.betas.split(","))
346
+ opt = torch.optim.AdamW(model.parameters(), lr=args.lr, betas=betas)
347
+ opt_d = torch.optim.AdamW(mrd.parameters(), lr=args.adv_lr, betas=betas)
348
+ sched = None if args.const_lr else torch.optim.lr_scheduler.ExponentialLR(opt, gamma=0.9998)
349
+ if sched is not None and args.resume_step > 0:
350
+ for _ in range(args.resume_step):
351
+ sched.step()
352
+
353
+ RUN = os.path.join("runs", args.tag)
354
+ os.makedirs(RUN, exist_ok=True)
355
+ t0 = time.time()
356
+ for step in range(args.resume_step + 1, args.steps + 1):
357
+ idx = np.random.choice(len(files), min(args.batch, len(files)), replace=False)
358
+ cnt = 0
359
+ content_total = None
360
+ opt.zero_grad()
361
+ gan_pair = None
362
+ for ii, i in enumerate(idx):
363
+ d = np.load(files[i])
364
+ mel_t = torch.from_numpy(d["mel"]).to(device)
365
+ audio = torch.from_numpy(d["audio"]).to(device)
366
+ T = mel_t.shape[1]
367
+ seg = args.seg
368
+ s = 0
369
+ if T > seg:
370
+ s = np.random.randint(0, T - seg)
371
+ if np.random.rand() < args.mix_prob:
372
+ # live student mel (used only for non-deployment packs)
373
+ ids = torch.from_numpy(d["phone_ids"]).long().to(device)
374
+ with torch.no_grad():
375
+ dd = dur(ids)
376
+ mel_s = ac(ids, dd)
377
+ seg_s = min(seg, mel_s.shape[1])
378
+ if seg_s < 16:
379
+ continue
380
+ s_s = np.random.randint(0, max(1, mel_s.shape[1] - seg_s + 1))
381
+ mel = mel_s[:, s_s:s_s + seg_s]
382
+ a0, a1 = s_s * 256, (s_s + seg_s) * 256
383
+ if a1 > audio.shape[0]:
384
+ a1 = audio.shape[0]
385
+ if a1 - a0 < 1024:
386
+ continue
387
+ audio = audio[a0:a1]
388
+ else:
389
+ mel = mel_t[:, s:s + seg] if T > seg else mel_t
390
+ audio = audio[s * 256:(s + mel.shape[1]) * 256]
391
+ noise = torch.zeros(4, mel.shape[1], device=device) if args.noise_zeros \
392
+ else torch.randn(4, mel.shape[1], device=device)
393
+ wav = model.synthesize(mel, noise)
394
+ n_w = min(wav.shape[-1], audio.shape[-1])
395
+ wav_c, aud_c = wav[:n_w].unsqueeze(0), audio[:n_w].unsqueeze(0)
396
+ if gan_pair is None:
397
+ gan_pair = (wav_c, aud_c)
398
+
399
+ l_wav = F.l1_loss(wav[:n_w], audio[:n_w])
400
+ l_stft = stft_loss(wav_c, aud_c, device)
401
+ rms_p = wav[:n_w].square().mean().clamp_min(1e-8).sqrt()
402
+ rms_t = audio[:n_w].square().mean().clamp_min(1e-8).sqrt()
403
+ l_loud = F.l1_loss(rms_p, rms_t)
404
+
405
+ # feature-hint + signature-temporal: first item of the step only
406
+ l_hint = torch.zeros((), device=device)
407
+ l_sigt = torch.zeros((), device=device)
408
+ need_feats = (args.feature_hint_weight > 0 or args.sig_temporal_weight > 0) and ii == 0
409
+ if need_feats:
410
+ with torch.no_grad():
411
+ tf.clear()
412
+ teacher.decode(mel.unsqueeze(0))
413
+ t_feats = {k: v.detach() for k, v in tf.feats.items()}
414
+ s_feats = {}
415
+ x = model.embed(mel) + model.noise_adapter(noise)
416
+ s_feats["embed"] = x
417
+ x2 = model.norm(x.transpose(0, 1)).transpose(0, 1)
418
+ cn_idx = min(3, len(model.blocks) - 1)
419
+ for bi, b in enumerate(model.blocks):
420
+ x2 = b(x2)
421
+ if bi == cn_idx:
422
+ s_feats["cn3"] = x2
423
+ t_embed = t_feats.get("embed")
424
+ if t_embed is not None:
425
+ t_embed = t_embed[0]
426
+ Tt = min(t_embed.shape[-1], s_feats["embed"].shape[-1])
427
+ t_feats["embed"] = t_embed[:, :Tt].unsqueeze(0)
428
+ s_feats["embed"] = s_feats["embed"][:, :Tt].unsqueeze(0)
429
+ t_cn3 = t_feats.get("cn3")
430
+ if t_cn3 is not None:
431
+ t_cn3 = t_cn3[0]
432
+ Tt = min(t_cn3.shape[-1], s_feats["cn3"].shape[-1])
433
+ t_feats["cn3"] = t_cn3[:, :Tt].unsqueeze(0)
434
+ s_feats["cn3"] = s_feats["cn3"][:, :Tt].unsqueeze(0)
435
+ l_hint = channel_moment_hint_loss(s_feats, t_feats)
436
+ if args.sig_temporal_weight > 0:
437
+ l_sigt = signature_temporal_loss(s_feats, t_feats)
438
+
439
+ l_hf = high_band_excess_loss(wav_c, aud_c, 24000, 6000.0, 1.0) \
440
+ if args.high_band_excess_weight > 0 else torch.zeros((), device=device)
441
+ l_quiet = quiet_ceiling_loss(wav_c[0], aud_c[0], 0.25, 1024, 256, 0.5) \
442
+ if args.quiet_ceiling_weight > 0 else torch.zeros((), device=device)
443
+
444
+ item = (args.wav_weight * l_wav + 1.0 * l_stft + args.loud_weight * l_loud
445
+ + args.feature_hint_weight * l_hint
446
+ + args.sig_temporal_weight * l_sigt
447
+ + args.high_band_excess_weight * l_hf
448
+ + args.quiet_ceiling_weight * l_quiet) / max(args.batch, 1)
449
+ content_total = item if content_total is None else content_total + item
450
+ cnt += 1
451
+
452
+ if cnt == 0 or content_total is None:
453
+ continue
454
+
455
+ content_total.backward(retain_graph=True)
456
+ l_adv = torch.zeros((), device=device)
457
+ l_adv_feat = torch.zeros((), device=device)
458
+ if step >= args.adv_start_step and gan_pair is not None:
459
+ wav_g, aud_g = gan_pair
460
+ nw = min(wav_g.shape[-1], aud_g.shape[-1])
461
+ wav_g, aud_g = wav_g[..., :nw], aud_g[..., :nw]
462
+ opt_d.zero_grad()
463
+ real_scores, _ = mrd(aud_g)
464
+ fake_scores, _ = mrd(wav_g.detach())
465
+ d_loss = discriminator_lsgan_loss(real_scores, fake_scores)
466
+ d_loss.backward()
467
+ torch.nn.utils.clip_grad_norm_(mrd.parameters(), 5.0)
468
+ opt_d.step()
469
+ for p in mrd.parameters():
470
+ p.requires_grad_(False)
471
+ fake_scores_g, fake_feats_g = mrd(wav_g)
472
+ _, real_feats_g = mrd(aud_g)
473
+ l_adv = generator_lsgan_loss(fake_scores_g)
474
+ l_adv_feat = feature_matching_loss(real_feats_g, fake_feats_g)
475
+ for p in mrd.parameters():
476
+ p.requires_grad_(True)
477
+ gan_loss = (args.adv_weight * l_adv + args.adv_feature_weight * l_adv_feat) / max(args.batch, 1)
478
+ gan_loss.backward()
479
+
480
+ torch.nn.utils.clip_grad_norm_(model.parameters(), 5.0)
481
+ opt.step()
482
+ if sched is not None:
483
+ sched.step()
484
+
485
+ if step % 100 == 0 or step == 1:
486
+ dt = time.time() - t0
487
+ print(f"step {step}/{args.steps} | wav {l_wav.item():.4f} stft {l_stft.item():.3f} "
488
+ f"hint {l_hint.item():.4f} sigt {l_sigt.item():.4f} hf {l_hf.item():.4f} "
489
+ f"quiet {l_quiet.item():.5f} adv {l_adv.item():.3f} fm {l_adv_feat.item():.3f} "
490
+ f"| {step/dt:.1f} sps", flush=True)
491
+ if step % 500 == 0:
492
+ torch.save(model.state_dict(), os.path.join(RUN, "last.pt"))
493
+ with open(os.path.join(RUN, "last_step.txt"), "w") as f:
494
+ f.write(str(step))
495
+
496
+ torch.save(model.state_dict(), os.path.join(RUN, "final.pt"))
497
+ print("DECODER DONE")
498
+
499
+
500
+ if __name__ == "__main__":
501
+ main()
train/train_duration.py ADDED
@@ -0,0 +1,126 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Train the duration student: phoneme ids -> per-phoneme frame counts.
2
+
3
+ Targets come from the teacher pack (the teacher's durations, converted to
4
+ mel-frame units at build time). Loss: smooth-L1 on log-duration plus a
5
+ total-length term (weight 0.35).
6
+
7
+ The shipped checkpoint is the third stage of a small chain: a base run
8
+ followed by two pacing fine-tunes at lower learning rates (see train/README.md
9
+ for the exact commands). Pacing is a real quality lever on the full system:
10
+ the end-to-end evaluation improved when the student's total-length error
11
+ dropped, even though nothing else changed.
12
+
13
+ python train_duration.py --pack packs/train --hidden 12 --depth 3 --steps 4000
14
+ """
15
+ import argparse
16
+ import glob
17
+ import os
18
+ import time
19
+
20
+ import numpy as np
21
+ import torch
22
+
23
+ import sys
24
+ from pathlib import Path
25
+
26
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
27
+ import torch.nn.functional as F
28
+
29
+ from microtts.models import DurationStudent
30
+
31
+
32
+ def main():
33
+ ap = argparse.ArgumentParser()
34
+ ap.add_argument("--pack", default="packs/train")
35
+ ap.add_argument("--steps", type=int, default=4000)
36
+ ap.add_argument("--batch", type=int, default=32)
37
+ ap.add_argument("--lr", type=float, default=2e-3)
38
+ ap.add_argument("--total-weight", type=float, default=0.35)
39
+ ap.add_argument("--const-lr", action="store_true",
40
+ help="constant lr (the released pacing fine-tunes used this)")
41
+ ap.add_argument("--init", default=None,
42
+ help="checkpoint to continue from (pacing fine-tune stages)")
43
+ ap.add_argument("--resume-step", type=int, default=0)
44
+ ap.add_argument("--device", default="cuda")
45
+ ap.add_argument("--tag", default="dur")
46
+ ap.add_argument("--hidden", type=int, default=12)
47
+ ap.add_argument("--depth", type=int, default=3)
48
+ ap.add_argument("--seed", type=int, default=0)
49
+ args = ap.parse_args()
50
+
51
+ torch.manual_seed(args.seed)
52
+ import random
53
+ random.seed(args.seed)
54
+
55
+ device = torch.device(args.device if torch.cuda.is_available() else "cpu")
56
+ files = sorted(glob.glob(os.path.join(args.pack, "*.npz")))
57
+ print(f"pack: {len(files)} rows, device {device}")
58
+ model = DurationStudent(hidden=args.hidden, depth=args.depth).to(device)
59
+ if args.init:
60
+ model.load_state_dict(torch.load(args.init, map_location=device, weights_only=False))
61
+ print(f"duration resume init: {args.init}")
62
+ n = sum(p.numel() for p in model.parameters())
63
+ print(f"duration params: {n:,}")
64
+ opt = torch.optim.AdamW(model.parameters(), lr=args.lr, betas=(0.8, 0.99))
65
+ sched = None if args.const_lr else torch.optim.lr_scheduler.ExponentialLR(opt, gamma=0.9995)
66
+ if sched is not None and args.resume_step > 0:
67
+ for _ in range(args.resume_step):
68
+ sched.step()
69
+
70
+ RUN = os.path.join("runs", args.tag)
71
+ os.makedirs(RUN, exist_ok=True)
72
+ t0 = time.time()
73
+ for step in range(args.resume_step + 1, args.steps + 1):
74
+ idx = np.random.choice(len(files), min(args.batch, len(files)), replace=False)
75
+ ids_list, dur_list = [], []
76
+ for i in idx:
77
+ d = np.load(files[i])
78
+ ids_list.append(torch.from_numpy(d["phone_ids"]))
79
+ dur_list.append(torch.from_numpy(d["durations"]))
80
+ # pad to max len in batch
81
+ T = max(x.shape[0] for x in ids_list)
82
+ ids = torch.zeros(args.batch, T, dtype=torch.long)
83
+ durs = torch.zeros(args.batch, T)
84
+ for b, (x, y) in enumerate(zip(ids_list, dur_list)):
85
+ ids[b, :x.shape[0]] = x
86
+ durs[b, :y.shape[0]] = y.float()
87
+ ids, durs = ids.to(device), durs.to(device)
88
+
89
+ opt.zero_grad()
90
+ # per-row forward (model takes 1D ids):
91
+ # smooth_l1(log pred, log target, beta=0.25) + 0.35 * log-total-frames MSE
92
+ losses = []
93
+ for b in range(args.batch):
94
+ n_tok = int((ids[b] > 0).sum())
95
+ if n_tok < 2:
96
+ continue
97
+ xb = ids[b, :n_tok]
98
+ log_pred = model(xb, round_out=False).float()
99
+ log_tgt = torch.log(durs[b, :n_tok].clamp_min(1))
100
+ token_loss = F.smooth_l1_loss(log_pred, log_tgt, beta=0.25)
101
+ total_loss = (torch.log(torch.exp(log_pred).clamp_min(1).sum().clamp_min(1))
102
+ - torch.log(durs[b, :n_tok].clamp_min(1).sum().clamp_min(1))) ** 2
103
+ losses.append(token_loss + args.total_weight * total_loss)
104
+ if not losses:
105
+ continue
106
+ loss = sum(losses) / len(losses)
107
+ loss.backward()
108
+ torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
109
+ opt.step()
110
+ if sched is not None:
111
+ sched.step()
112
+
113
+ if step % 200 == 0 or step == 1:
114
+ dt = time.time() - t0
115
+ print(f"step {step}/{args.steps} | dur {loss.item():.4f} | {step/dt:.1f} sps", flush=True)
116
+ if step % 500 == 0:
117
+ torch.save(model.state_dict(), os.path.join(RUN, "last.pt"))
118
+ with open(os.path.join(RUN, "last_step.txt"), "w") as f:
119
+ f.write(str(step))
120
+
121
+ torch.save(model.state_dict(), os.path.join(RUN, "final.pt"))
122
+ print("DURATION DONE")
123
+
124
+
125
+ if __name__ == "__main__":
126
+ main()