Upload folder using huggingface_hub
Browse files- .gitattributes +8 -0
- LICENSE +29 -0
- README.md +403 -0
- acoustic.pt +3 -0
- benchmark/eval_sentences.txt +24 -0
- benchmark/results.json +44 -0
- benchmark/rtf.py +52 -0
- benchmark/scores_release.json +9 -0
- benchmark/wer.py +57 -0
- config.json +23 -0
- decoder.pt +3 -0
- duration.pt +3 -0
- microtts/__init__.py +24 -0
- microtts/frontend.py +72 -0
- microtts/g2p/__init__.py +11 -0
- microtts/g2p/english.py +1394 -0
- microtts/g2p/g2p_data/LICENSE.apache-2.0.txt +201 -0
- microtts/g2p/g2p_data/NOTICE.md +16 -0
- microtts/g2p/g2p_data/oov_bart_en_us.npz +3 -0
- microtts/g2p/g2p_data/us_gold.json +0 -0
- microtts/g2p/g2p_data/us_silver.json +0 -0
- microtts/g2p/oov.py +244 -0
- microtts/models.py +225 -0
- microtts/synthesize.py +134 -0
- model.safetensors +3 -0
- requirements.txt +3 -0
- samples/sample_0.wav +3 -0
- samples/sample_1.wav +3 -0
- samples/sample_2.wav +3 -0
- samples/sample_3.wav +3 -0
- samples/sample_4.wav +3 -0
- samples/sample_5.wav +3 -0
- samples/sample_6.wav +3 -0
- samples/sample_7.wav +3 -0
- tilt.npy +3 -0
- train/README.md +130 -0
- train/average_checkpoints.py +74 -0
- train/build_deploy_pack.py +112 -0
- train/build_pack.py +119 -0
- train/compute_tilt.py +91 -0
- train/init_decoder.py +75 -0
- train/train_acoustic.py +215 -0
- train/train_decoder.py +501 -0
- train/train_duration.py +126 -0
.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()
|