Luigi commited on
Commit
a37967e
Β·
verified Β·
1 Parent(s): 948a7fa

PrimeTTS: full training pipeline + weights (fine-tune of Inflect-Nano-v1)

Browse files
README.md ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: apache-2.0
3
+ language:
4
+ - zh
5
+ - en
6
+ tags:
7
+ - text-to-speech
8
+ - tts
9
+ - onnx
10
+ - on-device
11
+ - jetson
12
+ - telephony
13
+ - mandarin
14
+ - taiwanese-mandarin
15
+ base_model: owensong/Inflect-Nano-v1
16
+ library_name: onnxruntime
17
+ pipeline_tag: text-to-speech
18
+ ---
19
+
20
+ # PrimeTTS β€” tiny bilingual zh-TW + English TTS (8 kHz, CPU)
21
+
22
+ **PrimeTTS** is an ultra-small **4.63M-parameter** Mandarin (Taiwan) + English text-to-speech model
23
+ that runs **entirely on CPU** via `onnxruntime` and emits **8 kHz** audio β€” sized for **G.711
24
+ telephony** and **on-device (Jetson-class)** deployment.
25
+
26
+ It is a **fine-tune of [`owensong/Inflect-Nano-v1`](https://huggingface.co/owensong/Inflect-Nano-v1)**
27
+ (Apache-2.0) β€” the **same, frozen architecture**, retrained for zh-TW + English. No architecture
28
+ changes, no neural-architecture-search.
29
+
30
+ - **Acoustic** (`MicroFastSpeech`, ~3.47M): FastSpeech-style depthwise Conv-FFN (**no attention**),
31
+ external durations + length regulator, frame-pitch, BiGRU, postnet.
32
+ - **Vocoder** (~1.17M): Snake-HiFiGAN. We added an **8 kHz variant** (`snake_8k`: sr 8000, n_fft 512,
33
+ hop 128, 80 mels) and a **zh-TW + English frontend** (bopomofo + arpabet, one unified phone sequence).
34
+
35
+ **Live demo:** https://huggingface.co/spaces/Luigi/inflect-nano-zhtw-en-8k-demo
36
+
37
+ ---
38
+
39
+ ## How these weights were obtained β€” the two things that matter
40
+
41
+ Inflect-Nano-v1's 4.63M architecture is **not capacity-limited** for this task. We confirmed the
42
+ original English checkpoint scores ~0.05 CER on our eval, yet our first retrains were unintelligible
43
+ (~0.88 CER). The gap was **not** the model size. Two fixes β€” **architecture frozen** β€” took held-out
44
+ Mandarin CER from **~0.88 β†’ ~0.06**:
45
+
46
+ 1. **Phone-level forced alignment.** FastSpeech needs a per-phone duration for every training clip.
47
+ Crude char/letter-CTC alignment (then splitting a char's span across its phones by heuristic) gives
48
+ *wrong relative phone durations* → the acoustic learns a mis-timed phone→mel map → over-smoothed,
49
+ garbled output in **every** language. Replace it with **true phone-level forced alignment**
50
+ (`align_durations_v4.py`: the espeak phoneme-CTC model `facebook/wav2vec2-lv-60-espeak-cv-ft` +
51
+ `torchaudio.forced_align`, aligning *your frontend's own phone sequence* mapped to IPA). This alone:
52
+ Mandarin 0.88 β†’ 0.40.
53
+
54
+ 2. **Diverse, well-covered training text.** The model can only pronounce characters/words it has seen.
55
+ A narrow corpus (β‰ˆ234 Han chars) left ~39% of held-out characters unseen. Expanding character/word
56
+ coverage (`select_diverse_text.py`: Tatoeba β†’ OpenCC `s2twp` β†’ greedy coverage) took Mandarin
57
+ 0.40 β†’ **0.06**. Applied symmetrically to English, the same recipe yields a genuinely **bilingual**
58
+ model (zh-only β‰ˆ 0.13, English β‰ˆ 0.16) in one 4.63M net β€” no language routing.
59
+
60
+ Everything else (loss weights, steps, etc.) is essentially Inflect-Nano-v1's defaults.
61
+
62
+ ---
63
+
64
+ ## Reproduce / fine-tune (your own voice or language)
65
+
66
+ ```
67
+ scripts/ full pipeline (frontend, aligner, corpus-gen, train, export, eval)
68
+ inflect_nano/ the trainer (acoustic.py + vocoder.py), forked from Inflect-Nano-v1 (LICENSE included)
69
+ weights/ deployable ONNX (encoder/decoder/vocoder) + meta.json + symbol_table.json
70
+ + acoustic_zh_v2_60k.pt (PyTorch checkpoint, to resume / fine-tune)
71
+ ```
72
+
73
+ **Step 1 β€” Teacher corpus.** Synthesize (text β†’ audio) clips in ONE target voice, ASR-gated for
74
+ fidelity. We used **BreezyVoice** (MediaTek, CosyVoice-based; single "mark" voice, does zh + en) with a
75
+ **Breeze-ASR-25** gate (`gen_breezy_corpus.py` β€” keeps a clip only if its ASR transcript matches the
76
+ intended text, t2s-normalized). Any clean single-speaker TTS/recordings work.
77
+
78
+ **Step 2 β€” Diverse text.** `python scripts/select_diverse_text.py --lang zh --n 6000 --out zh.tsv`
79
+ (and `--lang en`). Feed the `.tsv` to step 1. Coverage is the single biggest driver of held-out quality.
80
+
81
+ **Step 3 β€” Phone-level alignment.** `python scripts/align_durations_v4.py --manifest corpus/manifest.jsonl
82
+ --out align.jsonl` β†’ per-phone durations from real audio. **This is the key fix β€” do not skip it.**
83
+
84
+ **Step 4 β€” Train acoustic.** `inflect_nano.acoustic --durations-jsonl align.jsonl --vocoder-variant
85
+ snake_8k --sample-rate 8000 ...` (see `scripts/run_bilingual.sh`). Mix languages in one corpus, single
86
+ speaker, for native code-mix. ~60k steps, batch 16.
87
+
88
+ **Step 5 β€” Train vocoder.** `inflect_nano.vocoder --train-jsonl voc_rows.jsonl --variant snake_8k ...`
89
+ (see `scripts/run_voc_retrain.sh`). Train on the same diverse audio; higher `--stft-weight` = crisper.
90
+
91
+ **Step 6 β€” Export ONNX.** `python scripts/export_8k.py --acoustic-ckpt <pt> --vocoder-ckpt <pt>
92
+ --out-dir onnx/` β†’ `acoustic_encoder.onnx` β†’ numpy length-regulator (`host_regulate`) β†’
93
+ `acoustic_decoder.onnx` β†’ `vocoder.onnx`. Fully torch-free at inference.
94
+
95
+ **Step 7 β€” Evaluate.** `synth_from_text.py` + `assess_big.py` (offline X-ASR CER/WER). Use β‰₯30 held-out
96
+ sentences β€” small eval sets are too noisy.
97
+
98
+ ### Inference (torch-free, CPU)
99
+ ```python
100
+ import frontend_bopomofo as F, numpy as np, onnxruntime as ort
101
+ o = F.text_to_ids("您ε₯½,ζ­‘θΏŽδ½Ώη”¨ PrimeTTS。") # bopomofo + arpabet -> ids
102
+ # encoder.onnx -> host_regulate (numpy) -> decoder.onnx -> vocoder.onnx -> 8 kHz wav
103
+ ```
104
+ See `scripts/synth_from_text.py` for the full ~40-line runtime (also runs as-is on a Jetson Nano CPU).
105
+
106
+ ---
107
+
108
+ ## Credits & licenses
109
+
110
+ - **Base model / trainer:** [`owensong/Inflect-Nano-v1`](https://huggingface.co/owensong/Inflect-Nano-v1)
111
+ (Apache-2.0; `inflect_nano/LICENSE.inflect-nano`).
112
+ - **Teacher:** BreezyVoice (MediaTek Research). **Gate ASR:** `Breeze-ASR-25` (MediaTek Research).
113
+ - **Aligner:** `facebook/wav2vec2-lv-60-espeak-cv-ft` + `torchaudio.forced_align`.
114
+ - **Frontend:** `g2pw` (Taiwan bopomofo, polyphone disambiguation) + `g2p_en` (arpabet).
115
+ - **Eval ASR:** sherpa-onnx X-ASR (zh-en Zipformer). **Text:** Tatoeba (CC-BY 2.0 FR).
116
+
117
+ This repository: **Apache-2.0**.
inflect_nano/LICENSE.inflect-nano 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.
inflect_nano/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Inflect-Nano-v1 runtime package."""
inflect_nano/acoustic.py ADDED
@@ -0,0 +1,1295 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import math
6
+ import random
7
+ import sys
8
+ import time
9
+ from dataclasses import asdict, dataclass
10
+ from pathlib import Path
11
+
12
+ import torch
13
+ from torch import nn
14
+ import torch.nn.functional as F
15
+ import torchaudio
16
+
17
+ SCRIPT_ROOT = Path(__file__).resolve().parent
18
+ PROJECT_ROOT = SCRIPT_ROOT.parents[0]
19
+ FRONTEND_ROOT = PROJECT_ROOT / "third_party" / "tiny_tts_frontend"
20
+ sys.path = [str(FRONTEND_ROOT), str(SCRIPT_ROOT)] + [p for p in sys.path if p]
21
+
22
+ from inflect_nano.vocoder import HifiGanConfig, HifiGanGenerator, MelFrontend, make_config
23
+
24
+
25
+ @dataclass
26
+ class MicroFastSpeechConfig:
27
+ vocab_size: int = 256
28
+ tone_size: int = 16
29
+ lang_size: int = 4
30
+ n_mels: int = 80
31
+ hidden: int = 168
32
+ encoder_layers: int = 5
33
+ decoder_layers: int = 6
34
+ decoder_ff_mult: int = 3
35
+ kernel_size: int = 7
36
+ speaker_count: int = 2
37
+ speaker_dim: int = 64
38
+ dropout: float = 0.08
39
+ sample_rate: int = 24000
40
+ max_frames: int = 1400
41
+ postnet_scale: float = 0.10
42
+ use_frame_pitch: bool = True
43
+ abs_frame_bins: int = 512
44
+ use_contextual_predictors: bool = False
45
+ use_group_duration_planner: bool = False
46
+
47
+
48
+ def count_parameters(model: nn.Module) -> int:
49
+ return sum(p.numel() for p in model.parameters())
50
+
51
+
52
+ def load_rows(path: Path, max_rows: int = 0) -> list[dict]:
53
+ rows = []
54
+ with path.open("r", encoding="utf-8") as f:
55
+ for line in f:
56
+ if line.strip():
57
+ row = json.loads(line)
58
+ if Path(str(row.get("target_audio") or "")).is_file():
59
+ rows.append(row)
60
+ if max_rows and len(rows) >= max_rows:
61
+ break
62
+ if not rows:
63
+ raise RuntimeError(f"No usable rows in {path}")
64
+ return rows
65
+
66
+
67
+ def load_audio(path: str, sample_rate: int, max_seconds: float) -> torch.Tensor:
68
+ import soundfile as _sf # avoid torchaudio.load (needs torchcodec/ffmpeg on torch>=2.1)
69
+ _a, sr = _sf.read(path, dtype="float32", always_2d=True)
70
+ wav = torch.from_numpy(_a.T) # [ch, T]
71
+ if wav.shape[0] > 1:
72
+ wav = wav.mean(dim=0, keepdim=True)
73
+ if sr != sample_rate:
74
+ wav = torchaudio.functional.resample(wav, sr, sample_rate)
75
+ return wav[:, : int(sample_rate * max_seconds)].squeeze(0).clamp(-1.0, 1.0)
76
+
77
+
78
+ def fit_durations(durations: list[int], target_frames: int) -> list[int]:
79
+ if sum(durations) == target_frames:
80
+ return list(durations)
81
+ total = max(1, sum(durations))
82
+ raw = [max(0.0, d * target_frames / total) for d in durations]
83
+ out = [int(math.floor(x)) for x in raw]
84
+ order = sorted(((raw[i] - out[i], i) for i in range(len(out))), reverse=True)
85
+ for _, idx in order[: max(0, target_frames - sum(out))]:
86
+ out[idx] += 1
87
+ while sum(out) > target_frames:
88
+ idx = max(range(len(out)), key=lambda i: out[i])
89
+ out[idx] -= 1
90
+ return out
91
+
92
+
93
+ def pad_1d(items: list[torch.Tensor], value: float = 0.0) -> torch.Tensor:
94
+ max_len = max(x.numel() for x in items)
95
+ out = torch.full((len(items), max_len), value, dtype=items[0].dtype)
96
+ for i, item in enumerate(items):
97
+ out[i, : item.numel()] = item
98
+ return out
99
+
100
+
101
+ def pad_2d(items: list[torch.Tensor], value: float = 0.0) -> torch.Tensor:
102
+ max_len = max(x.shape[0] for x in items)
103
+ dim = items[0].shape[1]
104
+ out = torch.full((len(items), max_len, dim), value, dtype=items[0].dtype)
105
+ for i, item in enumerate(items):
106
+ out[i, : item.shape[0]] = item
107
+ return out
108
+
109
+
110
+ def pad_mels(items: list[torch.Tensor]) -> tuple[torch.Tensor, torch.Tensor]:
111
+ max_len = max(x.shape[-1] for x in items)
112
+ n_mels = items[0].shape[0]
113
+ out = torch.zeros(len(items), n_mels, max_len, dtype=items[0].dtype)
114
+ mask = torch.zeros(len(items), max_len, dtype=torch.bool)
115
+ for i, mel in enumerate(items):
116
+ frames = mel.shape[-1]
117
+ out[i, :, :frames] = mel
118
+ mask[i, :frames] = True
119
+ return out, mask
120
+
121
+
122
+ def pad_wavs(items: list[torch.Tensor], frames: list[int], hop_size: int) -> torch.Tensor:
123
+ max_len = max(max(1, int(frame_count)) * hop_size for frame_count in frames)
124
+ out = torch.zeros(len(items), max_len, dtype=items[0].dtype)
125
+ for i, (wav, frame_count) in enumerate(zip(items, frames)):
126
+ length = max(1, int(frame_count)) * hop_size
127
+ cropped = wav[:length]
128
+ out[i, : cropped.numel()] = cropped
129
+ return out
130
+
131
+
132
+ def aggregate_token_features(mel: torch.Tensor, durations: list[int]) -> tuple[torch.Tensor, torch.Tensor]:
133
+ # mel: [80, frames], log-mel from the exact V2+ frontend.
134
+ frames = mel.shape[-1]
135
+ amp = torch.exp(mel).clamp_min(1e-5)
136
+ energy_frame = mel.mean(dim=0)
137
+ bins = torch.linspace(0.0, 1.0, mel.shape[0], device=mel.device).view(-1, 1)
138
+ bright_frame = (amp * bins).sum(dim=0) / amp.sum(dim=0).clamp_min(1e-5)
139
+ energies = []
140
+ brights = []
141
+ pos = 0
142
+ for dur in durations:
143
+ end = min(frames, pos + max(0, int(dur)))
144
+ if end > pos:
145
+ energies.append(energy_frame[pos:end].mean())
146
+ brights.append(bright_frame[pos:end].mean())
147
+ else:
148
+ energies.append(torch.zeros((), device=mel.device, dtype=mel.dtype))
149
+ brights.append(torch.zeros((), device=mel.device, dtype=mel.dtype))
150
+ pos = end
151
+ return torch.stack(energies), torch.stack(brights)
152
+
153
+
154
+ def aggregate_token_pitch(pitch_frame: torch.Tensor, durations: list[int]) -> torch.Tensor:
155
+ # pitch_frame: [2, frames] with normalized log-f0 and voiced flag.
156
+ frames = pitch_frame.shape[-1]
157
+ out = []
158
+ pos = 0
159
+ for dur in durations:
160
+ end = min(frames, pos + max(0, int(dur)))
161
+ if end > pos:
162
+ span = pitch_frame[:, pos:end]
163
+ voiced = span[1].mean()
164
+ voiced_mask = span[1] > 0.5
165
+ if bool(voiced_mask.any()):
166
+ log_f0 = span[0, voiced_mask].mean()
167
+ else:
168
+ log_f0 = torch.zeros((), dtype=pitch_frame.dtype)
169
+ out.append(torch.stack([log_f0, voiced]))
170
+ else:
171
+ out.append(torch.zeros(2, dtype=pitch_frame.dtype))
172
+ pos = end
173
+ return torch.stack(out, dim=0)
174
+
175
+
176
+ def extract_pitch_features(wav: torch.Tensor, sample_rate: int, frames: int) -> torch.Tensor:
177
+ # Returns [2, frames]: normalized log-f0 and voiced flag. The detector can
178
+ # produce octave spikes, so clip to speech range and median-smooth lightly.
179
+ pitch = torchaudio.functional.detect_pitch_frequency(
180
+ wav.unsqueeze(0).cpu(),
181
+ sample_rate,
182
+ frame_time=256 / sample_rate,
183
+ ).squeeze(0)
184
+ if pitch.numel() < frames:
185
+ pitch = F.pad(pitch, (0, frames - pitch.numel()), value=0.0)
186
+ pitch = pitch[:frames]
187
+ voiced = ((pitch >= 55.0) & (pitch <= 420.0)).float()
188
+ pitch = pitch.clamp(55.0, 420.0)
189
+ # Median filter over 5 frames to reduce spurious jumps.
190
+ if pitch.numel() >= 5:
191
+ padded = F.pad(pitch.view(1, 1, -1), (2, 2), mode="replicate")
192
+ windows = padded.unfold(-1, 5, 1).squeeze(0).squeeze(0)
193
+ pitch = windows.median(dim=-1).values
194
+ log_f0 = (torch.log(pitch) - math.log(140.0)) / 0.45
195
+ log_f0 = log_f0.clamp(-3.0, 3.0) * voiced
196
+ return torch.stack([log_f0, voiced], dim=0)
197
+
198
+
199
+ class ConvFFNBlock(nn.Module):
200
+ def __init__(self, hidden: int, kernel_size: int, dropout: float, ff_mult: int = 4) -> None:
201
+ super().__init__()
202
+ pad = kernel_size // 2
203
+ self.norm1 = nn.LayerNorm(hidden)
204
+ self.depth = nn.Conv1d(hidden, hidden * 2, kernel_size, padding=pad, groups=hidden)
205
+ self.point = nn.Conv1d(hidden, hidden, 1)
206
+ self.drop = nn.Dropout(dropout)
207
+ self.norm2 = nn.LayerNorm(hidden)
208
+ self.ff = nn.Sequential(
209
+ nn.Linear(hidden, hidden * ff_mult),
210
+ nn.SiLU(),
211
+ nn.Dropout(dropout),
212
+ nn.Linear(hidden * ff_mult, hidden),
213
+ )
214
+
215
+ def forward(self, x: torch.Tensor, mask: torch.Tensor | None = None) -> torch.Tensor:
216
+ y = self.norm1(x).transpose(1, 2)
217
+ a, b = self.depth(y).chunk(2, dim=1)
218
+ y = self.point(a * torch.sigmoid(b)).transpose(1, 2)
219
+ x = x + self.drop(y)
220
+ x = x + self.drop(self.ff(self.norm2(x)))
221
+ if mask is not None:
222
+ x = x * mask.unsqueeze(-1)
223
+ return x
224
+
225
+
226
+ class MicroFastSpeech(nn.Module):
227
+ def __init__(self, cfg: MicroFastSpeechConfig) -> None:
228
+ super().__init__()
229
+ self.cfg = cfg
230
+ # Phone id 0 is a real inserted blank/silence token from TinyTTS, not
231
+ # padding. Padding is tracked by duration masks instead.
232
+ self.phone = nn.Embedding(cfg.vocab_size, cfg.hidden)
233
+ self.tone = nn.Embedding(cfg.tone_size, cfg.hidden)
234
+ self.lang = nn.Embedding(cfg.lang_size, cfg.hidden)
235
+ self.speaker = nn.Embedding(cfg.speaker_count, cfg.speaker_dim)
236
+ self.speaker_proj = nn.Linear(cfg.speaker_dim, cfg.hidden)
237
+ self.encoder = nn.ModuleList([ConvFFNBlock(cfg.hidden, cfg.kernel_size, cfg.dropout) for _ in range(cfg.encoder_layers)])
238
+ self.duration_head = nn.Sequential(nn.LayerNorm(cfg.hidden), nn.Linear(cfg.hidden, cfg.hidden), nn.SiLU(), nn.Linear(cfg.hidden, 1))
239
+ self.energy_head = nn.Sequential(nn.LayerNorm(cfg.hidden), nn.Linear(cfg.hidden, cfg.hidden // 2), nn.SiLU(), nn.Linear(cfg.hidden // 2, 1))
240
+ self.bright_head = nn.Sequential(nn.LayerNorm(cfg.hidden), nn.Linear(cfg.hidden, cfg.hidden // 2), nn.SiLU(), nn.Linear(cfg.hidden // 2, 1))
241
+ self.pitch_head = nn.Sequential(nn.LayerNorm(cfg.hidden), nn.Linear(cfg.hidden, cfg.hidden), nn.SiLU(), nn.Linear(cfg.hidden, 2))
242
+ self.group_duration_delta = nn.Linear(cfg.hidden, 1) if cfg.use_group_duration_planner else None
243
+ if self.group_duration_delta is not None:
244
+ nn.init.zeros_(self.group_duration_delta.weight)
245
+ nn.init.zeros_(self.group_duration_delta.bias)
246
+ self.predictor_context = (
247
+ ConvFFNBlock(cfg.hidden, 5, cfg.dropout, 2) if cfg.use_contextual_predictors else nn.Identity()
248
+ )
249
+ self.duration_delta = nn.Linear(cfg.hidden, 1) if cfg.use_contextual_predictors else None
250
+ self.energy_delta = nn.Linear(cfg.hidden, 1) if cfg.use_contextual_predictors else None
251
+ self.bright_delta = nn.Linear(cfg.hidden, 1) if cfg.use_contextual_predictors else None
252
+ self.pitch_delta = nn.Linear(cfg.hidden, 2) if cfg.use_contextual_predictors else None
253
+ if cfg.use_contextual_predictors:
254
+ for layer in (self.duration_delta, self.energy_delta, self.bright_delta, self.pitch_delta):
255
+ nn.init.zeros_(layer.weight)
256
+ nn.init.zeros_(layer.bias)
257
+ self.energy_proj = nn.Linear(1, cfg.hidden)
258
+ self.bright_proj = nn.Linear(1, cfg.hidden)
259
+ self.pitch_proj = nn.Sequential(nn.Linear(2, cfg.hidden), nn.SiLU(), nn.Linear(cfg.hidden, cfg.hidden))
260
+ self.abs_frame = nn.Embedding(cfg.abs_frame_bins, cfg.hidden)
261
+ self.frame_proj = nn.Sequential(nn.Linear(8, cfg.hidden), nn.SiLU(), nn.Linear(cfg.hidden, cfg.hidden))
262
+ self.local_ctx = nn.Sequential(
263
+ nn.Linear(cfg.hidden * 3, cfg.hidden * 2),
264
+ nn.SiLU(),
265
+ nn.Linear(cfg.hidden * 2, cfg.hidden),
266
+ )
267
+ self.decoder = nn.ModuleList([ConvFFNBlock(cfg.hidden, cfg.kernel_size, cfg.dropout, cfg.decoder_ff_mult) for _ in range(cfg.decoder_layers)])
268
+ self.frame_gru = nn.GRU(cfg.hidden, cfg.hidden // 2, num_layers=1, batch_first=True, bidirectional=True)
269
+ self.mel_head = nn.Sequential(nn.LayerNorm(cfg.hidden), nn.Linear(cfg.hidden, cfg.hidden), nn.SiLU(), nn.Linear(cfg.hidden, cfg.n_mels))
270
+ self.postnet = nn.Sequential(
271
+ nn.Conv1d(cfg.n_mels, cfg.hidden, 5, padding=2),
272
+ nn.Tanh(),
273
+ nn.Conv1d(cfg.hidden, cfg.hidden, 5, padding=2),
274
+ nn.Tanh(),
275
+ nn.Conv1d(cfg.hidden, cfg.n_mels, 5, padding=2),
276
+ )
277
+
278
+ def encode(self, phone: torch.Tensor, tone: torch.Tensor, lang: torch.Tensor, speaker: torch.Tensor, token_mask: torch.Tensor) -> torch.Tensor:
279
+ x = self.phone(phone) + self.tone(tone.clamp_max(self.cfg.tone_size - 1)) + self.lang(lang.clamp_max(self.cfg.lang_size - 1))
280
+ x = x + self.speaker_proj(self.speaker(speaker)).unsqueeze(1)
281
+ x = x * token_mask.unsqueeze(-1)
282
+ for block in self.encoder:
283
+ x = block(x, token_mask)
284
+ return x
285
+
286
+ def regulate(self, encoded: torch.Tensor, durations: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
287
+ batch_frames = []
288
+ batch_meta = []
289
+ lengths = []
290
+ device = encoded.device
291
+ for b in range(encoded.shape[0]):
292
+ reps = []
293
+ meta = []
294
+ durs = durations[b].long().clamp_min(0)
295
+ token_count = max(1, int((durs > 0).sum().item()))
296
+ for i, dur_t in enumerate(durs.tolist()):
297
+ dur = int(dur_t)
298
+ if dur <= 0:
299
+ continue
300
+ reps.append(encoded[b, i].view(1, -1).expand(dur, -1))
301
+ rel = torch.linspace(0.0, 1.0, dur, device=device)
302
+ token_pos = torch.full((dur,), i / max(1, token_count - 1), device=device)
303
+ log_dur = torch.full((dur,), math.log1p(dur) / 6.0, device=device)
304
+ inv_rel = 1.0 - rel
305
+ center = 1.0 - torch.abs(rel * 2.0 - 1.0)
306
+ meta.append(
307
+ torch.stack(
308
+ [
309
+ rel,
310
+ inv_rel,
311
+ center,
312
+ torch.sin(rel * math.pi),
313
+ torch.cos(rel * math.pi),
314
+ token_pos,
315
+ log_dur,
316
+ torch.full_like(rel, dur / 40.0),
317
+ ],
318
+ dim=-1,
319
+ )
320
+ )
321
+ if reps:
322
+ frames = torch.cat(reps, dim=0)
323
+ frame_meta = torch.cat(meta, dim=0)
324
+ else:
325
+ frames = encoded[b, :1]
326
+ frame_meta = torch.zeros(1, 8, device=device)
327
+ batch_frames.append(frames[: self.cfg.max_frames])
328
+ batch_meta.append(frame_meta[: self.cfg.max_frames])
329
+ lengths.append(min(frames.shape[0], self.cfg.max_frames))
330
+ max_len = max(lengths)
331
+ out = torch.zeros(encoded.shape[0], max_len, encoded.shape[-1], device=device)
332
+ meta_out = torch.zeros(encoded.shape[0], max_len, 8, device=device)
333
+ mask = torch.zeros(encoded.shape[0], max_len, dtype=torch.bool, device=device)
334
+ for b, frames in enumerate(batch_frames):
335
+ n = min(frames.shape[0], max_len)
336
+ out[b, :n] = frames[:n]
337
+ meta_out[b, :n] = batch_meta[b][:n]
338
+ mask[b, :n] = True
339
+ return out, meta_out, mask
340
+
341
+ def add_local_context(self, encoded: torch.Tensor, durations: torch.Tensor) -> torch.Tensor:
342
+ device = encoded.device
343
+ batch_frames = []
344
+ for b in range(encoded.shape[0]):
345
+ reps = []
346
+ durs = durations[b].long().clamp_min(0)
347
+ for i, dur_t in enumerate(durs.tolist()):
348
+ dur = int(dur_t)
349
+ if dur <= 0:
350
+ continue
351
+ prev_i = max(0, i - 1)
352
+ next_i = min(encoded.shape[1] - 1, i + 1)
353
+ ctx = torch.cat([encoded[b, prev_i], encoded[b, i], encoded[b, next_i]], dim=-1)
354
+ reps.append(ctx.view(1, -1).expand(dur, -1))
355
+ if reps:
356
+ frames = torch.cat(reps, dim=0)
357
+ else:
358
+ frames = torch.zeros(1, encoded.shape[-1] * 3, device=device)
359
+ batch_frames.append(frames[: self.cfg.max_frames])
360
+ max_len = max(x.shape[0] for x in batch_frames)
361
+ ctx_out = torch.zeros(encoded.shape[0], max_len, encoded.shape[-1] * 3, device=device)
362
+ for b, frames in enumerate(batch_frames):
363
+ ctx_out[b, : frames.shape[0]] = frames
364
+ return self.local_ctx(ctx_out)
365
+
366
+ def expand_token_feature(self, feature: torch.Tensor, durations: torch.Tensor) -> torch.Tensor:
367
+ device = feature.device
368
+ batch_frames = []
369
+ for b in range(feature.shape[0]):
370
+ reps = []
371
+ durs = durations[b].long().clamp_min(0)
372
+ for i, dur_t in enumerate(durs.tolist()):
373
+ dur = int(dur_t)
374
+ if dur <= 0:
375
+ continue
376
+ reps.append(feature[b, i].view(1, -1).expand(dur, -1))
377
+ if reps:
378
+ frames = torch.cat(reps, dim=0)
379
+ else:
380
+ frames = torch.zeros(1, feature.shape[-1], device=device)
381
+ batch_frames.append(frames[: self.cfg.max_frames])
382
+ max_len = max(x.shape[0] for x in batch_frames)
383
+ out = torch.zeros(feature.shape[0], max_len, feature.shape[-1], device=device)
384
+ for b, frames in enumerate(batch_frames):
385
+ out[b, : frames.shape[0]] = frames
386
+ return out
387
+
388
+ def forward(
389
+ self,
390
+ phone: torch.Tensor,
391
+ tone: torch.Tensor,
392
+ lang: torch.Tensor,
393
+ speaker: torch.Tensor,
394
+ durations: torch.Tensor,
395
+ energy_target: torch.Tensor | None = None,
396
+ bright_target: torch.Tensor | None = None,
397
+ pitch_frame: torch.Tensor | None = None,
398
+ predicted_prosody_mix: float = 0.0,
399
+ detach_mixed_predictions: bool = True,
400
+ ) -> dict[str, torch.Tensor]:
401
+ token_mask = durations.gt(0)
402
+ encoded = self.encode(phone, tone, lang, speaker, token_mask)
403
+ log_dur, energy_pred, bright_pred, pitch_pred = self.predict_prosody(encoded, token_mask)
404
+ mixed_energy_pred = energy_pred.detach() if detach_mixed_predictions else energy_pred
405
+ mixed_bright_pred = bright_pred.detach() if detach_mixed_predictions else bright_pred
406
+ if energy_target is not None:
407
+ energy = torch.lerp(energy_target, mixed_energy_pred, predicted_prosody_mix)
408
+ else:
409
+ energy = energy_pred
410
+ if bright_target is not None:
411
+ bright = torch.lerp(bright_target, mixed_bright_pred, predicted_prosody_mix)
412
+ else:
413
+ bright = bright_pred
414
+ conditioned = encoded + self.energy_proj(energy.unsqueeze(-1)) + self.bright_proj(bright.unsqueeze(-1))
415
+ frames, frame_meta, frame_mask = self.regulate(conditioned, durations)
416
+ x = frames + self.frame_proj(frame_meta) + self.add_local_context(conditioned, durations)
417
+ pos = torch.arange(x.shape[1], device=x.device)
418
+ pos = torch.div(pos * self.cfg.abs_frame_bins, max(1, self.cfg.max_frames), rounding_mode="floor").clamp_max(
419
+ self.cfg.abs_frame_bins - 1
420
+ )
421
+ x = x + self.abs_frame(pos).unsqueeze(0)
422
+ if self.cfg.use_frame_pitch:
423
+ if pitch_frame is not None:
424
+ pitch_frame = pitch_frame[:, :, : x.shape[1]].transpose(1, 2)
425
+ if pitch_frame.shape[1] < x.shape[1]:
426
+ pitch_frame = F.pad(pitch_frame, (0, 0, 0, x.shape[1] - pitch_frame.shape[1]))
427
+ if predicted_prosody_mix > 0.0:
428
+ mixed_pitch_pred = pitch_pred.detach() if detach_mixed_predictions else pitch_pred
429
+ predicted_pitch_frame = self.expand_token_feature(mixed_pitch_pred, durations)[:, : x.shape[1]]
430
+ pitch_frame = torch.lerp(pitch_frame, predicted_pitch_frame, predicted_prosody_mix)
431
+ else:
432
+ pitch_frame = self.expand_token_feature(pitch_pred, durations)[:, : x.shape[1]]
433
+ x = x + self.pitch_proj(pitch_frame)
434
+ for block in self.decoder:
435
+ x = block(x, frame_mask)
436
+ x = x + self.frame_gru(x)[0]
437
+ mel = self.mel_head(x).transpose(1, 2)
438
+ mel = mel + self.cfg.postnet_scale * self.postnet(mel)
439
+ group_log_dur, group_mask = self.group_log_durations(phone, log_dur, encoded)
440
+ return {
441
+ "mel": mel,
442
+ "frame_mask": frame_mask,
443
+ "log_dur": log_dur,
444
+ "group_log_dur": group_log_dur,
445
+ "group_mask": group_mask,
446
+ "energy": energy_pred,
447
+ "bright": bright_pred,
448
+ "pitch": pitch_pred,
449
+ "token_mask": token_mask,
450
+ }
451
+
452
+ def predict_prosody(
453
+ self, encoded: torch.Tensor, token_mask: torch.Tensor
454
+ ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]:
455
+ log_dur = self.duration_head(encoded).squeeze(-1)
456
+ energy = self.energy_head(encoded).squeeze(-1)
457
+ bright = self.bright_head(encoded).squeeze(-1)
458
+ pitch = self.pitch_head(encoded)
459
+ if self.cfg.use_contextual_predictors:
460
+ context = self.predictor_context(encoded, token_mask)
461
+ log_dur = log_dur + self.duration_delta(context).squeeze(-1)
462
+ energy = energy + self.energy_delta(context).squeeze(-1)
463
+ bright = bright + self.bright_delta(context).squeeze(-1)
464
+ pitch = pitch + self.pitch_delta(context)
465
+ return log_dur, energy, bright, pitch
466
+
467
+ def group_log_durations(
468
+ self, phone: torch.Tensor, log_dur: torch.Tensor, encoded: torch.Tensor
469
+ ) -> tuple[torch.Tensor, torch.Tensor]:
470
+ """Predict stable blank-plus-phone region durations at visible phones."""
471
+ base_dur = torch.expm1(log_dur).clamp_min(0.05)
472
+ grouped = torch.zeros_like(log_dur)
473
+ group_mask = torch.zeros_like(phone, dtype=torch.bool)
474
+ delta = self.group_duration_delta(encoded).squeeze(-1) if self.group_duration_delta is not None else None
475
+ for batch_index in range(phone.shape[0]):
476
+ pending: list[torch.Tensor] = []
477
+ last_visible: int | None = None
478
+ for token_index in range(phone.shape[1]):
479
+ pending.append(base_dur[batch_index, token_index])
480
+ if int(phone[batch_index, token_index].item()) != 0:
481
+ value = torch.stack(pending).sum()
482
+ if delta is not None:
483
+ value = value * torch.exp(delta[batch_index, token_index].clamp(-1.5, 1.5))
484
+ grouped[batch_index, token_index] = torch.log1p(value)
485
+ group_mask[batch_index, token_index] = True
486
+ pending = []
487
+ last_visible = token_index
488
+ if pending and last_visible is not None:
489
+ value = torch.expm1(grouped[batch_index, last_visible]) + torch.stack(pending).sum()
490
+ grouped[batch_index, last_visible] = torch.log1p(value)
491
+ return grouped, group_mask
492
+
493
+ def apply_group_duration_plan(
494
+ self, phone: torch.Tensor, log_dur: torch.Tensor, encoded: torch.Tensor, length_scale: float, max_duration: int
495
+ ) -> torch.Tensor:
496
+ base = torch.expm1(log_dur).clamp_min(0.05)
497
+ group_log, _ = self.group_log_durations(phone, log_dur, encoded)
498
+ planned = torch.zeros_like(base, dtype=torch.long)
499
+ for batch_index in range(phone.shape[0]):
500
+ pending: list[int] = []
501
+ last_visible: int | None = None
502
+ for token_index in range(phone.shape[1]):
503
+ pending.append(token_index)
504
+ if int(phone[batch_index, token_index].item()) != 0:
505
+ target = max(len(pending), int(round(float(torch.expm1(group_log[batch_index, token_index]) * length_scale))))
506
+ weights = base[batch_index, pending]
507
+ remaining = target - len(pending)
508
+ raw = weights / weights.sum().clamp_min(1e-6) * remaining
509
+ allocated = torch.ones_like(raw, dtype=torch.long) + torch.floor(raw).long()
510
+ remainder = target - int(allocated.sum().item())
511
+ if remainder > 0:
512
+ order = torch.argsort(raw - torch.floor(raw), descending=True)
513
+ allocated[order[:remainder]] += 1
514
+ planned[batch_index, pending] = allocated
515
+ pending = []
516
+ last_visible = token_index
517
+ if pending and last_visible is not None:
518
+ planned[batch_index, last_visible] += max(1, int(round(float(base[batch_index, pending].sum() * length_scale))))
519
+ return planned.clamp(0, max_duration)
520
+
521
+ @torch.no_grad()
522
+ def infer(
523
+ self,
524
+ phone: torch.Tensor,
525
+ tone: torch.Tensor,
526
+ lang: torch.Tensor,
527
+ speaker: torch.Tensor,
528
+ length_scale: float = 1.0,
529
+ min_duration: int = 1,
530
+ max_duration: int = 80,
531
+ pitch_scale: float = 1.0,
532
+ energy_scale: float = 1.0,
533
+ smooth_predictors: bool = False,
534
+ ) -> torch.Tensor:
535
+ # In single-sample inference there is no padded tail; id 0 remains the
536
+ # explicit blank/pause token and must keep duration.
537
+ token_mask = torch.ones_like(phone, dtype=torch.bool)
538
+ encoded = self.encode(phone, tone, lang, speaker, token_mask)
539
+ log_dur, energy, bright, pitch = self.predict_prosody(encoded, token_mask)
540
+ if self.group_duration_delta is not None:
541
+ durations = self.apply_group_duration_plan(phone, log_dur, encoded, length_scale, max_duration)
542
+ durations = durations.masked_fill(~token_mask, 0)
543
+ else:
544
+ pred_dur = torch.expm1(log_dur).clamp(0, max_duration) * length_scale
545
+ durations = torch.round(pred_dur).long().clamp_min(min_duration).masked_fill(~token_mask, 0)
546
+ energy = energy * energy_scale
547
+ pitch = torch.stack([pitch[..., 0] * pitch_scale, pitch[..., 1].clamp(0.0, 1.0)], dim=-1)
548
+ if smooth_predictors and phone.shape[1] >= 3:
549
+ energy = F.avg_pool1d(energy.unsqueeze(1), 3, stride=1, padding=1).squeeze(1)
550
+ bright = F.avg_pool1d(bright.unsqueeze(1), 3, stride=1, padding=1).squeeze(1)
551
+ pitch_t = pitch.transpose(1, 2)
552
+ pitch = F.avg_pool1d(pitch_t, 3, stride=1, padding=1).transpose(1, 2)
553
+ conditioned = encoded + self.energy_proj(energy.unsqueeze(-1)) + self.bright_proj(bright.unsqueeze(-1))
554
+ frames, frame_meta, frame_mask = self.regulate(conditioned, durations)
555
+ x = frames + self.frame_proj(frame_meta) + self.add_local_context(conditioned, durations)
556
+ pos = torch.arange(x.shape[1], device=x.device)
557
+ pos = torch.div(pos * self.cfg.abs_frame_bins, max(1, self.cfg.max_frames), rounding_mode="floor").clamp_max(
558
+ self.cfg.abs_frame_bins - 1
559
+ )
560
+ x = x + self.abs_frame(pos).unsqueeze(0)
561
+ if self.cfg.use_frame_pitch:
562
+ pitch_frame = self.expand_token_feature(pitch, durations)[:, : x.shape[1]]
563
+ x = x + self.pitch_proj(pitch_frame)
564
+ for block in self.decoder:
565
+ x = block(x, frame_mask)
566
+ x = x + self.frame_gru(x)[0]
567
+ mel = self.mel_head(x).transpose(1, 2)
568
+ mel = mel + self.cfg.postnet_scale * self.postnet(mel)
569
+ return mel
570
+
571
+
572
+ def collate(batch: list[dict], cfg: MicroFastSpeechConfig, mel_frontend: MelFrontend, device: torch.device, max_seconds: float, hop_size: int):
573
+ phones = [torch.LongTensor(x["phone_ids"]) for x in batch]
574
+ tones = [torch.LongTensor(x["tone_ids"]) for x in batch]
575
+ langs = [torch.LongTensor(x["lang_ids"]) for x in batch]
576
+ durations_raw = [list(map(int, x["hifigan_durations"])) for x in batch]
577
+ speakers = torch.LongTensor([int(x["speaker_id"]) for x in batch])
578
+ phone = pad_1d(phones, 0).long()
579
+ tone = pad_1d(tones, 0).long()
580
+ lang = pad_1d(langs, 0).long()
581
+ mels = []
582
+ durations = []
583
+ energies = []
584
+ brights = []
585
+ pitches = []
586
+ token_pitches = []
587
+ wavs = []
588
+ frame_counts = []
589
+ with torch.no_grad():
590
+ for row, dur in zip(batch, durations_raw):
591
+ wav_1d = load_audio(str(row["target_audio"]), cfg.sample_rate, max_seconds)
592
+ wav = wav_1d.unsqueeze(0).to(device)
593
+ mel = mel_frontend(wav).squeeze(0).detach().cpu()
594
+ dur = fit_durations(dur[: len(row["phone_ids"])], min(mel.shape[-1], cfg.max_frames))
595
+ mel = mel[:, : sum(dur)]
596
+ energy, bright = aggregate_token_features(mel, dur)
597
+ pitch = extract_pitch_features(wav_1d, cfg.sample_rate, mel.shape[-1])
598
+ token_pitch = aggregate_token_pitch(pitch, dur)
599
+ mels.append(mel)
600
+ durations.append(torch.LongTensor(dur))
601
+ energies.append(energy)
602
+ brights.append(bright)
603
+ pitches.append(pitch)
604
+ token_pitches.append(token_pitch)
605
+ wavs.append(wav_1d)
606
+ frame_counts.append(mel.shape[-1])
607
+ duration = pad_1d(durations, 0).long()
608
+ energy = pad_1d(energies, 0.0).float()
609
+ bright = pad_1d(brights, 0.0).float()
610
+ token_pitch = pad_2d(token_pitches, 0.0).float()
611
+ target_mel, frame_mask = pad_mels(mels)
612
+ pitch_frame, _ = pad_mels(pitches)
613
+ target_wav = pad_wavs(wavs, frame_counts, hop_size)
614
+ return (
615
+ phone.to(device),
616
+ tone.to(device),
617
+ lang.to(device),
618
+ speakers.to(device),
619
+ duration.to(device),
620
+ energy.to(device),
621
+ bright.to(device),
622
+ token_pitch.to(device),
623
+ target_mel.to(device),
624
+ frame_mask.to(device),
625
+ pitch_frame.to(device),
626
+ target_wav.to(device),
627
+ )
628
+
629
+
630
+ def prepare_row_features(
631
+ row: dict,
632
+ cfg: MicroFastSpeechConfig,
633
+ mel_frontend: MelFrontend,
634
+ device: torch.device,
635
+ max_seconds: float,
636
+ ) -> dict:
637
+ dur = list(map(int, row["hifigan_durations"]))
638
+ wav_1d = load_audio(str(row["target_audio"]), cfg.sample_rate, max_seconds)
639
+ with torch.no_grad():
640
+ wav = wav_1d.unsqueeze(0).to(device)
641
+ mel = mel_frontend(wav).squeeze(0).detach().cpu()
642
+ dur = fit_durations(dur[: len(row["phone_ids"])], min(mel.shape[-1], cfg.max_frames))
643
+ mel = mel[:, : sum(dur)]
644
+ energy, bright = aggregate_token_features(mel, dur)
645
+ pitch = extract_pitch_features(wav_1d, cfg.sample_rate, mel.shape[-1])
646
+ token_pitch = aggregate_token_pitch(pitch, dur)
647
+ return {
648
+ "phone": torch.LongTensor(row["phone_ids"]),
649
+ "tone": torch.LongTensor(row["tone_ids"]),
650
+ "lang": torch.LongTensor(row["lang_ids"]),
651
+ "speaker": int(row["speaker_id"]),
652
+ "duration": torch.LongTensor(dur),
653
+ "energy": energy.float(),
654
+ "bright": bright.float(),
655
+ "token_pitch": token_pitch.float(),
656
+ "target_mel": mel.float(),
657
+ "pitch_frame": pitch.float(),
658
+ "target_wav": wav_1d.float(),
659
+ "frame_count": int(mel.shape[-1]),
660
+ }
661
+
662
+
663
+ def collate_prepared(batch: list[dict], device: torch.device, hop_size: int):
664
+ phone = pad_1d([x["phone"] for x in batch], 0).long()
665
+ tone = pad_1d([x["tone"] for x in batch], 0).long()
666
+ lang = pad_1d([x["lang"] for x in batch], 0).long()
667
+ speakers = torch.LongTensor([int(x["speaker"]) for x in batch])
668
+ duration = pad_1d([x["duration"] for x in batch], 0).long()
669
+ energy = pad_1d([x["energy"] for x in batch], 0.0).float()
670
+ bright = pad_1d([x["bright"] for x in batch], 0.0).float()
671
+ token_pitch = pad_2d([x["token_pitch"] for x in batch], 0.0).float()
672
+ target_mel, frame_mask = pad_mels([x["target_mel"] for x in batch])
673
+ pitch_frame, _ = pad_mels([x["pitch_frame"] for x in batch])
674
+ target_wav = pad_wavs([x["target_wav"] for x in batch], [int(x["frame_count"]) for x in batch], hop_size)
675
+ return (
676
+ phone.to(device),
677
+ tone.to(device),
678
+ lang.to(device),
679
+ speakers.to(device),
680
+ duration.to(device),
681
+ energy.to(device),
682
+ bright.to(device),
683
+ token_pitch.to(device),
684
+ target_mel.to(device),
685
+ frame_mask.to(device),
686
+ pitch_frame.to(device),
687
+ target_wav.to(device),
688
+ )
689
+
690
+
691
+ def masked_l1(pred: torch.Tensor, target: torch.Tensor, mask: torch.Tensor) -> torch.Tensor:
692
+ common = min(pred.shape[-1], target.shape[-1], mask.shape[-1])
693
+ pred = pred[..., :common]
694
+ target = target[..., :common]
695
+ mask = mask[:, :common].unsqueeze(1)
696
+ return (torch.abs(pred - target) * mask).sum() / (mask.sum() * pred.shape[1]).clamp_min(1.0)
697
+
698
+
699
+ def masked_mse(pred: torch.Tensor, target: torch.Tensor, mask: torch.Tensor) -> torch.Tensor:
700
+ common = min(pred.shape[-1], target.shape[-1], mask.shape[-1])
701
+ pred = pred[..., :common]
702
+ target = target[..., :common]
703
+ mask = mask[:, :common].unsqueeze(1)
704
+ return (((pred - target) ** 2) * mask).sum() / (mask.sum() * pred.shape[1]).clamp_min(1.0)
705
+
706
+
707
+ def weighted_frame_l1(pred: torch.Tensor, target: torch.Tensor, wmap: torch.Tensor, valid_count: torch.Tensor) -> torch.Tensor:
708
+ """L1 with a PER-FRAME weight map wmap [B,T] (0 in pad). Normalized by valid_count*n_mels so
709
+ that wmap == frame_mask reproduces masked_l1 exactly. Used for per-language mel weighting (rank 4)."""
710
+ common = min(pred.shape[-1], target.shape[-1], wmap.shape[-1])
711
+ p = pred[..., :common]; t = target[..., :common]; w = wmap[:, :common].unsqueeze(1)
712
+ return (torch.abs(p - t) * w).sum() / (valid_count * pred.shape[1]).clamp_min(1.0)
713
+
714
+
715
+ def weighted_frame_mse(pred: torch.Tensor, target: torch.Tensor, wmap: torch.Tensor, valid_count: torch.Tensor) -> torch.Tensor:
716
+ common = min(pred.shape[-1], target.shape[-1], wmap.shape[-1])
717
+ p = pred[..., :common]; t = target[..., :common]; w = wmap[:, :common].unsqueeze(1)
718
+ return (((p - t) ** 2) * w).sum() / (valid_count * pred.shape[1]).clamp_min(1.0)
719
+
720
+
721
+ def masked_delta_loss(pred: torch.Tensor, target: torch.Tensor, mask: torch.Tensor) -> torch.Tensor:
722
+ common = min(pred.shape[-1], target.shape[-1], mask.shape[-1])
723
+ if common < 2:
724
+ return torch.zeros((), device=pred.device)
725
+ dp = pred[..., 1:common] - pred[..., : common - 1]
726
+ dt = target[..., 1:common] - target[..., : common - 1]
727
+ dm = (mask[:, 1:common] & mask[:, : common - 1]).unsqueeze(1)
728
+ return (torch.abs(dp - dt) * dm).sum() / (dm.sum() * pred.shape[1]).clamp_min(1.0)
729
+
730
+
731
+ def masked_accel_loss(pred: torch.Tensor, target: torch.Tensor, mask: torch.Tensor) -> torch.Tensor:
732
+ common = min(pred.shape[-1], target.shape[-1], mask.shape[-1])
733
+ if common < 3:
734
+ return torch.zeros((), device=pred.device)
735
+ dp = pred[..., 2:common] - 2.0 * pred[..., 1 : common - 1] + pred[..., : common - 2]
736
+ dt = target[..., 2:common] - 2.0 * target[..., 1 : common - 1] + target[..., : common - 2]
737
+ dm = (mask[:, 2:common] & mask[:, 1 : common - 1] & mask[:, : common - 2]).unsqueeze(1)
738
+ return (torch.abs(dp - dt) * dm).sum() / (dm.sum() * pred.shape[1]).clamp_min(1.0)
739
+
740
+
741
+ def token_mse(pred: torch.Tensor, target: torch.Tensor, mask: torch.Tensor) -> torch.Tensor:
742
+ common = min(pred.shape[1], target.shape[1], mask.shape[1])
743
+ pred = pred[:, :common]
744
+ target = target[:, :common]
745
+ mask = mask[:, :common]
746
+ return (((pred - target) ** 2) * mask).sum() / mask.sum().clamp_min(1.0)
747
+
748
+
749
+ def token_mse_nd(pred: torch.Tensor, target: torch.Tensor, mask: torch.Tensor) -> torch.Tensor:
750
+ common = min(pred.shape[1], target.shape[1], mask.shape[1])
751
+ pred = pred[:, :common]
752
+ target = target[:, :common]
753
+ mask = mask[:, :common].unsqueeze(-1)
754
+ return (((pred - target) ** 2) * mask).sum() / (mask.sum() * pred.shape[-1]).clamp_min(1.0)
755
+
756
+
757
+ def group_duration_targets(phone: torch.Tensor, durations: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
758
+ grouped = torch.zeros_like(durations, dtype=torch.float32)
759
+ mask = torch.zeros_like(phone, dtype=torch.bool)
760
+ for batch_index in range(phone.shape[0]):
761
+ pending: list[torch.Tensor] = []
762
+ last_visible: int | None = None
763
+ for token_index in range(phone.shape[1]):
764
+ pending.append(durations[batch_index, token_index].float())
765
+ if int(phone[batch_index, token_index].item()) != 0:
766
+ grouped[batch_index, token_index] = torch.log1p(torch.stack(pending).sum())
767
+ mask[batch_index, token_index] = True
768
+ pending = []
769
+ last_visible = token_index
770
+ if pending and last_visible is not None:
771
+ value = torch.expm1(grouped[batch_index, last_visible]) + torch.stack(pending).sum()
772
+ grouped[batch_index, last_visible] = torch.log1p(value)
773
+ return grouped, mask
774
+
775
+
776
+ def masked_wav_l1(pred: torch.Tensor, target: torch.Tensor, frame_mask: torch.Tensor, hop_size: int) -> torch.Tensor:
777
+ if pred.dim() == 3:
778
+ pred = pred.squeeze(1)
779
+ common = min(pred.shape[-1], target.shape[-1], frame_mask.shape[-1] * hop_size)
780
+ pred = pred[:, :common]
781
+ target = target[:, :common]
782
+ sample_mask = frame_mask.repeat_interleave(hop_size, dim=1)[:, :common].to(pred.dtype)
783
+ return (torch.abs(pred - target) * sample_mask).sum() / sample_mask.sum().clamp_min(1.0)
784
+
785
+
786
+ def load_frozen_vocoder(path: Path, device: torch.device) -> tuple[HifiGanGenerator, HifiGanConfig]:
787
+ ckpt = torch.load(path, map_location=device, weights_only=False)
788
+ cfg_payload = ckpt.get("config") or {"variant": "v2plus"}
789
+ cfg = HifiGanConfig(**cfg_payload) if isinstance(cfg_payload, dict) else cfg_payload
790
+ vocoder = HifiGanGenerator(cfg).to(device)
791
+ vocoder.load_state_dict(ckpt["generator"])
792
+ vocoder.eval()
793
+ for param in vocoder.parameters():
794
+ param.requires_grad_(False)
795
+ return vocoder, cfg
796
+
797
+
798
+ def load_model_state_flexible(model: nn.Module, state: dict[str, torch.Tensor]) -> tuple[int, int]:
799
+ current = model.state_dict()
800
+ compatible = {key: value for key, value in state.items() if key in current and current[key].shape == value.shape}
801
+ model.load_state_dict(compatible, strict=False)
802
+ return len(compatible), len(state) - len(compatible)
803
+
804
+
805
+ def set_trainable_by_mode(model: MicroFastSpeech, mode: str) -> None:
806
+ if mode == "all":
807
+ for param in model.parameters():
808
+ param.requires_grad_(True)
809
+ return
810
+ for param in model.parameters():
811
+ param.requires_grad_(False)
812
+ prefixes: tuple[str, ...]
813
+ if mode == "duration":
814
+ prefixes = ("phone.", "tone.", "lang.", "speaker.", "speaker_proj.", "encoder.", "duration_head.")
815
+ elif mode == "predictors":
816
+ prefixes = (
817
+ "phone.",
818
+ "tone.",
819
+ "lang.",
820
+ "speaker.",
821
+ "speaker_proj.",
822
+ "encoder.",
823
+ "duration_head.",
824
+ "energy_head.",
825
+ "bright_head.",
826
+ "pitch_head.",
827
+ )
828
+ elif mode == "heads":
829
+ prefixes = (
830
+ "duration_head.",
831
+ "energy_head.",
832
+ "bright_head.",
833
+ "pitch_head.",
834
+ "predictor_context.",
835
+ "duration_delta.",
836
+ "energy_delta.",
837
+ "bright_delta.",
838
+ "pitch_delta.",
839
+ )
840
+ elif mode == "contextual":
841
+ prefixes = (
842
+ "predictor_context.",
843
+ "duration_delta.",
844
+ "energy_delta.",
845
+ "bright_delta.",
846
+ "pitch_delta.",
847
+ )
848
+ elif mode == "group_duration":
849
+ prefixes = ("group_duration_delta.",)
850
+ elif mode == "decoder_adapt":
851
+ prefixes = (
852
+ "energy_proj.",
853
+ "bright_proj.",
854
+ "pitch_proj.",
855
+ "abs_frame.",
856
+ "frame_proj.",
857
+ "local_ctx.",
858
+ "decoder.",
859
+ "frame_gru.",
860
+ "mel_head.",
861
+ "postnet.",
862
+ )
863
+ else:
864
+ raise ValueError(f"Unknown trainable mode: {mode}")
865
+ for name, param in model.named_parameters():
866
+ if name.startswith(prefixes):
867
+ param.requires_grad_(True)
868
+
869
+
870
+ def latest_checkpoint(out_dir: Path) -> Path | None:
871
+ found = []
872
+ for path in out_dir.glob("inflect-micro-fastspeech-*.pt"):
873
+ tail = path.stem.rsplit("-", 1)[-1]
874
+ if tail.isdigit():
875
+ found.append((int(tail), path))
876
+ return max(found)[1] if found else None
877
+
878
+
879
+ def save_checkpoint(path: Path, model: nn.Module, optim, cfg: MicroFastSpeechConfig, step: int, args, speakers: dict[str, int]) -> None:
880
+ path.parent.mkdir(parents=True, exist_ok=True)
881
+ tmp = path.with_suffix(path.suffix + ".tmp")
882
+ torch.save(
883
+ {
884
+ "model": model.state_dict(),
885
+ "optim": optim.state_dict(),
886
+ "config": asdict(cfg),
887
+ "step": step,
888
+ "speakers": speakers,
889
+ "args": {k: str(v) if isinstance(v, Path) else v for k, v in vars(args).items()},
890
+ "params": count_parameters(model),
891
+ },
892
+ tmp,
893
+ )
894
+ tmp.replace(path)
895
+
896
+
897
+ class MelDiscriminator(nn.Module):
898
+ """Training-only multi-conv mel discriminator (LSGAN). Discarded at inference, so the
899
+ exported ONNX graph is byte-for-byte unchanged. Adversarial + feature-matching loss push
900
+ the predicted mel onto the real-mel manifold, countering the L1/L2 regression-to-the-mean
901
+ over-smoothing floor (Ren et al. ACL 2022; GANSpeech IS2021)."""
902
+
903
+ def __init__(self, n_mels: int, ch: int = 64) -> None:
904
+ super().__init__()
905
+ from torch.nn.utils import weight_norm as wn
906
+ self.convs = nn.ModuleList([
907
+ wn(nn.Conv1d(n_mels, ch, 5, 1, 2)),
908
+ wn(nn.Conv1d(ch, ch, 5, 2, 2)),
909
+ wn(nn.Conv1d(ch, ch * 2, 5, 2, 2)),
910
+ wn(nn.Conv1d(ch * 2, ch * 2, 5, 2, 2)),
911
+ ])
912
+ self.post = wn(nn.Conv1d(ch * 2, 1, 3, 1, 1))
913
+
914
+ def forward(self, mel: torch.Tensor): # mel [B, n_mels, T]
915
+ fmaps = []
916
+ x = mel
917
+ for c in self.convs:
918
+ x = F.leaky_relu(c(x), 0.1)
919
+ fmaps.append(x)
920
+ return self.post(x), fmaps
921
+
922
+
923
+ class MelDiscriminator2D(nn.Module):
924
+ """RANK 5: training-only 2D time-frequency mel discriminator (spectral-norm). Treats the mel as
925
+ an image [B,1,n_mels,T] so it judges JOINT time-frequency texture (formant structure), not just
926
+ per-frame spectra like the Conv1d disc that failed in M11. Discarded at inference -> ONNX unchanged."""
927
+
928
+ def __init__(self, n_mels: int = 80) -> None:
929
+ super().__init__()
930
+ from torch.nn.utils import spectral_norm as sn
931
+ chs = [1, 32, 64, 128, 256, 256]
932
+ self.convs = nn.ModuleList([
933
+ sn(nn.Conv2d(chs[i], chs[i + 1], 5, 2, 2)) for i in range(5)
934
+ ])
935
+ self.post = sn(nn.Conv2d(256, 1, 3, 1, 1))
936
+
937
+ def forward(self, mel: torch.Tensor): # mel [B, n_mels, T] -> image [B,1,n_mels,T]
938
+ x = mel.unsqueeze(1)
939
+ fmaps = []
940
+ for c in self.convs:
941
+ x = F.leaky_relu(c(x), 0.2)
942
+ fmaps.append(x)
943
+ return self.post(x), fmaps
944
+
945
+
946
+ def train(args: argparse.Namespace) -> None:
947
+ device = torch.device(args.device)
948
+ rows = load_rows(args.durations_jsonl, args.max_rows)
949
+ speakers = {voice: idx for idx, voice in enumerate(sorted({str(r.get("voice_id") or "mark") for r in rows}))}
950
+ max_phone_id = max(max(map(int, r["phone_ids"])) for r in rows)
951
+ max_tone_id = max(max(map(int, r["tone_ids"])) for r in rows)
952
+ max_lang_id = max(max(map(int, r["lang_ids"])) for r in rows)
953
+ cfg = MicroFastSpeechConfig(
954
+ vocab_size=max(256, max_phone_id + 1),
955
+ tone_size=max(16, max_tone_id + 1),
956
+ lang_size=max(4, max_lang_id + 1),
957
+ speaker_count=max(2, len(speakers)),
958
+ hidden=args.hidden,
959
+ encoder_layers=args.encoder_layers,
960
+ decoder_layers=args.decoder_layers,
961
+ decoder_ff_mult=args.decoder_ff_mult,
962
+ max_frames=args.max_frames,
963
+ postnet_scale=args.postnet_scale,
964
+ abs_frame_bins=args.abs_frame_bins,
965
+ use_contextual_predictors=args.contextual_predictors,
966
+ use_group_duration_planner=args.group_duration_planner,
967
+ sample_rate=args.sample_rate,
968
+ n_mels=make_config(args.vocoder_variant).num_mels, # auto-match acoustic mel count to vocoder variant
969
+ )
970
+ for row in rows:
971
+ row["speaker_id"] = speakers[str(row.get("voice_id") or "mark")]
972
+ random.Random(args.seed).shuffle(rows)
973
+
974
+ model = MicroFastSpeech(cfg).to(device)
975
+ start_step = 0
976
+ if args.init_checkpoint and not args.resume:
977
+ ckpt = torch.load(args.init_checkpoint, map_location=device, weights_only=False)
978
+ copied, skipped = load_model_state_flexible(model, ckpt["model"])
979
+ print(f"Initialized model from {args.init_checkpoint} ({copied} tensors copied, {skipped} skipped)")
980
+ set_trainable_by_mode(model, args.trainable)
981
+ trainable_params = [param for param in model.parameters() if param.requires_grad]
982
+ optim = torch.optim.AdamW(trainable_params, lr=args.lr, betas=(0.9, 0.98), weight_decay=args.weight_decay)
983
+ mel_disc = None
984
+ disc_optim = None
985
+ if getattr(args, "mel_gan_weight", 0.0) > 0.0 or getattr(args, "mel_fm_weight", 0.0) > 0.0:
986
+ if getattr(args, "gan_2d", False):
987
+ mel_disc = MelDiscriminator2D(cfg.n_mels).to(device)
988
+ else:
989
+ mel_disc = MelDiscriminator(cfg.n_mels).to(device)
990
+ disc_optim = torch.optim.AdamW(mel_disc.parameters(), lr=args.disc_lr, betas=(0.5, 0.9))
991
+ print(f"Mel-GAN ON ({'2D' if getattr(args,'gan_2d',False) else '1D'}): adv={args.mel_gan_weight} "
992
+ f"fm={'auto' if getattr(args,'gan_fm_auto',False) else args.mel_fm_weight} warmup={args.gan_warmup_steps} "
993
+ f"r1={getattr(args,'gan_r1_gamma',0.0)} crop={getattr(args,'gan_crop',0)} "
994
+ f"disc_lr={args.disc_lr} disc_params={sum(p.numel() for p in mel_disc.parameters()):,}", flush=True)
995
+ if args.resume:
996
+ ckpt_path = latest_checkpoint(args.out_dir)
997
+ if ckpt_path:
998
+ ckpt = torch.load(ckpt_path, map_location=device, weights_only=False)
999
+ model.load_state_dict(ckpt["model"])
1000
+ optim.load_state_dict(ckpt["optim"])
1001
+ start_step = int(ckpt.get("step") or 0)
1002
+ print(f"Resumed {ckpt_path} at step {start_step}")
1003
+
1004
+ hifi_cfg = make_config(args.vocoder_variant)
1005
+ mel_frontend = MelFrontend(hifi_cfg).to(device)
1006
+ assert hifi_cfg.sample_rate == cfg.sample_rate, f"vocoder sr {hifi_cfg.sample_rate} != cfg sr {cfg.sample_rate}"
1007
+ prepared_rows = None
1008
+ if args.preload_features:
1009
+ print("Preloading audio/mel/pitch features...", flush=True)
1010
+ prepared_rows = [prepare_row_features(row, cfg, mel_frontend, device, args.max_seconds) for row in rows]
1011
+ total_frames = sum(int(row["frame_count"]) for row in prepared_rows)
1012
+ print(f"Preloaded {len(prepared_rows)} rows ({total_frames:,} frames)", flush=True)
1013
+ consistency_vocoder = None
1014
+ if args.vocoder_checkpoint:
1015
+ consistency_vocoder, consistency_cfg = load_frozen_vocoder(args.vocoder_checkpoint, device)
1016
+ if consistency_cfg.hop_size != hifi_cfg.hop_size:
1017
+ raise RuntimeError(f"Vocoder hop mismatch: {consistency_cfg.hop_size} != {hifi_cfg.hop_size}")
1018
+ print(f"Loaded frozen vocoder consistency checkpoint: {args.vocoder_checkpoint}")
1019
+ if (args.vocoder_wav_weight > 0.0 or args.vocoder_mel_weight > 0.0) and consistency_vocoder is None:
1020
+ raise RuntimeError("--vocoder-checkpoint is required when vocoder consistency losses are enabled")
1021
+ args.out_dir.mkdir(parents=True, exist_ok=True)
1022
+ (args.out_dir / "config.json").write_text(
1023
+ json.dumps({"config": asdict(cfg), "speakers": speakers, "rows": len(rows), "params": count_parameters(model)}, indent=2),
1024
+ encoding="utf-8",
1025
+ )
1026
+
1027
+ print(f"Rows: {len(rows)}")
1028
+ print(f"Speakers: {speakers}")
1029
+ print(f"Acoustic params: {count_parameters(model):,} ({count_parameters(model)/1_000_000:.3f}M)")
1030
+ print(f"Trainable params: {sum(p.numel() for p in model.parameters() if p.requires_grad):,} mode={args.trainable}")
1031
+ print(f"Total with V2+ vocoder: {(count_parameters(model)+1_426_842):,} ({(count_parameters(model)+1_426_842)/1_000_000:.3f}M)")
1032
+
1033
+ rng = random.Random(args.seed + start_step)
1034
+ # language-balanced sampling pool: replicate English-row indices by --en-upsample
1035
+ en_up = max(1, int(round(args.en_upsample)))
1036
+ sample_pool = list(range(len(rows)))
1037
+ if en_up > 1:
1038
+ en_idx = [i for i, r in enumerate(rows) if str(r.get("id") or "").startswith("en")]
1039
+ sample_pool = sample_pool + en_idx * (en_up - 1)
1040
+ print(f"lang-balance: {len(en_idx)} en rows upsampled x{en_up} -> pool {len(sample_pool)} "
1041
+ f"(en exposure ~{100*len(en_idx)*en_up/len(sample_pool):.0f}%)")
1042
+ step = start_step
1043
+ started = time.time()
1044
+ while step < args.steps:
1045
+ source_rows = prepared_rows if prepared_rows is not None else rows
1046
+ batch = [source_rows[sample_pool[rng.randrange(len(sample_pool))]] for _ in range(args.batch_size)]
1047
+ if prepared_rows is not None:
1048
+ phone, tone, lang, speaker, durations, energy_t, bright_t, pitch_token_t, target_mel, frame_mask, pitch_frame, target_wav = collate_prepared(
1049
+ batch, device, hifi_cfg.hop_size
1050
+ )
1051
+ else:
1052
+ phone, tone, lang, speaker, durations, energy_t, bright_t, pitch_token_t, target_mel, frame_mask, pitch_frame, target_wav = collate(
1053
+ batch, cfg, mel_frontend, device, args.max_seconds, hifi_cfg.hop_size
1054
+ )
1055
+ out = model(phone, tone, lang, speaker, durations, energy_t, bright_t, pitch_frame)
1056
+ token_mask = out["token_mask"]
1057
+ log_dur_t = torch.log1p(durations.float())
1058
+ group_log_dur_t, group_mask = group_duration_targets(phone, durations)
1059
+ mel_l1 = masked_l1(out["mel"], target_mel, frame_mask)
1060
+ mel_mse = masked_mse(out["mel"], target_mel, frame_mask)
1061
+ # RANK 4: per-language mel-loss weighting. Down-weight English frames so they stop
1062
+ # crowding out Chinese in the 4.6M budget. At 1:1 reproduces M7 exactly (gated off).
1063
+ _lw_zh = getattr(args, "lang_loss_zh_weight", 1.0)
1064
+ _lw_en = getattr(args, "lang_loss_en_weight", 1.0)
1065
+ if _lw_zh != 1.0 or _lw_en != 1.0:
1066
+ T = out["mel"].shape[-1]
1067
+ lang_frame = model.expand_token_feature(lang.unsqueeze(-1).float(), durations).squeeze(-1) # [B,Tf]
1068
+ common = min(T, lang_frame.shape[-1], frame_mask.shape[-1])
1069
+ fm = frame_mask[:, :common].to(out["mel"].dtype)
1070
+ lf = lang_frame[:, :common]
1071
+ wmap = fm * torch.where(lf < 0.5, float(_lw_zh), float(_lw_en)) # lang 0 = zh
1072
+ vc = frame_mask[:, :common].sum()
1073
+ mel_l1 = weighted_frame_l1(out["mel"], target_mel, wmap, vc)
1074
+ mel_mse = weighted_frame_mse(out["mel"], target_mel, wmap, vc)
1075
+ delta = masked_delta_loss(out["mel"], target_mel, frame_mask)
1076
+ accel = masked_accel_loss(out["mel"], target_mel, frame_mask)
1077
+ dur_loss = token_mse(out["log_dur"], log_dur_t, token_mask)
1078
+ group_dur_loss = token_mse(out["group_log_dur"], group_log_dur_t, group_mask)
1079
+ energy_loss = token_mse(out["energy"], energy_t, token_mask)
1080
+ bright_loss = token_mse(out["bright"], bright_t, token_mask)
1081
+ pitch_loss = token_mse_nd(out["pitch"], pitch_token_t, token_mask)
1082
+ predicted_prosody_mel_loss = torch.zeros((), device=device)
1083
+ predicted_prosody_delta_loss = torch.zeros((), device=device)
1084
+ if args.predicted_prosody_mel_weight > 0.0 or args.predicted_prosody_delta_weight > 0.0:
1085
+ # Train the predictor heads against the acoustic result they produce at
1086
+ # inference, while retaining reference durations so this path remains
1087
+ # differentiable and isolates prosody exposure bias.
1088
+ predicted_conditioning = model(phone, tone, lang, speaker, durations)
1089
+ if args.predicted_prosody_mel_weight > 0.0:
1090
+ predicted_prosody_mel_loss = masked_l1(predicted_conditioning["mel"], target_mel, frame_mask)
1091
+ if args.predicted_prosody_delta_weight > 0.0:
1092
+ predicted_prosody_delta_loss = masked_delta_loss(predicted_conditioning["mel"], target_mel, frame_mask)
1093
+ robust_prosody_mel_loss = torch.zeros((), device=device)
1094
+ robust_prosody_delta_loss = torch.zeros((), device=device)
1095
+ if args.robust_prosody_mel_weight > 0.0 or args.robust_prosody_delta_weight > 0.0:
1096
+ robust_conditioning = model(
1097
+ phone,
1098
+ tone,
1099
+ lang,
1100
+ speaker,
1101
+ durations,
1102
+ energy_t,
1103
+ bright_t,
1104
+ pitch_frame,
1105
+ predicted_prosody_mix=args.robust_prosody_mix,
1106
+ detach_mixed_predictions=True,
1107
+ )
1108
+ if args.robust_prosody_mel_weight > 0.0:
1109
+ robust_prosody_mel_loss = masked_l1(robust_conditioning["mel"], target_mel, frame_mask)
1110
+ if args.robust_prosody_delta_weight > 0.0:
1111
+ robust_prosody_delta_loss = masked_delta_loss(robust_conditioning["mel"], target_mel, frame_mask)
1112
+ voc_wav_loss = torch.zeros((), device=device)
1113
+ voc_mel_loss = torch.zeros((), device=device)
1114
+ voc_mrstft_loss = torch.zeros((), device=device)
1115
+ _mrstft_w = getattr(args, "vocoder_mrstft_weight", 0.0)
1116
+ if consistency_vocoder is not None and (args.vocoder_wav_weight > 0.0 or args.vocoder_mel_weight > 0.0 or _mrstft_w > 0.0):
1117
+ pred_wav = consistency_vocoder(out["mel"].clamp(-12.0, 2.0))
1118
+ if args.vocoder_wav_weight > 0.0:
1119
+ voc_wav_loss = masked_wav_l1(pred_wav, target_wav, frame_mask, hifi_cfg.hop_size)
1120
+ if args.vocoder_mel_weight > 0.0:
1121
+ pred_recon_mel = mel_frontend(pred_wav.squeeze(1))
1122
+ voc_mel_loss = masked_l1(pred_recon_mel, target_mel, frame_mask)
1123
+ if _mrstft_w > 0.0:
1124
+ # RANK 3: multi-resolution STFT magnitude loss through the frozen vocoder.
1125
+ # 8kHz-appropriate FFT sizes (<=512; >512 over-resolves a 4kHz-Nyquist signal).
1126
+ from .vocoder import stft_mag_loss
1127
+ _hop = hifi_cfg.hop_size
1128
+ _common = min(pred_wav.shape[-1], target_wav.shape[-1], frame_mask.shape[-1] * _hop)
1129
+ _sm = frame_mask.repeat_interleave(_hop, dim=1)[:, :_common].to(pred_wav.dtype)
1130
+ _pw = pred_wav.squeeze(1)[:, :_common] * _sm
1131
+ _tw = (target_wav.squeeze(1) if target_wav.dim() == 3 else target_wav)[:, :_common] * _sm
1132
+ voc_mrstft_loss = stft_mag_loss(_pw, _tw, (128, 256, 512), (32, 64, 128), (128, 256, 512))
1133
+ # ramp the MR-STFT weight in from --mrstft-warmup-steps over 4000 steps (limit early vocoder-quirk exploitation)
1134
+ mrstft_eff = _mrstft_w * min(1.0, max(0.0, (step - args.mrstft_warmup_steps) / 4000.0)) if _mrstft_w > 0.0 else 0.0
1135
+ loss = (
1136
+ mel_l1
1137
+ + args.mse_weight * mel_mse
1138
+ + args.delta_weight * delta
1139
+ + args.accel_weight * accel
1140
+ + args.duration_weight * dur_loss
1141
+ + args.group_duration_weight * group_dur_loss
1142
+ + args.energy_weight * energy_loss
1143
+ + args.bright_weight * bright_loss
1144
+ + args.pitch_weight * pitch_loss
1145
+ + args.predicted_prosody_mel_weight * predicted_prosody_mel_loss
1146
+ + args.predicted_prosody_delta_weight * predicted_prosody_delta_loss
1147
+ + args.robust_prosody_mel_weight * robust_prosody_mel_loss
1148
+ + args.robust_prosody_delta_weight * robust_prosody_delta_loss
1149
+ + args.vocoder_wav_weight * voc_wav_loss
1150
+ + args.vocoder_mel_weight * voc_mel_loss
1151
+ + mrstft_eff * voc_mrstft_loss
1152
+ )
1153
+ gan_g = torch.zeros((), device=device)
1154
+ gan_fm = torch.zeros((), device=device)
1155
+ gan_d = torch.zeros((), device=device)
1156
+ if mel_disc is not None and step >= args.gan_warmup_steps:
1157
+ T = out["mel"].shape[-1]
1158
+ m = frame_mask[:, :T].unsqueeze(1).to(out["mel"].dtype)
1159
+ real = target_mel[..., :T] * m
1160
+ fake = out["mel"] * m
1161
+ # RANK 5: optional random time-crop (2D disc judges local TF texture; stabilizes + speeds).
1162
+ _crop = getattr(args, "gan_crop", 0)
1163
+ if _crop > 0 and T > _crop:
1164
+ _s = int(torch.randint(0, T - _crop + 1, (1,)).item())
1165
+ real = real[..., _s:_s + _crop]; fake = fake[..., _s:_s + _crop]
1166
+ # Discriminator step (LSGAN) with optional lazy R1 on real mels every 16 steps.
1167
+ _r1 = getattr(args, "gan_r1_gamma", 0.0)
1168
+ do_r1 = _r1 > 0.0 and (step % 16 == 0)
1169
+ if do_r1:
1170
+ real = real.detach().requires_grad_(True)
1171
+ d_real, _ = mel_disc(real)
1172
+ d_fake_d, _ = mel_disc(fake.detach())
1173
+ gan_d = 0.5 * ((d_real - 1.0) ** 2).mean() + 0.5 * (d_fake_d ** 2).mean()
1174
+ if do_r1:
1175
+ gp = torch.autograd.grad(d_real.sum(), real, create_graph=True)[0]
1176
+ gan_d = gan_d + (_r1 / 2.0) * gp.pow(2).flatten(1).sum(1).mean()
1177
+ disc_optim.zero_grad(set_to_none=True)
1178
+ gan_d.backward()
1179
+ torch.nn.utils.clip_grad_norm_(mel_disc.parameters(), args.grad_clip)
1180
+ disc_optim.step()
1181
+ # Generator adversarial + feature-matching (real features detached).
1182
+ d_fake_g, feats_fake = mel_disc(fake)
1183
+ _, feats_real = mel_disc(real.detach())
1184
+ gan_g = ((d_fake_g - 1.0) ** 2).mean()
1185
+ gan_fm = sum(F.l1_loss(ff, fr.detach()) for ff, fr in zip(feats_fake, feats_real)) / len(feats_fake)
1186
+ # auto-FM scaling: lambda_FM = (recon / FM).detach().clamp[0,50] (GANSpeech-style; FM does the work)
1187
+ if getattr(args, "gan_fm_auto", False):
1188
+ fm_w = (mel_l1.detach() / (gan_fm.detach() + 1e-8)).clamp(0.0, 50.0)
1189
+ else:
1190
+ fm_w = args.mel_fm_weight
1191
+ loss = loss + args.mel_gan_weight * gan_g + fm_w * gan_fm
1192
+ optim.zero_grad(set_to_none=True)
1193
+ loss.backward()
1194
+ grad = torch.nn.utils.clip_grad_norm_(trainable_params, args.grad_clip)
1195
+ optim.step()
1196
+ step += 1
1197
+
1198
+ if step == 1 or step % args.log_interval == 0:
1199
+ elapsed = max(1e-6, time.time() - started)
1200
+ speed = (step - start_step) / elapsed
1201
+ eta = (args.steps - step) / max(1e-6, speed)
1202
+ print(
1203
+ f"step={step}/{args.steps} loss={loss.item():.4f} mel={mel_l1.item():.4f} "
1204
+ f"mse={mel_mse.item():.4f} delta={delta.item():.4f} accel={accel.item():.4f} dur={dur_loss.item():.4f} "
1205
+ f"gdur={group_dur_loss.item():.4f} "
1206
+ f"energy={energy_loss.item():.4f} bright={bright_loss.item():.4f} "
1207
+ f"pitch={pitch_loss.item():.4f} pmel={predicted_prosody_mel_loss.item():.4f} "
1208
+ f"pdelta={predicted_prosody_delta_loss.item():.4f} rmel={robust_prosody_mel_loss.item():.4f} "
1209
+ f"rdelta={robust_prosody_delta_loss.item():.4f} vwav={voc_wav_loss.item():.4f} "
1210
+ f"vmel={voc_mel_loss.item():.4f} mrstft={voc_mrstft_loss.item():.4f} ganG={gan_g.item():.4f} ganFM={gan_fm.item():.4f} ganD={gan_d.item():.4f} grad={float(grad):.2f} "
1211
+ f"speed={speed:.3f} step/s eta={eta/60:.1f}m",
1212
+ flush=True,
1213
+ )
1214
+ if step % args.save_interval == 0 or step >= args.steps:
1215
+ save_checkpoint(args.out_dir / f"inflect-micro-fastspeech-{step}.pt", model, optim, cfg, step, args, speakers)
1216
+ save_checkpoint(args.out_dir / "inflect-micro-fastspeech-latest.pt", model, optim, cfg, step, args, speakers)
1217
+
1218
+ print(f"Done. {args.out_dir}")
1219
+
1220
+
1221
+ def main() -> None:
1222
+ ap = argparse.ArgumentParser(description="Train Inflect Micro duration-conditioned acoustic model.")
1223
+ ap.add_argument("--durations-jsonl", type=Path, required=True)
1224
+ ap.add_argument("--out-dir", type=Path, required=True)
1225
+ ap.add_argument("--vocoder-variant", type=str, default="v2plus")
1226
+ ap.add_argument("--sample-rate", type=int, default=24000)
1227
+ ap.add_argument("--en-upsample", type=float, default=1.0,
1228
+ help="Oversample English rows (id starts 'en') by this factor in the "
1229
+ "training sampler, to balance a zh-dominant bilingual corpus.")
1230
+ ap.add_argument("--max-rows", type=int, default=0)
1231
+ ap.add_argument("--steps", type=int, default=20000)
1232
+ ap.add_argument("--batch-size", type=int, default=6)
1233
+ ap.add_argument("--lr", type=float, default=2.0e-4)
1234
+ ap.add_argument("--weight-decay", type=float, default=1.0e-4)
1235
+ ap.add_argument("--hidden", type=int, default=168)
1236
+ ap.add_argument("--encoder-layers", type=int, default=5)
1237
+ ap.add_argument("--decoder-layers", type=int, default=6)
1238
+ ap.add_argument("--decoder-ff-mult", type=int, default=3)
1239
+ ap.add_argument("--max-seconds", type=float, default=12.0)
1240
+ ap.add_argument("--max-frames", type=int, default=1400)
1241
+ ap.add_argument("--mse-weight", type=float, default=0.25)
1242
+ ap.add_argument("--delta-weight", type=float, default=0.18)
1243
+ # Training-only mel GAN (anti over-smoothing). Discriminator discarded at inference -> ONNX unchanged.
1244
+ ap.add_argument("--mel-gan-weight", type=float, default=0.0, help="generator adversarial loss weight (0=off)")
1245
+ ap.add_argument("--mel-fm-weight", type=float, default=0.0, help="feature-matching loss weight")
1246
+ ap.add_argument("--disc-lr", type=float, default=2.0e-4, help="mel discriminator learning rate")
1247
+ ap.add_argument("--gan-warmup-steps", type=int, default=2000, help="steps of pure recon before GAN kicks in")
1248
+ # RANK 5: corrected GAN β€” 2D TF discriminator + auto-FM + R1 + crop
1249
+ ap.add_argument("--gan-2d", action="store_true", help="use 2D time-frequency mel discriminator (spectral-norm)")
1250
+ ap.add_argument("--gan-fm-auto", action="store_true", help="auto-scale feature-matching weight = (recon/FM).clamp[0,50]")
1251
+ ap.add_argument("--gan-r1-gamma", type=float, default=0.0, help="lazy R1 gradient-penalty gamma (every 16 steps)")
1252
+ ap.add_argument("--gan-crop", type=int, default=0, help="random time-crop width for the disc (0=off)")
1253
+ # RANK 3: multi-resolution STFT loss through the frozen consistency vocoder (anti over-smoothing, loss-only)
1254
+ ap.add_argument("--vocoder-mrstft-weight", type=float, default=0.0, help="MR-STFT-through-vocoder loss weight (0=off)")
1255
+ ap.add_argument("--mrstft-warmup-steps", type=int, default=4000, help="step at which MR-STFT ramp begins")
1256
+ # RANK 4: per-language mel-loss weighting (anti capacity-interference). 1:1 = M7 (gated off).
1257
+ ap.add_argument("--lang-loss-zh-weight", type=float, default=1.0, help="mel-loss weight on zh frames")
1258
+ ap.add_argument("--lang-loss-en-weight", type=float, default=1.0, help="mel-loss weight on en frames")
1259
+ ap.add_argument("--accel-weight", type=float, default=0.0)
1260
+ ap.add_argument("--duration-weight", type=float, default=0.08)
1261
+ ap.add_argument("--group-duration-weight", type=float, default=0.0)
1262
+ ap.add_argument("--energy-weight", type=float, default=0.04)
1263
+ ap.add_argument("--bright-weight", type=float, default=0.04)
1264
+ ap.add_argument("--pitch-weight", type=float, default=0.04)
1265
+ ap.add_argument("--predicted-prosody-mel-weight", type=float, default=0.0)
1266
+ ap.add_argument("--predicted-prosody-delta-weight", type=float, default=0.0)
1267
+ ap.add_argument("--robust-prosody-mix", type=float, default=0.0)
1268
+ ap.add_argument("--robust-prosody-mel-weight", type=float, default=0.0)
1269
+ ap.add_argument("--robust-prosody-delta-weight", type=float, default=0.0)
1270
+ ap.add_argument("--grad-clip", type=float, default=5.0)
1271
+ ap.add_argument("--postnet-scale", type=float, default=0.10)
1272
+ ap.add_argument("--abs-frame-bins", type=int, default=512)
1273
+ ap.add_argument("--init-checkpoint", type=Path)
1274
+ ap.add_argument("--vocoder-checkpoint", type=Path)
1275
+ ap.add_argument("--vocoder-wav-weight", type=float, default=0.0)
1276
+ ap.add_argument("--vocoder-mel-weight", type=float, default=0.0)
1277
+ ap.add_argument("--save-interval", type=int, default=2000)
1278
+ ap.add_argument("--log-interval", type=int, default=50)
1279
+ ap.add_argument("--seed", type=int, default=42)
1280
+ ap.add_argument("--resume", action="store_true")
1281
+ ap.add_argument("--preload-features", action="store_true", help="Cache decoded audio, mels, pitch, and token features in RAM before training.")
1282
+ ap.add_argument("--device", default="cuda" if torch.cuda.is_available() else "cpu")
1283
+ ap.add_argument(
1284
+ "--trainable",
1285
+ choices=["all", "duration", "predictors", "heads", "contextual", "group_duration", "decoder_adapt"],
1286
+ default="all",
1287
+ )
1288
+ ap.add_argument("--contextual-predictors", action="store_true")
1289
+ ap.add_argument("--group-duration-planner", action="store_true")
1290
+ args = ap.parse_args()
1291
+ train(args)
1292
+
1293
+
1294
+ if __name__ == "__main__":
1295
+ main()
inflect_nano/text_cleaning.py ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import re
4
+
5
+
6
+ _QUOTE_TRANSLATION = str.maketrans(
7
+ {
8
+ "\u2018": "'",
9
+ "\u2019": "'",
10
+ "\u201c": "",
11
+ "\u201d": "",
12
+ "\u2014": ",",
13
+ "\u2013": ",",
14
+ ";": ",",
15
+ ":": ",",
16
+ "\n": ".",
17
+ }
18
+ )
19
+
20
+
21
+ def clean_tinytts_text(text: str) -> str:
22
+ """Normalize text into punctuation TinyTTS actually has symbols for."""
23
+ text = str(text).translate(_QUOTE_TRANSLATION)
24
+ text = text.replace("...", "…")
25
+ text = re.sub(r"\s+", " ", text).strip()
26
+ text = re.sub(r"\s+([,.!?…])", r"\1", text)
27
+ text = re.sub(r"([,.!?…]){2,}", r"\1", text)
28
+ return text
inflect_nano/vocoder.py ADDED
@@ -0,0 +1,844 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import math
6
+ import random
7
+ import time
8
+ from dataclasses import asdict, dataclass
9
+ from pathlib import Path
10
+
11
+ import torch
12
+ import torch.nn as nn
13
+ import torch.nn.functional as F
14
+ import torchaudio
15
+ from torch.nn.utils import remove_weight_norm, spectral_norm, weight_norm
16
+ from torch.utils.data import DataLoader, Dataset
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class HifiGanConfig:
21
+ variant: str
22
+ sample_rate: int = 24000
23
+ n_fft: int = 1024
24
+ hop_size: int = 256
25
+ win_size: int = 1024
26
+ num_mels: int = 80
27
+ fmin: float = 0.0
28
+ fmax: float = 12000.0
29
+ resblock: str = "1"
30
+ upsample_rates: tuple[int, ...] = (8, 8, 2, 2)
31
+ upsample_kernel_sizes: tuple[int, ...] = (16, 16, 4, 4)
32
+ upsample_initial_channel: int = 128
33
+ resblock_kernel_sizes: tuple[int, ...] = (3, 7, 11)
34
+ resblock_dilation_sizes: tuple[tuple[int, ...], ...] = ((1, 3, 5), (1, 3, 5), (1, 3, 5))
35
+ activation: str = "lrelu"
36
+ conditioning_channels: int = 0
37
+
38
+
39
+ def make_config(variant: str) -> HifiGanConfig:
40
+ if variant == "v2":
41
+ return HifiGanConfig(variant="v2")
42
+ if variant == "v2plus":
43
+ return HifiGanConfig(variant="v2plus", upsample_initial_channel=160)
44
+ if variant == "v2wide":
45
+ return HifiGanConfig(variant="v2wide", upsample_initial_channel=176)
46
+ if variant == "snake_v2mid":
47
+ return HifiGanConfig(variant="snake_v2mid", upsample_initial_channel=144, activation="snake")
48
+ if variant == "snake_8k":
49
+ return HifiGanConfig(variant="snake_8k", sample_rate=8000, n_fft=512, hop_size=128,
50
+ win_size=512, num_mels=80, fmin=0.0, fmax=4000.0,
51
+ upsample_rates=(8, 4, 2, 2), upsample_kernel_sizes=(16, 8, 4, 4),
52
+ upsample_initial_channel=144, activation="snake")
53
+ if variant == "snake_8k40":
54
+ # 8kHz variant with 40 mels (vs 80): 0-4kHz over-resolved at 80 -> correlated channels
55
+ # encourage L1 mean-collapse; 40 mels is the natural count for a 4kHz band (anti over-smoothing).
56
+ return HifiGanConfig(variant="snake_8k40", sample_rate=8000, n_fft=512, hop_size=128,
57
+ win_size=512, num_mels=40, fmin=0.0, fmax=4000.0,
58
+ upsample_rates=(8, 4, 2, 2), upsample_kernel_sizes=(16, 8, 4, 4),
59
+ upsample_initial_channel=144, activation="snake")
60
+ if variant == "snake_v2balanced":
61
+ return HifiGanConfig(variant="snake_v2balanced", upsample_initial_channel=160, activation="snake")
62
+ if variant == "source_snake_v2balanced":
63
+ return HifiGanConfig(
64
+ variant="source_snake_v2balanced",
65
+ upsample_initial_channel=160,
66
+ activation="snake",
67
+ conditioning_channels=5,
68
+ )
69
+ if variant == "v3":
70
+ return HifiGanConfig(
71
+ variant="v3",
72
+ resblock="2",
73
+ upsample_rates=(8, 8, 4),
74
+ upsample_kernel_sizes=(16, 16, 8),
75
+ upsample_initial_channel=256,
76
+ resblock_kernel_sizes=(3, 5, 7),
77
+ resblock_dilation_sizes=((1, 2), (2, 6), (3, 12)),
78
+ )
79
+ raise ValueError(f"Unknown variant: {variant}")
80
+
81
+
82
+ def get_padding(kernel_size: int, dilation: int = 1) -> int:
83
+ return int((kernel_size * dilation - dilation) / 2)
84
+
85
+
86
+ class SnakeActivation(nn.Module):
87
+ def __init__(self, channels: int):
88
+ super().__init__()
89
+ self.log_alpha = nn.Parameter(torch.zeros(1, channels, 1))
90
+
91
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
92
+ alpha = self.log_alpha.exp().clamp(1e-4, 100.0)
93
+ return x + torch.sin(alpha * x).pow(2) / alpha
94
+
95
+
96
+ def make_activation(channels: int, activation: str) -> nn.Module:
97
+ if activation == "snake":
98
+ return SnakeActivation(channels)
99
+ return nn.LeakyReLU(0.1)
100
+
101
+
102
+ class ResBlock1(nn.Module):
103
+ def __init__(self, channels: int, kernel_size: int, dilations: tuple[int, ...], activation: str = "lrelu"):
104
+ super().__init__()
105
+ self.convs1 = nn.ModuleList(
106
+ [
107
+ weight_norm(
108
+ nn.Conv1d(
109
+ channels,
110
+ channels,
111
+ kernel_size,
112
+ 1,
113
+ dilation=d,
114
+ padding=get_padding(kernel_size, d),
115
+ )
116
+ )
117
+ for d in dilations
118
+ ]
119
+ )
120
+ self.convs2 = nn.ModuleList(
121
+ [
122
+ weight_norm(
123
+ nn.Conv1d(channels, channels, kernel_size, 1, dilation=1, padding=get_padding(kernel_size, 1))
124
+ )
125
+ for _ in dilations
126
+ ]
127
+ )
128
+ self.acts1 = nn.ModuleList([make_activation(channels, activation) for _ in dilations])
129
+ self.acts2 = nn.ModuleList([make_activation(channels, activation) for _ in dilations])
130
+
131
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
132
+ for c1, c2, a1, a2 in zip(self.convs1, self.convs2, self.acts1, self.acts2):
133
+ y = a1(x)
134
+ y = c1(y)
135
+ y = a2(y)
136
+ y = c2(y)
137
+ x = x + y
138
+ return x
139
+
140
+ def remove_weight_norm(self) -> None:
141
+ for layer in list(self.convs1) + list(self.convs2):
142
+ remove_weight_norm(layer)
143
+
144
+
145
+ class ResBlock2(nn.Module):
146
+ def __init__(self, channels: int, kernel_size: int, dilations: tuple[int, ...], activation: str = "lrelu"):
147
+ super().__init__()
148
+ self.convs = nn.ModuleList(
149
+ [
150
+ weight_norm(
151
+ nn.Conv1d(
152
+ channels,
153
+ channels,
154
+ kernel_size,
155
+ 1,
156
+ dilation=d,
157
+ padding=get_padding(kernel_size, d),
158
+ )
159
+ )
160
+ for d in dilations
161
+ ]
162
+ )
163
+ self.acts = nn.ModuleList([make_activation(channels, activation) for _ in dilations])
164
+
165
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
166
+ for conv, act in zip(self.convs, self.acts):
167
+ y = act(x)
168
+ y = conv(y)
169
+ x = x + y
170
+ return x
171
+
172
+ def remove_weight_norm(self) -> None:
173
+ for layer in self.convs:
174
+ remove_weight_norm(layer)
175
+
176
+
177
+ class HifiGanGenerator(nn.Module):
178
+ def __init__(self, cfg: HifiGanConfig):
179
+ super().__init__()
180
+ self.cfg = cfg
181
+ self.num_kernels = len(cfg.resblock_kernel_sizes)
182
+ self.num_upsamples = len(cfg.upsample_rates)
183
+ self.conv_pre = weight_norm(
184
+ nn.Conv1d(cfg.num_mels + cfg.conditioning_channels, cfg.upsample_initial_channel, 7, 1, padding=3)
185
+ )
186
+ self.ups = nn.ModuleList()
187
+ self.up_acts = nn.ModuleList()
188
+ self.resblocks = nn.ModuleList()
189
+ resblock_cls = ResBlock1 if cfg.resblock == "1" else ResBlock2
190
+ for i, (rate, kernel) in enumerate(zip(cfg.upsample_rates, cfg.upsample_kernel_sizes)):
191
+ in_ch = cfg.upsample_initial_channel // (2**i)
192
+ out_ch = cfg.upsample_initial_channel // (2 ** (i + 1))
193
+ self.up_acts.append(make_activation(in_ch, cfg.activation))
194
+ self.ups.append(
195
+ weight_norm(
196
+ nn.ConvTranspose1d(
197
+ in_ch,
198
+ out_ch,
199
+ kernel,
200
+ rate,
201
+ padding=(kernel - rate) // 2,
202
+ )
203
+ )
204
+ )
205
+ for k, d in zip(cfg.resblock_kernel_sizes, cfg.resblock_dilation_sizes):
206
+ self.resblocks.append(resblock_cls(out_ch, k, d, cfg.activation))
207
+ final_ch = cfg.upsample_initial_channel // (2 ** len(cfg.upsample_rates))
208
+ self.post_act = make_activation(final_ch, cfg.activation)
209
+ self.conv_post = weight_norm(nn.Conv1d(final_ch, 1, 7, 1, padding=3))
210
+
211
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
212
+ x = self.conv_pre(x)
213
+ for i, up in enumerate(self.ups):
214
+ x = self.up_acts[i](x)
215
+ x = up(x)
216
+ xs = 0.0
217
+ for j in range(self.num_kernels):
218
+ xs = xs + self.resblocks[i * self.num_kernels + j](x)
219
+ x = xs / self.num_kernels
220
+ x = self.post_act(x)
221
+ x = self.conv_post(x)
222
+ return torch.tanh(x)
223
+
224
+ def remove_weight_norm(self) -> None:
225
+ remove_weight_norm(self.conv_pre)
226
+ for up in self.ups:
227
+ remove_weight_norm(up)
228
+ for block in self.resblocks:
229
+ block.remove_weight_norm()
230
+ remove_weight_norm(self.conv_post)
231
+
232
+
233
+ def extract_source_features(
234
+ wav: torch.Tensor,
235
+ cfg: HifiGanConfig,
236
+ frames: int,
237
+ dropout: float = 0.0,
238
+ noise: float = 0.0,
239
+ ) -> torch.Tensor:
240
+ """Return low-rate F0/voicing features for source-conditioned generators."""
241
+ pitch = torchaudio.functional.detect_pitch_frequency(
242
+ wav.detach().cpu(),
243
+ sample_rate=cfg.sample_rate,
244
+ frame_time=cfg.hop_size / cfg.sample_rate,
245
+ win_length=30,
246
+ ).to(wav.device)
247
+ if pitch.ndim == 1:
248
+ pitch = pitch.unsqueeze(0)
249
+ if pitch.shape[-1] < frames:
250
+ pitch = F.pad(pitch, (0, frames - pitch.shape[-1]), value=0.0)
251
+ pitch = pitch[..., :frames]
252
+ voiced = ((pitch >= 55.0) & (pitch <= 420.0)).float()
253
+ pitch = pitch.clamp(55.0, 420.0)
254
+ log_f0 = ((torch.log(pitch) - math.log(140.0)) / 0.45).clamp(-3.0, 3.0) * voiced
255
+ if noise > 0.0:
256
+ log_f0 = (log_f0 + torch.randn_like(log_f0) * noise * voiced).clamp(-3.0, 3.0)
257
+ jump = F.pad((log_f0[..., 1:] - log_f0[..., :-1]).abs(), (1, 0))
258
+ confidence = torch.exp(-1.5 * jump) * voiced
259
+ reconstructed_f0 = torch.exp(log_f0 * 0.45 + math.log(140.0))
260
+ phase = torch.cumsum(2.0 * math.pi * reconstructed_f0 * (cfg.hop_size / cfg.sample_rate), dim=-1)
261
+ source = torch.stack(
262
+ [log_f0, voiced, confidence, torch.sin(phase) * confidence, torch.cos(phase) * confidence],
263
+ dim=1,
264
+ )
265
+ if dropout > 0.0:
266
+ # Drop the complete source sketch for some examples so inference remains
267
+ # stable when predicted F0 confidence is poor.
268
+ keep = (torch.rand(source.shape[0], 1, 1, device=source.device) >= dropout).to(source.dtype)
269
+ source = source * keep
270
+ return source
271
+
272
+
273
+ class DiscriminatorP(nn.Module):
274
+ def __init__(self, period: int):
275
+ super().__init__()
276
+ self.period = period
277
+ self.convs = nn.ModuleList(
278
+ [
279
+ weight_norm(nn.Conv2d(1, 32, (5, 1), (3, 1), padding=(2, 0))),
280
+ weight_norm(nn.Conv2d(32, 128, (5, 1), (3, 1), padding=(2, 0))),
281
+ weight_norm(nn.Conv2d(128, 512, (5, 1), (3, 1), padding=(2, 0))),
282
+ weight_norm(nn.Conv2d(512, 1024, (5, 1), (3, 1), padding=(2, 0))),
283
+ weight_norm(nn.Conv2d(1024, 1024, (5, 1), 1, padding=(2, 0))),
284
+ ]
285
+ )
286
+ self.conv_post = weight_norm(nn.Conv2d(1024, 1, (3, 1), 1, padding=(1, 0)))
287
+
288
+ def forward(self, x: torch.Tensor) -> tuple[torch.Tensor, list[torch.Tensor]]:
289
+ fmap = []
290
+ b, c, t = x.shape
291
+ if t % self.period != 0:
292
+ x = F.pad(x, (0, self.period - (t % self.period)), mode="reflect")
293
+ t = x.shape[-1]
294
+ x = x.view(b, c, t // self.period, self.period)
295
+ for conv in self.convs:
296
+ x = F.leaky_relu(conv(x), 0.1)
297
+ fmap.append(x)
298
+ x = self.conv_post(x)
299
+ fmap.append(x)
300
+ return torch.flatten(x, 1, -1), fmap
301
+
302
+
303
+ class MultiPeriodDiscriminator(nn.Module):
304
+ def __init__(self):
305
+ super().__init__()
306
+ self.discriminators = nn.ModuleList([DiscriminatorP(p) for p in (2, 3, 5, 7, 11)])
307
+
308
+ def forward(self, y: torch.Tensor, y_hat: torch.Tensor):
309
+ y_d_rs, y_d_gs, fmap_rs, fmap_gs = [], [], [], []
310
+ for d in self.discriminators:
311
+ y_d_r, fmap_r = d(y)
312
+ y_d_g, fmap_g = d(y_hat)
313
+ y_d_rs.append(y_d_r)
314
+ y_d_gs.append(y_d_g)
315
+ fmap_rs.append(fmap_r)
316
+ fmap_gs.append(fmap_g)
317
+ return y_d_rs, y_d_gs, fmap_rs, fmap_gs
318
+
319
+
320
+ class DiscriminatorS(nn.Module):
321
+ def __init__(self, use_spectral_norm: bool = False):
322
+ super().__init__()
323
+ norm = spectral_norm if use_spectral_norm else weight_norm
324
+ self.convs = nn.ModuleList(
325
+ [
326
+ norm(nn.Conv1d(1, 128, 15, 1, padding=7)),
327
+ norm(nn.Conv1d(128, 128, 41, 2, groups=4, padding=20)),
328
+ norm(nn.Conv1d(128, 256, 41, 2, groups=16, padding=20)),
329
+ norm(nn.Conv1d(256, 512, 41, 4, groups=16, padding=20)),
330
+ norm(nn.Conv1d(512, 1024, 41, 4, groups=16, padding=20)),
331
+ norm(nn.Conv1d(1024, 1024, 41, 1, groups=16, padding=20)),
332
+ norm(nn.Conv1d(1024, 1024, 5, 1, padding=2)),
333
+ ]
334
+ )
335
+ self.conv_post = norm(nn.Conv1d(1024, 1, 3, 1, padding=1))
336
+
337
+ def forward(self, x: torch.Tensor) -> tuple[torch.Tensor, list[torch.Tensor]]:
338
+ fmap = []
339
+ for conv in self.convs:
340
+ x = F.leaky_relu(conv(x), 0.1)
341
+ fmap.append(x)
342
+ x = self.conv_post(x)
343
+ fmap.append(x)
344
+ return torch.flatten(x, 1, -1), fmap
345
+
346
+
347
+ class MultiScaleDiscriminator(nn.Module):
348
+ def __init__(self):
349
+ super().__init__()
350
+ self.discriminators = nn.ModuleList([DiscriminatorS(True), DiscriminatorS(), DiscriminatorS()])
351
+ self.meanpools = nn.ModuleList([nn.AvgPool1d(4, 2, padding=2), nn.AvgPool1d(4, 2, padding=2)])
352
+
353
+ def forward(self, y: torch.Tensor, y_hat: torch.Tensor):
354
+ y_d_rs, y_d_gs, fmap_rs, fmap_gs = [], [], [], []
355
+ for i, d in enumerate(self.discriminators):
356
+ if i:
357
+ y = self.meanpools[i - 1](y)
358
+ y_hat = self.meanpools[i - 1](y_hat)
359
+ y_d_r, fmap_r = d(y)
360
+ y_d_g, fmap_g = d(y_hat)
361
+ y_d_rs.append(y_d_r)
362
+ y_d_gs.append(y_d_g)
363
+ fmap_rs.append(fmap_r)
364
+ fmap_gs.append(fmap_g)
365
+ return y_d_rs, y_d_gs, fmap_rs, fmap_gs
366
+
367
+
368
+ class SpectrogramDiscriminator(nn.Module):
369
+ def __init__(self):
370
+ super().__init__()
371
+ channels = (32, 64, 128, 128)
372
+ layers: list[nn.Module] = []
373
+ in_ch = 1
374
+ for out_ch, stride in zip(channels, ((1, 2), (2, 2), (2, 2), (2, 1))):
375
+ layers.append(weight_norm(nn.Conv2d(in_ch, out_ch, (5, 5), stride=stride, padding=(2, 2))))
376
+ in_ch = out_ch
377
+ self.convs = nn.ModuleList(layers)
378
+ self.conv_post = weight_norm(nn.Conv2d(in_ch, 1, (3, 3), padding=(1, 1)))
379
+
380
+ def forward(self, x: torch.Tensor) -> tuple[torch.Tensor, list[torch.Tensor]]:
381
+ fmap = []
382
+ for conv in self.convs:
383
+ x = F.leaky_relu(conv(x), 0.1)
384
+ fmap.append(x)
385
+ x = self.conv_post(x)
386
+ fmap.append(x)
387
+ return torch.flatten(x, 1, -1), fmap
388
+
389
+
390
+ class MultiResolutionSpectrogramDiscriminator(nn.Module):
391
+ def __init__(self, fft_sizes: tuple[int, ...] = (256, 512, 1024), hop_sizes: tuple[int, ...] = (64, 128, 256), win_lengths: tuple[int, ...] = (256, 512, 1024)):
392
+ super().__init__()
393
+ self.fft_sizes = fft_sizes
394
+ self.hop_sizes = hop_sizes
395
+ self.win_lengths = win_lengths
396
+ self.discriminators = nn.ModuleList([SpectrogramDiscriminator() for _ in fft_sizes])
397
+
398
+ def _features(self, wav: torch.Tensor, fft: int, hop: int, win_len: int) -> torch.Tensor:
399
+ wav = wav.squeeze(1)
400
+ window = torch.hann_window(win_len, device=wav.device)
401
+ spec = torch.stft(wav, n_fft=fft, hop_length=hop, win_length=win_len, window=window, return_complex=True)
402
+ mag = torch.log(spec.abs().clamp_min(1e-5))
403
+ mean = mag.mean(dim=(1, 2), keepdim=True)
404
+ std = mag.std(dim=(1, 2), keepdim=True).clamp_min(1e-4)
405
+ return ((mag - mean) / std).unsqueeze(1)
406
+
407
+ def forward(self, y: torch.Tensor, y_hat: torch.Tensor):
408
+ y_d_rs, y_d_gs, fmap_rs, fmap_gs = [], [], [], []
409
+ for disc, fft, hop, win_len in zip(self.discriminators, self.fft_sizes, self.hop_sizes, self.win_lengths):
410
+ y_feat = self._features(y, fft, hop, win_len)
411
+ y_hat_feat = self._features(y_hat, fft, hop, win_len)
412
+ y_d_r, fmap_r = disc(y_feat)
413
+ y_d_g, fmap_g = disc(y_hat_feat)
414
+ y_d_rs.append(y_d_r)
415
+ y_d_gs.append(y_d_g)
416
+ fmap_rs.append(fmap_r)
417
+ fmap_gs.append(fmap_g)
418
+ return y_d_rs, y_d_gs, fmap_rs, fmap_gs
419
+
420
+
421
+ class MelFrontend(nn.Module):
422
+ def __init__(self, cfg: HifiGanConfig):
423
+ super().__init__()
424
+ self.mel = torchaudio.transforms.MelSpectrogram(
425
+ sample_rate=cfg.sample_rate,
426
+ n_fft=cfg.n_fft,
427
+ win_length=cfg.win_size,
428
+ hop_length=cfg.hop_size,
429
+ f_min=cfg.fmin,
430
+ f_max=cfg.fmax,
431
+ n_mels=cfg.num_mels,
432
+ power=1.0,
433
+ center=True,
434
+ norm="slaney",
435
+ mel_scale="slaney",
436
+ )
437
+
438
+ def forward(self, wav: torch.Tensor) -> torch.Tensor:
439
+ return torch.log(torch.clamp(self.mel(wav), min=1e-5))
440
+
441
+
442
+ def load_rows(path: Path, max_rows: int, min_seconds: float, max_seconds: float) -> list[dict]:
443
+ rows = []
444
+ with path.open("r", encoding="utf-8-sig") as f:
445
+ for line in f:
446
+ if not line.strip():
447
+ continue
448
+ row = json.loads(line)
449
+ audio = Path(str(row.get("target_audio") or ""))
450
+ text = str(row.get("target_text") or "").strip()
451
+ dur = float(row.get("target_duration_s") or 0.0)
452
+ if audio.is_file() and text and min_seconds <= (dur or 4.0) <= max_seconds:
453
+ rows.append({"audio": str(audio), "text": text, "duration": dur})
454
+ if max_rows > 0 and len(rows) >= max_rows:
455
+ break
456
+ if not rows:
457
+ raise RuntimeError(f"No rows loaded from {path}")
458
+ return rows
459
+
460
+
461
+ def load_audio(path: str, sample_rate: int) -> torch.Tensor:
462
+ import soundfile as _sf # avoid torchaudio.load (needs torchcodec/ffmpeg on torch>=2.1)
463
+ _a, sr = _sf.read(path, dtype="float32", always_2d=True)
464
+ wav = torch.from_numpy(_a.T) # [ch, T]
465
+ if wav.shape[0] > 1:
466
+ wav = wav.mean(dim=0, keepdim=True)
467
+ if sr != sample_rate:
468
+ wav = torchaudio.functional.resample(wav, sr, sample_rate)
469
+ wav = wav.squeeze(0)
470
+ return wav.clamp(-1, 1)
471
+
472
+
473
+ class AudioDataset(Dataset):
474
+ def __init__(self, rows: list[dict], cfg: HifiGanConfig, segment_size: int, seed: int):
475
+ self.rows = rows
476
+ self.cfg = cfg
477
+ self.segment_size = segment_size
478
+ self.rng = random.Random(seed)
479
+
480
+ def __len__(self) -> int:
481
+ return len(self.rows)
482
+
483
+ def __getitem__(self, idx: int) -> torch.Tensor:
484
+ wav = load_audio(self.rows[idx]["audio"], self.cfg.sample_rate)
485
+ if wav.numel() >= self.segment_size:
486
+ start = self.rng.randint(0, wav.numel() - self.segment_size)
487
+ return wav[start : start + self.segment_size]
488
+ return F.pad(wav, (0, self.segment_size - wav.numel()))
489
+
490
+
491
+ def feature_loss(fmap_r, fmap_g) -> torch.Tensor:
492
+ loss = 0.0
493
+ for dr, dg in zip(fmap_r, fmap_g):
494
+ for rl, gl in zip(dr, dg):
495
+ loss = loss + F.l1_loss(rl.detach(), gl)
496
+ return loss * 2
497
+
498
+
499
+ def discriminator_loss(disc_real_outputs, disc_generated_outputs) -> torch.Tensor:
500
+ loss = 0.0
501
+ for dr, dg in zip(disc_real_outputs, disc_generated_outputs):
502
+ loss = loss + torch.mean((1 - dr) ** 2) + torch.mean(dg**2)
503
+ return loss
504
+
505
+
506
+ def generator_loss(disc_outputs) -> torch.Tensor:
507
+ loss = 0.0
508
+ for dg in disc_outputs:
509
+ loss = loss + torch.mean((1 - dg) ** 2)
510
+ return loss
511
+
512
+
513
+ def stft_mag_loss(y_hat: torch.Tensor, y: torch.Tensor, fft_sizes: tuple[int, ...], hop_sizes: tuple[int, ...], win_lengths: tuple[int, ...]) -> torch.Tensor:
514
+ # Multi-resolution spectral loss catches buzz/shimmer that can hide behind
515
+ # mel loss, especially for a small generator near convergence.
516
+ y_hat = y_hat.squeeze(1)
517
+ y = y.squeeze(1)
518
+ total = torch.zeros((), device=y.device)
519
+ for fft, hop, win_len in zip(fft_sizes, hop_sizes, win_lengths):
520
+ window = torch.hann_window(win_len, device=y.device)
521
+ pred = torch.stft(y_hat, n_fft=fft, hop_length=hop, win_length=win_len, window=window, return_complex=True)
522
+ target = torch.stft(y, n_fft=fft, hop_length=hop, win_length=win_len, window=window, return_complex=True)
523
+ pred_mag = pred.abs().clamp_min(1e-7)
524
+ target_mag = target.abs().clamp_min(1e-7)
525
+ sc = torch.linalg.vector_norm(target_mag - pred_mag) / torch.linalg.vector_norm(target_mag).clamp_min(1e-7)
526
+ log_mag = F.l1_loss(torch.log(pred_mag), torch.log(target_mag))
527
+ total = total + sc + log_mag
528
+ return total / max(1, len(fft_sizes))
529
+
530
+
531
+ def count_parameters(module: nn.Module) -> int:
532
+ return sum(p.numel() for p in module.parameters())
533
+
534
+
535
+ def jsonable_args(args: argparse.Namespace) -> dict:
536
+ return {k: str(v) if isinstance(v, Path) else v for k, v in vars(args).items()}
537
+
538
+
539
+ def save_checkpoint(
540
+ path: Path,
541
+ generator: nn.Module,
542
+ mpd: nn.Module,
543
+ msd: nn.Module,
544
+ optim_g,
545
+ optim_d,
546
+ cfg: HifiGanConfig,
547
+ step: int,
548
+ args,
549
+ mrsd: nn.Module | None = None,
550
+ ) -> None:
551
+ path.parent.mkdir(parents=True, exist_ok=True)
552
+ tmp = path.with_suffix(path.suffix + ".tmp")
553
+ payload = {
554
+ "generator": generator.state_dict(),
555
+ "mpd": mpd.state_dict(),
556
+ "msd": msd.state_dict(),
557
+ "optim_g": optim_g.state_dict(),
558
+ "optim_d": optim_d.state_dict(),
559
+ "config": asdict(cfg),
560
+ "step": step,
561
+ "args": jsonable_args(args),
562
+ "generator_params": count_parameters(generator),
563
+ }
564
+ if mrsd is not None:
565
+ payload["mrsd"] = mrsd.state_dict()
566
+ torch.save(payload, tmp)
567
+ tmp.replace(path)
568
+
569
+
570
+ def checkpoint_step(path: Path) -> int:
571
+ stem = path.stem
572
+ tail = stem.rsplit("-", 1)[-1]
573
+ return int(tail) if tail.isdigit() else -1
574
+
575
+
576
+ def prune_checkpoints(out_dir: Path, variant: str, keep: int) -> None:
577
+ if keep <= 0:
578
+ return
579
+ numbered = [p for p in out_dir.glob(f"hifigan-{variant}-*.pt") if checkpoint_step(p) >= 0]
580
+ numbered.sort(key=checkpoint_step, reverse=True)
581
+ for old in numbered[keep:]:
582
+ old.unlink(missing_ok=True)
583
+
584
+
585
+ def latest_checkpoint(out_dir: Path) -> Path | None:
586
+ numbered = [p for p in out_dir.glob("hifigan-*-*.pt") if checkpoint_step(p) >= 0]
587
+ if numbered:
588
+ return max(numbered, key=checkpoint_step)
589
+ ckpts = sorted(out_dir.glob("hifigan-*-latest.pt"), key=lambda p: p.stat().st_mtime, reverse=True)
590
+ return ckpts[0] if ckpts else None
591
+
592
+
593
+ def partial_load_state(module: nn.Module, state: dict[str, torch.Tensor]) -> tuple[int, int]:
594
+ current = module.state_dict()
595
+ patched: dict[str, torch.Tensor] = {}
596
+ copied = 0
597
+ skipped = 0
598
+ for name, target in current.items():
599
+ source = state.get(name)
600
+ if source is None:
601
+ skipped += 1
602
+ continue
603
+ if source.shape == target.shape:
604
+ patched[name] = source
605
+ copied += 1
606
+ continue
607
+ if source.ndim != target.ndim:
608
+ skipped += 1
609
+ continue
610
+ value = target.clone()
611
+ slices = tuple(slice(0, min(a, b)) for a, b in zip(target.shape, source.shape))
612
+ value[slices] = source[slices].to(value.device, value.dtype)
613
+ patched[name] = value
614
+ copied += 1
615
+ module.load_state_dict(patched, strict=False)
616
+ return copied, skipped
617
+
618
+
619
+ def train(args: argparse.Namespace) -> None:
620
+ torch.backends.cudnn.benchmark = True
621
+ cfg = make_config(args.variant)
622
+ device = torch.device(args.device)
623
+ rows = load_rows(args.train_jsonl, args.max_rows, args.min_seconds, args.max_seconds)
624
+ rng = random.Random(args.seed)
625
+ rng.shuffle(rows)
626
+ dataset = AudioDataset(rows, cfg, args.segment_size, args.seed)
627
+ loader = DataLoader(dataset, batch_size=args.batch_size, shuffle=True, drop_last=True, num_workers=args.num_workers)
628
+ mel_frontend = MelFrontend(cfg).to(device)
629
+ generator = HifiGanGenerator(cfg).to(device)
630
+ mpd = MultiPeriodDiscriminator().to(device)
631
+ msd = MultiScaleDiscriminator().to(device)
632
+ mrsd = MultiResolutionSpectrogramDiscriminator().to(device) if args.spec_disc_weight > 0.0 else None
633
+ optim_g = torch.optim.AdamW(generator.parameters(), lr=args.lr, betas=(0.8, 0.99))
634
+ disc_params = list(mpd.parameters()) + list(msd.parameters())
635
+ if mrsd is not None:
636
+ disc_params += list(mrsd.parameters())
637
+ optim_d = torch.optim.AdamW(disc_params, lr=args.lr, betas=(0.8, 0.99))
638
+ start_step = 0
639
+ if args.init_checkpoint and not args.resume:
640
+ ckpt = torch.load(args.init_checkpoint, map_location=device, weights_only=False)
641
+ if args.partial_init:
642
+ copied, skipped = partial_load_state(generator, ckpt["generator"])
643
+ print(f"Partially initialized generator from {args.init_checkpoint}: copied={copied} skipped={skipped}")
644
+ else:
645
+ generator.load_state_dict(ckpt["generator"])
646
+ if "mpd" in ckpt and "msd" in ckpt:
647
+ mpd.load_state_dict(ckpt["mpd"])
648
+ msd.load_state_dict(ckpt["msd"])
649
+ if mrsd is not None and "mrsd" in ckpt:
650
+ mrsd.load_state_dict(ckpt["mrsd"])
651
+ can_load_disc_optim = mrsd is None or "mrsd" in ckpt
652
+ if not args.partial_init and not args.reset_optim and "optim_g" in ckpt:
653
+ optim_g.load_state_dict(ckpt["optim_g"])
654
+ if not args.partial_init and not args.reset_optim and can_load_disc_optim and "optim_d" in ckpt:
655
+ optim_d.load_state_dict(ckpt["optim_d"])
656
+ for group in optim_g.param_groups:
657
+ group["lr"] = args.lr
658
+ for group in optim_d.param_groups:
659
+ group["lr"] = args.lr
660
+ start_step = int(ckpt.get("step") or 0)
661
+ print(f"Initialized {args.init_checkpoint} at step {start_step}; lr={args.lr:g}")
662
+ if args.resume:
663
+ ckpt_path = latest_checkpoint(args.out_dir)
664
+ if ckpt_path:
665
+ ckpt = torch.load(ckpt_path, map_location=device, weights_only=False)
666
+ generator.load_state_dict(ckpt["generator"])
667
+ mpd.load_state_dict(ckpt["mpd"])
668
+ msd.load_state_dict(ckpt["msd"])
669
+ if mrsd is not None and "mrsd" in ckpt:
670
+ mrsd.load_state_dict(ckpt["mrsd"])
671
+ optim_g.load_state_dict(ckpt["optim_g"])
672
+ optim_d.load_state_dict(ckpt["optim_d"])
673
+ for group in optim_g.param_groups:
674
+ group["lr"] = args.lr
675
+ for group in optim_d.param_groups:
676
+ group["lr"] = args.lr
677
+ start_step = int(ckpt.get("step") or 0)
678
+ print(f"Resumed {ckpt_path} at step {start_step}; lr={args.lr:g}")
679
+
680
+ args.out_dir.mkdir(parents=True, exist_ok=True)
681
+ prune_checkpoints(args.out_dir, args.variant, args.keep_checkpoints)
682
+ (args.out_dir / "config.json").write_text(
683
+ json.dumps(
684
+ {
685
+ "config": asdict(cfg),
686
+ "args": jsonable_args(args),
687
+ "rows": len(rows),
688
+ "generator_params": count_parameters(generator),
689
+ "mpd_params": count_parameters(mpd),
690
+ "msd_params": count_parameters(msd),
691
+ "mrsd_params": count_parameters(mrsd) if mrsd is not None else 0,
692
+ },
693
+ indent=2,
694
+ ),
695
+ encoding="utf-8",
696
+ )
697
+ print(f"Variant: {args.variant}")
698
+ print(f"Rows: {len(rows)}")
699
+ print(f"Generator params: {count_parameters(generator):,} ({count_parameters(generator)/1_000_000:.3f}M)")
700
+ print(f"MPD params: {count_parameters(mpd):,} MSD params: {count_parameters(msd):,} (training only)")
701
+ if mrsd is not None:
702
+ print(f"MRSD params: {count_parameters(mrsd):,} (training only)")
703
+ if args.steps == 0:
704
+ return
705
+
706
+ step = start_step
707
+ started = time.time()
708
+ try:
709
+ while step < args.steps:
710
+ for wav in loader:
711
+ step += 1
712
+ y = wav.unsqueeze(1).to(device)
713
+ with torch.no_grad():
714
+ mel = mel_frontend(wav.to(device))
715
+ if cfg.conditioning_channels:
716
+ source = extract_source_features(
717
+ wav.to(device),
718
+ cfg,
719
+ mel.shape[-1],
720
+ dropout=args.source_dropout,
721
+ noise=args.source_noise,
722
+ )
723
+ generator_input = torch.cat([mel, source], dim=1)
724
+ else:
725
+ generator_input = mel
726
+ y_hat = generator(generator_input)
727
+ common = min(y.shape[-1], y_hat.shape[-1])
728
+ y = y[..., :common]
729
+ y_hat = y_hat[..., :common]
730
+ y_mel = mel_frontend(y.squeeze(1))
731
+ y_hat_mel = mel_frontend(y_hat.squeeze(1))
732
+
733
+ optim_d.zero_grad(set_to_none=True)
734
+ y_df_hat_r, y_df_hat_g, _, _ = mpd(y, y_hat.detach())
735
+ y_ds_hat_r, y_ds_hat_g, _, _ = msd(y, y_hat.detach())
736
+ loss_disc = discriminator_loss(y_df_hat_r, y_df_hat_g) + discriminator_loss(y_ds_hat_r, y_ds_hat_g)
737
+ loss_spec_disc = torch.zeros((), device=device)
738
+ if mrsd is not None:
739
+ y_dm_hat_r, y_dm_hat_g, _, _ = mrsd(y, y_hat.detach())
740
+ loss_spec_disc = discriminator_loss(y_dm_hat_r, y_dm_hat_g)
741
+ loss_disc = loss_disc + args.spec_disc_weight * loss_spec_disc
742
+ loss_disc.backward()
743
+ torch.nn.utils.clip_grad_norm_(disc_params, args.grad_clip)
744
+ optim_d.step()
745
+
746
+ optim_g.zero_grad(set_to_none=True)
747
+ mel_loss = F.l1_loss(y_mel, y_hat_mel) * args.mel_weight
748
+ y_df_hat_r, y_df_hat_g, fmap_f_r, fmap_f_g = mpd(y, y_hat)
749
+ y_ds_hat_r, y_ds_hat_g, fmap_s_r, fmap_s_g = msd(y, y_hat)
750
+ loss_fm = feature_loss(fmap_f_r, fmap_f_g) + feature_loss(fmap_s_r, fmap_s_g)
751
+ loss_gen = generator_loss(y_df_hat_g) + generator_loss(y_ds_hat_g)
752
+ loss_spec_gen = torch.zeros((), device=device)
753
+ loss_spec_fm = torch.zeros((), device=device)
754
+ if mrsd is not None:
755
+ y_dm_hat_r, y_dm_hat_g, fmap_m_r, fmap_m_g = mrsd(y, y_hat)
756
+ loss_spec_gen = generator_loss(y_dm_hat_g)
757
+ loss_spec_fm = feature_loss(fmap_m_r, fmap_m_g)
758
+ wav_l1 = F.l1_loss(y_hat, y) * args.wav_weight
759
+ stft_loss = torch.zeros((), device=device)
760
+ if args.stft_weight > 0.0:
761
+ stft_loss = stft_mag_loss(y_hat, y, (512, 1024, 2048), (128, 256, 512), (512, 1024, 2048)) * args.stft_weight
762
+ loss_g = (
763
+ mel_loss
764
+ + args.fm_weight * loss_fm
765
+ + args.adv_weight * loss_gen
766
+ + wav_l1
767
+ + stft_loss
768
+ + args.spec_disc_weight * loss_spec_gen
769
+ + args.spec_fm_weight * loss_spec_fm
770
+ )
771
+ loss_g.backward()
772
+ grad_g = torch.nn.utils.clip_grad_norm_(generator.parameters(), args.grad_clip)
773
+ optim_g.step()
774
+
775
+ if step == 1 or step % args.log_interval == 0:
776
+ elapsed = max(time.time() - started, 1e-6)
777
+ speed = (step - start_step) / elapsed
778
+ eta = (args.steps - step) / max(speed, 1e-6)
779
+ print(
780
+ f"step={step}/{args.steps} g={loss_g.item():.4f} d={loss_disc.item():.4f} "
781
+ f"mel={mel_loss.item():.4f} fm={loss_fm.item():.4f} adv={loss_gen.item():.4f} "
782
+ f"wav={wav_l1.item():.4f} stft={stft_loss.item():.4f} "
783
+ f"sd={loss_spec_disc.item():.4f} sfm={loss_spec_fm.item():.4f} sadv={loss_spec_gen.item():.4f} "
784
+ f"grad={float(grad_g):.3f} speed={speed:.3f} step/s eta={eta/60:.1f}m",
785
+ flush=True,
786
+ )
787
+ if step % args.save_interval == 0 or step >= args.steps:
788
+ prune_checkpoints(args.out_dir, args.variant, max(args.keep_checkpoints - 1, 0))
789
+ save_checkpoint(args.out_dir / f"hifigan-{args.variant}-{step}.pt", generator, mpd, msd, optim_g, optim_d, cfg, step, args, mrsd)
790
+ save_checkpoint(args.out_dir / f"hifigan-{args.variant}-latest.pt", generator, mpd, msd, optim_g, optim_d, cfg, step, args, mrsd)
791
+ if step >= args.steps:
792
+ break
793
+ except KeyboardInterrupt:
794
+ if step > start_step:
795
+ save_checkpoint(args.out_dir / f"hifigan-{args.variant}-interrupt-{step}.pt", generator, mpd, msd, optim_g, optim_d, cfg, step, args, mrsd)
796
+ save_checkpoint(args.out_dir / f"hifigan-{args.variant}-latest.pt", generator, mpd, msd, optim_g, optim_d, cfg, step, args, mrsd)
797
+ print(f"Interrupted. Saved checkpoint at step {step}.", flush=True)
798
+ raise
799
+ save_checkpoint(args.out_dir / f"hifigan-{args.variant}-final.pt", generator, mpd, msd, optim_g, optim_d, cfg, step, args, mrsd)
800
+ print(f"Done. {args.out_dir}")
801
+
802
+
803
+ def main() -> None:
804
+ ap = argparse.ArgumentParser(description="Train exact-ish HiFi-GAN V2/V3 oracle vocoders on corrected Mark audio.")
805
+ ap.add_argument("--train-jsonl", type=Path, required=True)
806
+ ap.add_argument("--out-dir", type=Path, required=True)
807
+ ap.add_argument(
808
+ "--variant",
809
+ choices=["v2", "v2plus", "v2wide", "snake_v2mid", "snake_8k", "snake_8k40", "snake_v2balanced", "source_snake_v2balanced", "v3"],
810
+ required=True,
811
+ )
812
+ ap.add_argument("--steps", type=int, default=5000)
813
+ ap.add_argument("--max-rows", type=int, default=0)
814
+ ap.add_argument("--min-seconds", type=float, default=1.0)
815
+ ap.add_argument("--max-seconds", type=float, default=12.0)
816
+ ap.add_argument("--segment-size", type=int, default=8192)
817
+ ap.add_argument("--batch-size", type=int, default=8)
818
+ ap.add_argument("--num-workers", type=int, default=0)
819
+ ap.add_argument("--lr", type=float, default=2.0e-4)
820
+ ap.add_argument("--mel-weight", type=float, default=45.0)
821
+ ap.add_argument("--wav-weight", type=float, default=1.0)
822
+ ap.add_argument("--fm-weight", type=float, default=1.0)
823
+ ap.add_argument("--adv-weight", type=float, default=1.0)
824
+ ap.add_argument("--stft-weight", type=float, default=0.0)
825
+ ap.add_argument("--spec-disc-weight", type=float, default=0.0, help="Training-only multi-resolution spectrogram adversarial weight.")
826
+ ap.add_argument("--spec-fm-weight", type=float, default=0.0, help="Training-only spectrogram discriminator feature-matching weight.")
827
+ ap.add_argument("--source-dropout", type=float, default=0.0, help="Probability of dropping source conditioning per training example.")
828
+ ap.add_argument("--source-noise", type=float, default=0.0, help="Stddev of normalized log-F0 corruption for source conditioning.")
829
+ ap.add_argument("--grad-clip", type=float, default=1000.0)
830
+ ap.add_argument("--log-interval", type=int, default=50)
831
+ ap.add_argument("--save-interval", type=int, default=1000)
832
+ ap.add_argument("--keep-checkpoints", type=int, default=12)
833
+ ap.add_argument("--seed", type=int, default=1234)
834
+ ap.add_argument("--device", default="cuda")
835
+ ap.add_argument("--resume", action="store_true")
836
+ ap.add_argument("--init-checkpoint", type=Path)
837
+ ap.add_argument("--partial-init", action="store_true", help="Slice-copy compatible generator weights from init-checkpoint into a resized generator.")
838
+ ap.add_argument("--reset-optim", action="store_true", help="When initializing from a checkpoint, load model/discriminators but start fresh optimizers.")
839
+ args = ap.parse_args()
840
+ train(args)
841
+
842
+
843
+ if __name__ == "__main__":
844
+ main()
scripts/align_durations_v4.py ADDED
@@ -0,0 +1,181 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """v4 aligner: PHONE-LEVEL forced alignment via espeak phoneme-CTC.
3
+
4
+ Root-cause fix. v1/v3 did CHAR-level CTC then heuristically SPLIT each char span
5
+ across its phones -> wrong RELATIVE phone durations -> over-smoothed acoustic.
6
+ v4 aligns OUR frontend's phone sequence DIRECTLY:
7
+ each phone -> one IPA token in facebook/wav2vec2-lv-60-espeak-cv-ft's vocab
8
+ -> torchaudio.forced_align(emissions, our_phone_ipa_targets) -> per-phone frames.
9
+ No splitting. Durations are contiguous (cover all frames); fit_durations() rescales
10
+ to true mel length at train time, so only RELATIVE proportions matter -- and those
11
+ are now correct from the phone recognizer.
12
+
13
+ Non-alignable phones (SP / punctuation / OOV) carry a small relative value.
14
+ """
15
+ from __future__ import annotations
16
+ import argparse, json, sys
17
+ import numpy as np, torch, soundfile as sf, librosa
18
+ sys.path.insert(0, "/home/luigi/jetson-tts/mossnano/zhtw8k")
19
+
20
+ ESPEAK = "facebook/wav2vec2-lv-60-espeak-cv-ft"
21
+
22
+ # ARPABET (en, stress stripped) -> single IPA token present in the espeak vocab.
23
+ ARPA2IPA = {
24
+ 'AA':'Ι‘','AE':'Γ¦','AH':'ʌ','AO':'Ι”','AW':'aʊ','AY':'aΙͺ','B':'b','CH':'tΚƒ',
25
+ 'D':'d','DH':'Γ°','EH':'Ι›','ER':'ɚ','EY':'eΙͺ','F':'f','G':'Ι‘','HH':'h',
26
+ 'IH':'Ιͺ','IY':'i','JH':'dΚ’','K':'k','L':'l','M':'m','N':'n','NG':'Ε‹',
27
+ 'OW':'oʊ','OY':'Ι”Ιͺ','P':'p','R':'ΙΉ','S':'s','SH':'Κƒ','T':'t','TH':'ΞΈ',
28
+ 'UH':'ʊ','UW':'u','V':'v','W':'w','Y':'j','Z':'z','ZH':'Κ’',
29
+ }
30
+ # bopomofo (zh) -> toneless IPA token in the espeak Mandarin inventory (best-effort).
31
+ BOPO2IPA = {
32
+ 'γ„…':'p','ㄆ':'pΚ°','ㄇ':'m','γ„ˆ':'f','ㄉ':'t','γ„Š':'tΚ°','γ„‹':'n','γ„Œ':'l',
33
+ 'ㄍ':'k','γ„Ž':'kΚ°','ㄏ':'x','ㄐ':'tΙ•','γ„‘':'tΙ•h','γ„’':'Ι•','γ„“':'tΚƒ','γ„”':'tΚƒΚ°',
34
+ 'γ„•':'Κ‚','γ„–':'ʐ','γ„—':'ts','γ„˜':'tsh','γ„™':'s',
35
+ 'γ„§':'i','ㄨ':'u','γ„©':'y','γ„š':'a','γ„›':'o','γ„œ':'Ι€','ㄝ':'e','γ„ž':'ai',
36
+ 'γ„Ÿ':'ei','γ„ ':'au','γ„‘':'ou','γ„’':'a','γ„£':'Ι™','γ„€':'Ι‘','γ„₯':'Ι™','ㄦ':'ɚ',
37
+ 'γ„­':'Ι¨',
38
+ }
39
+
40
+
41
+ def build_frontend_with_charidx():
42
+ import frontend_bopomofo as F
43
+ import re
44
+ F._lazy()
45
+ def run(text):
46
+ text = F._zh_num(text)
47
+ bopo = F._g2pw(text)[0]
48
+ chars = list(text)
49
+ phones, tones, langs = [], [], []
50
+ i = 0
51
+ while i < len(chars):
52
+ b = bopo[i] if i < len(bopo) else None
53
+ ch = chars[i]
54
+ if b is not None:
55
+ units, tone = F._split_syllable(b)
56
+ for u in units:
57
+ phones.append(u); tones.append(min(tone,5)); langs.append(0)
58
+ i += 1
59
+ elif re.match(r'[A-Za-z]', ch):
60
+ j = i
61
+ while j < len(chars) and re.match(r"[A-Za-z']", chars[j]): j += 1
62
+ for p in F._g2pen(''.join(chars[i:j])):
63
+ p = p.strip()
64
+ if not p: continue
65
+ if p[-1].isdigit(): st=int(p[-1]); p=p[:-1]
66
+ else: st=0
67
+ if p in F.SYM2ID:
68
+ phones.append(p); tones.append(st); langs.append(1)
69
+ phones.append('SP'); tones.append(0); langs.append(1)
70
+ i = j
71
+ else:
72
+ if ch in F.PUNCT:
73
+ phones.append(ch); tones.append(0); langs.append(0)
74
+ elif ch.strip()=='' and phones and phones[-1]!='SP':
75
+ phones.append('SP'); tones.append(0); langs.append(0)
76
+ i += 1
77
+ return text, phones, tones, langs
78
+ return run, F
79
+
80
+
81
+ class Aligner:
82
+ def __init__(self, device):
83
+ from transformers import Wav2Vec2ForCTC
84
+ from huggingface_hub import hf_hub_download
85
+ self.vocab = json.load(open(hf_hub_download(ESPEAK, "vocab.json")))
86
+ self.blank = self.vocab["<pad>"]
87
+ self.model = Wav2Vec2ForCTC.from_pretrained(ESPEAK).to(device).eval()
88
+ self.device = device
89
+
90
+ def emissions(self, audio16k):
91
+ iv = torch.from_numpy(audio16k).float().unsqueeze(0).to(self.device)
92
+ with torch.inference_mode():
93
+ logits = self.model(iv).logits[0] # [T,V]
94
+ return torch.log_softmax(logits, dim=-1).cpu()
95
+
96
+ def durations(self, audio16k, phones, langs):
97
+ """Return integer relative duration per phone (contiguous CTC spans)."""
98
+ from torchaudio.functional import forced_align
99
+ emis = self.emissions(audio16k)
100
+ T = emis.shape[0]
101
+ # target = alignable phones mapped to a vocab token id
102
+ tgt_ids, tgt_pi = [], []
103
+ for pi, (p, lg) in enumerate(zip(phones, langs)):
104
+ tok = ARPA2IPA.get(p) if lg == 1 else BOPO2IPA.get(p)
105
+ if tok is not None and tok in self.vocab:
106
+ tgt_ids.append(self.vocab[tok]); tgt_pi.append(pi)
107
+ n = len(phones); dur = [0.0]*n
108
+ if len(tgt_ids) < 1 or len(tgt_ids) > T:
109
+ # fall back: uniform
110
+ for pi in range(n): dur[pi] = 1.0
111
+ return [max(1,int(round(x))) for x in dur], 0
112
+ tokens = torch.tensor([tgt_ids], dtype=torch.int32)
113
+ try:
114
+ aligned, _ = forced_align(emis.unsqueeze(0), tokens, blank=self.blank)
115
+ except Exception:
116
+ for pi in range(n): dur[pi] = 1.0
117
+ return [max(1,int(round(x))) for x in dur], 0
118
+ path = aligned[0].tolist()
119
+ # first frame of each emitted target token (in order)
120
+ starts = []; prev = self.blank
121
+ for fi, tk in enumerate(path):
122
+ if tk != self.blank and tk != prev:
123
+ starts.append(fi)
124
+ prev = tk
125
+ L = min(len(starts), len(tgt_pi))
126
+ # contiguous span per alignable phone: [start_k, start_{k+1})
127
+ for k in range(L):
128
+ s = starts[k]
129
+ e = starts[k+1] if k+1 < len(starts) else T
130
+ dur[tgt_pi[k]] = max(1.0, float(e - s))
131
+ # leading silence -> give to first alignable phone
132
+ if L >= 1 and starts[0] > 0:
133
+ dur[tgt_pi[0]] += starts[0]
134
+ # non-alignable phones (SP / punct / OOV): small relative value
135
+ anchored = [d for d in dur if d > 0]
136
+ base = (sum(anchored)/len(anchored)) if anchored else 4.0
137
+ for pi in range(n):
138
+ if dur[pi] == 0:
139
+ p = phones[pi]
140
+ dur[pi] = base*0.5 if p == 'SP' else base*0.25
141
+ return [max(1,int(round(x))) for x in dur], len(tgt_pi)
142
+
143
+
144
+ def main():
145
+ ap = argparse.ArgumentParser()
146
+ ap.add_argument("--manifest", required=True)
147
+ ap.add_argument("--out", required=True)
148
+ ap.add_argument("--limit", type=int, default=0)
149
+ ap.add_argument("--device", default="cuda")
150
+ args = ap.parse_args()
151
+ dev = args.device if torch.cuda.is_available() else "cpu"
152
+ run, F = build_frontend_with_charidx()
153
+ al = Aligner(dev)
154
+ rows = [json.loads(l) for l in open(args.manifest)]
155
+ if args.limit: rows = rows[:args.limit]
156
+ out = open(args.out, "w", encoding="utf-8"); n_ok = 0
157
+ for r in rows:
158
+ wav = r.get("wav") or r.get("target_audio")
159
+ try:
160
+ a, sr = sf.read(wav, dtype="float32")
161
+ if a.ndim > 1: a = a.mean(1)
162
+ a16 = librosa.resample(a, orig_sr=sr, target_sr=16000) if sr != 16000 else a
163
+ _, phones, tones, langs = run(r["text"])
164
+ durs, n_anchor = al.durations(a16, phones, langs)
165
+ phone_ids = [F.SYM2ID.get(p, F.SYM2ID['UNK']) for p in phones]
166
+ out.write(json.dumps({
167
+ "id": r["id"], "text": r["text"], "target_audio": wav,
168
+ "phone_ids": phone_ids, "tone_ids": tones, "lang_ids": langs,
169
+ "hifigan_durations": durs, "speaker_id": 0,
170
+ }, ensure_ascii=False)+"\n")
171
+ n_ok += 1
172
+ if n_ok <= 5:
173
+ print(f" {r['id']} n_ph={len(phones)} anchored={n_anchor} "
174
+ f"dur_sum={sum(durs)} dur[:18]={durs[:18]}", flush=True)
175
+ except Exception as e:
176
+ print(f" [skip {r.get('id')}] {type(e).__name__}: {e}", flush=True)
177
+ out.close(); print(f"DONE aligned {n_ok} rows -> {args.out}")
178
+
179
+
180
+ if __name__ == "__main__":
181
+ main()
scripts/assess_big.py ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """De-noised eval: score a synth dir against eval_big.jsonl (36 held-out sentences).
3
+ Run in moss-nano-venv. Usage: python assess_big.py --synth-dir m7_eval_big
4
+ Pairs with: synth_from_text.py --onnx-dir <m>_onnx --out-dir <m>_eval_big --texts eval_big.jsonl
5
+ Reports aggregate zh CER (zh + mix categories) and en WER, plus per-category, for low-noise comparison."""
6
+ import argparse, json, sys
7
+ ZT = "/home/luigi/jetson-tts/mossnano/zhtw8k"
8
+ sys.path.insert(0, ZT)
9
+ import xasr_offline as X
10
+
11
+
12
+ def main():
13
+ ap = argparse.ArgumentParser()
14
+ ap.add_argument("--synth-dir", required=True)
15
+ ap.add_argument("--tag", default="")
16
+ args = ap.parse_args()
17
+ lang = {r["id"]: r["lang"] for r in (json.loads(l) for l in open(f"{ZT}/eval_big.jsonl"))}
18
+ text = {r["id"]: r["text"] for r in (json.loads(l) for l in open(f"{ZT}/eval_big.jsonl"))}
19
+ rows = [json.loads(l) for l in open(f"{args.synth_dir}/synth.jsonl") if l.strip()]
20
+ cat = {"zh": [], "mix": [], "en": []}
21
+ for r in rows:
22
+ hyp = X.asr(r["wav"])
23
+ sc = X.score(text[r["id"]], hyp)
24
+ v = sc if not isinstance(sc, dict) else sc.get("cer", sc.get("wer"))
25
+ cat[lang[r["id"]]].append(v)
26
+
27
+ def avg(xs):
28
+ return sum(xs) / len(xs) if xs else float("nan")
29
+ zh_cer = avg(cat["zh"] + cat["mix"]) # CER over zh + code-mix
30
+ en_wer = avg(cat["en"])
31
+ print(f"[{args.tag}] N={len(rows)} zh-only={avg(cat['zh']):.3f} mix={avg(cat['mix']):.3f} "
32
+ f"en={avg(cat['en']):.3f}")
33
+ print(f"[{args.tag}] AGGREGATE zh_CER(zh+mix)={zh_cer:.3f} en_WER={en_wer:.3f} "
34
+ f"(n_zh={len(cat['zh'])} n_mix={len(cat['mix'])} n_en={len(cat['en'])})")
35
+
36
+
37
+ if __name__ == "__main__":
38
+ main()
scripts/export_8k.py ADDED
@@ -0,0 +1,136 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Export a trained zh-TW/en 8k Inflect-Nano (acoustic + snake_8k vocoder) to ONNX.
3
+ Config-driven from the train() checkpoints. FastSpeech split:
4
+ encoder.onnx -> numpy host_regulate -> decoder.onnx -> vocoder.onnx.
5
+ Validates full-pipeline parity vs torch. Run in moss-train-venv."""
6
+ from __future__ import annotations
7
+ import argparse, sys, math, json
8
+ from pathlib import Path
9
+ import numpy as np, torch
10
+
11
+ REPO = "/tmp/inflect-nano"
12
+ sys.path.insert(0, REPO)
13
+ from inflect_nano.acoustic import MicroFastSpeech, MicroFastSpeechConfig
14
+ from inflect_nano.vocoder import HifiGanGenerator, make_config
15
+
16
+
17
+ class EncoderHead(torch.nn.Module):
18
+ def __init__(self, m): super().__init__(); self.m = m
19
+ def forward(self, phone, tone, lang, speaker):
20
+ m = self.m
21
+ tok = torch.ones_like(phone, dtype=torch.bool)
22
+ enc = m.encode(phone, tone, lang, speaker, tok)
23
+ log_dur, energy, bright, pitch = m.predict_prosody(enc, tok)
24
+ dur = (torch.exp(log_dur) - 1.0).clamp(0, 80).round().clamp_min(1).long()
25
+ cond = enc + m.energy_proj(energy.unsqueeze(-1)) + m.bright_proj(bright.unsqueeze(-1))
26
+ pitch = torch.stack([pitch[..., 0], pitch[..., 1].clamp(0, 1)], dim=-1)
27
+ return cond, dur, pitch
28
+
29
+
30
+ class DecoderHead(torch.nn.Module):
31
+ def __init__(self, m): super().__init__(); self.m = m
32
+ def forward(self, frames, frame_meta, local_ctx_raw, abs_pos, pitch_frame, frame_mask):
33
+ m = self.m
34
+ x = frames + m.frame_proj(frame_meta) + m.local_ctx(local_ctx_raw)
35
+ x = x + m.abs_frame(abs_pos)
36
+ if m.cfg.use_frame_pitch:
37
+ x = x + m.pitch_proj(pitch_frame)
38
+ for blk in m.decoder:
39
+ x = blk(x, frame_mask)
40
+ x = x + m.frame_gru(x)[0]
41
+ mel = m.mel_head(x).transpose(1, 2)
42
+ return mel + m.cfg.postnet_scale * m.postnet(mel)
43
+
44
+
45
+ def host_regulate(cond, dur, pitch, abs_bins, max_frames):
46
+ c = cond[0]; d = dur[0].astype(np.int64); d[d < 0] = 0
47
+ T, H = c.shape
48
+ frames = np.repeat(c, d, axis=0); F = frames.shape[0]
49
+ tok = np.repeat(np.arange(T), d); starts = np.cumsum(d) - d
50
+ within = np.arange(F) - starts[tok]; dpf = d[tok].astype(np.float32)
51
+ rel = (within / np.maximum(dpf - 1, 1)).astype(np.float32)
52
+ tc = max(1, int((d > 0).sum())); token_pos = (tok / max(1, tc - 1)).astype(np.float32)
53
+ ld = (np.log1p(dpf) / 6.0).astype(np.float32); center = 1.0 - np.abs(rel * 2 - 1)
54
+ fm = np.stack([rel, 1 - rel, center, np.sin(rel*np.pi), np.cos(rel*np.pi), token_pos, ld, dpf/40.0], -1).astype(np.float32)
55
+ prev = np.concatenate([c[:1], c[:-1]], 0); nxt = np.concatenate([c[1:], c[-1:]], 0)
56
+ lc = np.repeat(np.concatenate([prev, c, nxt], -1), d, axis=0).astype(np.float32)
57
+ pos = np.arange(F); abs_pos = np.minimum(pos*abs_bins//max(1, max_frames), abs_bins-1).astype(np.int64)
58
+ pf = np.repeat(pitch[0], d, axis=0).astype(np.float32)
59
+ return {"frames": frames[None].astype(np.float32), "frame_meta": fm[None], "local_ctx_raw": lc[None],
60
+ "abs_pos": abs_pos[None], "pitch_frame": pf[None], "frame_mask": np.ones((1, F), bool)}
61
+
62
+
63
+ def main():
64
+ ap = argparse.ArgumentParser()
65
+ ap.add_argument("--acoustic-ckpt", required=True)
66
+ ap.add_argument("--vocoder-ckpt", required=True)
67
+ ap.add_argument("--out-dir", required=True)
68
+ ap.add_argument("--symbol-table", default="/home/luigi/jetson-tts/mossnano/zhtw8k/symbol_table.json")
69
+ args = ap.parse_args()
70
+ import onnxruntime as ort
71
+ OUT = Path(args.out_dir); OUT.mkdir(parents=True, exist_ok=True)
72
+ dev = torch.device("cpu")
73
+
74
+ ac = torch.load(args.acoustic_ckpt, map_location=dev, weights_only=False)
75
+ cfg = MicroFastSpeechConfig(**ac["config"])
76
+ m = MicroFastSpeech(cfg); m.load_state_dict(ac["model"]); m.eval()
77
+ enc, dec = EncoderHead(m).eval(), DecoderHead(m).eval()
78
+ print(f"acoustic: sr={cfg.sample_rate} vocab={cfg.vocab_size} tone={cfg.tone_size} lang={cfg.lang_size} "
79
+ f"abs_bins={cfg.abs_frame_bins} max_frames={cfg.max_frames}")
80
+
81
+ vc = torch.load(args.vocoder_ckpt, map_location=dev, weights_only=False)
82
+ vcfg = make_config(vc["config"]["variant"])
83
+ vm = HifiGanGenerator(vcfg); vm.load_state_dict(vc["generator"]); vm.remove_weight_norm(); vm.eval()
84
+ assert vcfg.sample_rate == cfg.sample_rate
85
+
86
+ # sample input: a short valid id sequence (plumbing test)
87
+ T = 40
88
+ g = torch.Generator().manual_seed(0)
89
+ phone = torch.randint(1, min(80, cfg.vocab_size), (1, T), generator=g)
90
+ tone = torch.randint(0, cfg.tone_size, (1, T), generator=g)
91
+ lang = torch.randint(0, cfg.lang_size, (1, T), generator=g)
92
+ spk = torch.zeros(1, dtype=torch.long)
93
+
94
+ with torch.no_grad():
95
+ cond, dur, pitch = enc(phone, tone, lang, spk)
96
+ reg = host_regulate(cond.numpy(), dur.numpy(), pitch.numpy(), cfg.abs_frame_bins, cfg.max_frames)
97
+ bt = tuple(torch.from_numpy(reg[k]).clone() for k in ["frames","frame_meta","local_ctx_raw","abs_pos","pitch_frame","frame_mask"])
98
+ mel_split = dec(*bt)
99
+ mel_ref = m.infer(phone, tone, lang, spk)
100
+ print(f"mel parity max_abs_diff={float((mel_ref-mel_split).abs().max()):.2e}")
101
+
102
+ torch.onnx.export(enc, (phone, tone, lang, spk), str(OUT/"acoustic_encoder.onnx"),
103
+ input_names=["phone","tone","lang","speaker"], output_names=["conditioned","durations","pitch"],
104
+ dynamic_axes={"phone":{1:"T"},"tone":{1:"T"},"lang":{1:"T"},"conditioned":{1:"T"},"durations":{1:"T"},"pitch":{1:"T"}},
105
+ opset_version=17, dynamo=False)
106
+ bn = ["frames","frame_meta","local_ctx_raw","abs_pos","pitch_frame","frame_mask"]
107
+ torch.onnx.export(dec, bt, str(OUT/"acoustic_decoder.onnx"),
108
+ input_names=bn, output_names=["mel"],
109
+ dynamic_axes={**{n:{1:"F"} for n in bn}, "mel":{2:"F"}}, opset_version=17, dynamo=False)
110
+ dummy = torch.randn(1, vcfg.num_mels, 60) # match vocoder mel count (40 for snake_8k40, 80 otherwise)
111
+ torch.onnx.export(vm, dummy, str(OUT/"vocoder.onnx"), input_names=["mel"], output_names=["wav"],
112
+ dynamic_axes={"mel":{2:"frames"},"wav":{2:"samples"}}, opset_version=17, dynamo=False)
113
+
114
+ # full-pipeline ONNX parity
115
+ sA = ort.InferenceSession(str(OUT/"acoustic_encoder.onnx"), providers=["CPUExecutionProvider"])
116
+ sB = ort.InferenceSession(str(OUT/"acoustic_decoder.onnx"), providers=["CPUExecutionProvider"])
117
+ sV = ort.InferenceSession(str(OUT/"vocoder.onnx"), providers=["CPUExecutionProvider"])
118
+ oc, od, op = sA.run(None, {"phone":phone.numpy(),"tone":tone.numpy(),"lang":lang.numpy(),"speaker":spk.numpy()})
119
+ reg2 = host_regulate(oc, od, op, cfg.abs_frame_bins, cfg.max_frames)
120
+ feeds = {n:(reg2[n].astype(np.float32) if reg2[n].dtype!=bool else reg2[n]) for n in bn}
121
+ feeds["abs_pos"] = reg2["abs_pos"].astype(np.int64)
122
+ mel_onnx = sB.run(None, feeds)[0]
123
+ wav_onnx = sV.run(None, {"mel": mel_onnx.astype(np.float32)})[0]
124
+ with torch.inference_mode(): wav_ref = vm(mel_ref).numpy()
125
+ n = min(wav_ref.shape[-1], wav_onnx.shape[-1])
126
+ print(f"FULL-PIPELINE wav parity max_abs_diff={float(np.abs(wav_ref[...,:n]-wav_onnx[...,:n]).max()):.2e}")
127
+ # save metadata for the Nano runtime
128
+ json.dump({"sample_rate":cfg.sample_rate,"abs_frame_bins":cfg.abs_frame_bins,"max_frames":cfg.max_frames,
129
+ "hop_size":vcfg.hop_size,"n_mels":cfg.n_mels,"use_frame_pitch":cfg.use_frame_pitch},
130
+ open(OUT/"meta.json","w"), indent=1)
131
+ print("sizes(KB):", {f.name: f.stat().st_size//1024 for f in OUT.glob("*.onnx")})
132
+ print("EXPORT_OK", OUT)
133
+
134
+
135
+ if __name__ == "__main__":
136
+ main()
scripts/frontend_bopomofo.py ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """zh-TW/en unified frontend for the Inflect-Nano retrain.
2
+ zh chars -> bopomofo (g2pw, Taiwan readings) -> zhuyin symbol units + tone (1-5);
3
+ en words -> arpabet (g2p_en) + stress; one sequence, per-phone language id (ZH/EN).
4
+ """
5
+ from __future__ import annotations
6
+ import re
7
+ from g2pw import G2PWConverter
8
+ from g2p_en import G2p
9
+
10
+ # 37 standard zhuyin symbols (U+3105..U+3129)
11
+ ZHUYIN = [chr(c) for c in range(0x3105, 0x312A)]
12
+ ARPABET = ['AA','AE','AH','AO','AW','AY','B','CH','D','DH','EH','ER','EY','F','G','HH',
13
+ 'IH','IY','JH','K','L','M','N','NG','OW','OY','P','R','S','SH','T','TH',
14
+ 'UH','UW','V','W','Y','Z','ZH']
15
+ PUNCT = [',', '.', '?', '!', '…', '-', "'"]
16
+ SPECIAL = ['_blank', '_pad', 'UNK', 'SP'] # SP = inter-word/space pause
17
+ SYMBOLS = SPECIAL + ZHUYIN + ARPABET + PUNCT
18
+ SYM2ID = {s: i for i, s in enumerate(SYMBOLS)}
19
+ LANG = {'ZH': 0, 'EN': 1} # per-phone language id
20
+
21
+ _g2pw = None
22
+ _g2pen = None
23
+ _zh_num = None
24
+
25
+ _ZH_DIGIT = {"0":"ι›Ά","1":"δΈ€","2":"二","3":"δΈ‰","4":"ε››","5":"δΊ”","6":"ε…­","7":"δΈƒ","8":"ε…«","9":"九"}
26
+
27
+ def _make_zh_normalizer():
28
+ import cn2an
29
+ def norm(text):
30
+ # long digit runs (>=5, e.g. phone/order numbers) -> digit-by-digit zh; else cardinal
31
+ def repl(m):
32
+ d = m.group(0)
33
+ if len(d) >= 5:
34
+ return "".join(_ZH_DIGIT[c] for c in d)
35
+ try:
36
+ return cn2an.an2cn(d, "low")
37
+ except Exception:
38
+ return "".join(_ZH_DIGIT[c] for c in d)
39
+ text = re.sub(r"\d+", repl, text)
40
+ text = text.replace(",", ",").replace("。", ".").replace("?", "?").replace("!", "!")
41
+ return text
42
+ return norm
43
+
44
+ def _lazy():
45
+ global _g2pw, _g2pen, _zh_num
46
+ if _g2pw is None:
47
+ _g2pw = G2PWConverter()
48
+ _g2pen = G2p()
49
+ _zh_num = _make_zh_normalizer()
50
+
51
+ def _split_syllable(syl: str):
52
+ """'ㄓㄨㄒ3' -> (['γ„“','ㄨ','γ„’'], tone 3)."""
53
+ tone = 0
54
+ if syl and syl[-1].isdigit():
55
+ tone = int(syl[-1]); syl = syl[:-1]
56
+ units = [c for c in syl if c in SYM2ID]
57
+ return units, tone
58
+
59
+ def text_to_phones(text: str):
60
+ _lazy()
61
+ text = _zh_num(text) # numbers->zh words, normalize punct
62
+ bopo = _g2pw(text)[0] # per-char bopomofo or None
63
+ chars = list(text)
64
+ phones, tones, langs = [], [], []
65
+ i = 0
66
+ while i < len(chars):
67
+ b = bopo[i] if i < len(bopo) else None
68
+ ch = chars[i]
69
+ if b is not None: # zh char
70
+ units, tone = _split_syllable(b)
71
+ for u in units:
72
+ phones.append(u); tones.append(min(tone, 5)); langs.append(LANG['ZH'])
73
+ i += 1
74
+ elif re.match(r'[A-Za-z]', ch): # English run -> g2p_en
75
+ j = i
76
+ while j < len(chars) and re.match(r"[A-Za-z']", chars[j]):
77
+ j += 1
78
+ word = ''.join(chars[i:j])
79
+ for p in _g2pen(word):
80
+ p = p.strip()
81
+ if not p:
82
+ continue
83
+ stress = 0
84
+ if p[-1].isdigit():
85
+ stress = int(p[-1]); p = p[:-1]
86
+ if p in SYM2ID:
87
+ phones.append(p); tones.append(stress); langs.append(LANG['EN'])
88
+ phones.append('SP'); tones.append(0); langs.append(LANG['EN'])
89
+ i = j
90
+ else: # punctuation / space / other
91
+ if ch in PUNCT:
92
+ phones.append(ch); tones.append(0); langs.append(LANG['ZH'])
93
+ elif ch.strip() == '':
94
+ if phones and phones[-1] != 'SP':
95
+ phones.append('SP'); tones.append(0); langs.append(LANG['ZH'])
96
+ i += 1
97
+ return phones, tones, langs
98
+
99
+ def text_to_ids(text: str):
100
+ phones, tones, langs = text_to_phones(text)
101
+ ids = [SYM2ID.get(p, SYM2ID['UNK']) for p in phones]
102
+ return {"phones": phones, "phone_ids": ids, "tone_ids": tones, "lang_ids": langs}
scripts/gen_breezy_corpus.py ADDED
@@ -0,0 +1,124 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Generate the BreezyVoice teacher corpus for distillation into Inflect-Nano.
3
+ Clean short reference (no prompt-leak) + INLINE ASR GATE: each clip is transcribed
4
+ (faster-whisper) and kept only if Han-CER vs intended text is below threshold; else
5
+ retried, then skipped. Writes 22.05kHz wav + manifest {id,text,wav,dur,cer}. Resumable.
6
+ Run in .venv-breezy with PYTHONPATH=BreezyVoice repo.
7
+ """
8
+ from __future__ import annotations
9
+ import argparse, json, os, sys, time
10
+ import soundfile as sf, numpy as np
11
+
12
+ BV = "/home/luigi/jetson-tts/third_party/BreezyVoice"
13
+ ZT = "/home/luigi/jetson-tts/mossnano/zhtw8k"
14
+ sys.path.insert(0, BV)
15
+ from single_inference import CustomCosyVoice, get_bopomofo_rare
16
+ from g2pw import G2PWConverter
17
+ from cosyvoice.utils.file_utils import load_wav
18
+ from faster_whisper import WhisperModel
19
+
20
+ REF_AUDIO = f"{ZT}/ref/ref_clean.wav"
21
+ REF_TEXT = open(f"{ZT}/ref/ref_clean.txt").read().strip()
22
+
23
+
24
+ import re as _re
25
+ import opencc as _opencc
26
+ _T2S = _opencc.OpenCC('t2s') # normalize trad<->simp so the gate scores REAL pronunciation, not script
27
+ def han(s): return "".join(c for c in s if "δΈ€" <= c <= "ιΏΏ")
28
+ def is_zh(text): return bool(_re.search(r"[δΈ€-ιΏΏ]", text))
29
+ def _lev(r, h):
30
+ if not r: return 0.0
31
+ d = list(range(len(h)+1))
32
+ for i in range(1, len(r)+1):
33
+ prev = d[0]; d[0] = i
34
+ for j in range(1, len(h)+1):
35
+ cur = d[j]; d[j] = min(d[j]+1, d[j-1]+1, prev+(r[i-1] != h[j-1])); prev = cur
36
+ return d[len(h)]/len(r)
37
+ def _enwords(s): return _re.findall(r"[a-z']+", s.lower())
38
+ def score(ref, hyp):
39
+ """Han-CER for zh/mix; word-error-rate for pure-English. Lower = better."""
40
+ if is_zh(ref):
41
+ return _lev(han(_T2S.convert(ref)), han(_T2S.convert(hyp)))
42
+ return _lev(_enwords(ref), _enwords(hyp)) # word-level Levenshtein ratio
43
+
44
+
45
+ def read_tsv(path, limit, skip):
46
+ rows = []
47
+ for line in open(path, encoding="utf-8"):
48
+ line = line.rstrip("\n")
49
+ if not line.strip(): continue
50
+ parts = line.split("\t")
51
+ rows.append((parts[0] if len(parts) > 1 else f"utt{len(rows):06d}", parts[-1].strip()))
52
+ rows = rows[skip:]
53
+ return rows[:limit] if limit else rows
54
+
55
+
56
+ def main():
57
+ ap = argparse.ArgumentParser()
58
+ ap.add_argument("--model", default="/home/luigi/jetson-tts/models/BreezyVoice")
59
+ ap.add_argument("--corpus", default="/home/luigi/jetson-tts/data/text/train.tsv")
60
+ ap.add_argument("--out-dir", required=True)
61
+ ap.add_argument("--limit", type=int, default=0)
62
+ ap.add_argument("--skip", type=int, default=0)
63
+ ap.add_argument("--min-sec", type=float, default=0.8)
64
+ ap.add_argument("--max-sec", type=float, default=20.0)
65
+ ap.add_argument("--cer-thresh", type=float, default=0.30)
66
+ ap.add_argument("--retries", type=int, default=3)
67
+ args = ap.parse_args()
68
+
69
+ os.makedirs(args.out_dir, exist_ok=True)
70
+ man_path = os.path.join(args.out_dir, "manifest.jsonl")
71
+ done = set()
72
+ if os.path.exists(man_path):
73
+ for l in open(man_path):
74
+ try: done.add(json.loads(l)["id"])
75
+ except Exception: pass
76
+ print(f"resuming: {len(done)} done | ref: {REF_TEXT}")
77
+
78
+ cv = CustomCosyVoice(args.model); conv = G2PWConverter()
79
+ asr = WhisperModel("SoybeanMilk/faster-whisper-Breeze-ASR-25", device="cuda", compute_type="float16") # zh-TW gate (traditional output)
80
+ ref_bopo = get_bopomofo_rare(cv.frontend.text_normalize_new(REF_TEXT, split=False), conv)
81
+ ref_wav = load_wav(REF_AUDIO, 16000)
82
+
83
+ rows = read_tsv(args.corpus, args.limit, args.skip)
84
+ todo = [(u, t) for u, t in rows if u not in done]
85
+ print(f"to synth: {len(todo)} / {len(rows)}")
86
+ mf = open(man_path, "a", encoding="utf-8")
87
+ t0 = time.time(); n_ok = 0; n_skip = 0; tot = 0.0
88
+ for i, (utt, text) in enumerate(todo):
89
+ bopo = get_bopomofo_rare(cv.frontend.text_normalize_new(text, split=False), conv)
90
+ best = None; best_cer = 9.9
91
+ for attempt in range(args.retries):
92
+ try:
93
+ out = cv.inference_zero_shot_no_normalize(bopo, ref_bopo, ref_wav)
94
+ w = out["tts_speech"].squeeze().cpu().numpy().astype(np.float32)
95
+ except Exception as e:
96
+ print(f" [err {utt}] {e}"); continue
97
+ dur = len(w) / 22050
98
+ if dur < args.min_sec or dur > args.max_sec: continue
99
+ tmp = os.path.join(args.out_dir, f".{utt}.tmp.wav"); sf.write(tmp, w, 22050)
100
+ segs, _ = asr.transcribe(tmp, language=("zh" if is_zh(text) else "en"), beam_size=5)
101
+ c = score(text, "".join(s.text for s in segs))
102
+ if c < best_cer: best_cer = c; best = (w, dur, tmp)
103
+ if c <= args.cer_thresh: break
104
+ if best is None or best_cer > args.cer_thresh:
105
+ n_skip += 1
106
+ if best and os.path.exists(best[2]): os.remove(best[2])
107
+ if n_skip <= 20 or n_skip % 50 == 0:
108
+ print(f" [SKIP {utt}] best_cer={best_cer:.2f} | {text[:30]}")
109
+ continue
110
+ w, dur, tmp = best
111
+ wp = os.path.join(args.out_dir, f"{utt}.wav"); os.replace(tmp, wp)
112
+ mf.write(json.dumps({"id": utt, "text": text, "wav": wp, "dur": round(dur, 3),
113
+ "cer": round(best_cer, 3)}, ensure_ascii=False) + "\n"); mf.flush()
114
+ n_ok += 1; tot += dur
115
+ if (i + 1) % 25 == 0:
116
+ el = time.time() - t0
117
+ print(f" {i+1}/{len(todo)} ok={n_ok} skip={n_skip} audio={tot/60:.1f}min "
118
+ f"{el/(i+1):.2f}s/clip eta={(len(todo)-i-1)*el/(i+1)/60:.0f}min")
119
+ mf.close()
120
+ print(f"DONE ok={n_ok} skip={n_skip} audio={tot/60:.1f}min in {(time.time()-t0)/60:.0f}min")
121
+
122
+
123
+ if __name__ == "__main__":
124
+ main()
scripts/probe_forced_synth.py ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """RANK-1 acoustic probe: synth N training clips with FORCED (ground-truth) durations through an
3
+ ONNX dir, isolating the phones->mel mapping from the duration predictor + g2p frontend.
4
+ Reads phone/tone/lang ids + GT durations directly from m2_align.jsonl (no frontend).
5
+ Run in moss-train-venv. Then ASR the wavs (probe_forced_asr via xasr_offline) -> CER.
6
+ Pairs the 0.80(forced)-vs-0.145(GT-mel) acoustic gap to a single number per config."""
7
+ import argparse, json, sys
8
+ from pathlib import Path
9
+ import numpy as np, soundfile as sf, onnxruntime as ort
10
+ ZT = "/home/luigi/jetson-tts/mossnano/zhtw8k"
11
+ sys.path.insert(0, ZT)
12
+ from synth_from_text import host_regulate
13
+
14
+
15
+ def main():
16
+ ap = argparse.ArgumentParser()
17
+ ap.add_argument("--onnx-dir", required=True)
18
+ ap.add_argument("--out-dir", required=True)
19
+ ap.add_argument("--n", type=int, default=30)
20
+ ap.add_argument("--align-jsonl", default=f"{ZT}/m2_align.jsonl")
21
+ args = ap.parse_args()
22
+ meta = json.load(open(f"{args.onnx_dir}/meta.json"))
23
+ so = ort.SessionOptions(); so.intra_op_num_threads = 4
24
+ sA = ort.InferenceSession(f"{args.onnx_dir}/acoustic_encoder.onnx", so, providers=["CPUExecutionProvider"])
25
+ sB = ort.InferenceSession(f"{args.onnx_dir}/acoustic_decoder.onnx", so, providers=["CPUExecutionProvider"])
26
+ sV = ort.InferenceSession(f"{args.onnx_dir}/vocoder.onnx", so, providers=["CPUExecutionProvider"])
27
+ bn = ["frames", "frame_meta", "local_ctx_raw", "abs_pos", "pitch_frame", "frame_mask"]
28
+ Path(args.out_dir).mkdir(parents=True, exist_ok=True)
29
+ rows = [json.loads(l) for l in open(args.align_jsonl) if l.strip()][:args.n]
30
+ out = open(f"{args.out_dir}/synth.jsonl", "w")
31
+ for i, r in enumerate(rows):
32
+ phone = np.array([r["phone_ids"]], np.int64); tone = np.array([r["tone_ids"]], np.int64)
33
+ lang = np.array([r["lang_ids"]], np.int64); spk = np.zeros(1, np.int64)
34
+ cond, _dur_pred, pitch = sA.run(None, {"phone": phone, "tone": tone, "lang": lang, "speaker": spk})
35
+ # substitute GT (forced) durations, rescaled so total ~ predicted length (stable regulator)
36
+ df = np.array([r["hifigan_durations"]], np.float32)
37
+ df = df * (_dur_pred.sum() / max(1.0, df.sum()))
38
+ reg = host_regulate(cond, df, pitch, meta["abs_frame_bins"], meta["max_frames"])
39
+ feeds = {n: (reg[n].astype(np.float32) if reg[n].dtype != bool else reg[n]) for n in bn}
40
+ feeds["abs_pos"] = reg["abs_pos"].astype(np.int64)
41
+ mel = sB.run(None, feeds)[0]
42
+ wav = sV.run(None, {"mel": mel.astype(np.float32)})[0].reshape(-1)
43
+ wp = f"{args.out_dir}/p{i:03d}.wav"; sf.write(wp, wav, meta["sample_rate"])
44
+ out.write(json.dumps({"id": f"p{i:03d}", "text": r["text"], "wav": wp}, ensure_ascii=False) + "\n")
45
+ out.close()
46
+ print(f"PROBE SYNTH DONE {len(rows)} clips -> {args.out_dir}/synth.jsonl")
47
+
48
+
49
+ if __name__ == "__main__":
50
+ main()
scripts/run_bilingual.sh ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # BILINGUAL NATIVE experiment: apply the proven recipe (v4 phone-align + diverse data) symmetrically.
3
+ # Merge zh-expanded corpus (15423, mark voice, incl code-mix) + diverse English-expanded (mark voice)
4
+ # -> one balanced single-voice bilingual corpus -> train ONE 4.63M model good at zh AND en (+code-mix).
5
+ # Run when English synth done + a GPU free. GPU via $1 (default 0).
6
+ set -e
7
+ ZT=/home/luigi/jetson-tts/mossnano/zhtw8k; LOG=$ZT/bilingual.log
8
+ TRAIN=/home/luigi/moss-train-venv/bin/python
9
+ GPU=${1:-0}
10
+ VOC=$ZT/m2_vocoder_8k/hifigan-snake_8k-latest.pt
11
+ exec >>"$LOG" 2>&1
12
+ echo "===== BILINGUAL START $(date) GPU=$GPU ====="
13
+
14
+ # 1) canonical-fix English-expand manifest wav paths from id
15
+ $TRAIN - <<PY
16
+ import json, os
17
+ p="$ZT/teacher_corpus_en_expand/manifest.jsonl"; D="$ZT/teacher_corpus_en_expand"
18
+ rows=[json.loads(l) for l in open(p) if l.strip()]
19
+ ok=0
20
+ for r in rows: r["wav"]=f"{D}/{r['id']}.wav"; ok+=os.path.exists(r["wav"])
21
+ open(p,"w").write("\n".join(json.dumps(r,ensure_ascii=False) for r in rows)+"\n")
22
+ print(f"[en-expand manifest] {len(rows)} rows, {ok} files exist")
23
+ PY
24
+
25
+ # 2) v4 phone-align the English-expand clips (espeak handles English natively)
26
+ echo "[align en-expand] $(date)"
27
+ CUDA_VISIBLE_DEVICES=$GPU $TRAIN $ZT/align_durations_v4.py \
28
+ --manifest $ZT/teacher_corpus_en_expand/manifest.jsonl \
29
+ --out $ZT/en_expand_v4_align.jsonl --device cuda
30
+
31
+ # 3) merge zh-expanded + en-expanded -> bilingual corpus (all mark voice, speaker 0)
32
+ $TRAIN - <<PY
33
+ import json
34
+ def fix(p):
35
+ rows=[json.loads(l) for l in open(p) if l.strip()]
36
+ for r in rows:
37
+ if not r["target_audio"].startswith("/"): r["target_audio"]="/home/luigi/jetson-tts/"+r["target_audio"]
38
+ return rows
39
+ a=fix("$ZT/m_v4_v2_align.jsonl"); b=fix("$ZT/en_expand_v4_align.jsonl")
40
+ out="$ZT/m_bilingual_align.jsonl"
41
+ open(out,"w").write("\n".join(json.dumps(r,ensure_ascii=False) for r in a+b)+"\n")
42
+ import re
43
+ en=sum(1 for r in a+b if all(ord(c)<128 for c in r["text"]))
44
+ print(f"[bilingual] {len(a)} + {len(b)} = {len(a)+len(b)} rows -> {out} | ~{en} pure-ASCII(en) rows")
45
+ PY
46
+
47
+ # 4) train the bilingual model (en-upsample 1: English now abundant via expansion)
48
+ echo "[bilingual acoustic] $(date)"
49
+ cd /tmp/inflect-nano
50
+ CUDA_VISIBLE_DEVICES=$GPU $TRAIN -m inflect_nano.acoustic --durations-jsonl $ZT/m_bilingual_align.jsonl \
51
+ --out-dir $ZT/bili_acoustic_8k --vocoder-variant snake_8k --sample-rate 8000 \
52
+ --steps 60000 --batch-size 16 --lr 2e-4 --max-frames 1000 --en-upsample 1 \
53
+ --vocoder-checkpoint $VOC --vocoder-mel-weight 1.0 \
54
+ --save-interval 5000 --log-interval 200 --device cuda
55
+ echo "===== BILINGUAL TRAIN DONE $(date) ====="
scripts/run_v4zh_v2.sh ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # v4zh-v2: retrain on DIVERSITY-EXPANDED corpus (existing 9042 + ~6500 broad-char Tatoeba/zh-TW).
3
+ # Tests the data-diversity hypothesis: held-out zh should drop from 0.40 toward the 0.21 seen-text floor.
4
+ # Keeps the proven v4 phone-level alignment. Run when teacher synth + GPU are free. GPU via $1 (default 1).
5
+ set -e
6
+ ZT=/home/luigi/jetson-tts/mossnano/zhtw8k; LOG=$ZT/v4zh_v2.log
7
+ TRAIN=/home/luigi/moss-train-venv/bin/python
8
+ GPU=${1:-1}
9
+ VOC=$ZT/m2_vocoder_8k/hifigan-snake_8k-latest.pt
10
+ exec >>"$LOG" 2>&1
11
+ echo "===== V4ZH_V2 START $(date) GPU=$GPU ====="
12
+
13
+ # 1) canonical-fix expansion manifest wav paths from id (idempotent, no doubling)
14
+ $TRAIN - <<PY
15
+ import json, os
16
+ p="$ZT/teacher_corpus_expand/manifest.jsonl"; D="$ZT/teacher_corpus_expand"
17
+ rows=[json.loads(l) for l in open(p) if l.strip()]
18
+ ok=0
19
+ for r in rows:
20
+ r["wav"]=f"{D}/{r['id']}.wav"; ok+=os.path.exists(r["wav"])
21
+ open(p,"w").write("\n".join(json.dumps(r,ensure_ascii=False) for r in rows)+"\n")
22
+ print(f"[expand manifest] {len(rows)} rows, {ok} files exist")
23
+ PY
24
+
25
+ # 2) v4-align the expansion clips (phone-level)
26
+ echo "[align expand] $(date)"
27
+ CUDA_VISIBLE_DEVICES=$GPU $TRAIN $ZT/align_durations_v4.py \
28
+ --manifest $ZT/teacher_corpus_expand/manifest.jsonl \
29
+ --out $ZT/m_v4_expand_align.jsonl --device cuda
30
+
31
+ # 3) abspath-fix + merge with existing v4 alignment
32
+ $TRAIN - <<PY
33
+ import json
34
+ R="/home/luigi/jetson-tts/"
35
+ def fix(p):
36
+ rows=[json.loads(l) for l in open(p) if l.strip()]
37
+ for r in rows:
38
+ if not r["target_audio"].startswith("/"): r["target_audio"]=R+r["target_audio"]
39
+ return rows
40
+ a=fix("$ZT/m_v4_align.jsonl"); b=fix("$ZT/m_v4_expand_align.jsonl")
41
+ out="$ZT/m_v4_v2_align.jsonl"
42
+ open(out,"w").write("\n".join(json.dumps(r,ensure_ascii=False) for r in a+b)+"\n")
43
+ print(f"[merged] {len(a)} + {len(b)} = {len(a)+len(b)} -> {out}")
44
+ PY
45
+
46
+ # 4) retrain on merged corpus (same recipe, frozen arch)
47
+ echo "[v4zh-v2 acoustic] $(date)"
48
+ cd /tmp/inflect-nano
49
+ CUDA_VISIBLE_DEVICES=$GPU $TRAIN -m inflect_nano.acoustic --durations-jsonl $ZT/m_v4_v2_align.jsonl \
50
+ --out-dir $ZT/v4zh_v2_acoustic_8k --vocoder-variant snake_8k --sample-rate 8000 \
51
+ --steps 60000 --batch-size 16 --lr 2e-4 --max-frames 1000 --en-upsample 2 \
52
+ --vocoder-checkpoint $VOC --vocoder-mel-weight 1.0 \
53
+ --save-interval 5000 --log-interval 200 --device cuda
54
+ echo "===== V4ZH_V2 TRAIN DONE $(date) ====="
scripts/run_voc_retrain.sh ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # Retrain the 8k Snake-HiFiGAN vocoder on the FULL diverse corpus (~21k, zh+en mark voice),
3
+ # longer + higher MR-STFT weight -> crisper, less-buzzy 8kHz audio (raises the GT-mel->audio ceiling).
4
+ set -e
5
+ ZT=/home/luigi/jetson-tts/mossnano/zhtw8k; LOG=$ZT/voc_retrain.log
6
+ TRAIN=/home/luigi/moss-train-venv/bin/python
7
+ exec >>"$LOG" 2>&1
8
+ echo "===== VOC RETRAIN START $(date) ====="
9
+ cd /tmp/inflect-nano
10
+ CUDA_VISIBLE_DEVICES=1 $TRAIN -m inflect_nano.vocoder --train-jsonl $ZT/bili_voc_rows.jsonl \
11
+ --out-dir $ZT/bili_vocoder_8k --variant snake_8k --steps 40000 --batch-size 16 \
12
+ --segment-size 8192 --min-seconds 0.8 --max-seconds 20 --num-workers 4 --stft-weight 2.5 \
13
+ --save-interval 5000 --log-interval 200 --device cuda
14
+ echo "===== VOC RETRAIN DONE $(date) ====="
scripts/select_diverse_text.py ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Select a DIVERSE training-text set to maximize phoneme/character coverage.
3
+
4
+ Why this matters: at 4.63M params the model is NOT capacity-limited β€” but it can only
5
+ pronounce characters/words it has SEEN. A narrow corpus (e.g. a few hundred Han chars)
6
+ leaves most held-out characters unseen -> garbled output. This script builds a broad,
7
+ coverage-maximizing text set from Tatoeba so the teacher (and then the student) cover
8
+ the common vocabulary.
9
+
10
+ zh-TW: Tatoeba `cmn` -> OpenCC s2twp (Taiwan traditional) -> greedy Han-CHAR coverage.
11
+ en : Tatoeba `eng` -> greedy WORD coverage (English phones are few; word/prosody variety matters).
12
+
13
+ Usage:
14
+ python select_diverse_text.py --lang zh --n 6000 --out expand_zh.tsv
15
+ python select_diverse_text.py --lang en --n 6000 --out expand_en.tsv
16
+ Then feed the .tsv (id<TAB>text) to gen_breezy_corpus.py to synthesize the teacher audio.
17
+
18
+ Deps: requests/urllib (download), opencc (zh only). Tatoeba dumps are CC-BY 2.0 FR.
19
+ """
20
+ import argparse, bz2, os, re, random, urllib.request
21
+
22
+ TATOEBA = "https://downloads.tatoeba.org/exports/per_language/{lang}/{lang}_sentences.tsv.bz2"
23
+ HAN = lambda s: set(c for c in s if "δΈ€" <= c <= "ιΏΏ")
24
+
25
+
26
+ def download(lang):
27
+ f = f"{lang}_sentences.tsv"
28
+ if not os.path.exists(f):
29
+ url = TATOEBA.format(lang=lang)
30
+ print("downloading", url)
31
+ urllib.request.urlretrieve(url, f + ".bz2")
32
+ with bz2.open(f + ".bz2", "rt", encoding="utf-8") as i, open(f, "w", encoding="utf-8") as o:
33
+ for line in i:
34
+ o.write(line)
35
+ return f
36
+
37
+
38
+ def select_zh(path, n, seed=42):
39
+ import opencc
40
+ cc = opencc.OpenCC("s2twp") # simplified -> Taiwan traditional (with phrase conversion)
41
+ allowed = set("οΌŒγ€‚οΌοΌŸγ€οΌšοΌ›β€¦")
42
+ seen_t, cands = set(), []
43
+ for l in open(path, encoding="utf-8"):
44
+ p = l.rstrip("\n").split("\t")
45
+ if len(p) < 3:
46
+ continue
47
+ t = cc.convert(p[2].strip()).replace(",", ",").replace("!", "!").replace("?", "?")
48
+ h = HAN(t)
49
+ if not (6 <= len(h) <= 26):
50
+ continue
51
+ if any(("δΈ€" <= c <= "ιΏΏ") is False and c not in allowed for c in t):
52
+ continue
53
+ if t not in seen_t:
54
+ seen_t.add(t); cands.append(t)
55
+ return greedy_cover(cands, HAN, n, seed)
56
+
57
+
58
+ def select_en(path, n, seed=42):
59
+ words = lambda s: set(re.findall(r"[a-z']+", s.lower()))
60
+ seen_t, cands = set(), []
61
+ for l in open(path, encoding="utf-8"):
62
+ p = l.rstrip("\n").split("\t")
63
+ if len(p) < 3:
64
+ continue
65
+ t = p[2].strip()
66
+ if not re.fullmatch(r"[A-Za-z0-9 ,.\-'?!]+", t):
67
+ continue
68
+ w = re.findall(r"[A-Za-z']+", t)
69
+ if not (4 <= len(w) <= 14) or any(len(x) > 15 for x in w):
70
+ continue
71
+ if t not in seen_t:
72
+ seen_t.add(t); cands.append(t)
73
+ return greedy_cover(cands, words, n, seed)
74
+
75
+
76
+ def greedy_cover(cands, unit, n, seed):
77
+ """Greedy max-coverage of `unit(text)` items, then random top-up to n for frequency."""
78
+ random.seed(seed); random.shuffle(cands)
79
+ covered, selected, rest = set(), [], []
80
+ cands.sort(key=lambda t: len(unit(t) - covered), reverse=True)
81
+ for t in cands:
82
+ if len(unit(t) - covered) >= 1 and len(selected) < n:
83
+ selected.append(t); covered |= unit(t)
84
+ else:
85
+ rest.append(t)
86
+ random.shuffle(rest)
87
+ selected += rest[: max(0, n - len(selected))]
88
+ print(f"selected {len(selected)} sentences | unique units covered: {len(covered)}")
89
+ return selected
90
+
91
+
92
+ def main():
93
+ ap = argparse.ArgumentParser()
94
+ ap.add_argument("--lang", choices=["zh", "en"], required=True)
95
+ ap.add_argument("--n", type=int, default=6000)
96
+ ap.add_argument("--out", required=True)
97
+ args = ap.parse_args()
98
+ path = download("cmn" if args.lang == "zh" else "eng")
99
+ sents = select_zh(path, args.n) if args.lang == "zh" else select_en(path, args.n)
100
+ with open(args.out, "w", encoding="utf-8") as o:
101
+ for i, t in enumerate(sents):
102
+ o.write(f"{args.lang}e{i:05d}\t{t}\n")
103
+ print("wrote", args.out, len(sents))
104
+
105
+
106
+ if __name__ == "__main__":
107
+ main()
scripts/symbol_table.json ADDED
@@ -0,0 +1,186 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "symbols": [
3
+ "_blank",
4
+ "_pad",
5
+ "UNK",
6
+ "SP",
7
+ "γ„…",
8
+ "ㄆ",
9
+ "ㄇ",
10
+ "γ„ˆ",
11
+ "ㄉ",
12
+ "γ„Š",
13
+ "γ„‹",
14
+ "γ„Œ",
15
+ "ㄍ",
16
+ "γ„Ž",
17
+ "ㄏ",
18
+ "ㄐ",
19
+ "γ„‘",
20
+ "γ„’",
21
+ "γ„“",
22
+ "γ„”",
23
+ "γ„•",
24
+ "γ„–",
25
+ "γ„—",
26
+ "γ„˜",
27
+ "γ„™",
28
+ "γ„š",
29
+ "γ„›",
30
+ "γ„œ",
31
+ "ㄝ",
32
+ "γ„ž",
33
+ "γ„Ÿ",
34
+ "γ„ ",
35
+ "γ„‘",
36
+ "γ„’",
37
+ "γ„£",
38
+ "γ„€",
39
+ "γ„₯",
40
+ "ㄦ",
41
+ "γ„§",
42
+ "ㄨ",
43
+ "γ„©",
44
+ "AA",
45
+ "AE",
46
+ "AH",
47
+ "AO",
48
+ "AW",
49
+ "AY",
50
+ "B",
51
+ "CH",
52
+ "D",
53
+ "DH",
54
+ "EH",
55
+ "ER",
56
+ "EY",
57
+ "F",
58
+ "G",
59
+ "HH",
60
+ "IH",
61
+ "IY",
62
+ "JH",
63
+ "K",
64
+ "L",
65
+ "M",
66
+ "N",
67
+ "NG",
68
+ "OW",
69
+ "OY",
70
+ "P",
71
+ "R",
72
+ "S",
73
+ "SH",
74
+ "T",
75
+ "TH",
76
+ "UH",
77
+ "UW",
78
+ "V",
79
+ "W",
80
+ "Y",
81
+ "Z",
82
+ "ZH",
83
+ ",",
84
+ ".",
85
+ "?",
86
+ "!",
87
+ "…",
88
+ "-",
89
+ "'"
90
+ ],
91
+ "sym2id": {
92
+ "_blank": 0,
93
+ "_pad": 1,
94
+ "UNK": 2,
95
+ "SP": 3,
96
+ "γ„…": 4,
97
+ "ㄆ": 5,
98
+ "ㄇ": 6,
99
+ "γ„ˆ": 7,
100
+ "ㄉ": 8,
101
+ "γ„Š": 9,
102
+ "γ„‹": 10,
103
+ "γ„Œ": 11,
104
+ "ㄍ": 12,
105
+ "γ„Ž": 13,
106
+ "ㄏ": 14,
107
+ "ㄐ": 15,
108
+ "γ„‘": 16,
109
+ "γ„’": 17,
110
+ "γ„“": 18,
111
+ "γ„”": 19,
112
+ "γ„•": 20,
113
+ "γ„–": 21,
114
+ "γ„—": 22,
115
+ "γ„˜": 23,
116
+ "γ„™": 24,
117
+ "γ„š": 25,
118
+ "γ„›": 26,
119
+ "γ„œ": 27,
120
+ "ㄝ": 28,
121
+ "γ„ž": 29,
122
+ "γ„Ÿ": 30,
123
+ "γ„ ": 31,
124
+ "γ„‘": 32,
125
+ "γ„’": 33,
126
+ "γ„£": 34,
127
+ "γ„€": 35,
128
+ "γ„₯": 36,
129
+ "ㄦ": 37,
130
+ "γ„§": 38,
131
+ "ㄨ": 39,
132
+ "γ„©": 40,
133
+ "AA": 41,
134
+ "AE": 42,
135
+ "AH": 43,
136
+ "AO": 44,
137
+ "AW": 45,
138
+ "AY": 46,
139
+ "B": 47,
140
+ "CH": 48,
141
+ "D": 49,
142
+ "DH": 50,
143
+ "EH": 51,
144
+ "ER": 52,
145
+ "EY": 53,
146
+ "F": 54,
147
+ "G": 55,
148
+ "HH": 56,
149
+ "IH": 57,
150
+ "IY": 58,
151
+ "JH": 59,
152
+ "K": 60,
153
+ "L": 61,
154
+ "M": 62,
155
+ "N": 63,
156
+ "NG": 64,
157
+ "OW": 65,
158
+ "OY": 66,
159
+ "P": 67,
160
+ "R": 68,
161
+ "S": 69,
162
+ "SH": 70,
163
+ "T": 71,
164
+ "TH": 72,
165
+ "UH": 73,
166
+ "UW": 74,
167
+ "V": 75,
168
+ "W": 76,
169
+ "Y": 77,
170
+ "Z": 78,
171
+ "ZH": 79,
172
+ ",": 80,
173
+ ".": 81,
174
+ "?": 82,
175
+ "!": 83,
176
+ "…": 84,
177
+ "-": 85,
178
+ "'": 86
179
+ },
180
+ "lang": {
181
+ "ZH": 0,
182
+ "EN": 1
183
+ },
184
+ "n_symbols": 87,
185
+ "max_tone": 5
186
+ }
scripts/synth_from_text.py ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """End-to-end synthesis from text via the exported 8k ONNX pipeline:
3
+ text -> bopomofo+arpabet frontend -> ids -> encoder.onnx -> numpy host_regulate
4
+ -> decoder.onnx -> vocoder.onnx -> 8kHz wav. Run in moss-train-venv (g2pw+ort).
5
+ Used for M1 eval (synthesize zh-TW/en/code-mix test sentences). X-ASR scoring is a
6
+ separate step in moss-nano-venv (xasr_offline.py) on the produced wavs."""
7
+ from __future__ import annotations
8
+ import argparse, json, sys
9
+ from pathlib import Path
10
+ import numpy as np, soundfile as sf, onnxruntime as ort
11
+
12
+ ZT = "/home/luigi/jetson-tts/mossnano/zhtw8k"
13
+ sys.path.insert(0, ZT)
14
+ import frontend_bopomofo as F # g2pw bopomofo + g2p_en arpabet -> ids
15
+
16
+
17
+ def host_regulate(cond, dur, pitch, abs_bins, max_frames):
18
+ c = cond[0]; d = dur[0].astype(np.int64); d[d < 0] = 0
19
+ T, H = c.shape
20
+ frames = np.repeat(c, d, axis=0); Fn = frames.shape[0]
21
+ tok = np.repeat(np.arange(T), d); starts = np.cumsum(d) - d
22
+ within = np.arange(Fn) - starts[tok]; dpf = d[tok].astype(np.float32)
23
+ rel = (within / np.maximum(dpf - 1, 1)).astype(np.float32)
24
+ tc = max(1, int((d > 0).sum())); token_pos = (tok / max(1, tc - 1)).astype(np.float32)
25
+ ld = (np.log1p(dpf) / 6.0).astype(np.float32); center = 1.0 - np.abs(rel * 2 - 1)
26
+ fm = np.stack([rel, 1 - rel, center, np.sin(rel*np.pi), np.cos(rel*np.pi), token_pos, ld, dpf/40.0], -1).astype(np.float32)
27
+ prev = np.concatenate([c[:1], c[:-1]], 0); nxt = np.concatenate([c[1:], c[-1:]], 0)
28
+ lc = np.repeat(np.concatenate([prev, c, nxt], -1), d, axis=0).astype(np.float32)
29
+ pos = np.arange(Fn); ap = np.minimum(pos*abs_bins//max(1, max_frames), abs_bins-1).astype(np.int64)
30
+ pf = np.repeat(pitch[0], d, axis=0).astype(np.float32)
31
+ return {"frames": frames[None].astype(np.float32), "frame_meta": fm[None], "local_ctx_raw": lc[None],
32
+ "abs_pos": ap[None], "pitch_frame": pf[None], "frame_mask": np.ones((1, Fn), bool)}
33
+
34
+
35
+ def main():
36
+ ap = argparse.ArgumentParser()
37
+ ap.add_argument("--onnx-dir", required=True)
38
+ ap.add_argument("--out-dir", required=True)
39
+ ap.add_argument("--texts", required=True, help="jsonl with {id,text}")
40
+ args = ap.parse_args()
41
+ meta = json.load(open(f"{args.onnx_dir}/meta.json"))
42
+ so = ort.SessionOptions(); so.intra_op_num_threads = 4
43
+ sA = ort.InferenceSession(f"{args.onnx_dir}/acoustic_encoder.onnx", so, providers=["CPUExecutionProvider"])
44
+ sB = ort.InferenceSession(f"{args.onnx_dir}/acoustic_decoder.onnx", so, providers=["CPUExecutionProvider"])
45
+ sV = ort.InferenceSession(f"{args.onnx_dir}/vocoder.onnx", so, providers=["CPUExecutionProvider"])
46
+ Path(args.out_dir).mkdir(parents=True, exist_ok=True)
47
+ sr = meta["sample_rate"]; bn = ["frames","frame_meta","local_ctx_raw","abs_pos","pitch_frame","frame_mask"]
48
+ rows = [json.loads(l) for l in open(args.texts) if l.strip()]
49
+ out_manifest = open(f"{args.out_dir}/synth.jsonl", "w")
50
+ for r in rows:
51
+ o = F.text_to_ids(r["text"])
52
+ phone = np.array([o["phone_ids"]], np.int64); tone = np.array([o["tone_ids"]], np.int64); lang = np.array([o["lang_ids"]], np.int64)
53
+ spk = np.zeros(1, np.int64)
54
+ cond, dur, pitch = sA.run(None, {"phone": phone, "tone": tone, "lang": lang, "speaker": spk})
55
+ reg = host_regulate(cond, dur, pitch, meta["abs_frame_bins"], meta["max_frames"])
56
+ feeds = {n: (reg[n].astype(np.float32) if reg[n].dtype != bool else reg[n]) for n in bn}
57
+ feeds["abs_pos"] = reg["abs_pos"].astype(np.int64)
58
+ mel = sB.run(None, feeds)[0]
59
+ wav = sV.run(None, {"mel": mel.astype(np.float32)})[0].reshape(-1)
60
+ wp = f"{args.out_dir}/{r['id']}.wav"; sf.write(wp, wav, sr)
61
+ out_manifest.write(json.dumps({"id": r["id"], "text": r["text"], "wav": wp, "dur": round(len(wav)/sr, 2)}, ensure_ascii=False) + "\n")
62
+ print(f" {r['id']}: {len(wav)/sr:.1f}s -> {wp}")
63
+ out_manifest.close()
64
+ print(f"DONE synth -> {args.out_dir}/synth.jsonl")
65
+
66
+
67
+ if __name__ == "__main__":
68
+ main()
scripts/xasr_offline.py ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Offline X-ASR (zh-en zipformer transducer, 2026-06-03) scorer β€” accurate code-mix
2
+ assessment. Reuses the model's own raw-ort decode (int64-patched) + OpenCC T2S norm.
3
+ Run in moss-nano-venv (onnxruntime 1.27 + kaldi_native_fbank + opencc + soundfile/librosa)."""
4
+ import os, re, sys
5
+ MDIR="/home/luigi/jetson-tts/models/xasr-offline/sherpa-onnx-x-asr-zipformer-transducer-zh-en-2026-06-03"
6
+ _cwd=os.getcwd(); os.chdir(MDIR); sys.path.insert(0, MDIR)
7
+ import importlib.util
8
+ _spec=importlib.util.spec_from_file_location("xasr_test", f"{MDIR}/test_onnx.py")
9
+ T=importlib.util.module_from_spec(_spec); _spec.loader.exec_module(T)
10
+ os.chdir(_cwd)
11
+ import opencc, cn2an
12
+ _ZD={"0":"ι›Ά","1":"δΈ€","2":"二","3":"δΈ‰","4":"ε››","5":"δΊ”","6":"ε…­","7":"δΈƒ","8":"ε…«","9":"九"}
13
+ def _numnorm(t):
14
+ def r(m):
15
+ d=m.group(0)
16
+ if len(d)>=5: return "".join(_ZD[c] for c in d)
17
+ try: return cn2an.an2cn(d,"low")
18
+ except: return "".join(_ZD[c] for c in d)
19
+ return re.sub(r"\d+",r,t)
20
+ _t2s=opencc.OpenCC('t2s'); _model=None; _id2tok=None
21
+ def _ensure():
22
+ global _model,_id2tok
23
+ if _model is None:
24
+ cwd=os.getcwd(); os.chdir(MDIR)
25
+ _id2tok=T.load_tokens("./tokens.txt"); _model=T.load_model(use_int8=False)
26
+ os.chdir(cwd)
27
+ return _model,_id2tok
28
+ def asr(wav):
29
+ import soundfile as sf, librosa, numpy as np
30
+ m,id2tok=_ensure()
31
+ samples,sr=sf.read(wav,dtype="float32")
32
+ if samples.ndim>1: samples=samples.mean(1)
33
+ if sr!=16000: samples=librosa.resample(samples,orig_sr=sr,target_sr=16000,res_type="soxr_hq")
34
+ if len(samples)<64000: samples=np.concatenate([samples,np.zeros(64000-len(samples),np.float32)]) # >=4s pad: short clips break encoder convs
35
+ feats=T.compute_feat(samples=samples.astype(np.float32), sample_rate=16000)
36
+ blank=0; hyp=[blank]*m.context_size
37
+ dout=m.run_decoder(hyp); enc=m.run_encoder(feats[None])
38
+ for k in range(enc.shape[1]):
39
+ jo=m.run_joiner(enc[0,k:k+1], dout); tid=int(jo.argmax())
40
+ if tid!=blank:
41
+ hyp.append(tid); dout=m.run_decoder(hyp[-m.context_size:])
42
+ toks=[id2tok[i] for i in hyp[m.context_size:]]
43
+ return "".join(toks).replace("▁"," ").strip()
44
+ def han(s): return "".join(c for c in s if "δΈ€"<=c<="ιΏΏ")
45
+ def _lev(r,h):
46
+ if not r: return 0.0
47
+ d=list(range(len(h)+1))
48
+ for i in range(1,len(r)+1):
49
+ p=d[0]; d[0]=i
50
+ for j in range(1,len(h)+1):
51
+ c=d[j]; d[j]=min(d[j]+1,d[j-1]+1,p+(r[i-1]!=h[j-1])); p=c
52
+ return d[len(h)]/len(r)
53
+ def score(ref,hyp):
54
+ is_zh=bool(re.search(r"[δΈ€-ιΏΏ]",ref))
55
+ if is_zh: return _lev(han(_t2s.convert(_numnorm(ref))),han(_t2s.convert(hyp)))
56
+ return _lev(re.findall(r"[a-z']+",ref.lower()),re.findall(r"[a-z']+",hyp.lower()))
weights/acoustic_decoder.onnx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:6ea97d8caf52af44a6372aa6cb445de6b70690561d02d2cf8514c019fbad91bc
3
+ size 8147039
weights/acoustic_encoder.onnx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:605048511cd3c8ab429768a8b11f7e2d45705379c9e6ff91200e88ab8664c18f
3
+ size 5782030
weights/acoustic_zh_v2_60k.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:559970e6a52d5ea9b7aaecf592835f8c589fe8b706223125a62a5e4c618ed670
3
+ size 41829011
weights/meta.json ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "sample_rate": 8000,
3
+ "abs_frame_bins": 512,
4
+ "max_frames": 1000,
5
+ "hop_size": 128,
6
+ "n_mels": 80,
7
+ "use_frame_pitch": true
8
+ }
weights/symbol_table.json ADDED
@@ -0,0 +1,186 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "symbols": [
3
+ "_blank",
4
+ "_pad",
5
+ "UNK",
6
+ "SP",
7
+ "γ„…",
8
+ "ㄆ",
9
+ "ㄇ",
10
+ "γ„ˆ",
11
+ "ㄉ",
12
+ "γ„Š",
13
+ "γ„‹",
14
+ "γ„Œ",
15
+ "ㄍ",
16
+ "γ„Ž",
17
+ "ㄏ",
18
+ "ㄐ",
19
+ "γ„‘",
20
+ "γ„’",
21
+ "γ„“",
22
+ "γ„”",
23
+ "γ„•",
24
+ "γ„–",
25
+ "γ„—",
26
+ "γ„˜",
27
+ "γ„™",
28
+ "γ„š",
29
+ "γ„›",
30
+ "γ„œ",
31
+ "ㄝ",
32
+ "γ„ž",
33
+ "γ„Ÿ",
34
+ "γ„ ",
35
+ "γ„‘",
36
+ "γ„’",
37
+ "γ„£",
38
+ "γ„€",
39
+ "γ„₯",
40
+ "ㄦ",
41
+ "γ„§",
42
+ "ㄨ",
43
+ "γ„©",
44
+ "AA",
45
+ "AE",
46
+ "AH",
47
+ "AO",
48
+ "AW",
49
+ "AY",
50
+ "B",
51
+ "CH",
52
+ "D",
53
+ "DH",
54
+ "EH",
55
+ "ER",
56
+ "EY",
57
+ "F",
58
+ "G",
59
+ "HH",
60
+ "IH",
61
+ "IY",
62
+ "JH",
63
+ "K",
64
+ "L",
65
+ "M",
66
+ "N",
67
+ "NG",
68
+ "OW",
69
+ "OY",
70
+ "P",
71
+ "R",
72
+ "S",
73
+ "SH",
74
+ "T",
75
+ "TH",
76
+ "UH",
77
+ "UW",
78
+ "V",
79
+ "W",
80
+ "Y",
81
+ "Z",
82
+ "ZH",
83
+ ",",
84
+ ".",
85
+ "?",
86
+ "!",
87
+ "…",
88
+ "-",
89
+ "'"
90
+ ],
91
+ "sym2id": {
92
+ "_blank": 0,
93
+ "_pad": 1,
94
+ "UNK": 2,
95
+ "SP": 3,
96
+ "γ„…": 4,
97
+ "ㄆ": 5,
98
+ "ㄇ": 6,
99
+ "γ„ˆ": 7,
100
+ "ㄉ": 8,
101
+ "γ„Š": 9,
102
+ "γ„‹": 10,
103
+ "γ„Œ": 11,
104
+ "ㄍ": 12,
105
+ "γ„Ž": 13,
106
+ "ㄏ": 14,
107
+ "ㄐ": 15,
108
+ "γ„‘": 16,
109
+ "γ„’": 17,
110
+ "γ„“": 18,
111
+ "γ„”": 19,
112
+ "γ„•": 20,
113
+ "γ„–": 21,
114
+ "γ„—": 22,
115
+ "γ„˜": 23,
116
+ "γ„™": 24,
117
+ "γ„š": 25,
118
+ "γ„›": 26,
119
+ "γ„œ": 27,
120
+ "ㄝ": 28,
121
+ "γ„ž": 29,
122
+ "γ„Ÿ": 30,
123
+ "γ„ ": 31,
124
+ "γ„‘": 32,
125
+ "γ„’": 33,
126
+ "γ„£": 34,
127
+ "γ„€": 35,
128
+ "γ„₯": 36,
129
+ "ㄦ": 37,
130
+ "γ„§": 38,
131
+ "ㄨ": 39,
132
+ "γ„©": 40,
133
+ "AA": 41,
134
+ "AE": 42,
135
+ "AH": 43,
136
+ "AO": 44,
137
+ "AW": 45,
138
+ "AY": 46,
139
+ "B": 47,
140
+ "CH": 48,
141
+ "D": 49,
142
+ "DH": 50,
143
+ "EH": 51,
144
+ "ER": 52,
145
+ "EY": 53,
146
+ "F": 54,
147
+ "G": 55,
148
+ "HH": 56,
149
+ "IH": 57,
150
+ "IY": 58,
151
+ "JH": 59,
152
+ "K": 60,
153
+ "L": 61,
154
+ "M": 62,
155
+ "N": 63,
156
+ "NG": 64,
157
+ "OW": 65,
158
+ "OY": 66,
159
+ "P": 67,
160
+ "R": 68,
161
+ "S": 69,
162
+ "SH": 70,
163
+ "T": 71,
164
+ "TH": 72,
165
+ "UH": 73,
166
+ "UW": 74,
167
+ "V": 75,
168
+ "W": 76,
169
+ "Y": 77,
170
+ "Z": 78,
171
+ "ZH": 79,
172
+ ",": 80,
173
+ ".": 81,
174
+ "?": 82,
175
+ "!": 83,
176
+ "…": 84,
177
+ "-": 85,
178
+ "'": 86
179
+ },
180
+ "lang": {
181
+ "ZH": 0,
182
+ "EN": 1
183
+ },
184
+ "n_symbols": 87,
185
+ "max_tone": 5
186
+ }
weights/vocoder.onnx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:fa20f208f9d006f07ad00965809aea72f461e5280f8592412f6d371095a2636f
3
+ size 4705722