UncleanCode commited on
Commit
256a3b6
·
verified ·
1 Parent(s): 340dedc

Add inference code (prosody v2), README, LICENSE, prompts, samples

Browse files
.gitattributes CHANGED
@@ -33,3 +33,10 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ prompts/clip0_waxal_yor_yor_1258.wav filter=lfs diff=lfs merge=lfs -text
37
+ prompts/clip1_waxal_yor_yor_1205.wav filter=lfs diff=lfs merge=lfs -text
38
+ prompts/clip2_waxal_yor_yor_1263.wav filter=lfs diff=lfs merge=lfs -text
39
+ prompts/clip3_waxal_yor_yor_1218.wav filter=lfs diff=lfs merge=lfs -text
40
+ samples/prosody_v2/para_hau_eng_pcm.mp3 filter=lfs diff=lfs merge=lfs -text
41
+ samples/prosody_v2/para_ibo_pcm_eng.mp3 filter=lfs diff=lfs merge=lfs -text
42
+ samples/prosody_v2/para_yor_eng.mp3 filter=lfs diff=lfs merge=lfs -text
LICENSE ADDED
@@ -0,0 +1,202 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ Apache License
3
+ Version 2.0, January 2004
4
+ http://www.apache.org/licenses/
5
+
6
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7
+
8
+ 1. Definitions.
9
+
10
+ "License" shall mean the terms and conditions for use, reproduction,
11
+ and distribution as defined by Sections 1 through 9 of this document.
12
+
13
+ "Licensor" shall mean the copyright owner or entity authorized by
14
+ the copyright owner that is granting the License.
15
+
16
+ "Legal Entity" shall mean the union of the acting entity and all
17
+ other entities that control, are controlled by, or are under common
18
+ control with that entity. For the purposes of this definition,
19
+ "control" means (i) the power, direct or indirect, to cause the
20
+ direction or management of such entity, whether by contract or
21
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
22
+ outstanding shares, or (iii) beneficial ownership of such entity.
23
+
24
+ "You" (or "Your") shall mean an individual or Legal Entity
25
+ exercising permissions granted by this License.
26
+
27
+ "Source" form shall mean the preferred form for making modifications,
28
+ including but not limited to software source code, documentation
29
+ source, and configuration files.
30
+
31
+ "Object" form shall mean any form resulting from mechanical
32
+ transformation or translation of a Source form, including but
33
+ not limited to compiled object code, generated documentation,
34
+ and conversions to other media types.
35
+
36
+ "Work" shall mean the work of authorship, whether in Source or
37
+ Object form, made available under the License, as indicated by a
38
+ copyright notice that is included in or attached to the work
39
+ (an example is provided in the Appendix below).
40
+
41
+ "Derivative Works" shall mean any work, whether in Source or Object
42
+ form, that is based on (or derived from) the Work and for which the
43
+ editorial revisions, annotations, elaborations, or other modifications
44
+ represent, as a whole, an original work of authorship. For the purposes
45
+ of this License, Derivative Works shall not include works that remain
46
+ separable from, or merely link (or bind by name) to the interfaces of,
47
+ the Work and Derivative Works thereof.
48
+
49
+ "Contribution" shall mean any work of authorship, including
50
+ the original version of the Work and any modifications or additions
51
+ to that Work or Derivative Works thereof, that is intentionally
52
+ submitted to Licensor for inclusion in the Work by the copyright owner
53
+ or by an individual or Legal Entity authorized to submit on behalf of
54
+ the copyright owner. For the purposes of this definition, "submitted"
55
+ means any form of electronic, verbal, or written communication sent
56
+ to the Licensor or its representatives, including but not limited to
57
+ communication on electronic mailing lists, source code control systems,
58
+ and issue tracking systems that are managed by, or on behalf of, the
59
+ Licensor for the purpose of discussing and improving the Work, but
60
+ excluding communication that is conspicuously marked or otherwise
61
+ designated in writing by the copyright owner as "Not a Contribution."
62
+
63
+ "Contributor" shall mean Licensor and any individual or Legal Entity
64
+ on behalf of whom a Contribution has been received by Licensor and
65
+ subsequently incorporated within the Work.
66
+
67
+ 2. Grant of Copyright License. Subject to the terms and conditions of
68
+ this License, each Contributor hereby grants to You a perpetual,
69
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70
+ copyright license to reproduce, prepare Derivative Works of,
71
+ publicly display, publicly perform, sublicense, and distribute the
72
+ Work and such Derivative Works in Source or Object form.
73
+
74
+ 3. Grant of Patent License. Subject to the terms and conditions of
75
+ this License, each Contributor hereby grants to You a perpetual,
76
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77
+ (except as stated in this section) patent license to make, have made,
78
+ use, offer to sell, sell, import, and otherwise transfer the Work,
79
+ where such license applies only to those patent claims licensable
80
+ by such Contributor that are necessarily infringed by their
81
+ Contribution(s) alone or by combination of their Contribution(s)
82
+ with the Work to which such Contribution(s) was submitted. If You
83
+ institute patent litigation against any entity (including a
84
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
85
+ or a Contribution incorporated within the Work constitutes direct
86
+ or contributory patent infringement, then any patent licenses
87
+ granted to You under this License for that Work shall terminate
88
+ as of the date such litigation is filed.
89
+
90
+ 4. Redistribution. You may reproduce and distribute copies of the
91
+ Work or Derivative Works thereof in any medium, with or without
92
+ modifications, and in Source or Object form, provided that You
93
+ meet the following conditions:
94
+
95
+ (a) You must give any other recipients of the Work or
96
+ Derivative Works a copy of this License; and
97
+
98
+ (b) You must cause any modified files to carry prominent notices
99
+ stating that You changed the files; and
100
+
101
+ (c) You must retain, in the Source form of any Derivative Works
102
+ that You distribute, all copyright, patent, trademark, and
103
+ attribution notices from the Source form of the Work,
104
+ excluding those notices that do not pertain to any part of
105
+ the Derivative Works; and
106
+
107
+ (d) If the Work includes a "NOTICE" text file as part of its
108
+ distribution, then any Derivative Works that You distribute must
109
+ include a readable copy of the attribution notices contained
110
+ within such NOTICE file, excluding those notices that do not
111
+ pertain to any part of the Derivative Works, in at least one
112
+ of the following places: within a NOTICE text file distributed
113
+ as part of the Derivative Works; within the Source form or
114
+ documentation, if provided along with the Derivative Works; or,
115
+ within a display generated by the Derivative Works, if and
116
+ wherever such third-party notices normally appear. The contents
117
+ of the NOTICE file are for informational purposes only and
118
+ do not modify the License. You may add Your own attribution
119
+ notices within Derivative Works that You distribute, alongside
120
+ or as an addendum to the NOTICE text from the Work, provided
121
+ that such additional attribution notices cannot be construed
122
+ as modifying the License.
123
+
124
+ You may add Your own copyright statement to Your modifications and
125
+ may provide additional or different license terms and conditions
126
+ for use, reproduction, or distribution of Your modifications, or
127
+ for any such Derivative Works as a whole, provided Your use,
128
+ reproduction, and distribution of the Work otherwise complies with
129
+ the conditions stated in this License.
130
+
131
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
132
+ any Contribution intentionally submitted for inclusion in the Work
133
+ by You to the Licensor shall be under the terms and conditions of
134
+ this License, without any additional terms or conditions.
135
+ Notwithstanding the above, nothing herein shall supersede or modify
136
+ the terms of any separate license agreement you may have executed
137
+ with Licensor regarding such Contributions.
138
+
139
+ 6. Trademarks. This License does not grant permission to use the trade
140
+ names, trademarks, service marks, or product names of the Licensor,
141
+ except as required for reasonable and customary use in describing the
142
+ origin of the Work and reproducing the content of the NOTICE file.
143
+
144
+ 7. Disclaimer of Warranty. Unless required by applicable law or
145
+ agreed to in writing, Licensor provides the Work (and each
146
+ Contributor provides its Contributions) on an "AS IS" BASIS,
147
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148
+ implied, including, without limitation, any warranties or conditions
149
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150
+ PARTICULAR PURPOSE. You are solely responsible for determining the
151
+ appropriateness of using or redistributing the Work and assume any
152
+ risks associated with Your exercise of permissions under this License.
153
+
154
+ 8. Limitation of Liability. In no event and under no legal theory,
155
+ whether in tort (including negligence), contract, or otherwise,
156
+ unless required by applicable law (such as deliberate and grossly
157
+ negligent acts) or agreed to in writing, shall any Contributor be
158
+ liable to You for damages, including any direct, indirect, special,
159
+ incidental, or consequential damages of any character arising as a
160
+ result of this License or out of the use or inability to use the
161
+ Work (including but not limited to damages for loss of goodwill,
162
+ work stoppage, computer failure or malfunction, or any and all
163
+ other commercial damages or losses), even if such Contributor
164
+ has been advised of the possibility of such damages.
165
+
166
+ 9. Accepting Warranty or Additional Liability. While redistributing
167
+ the Work or Derivative Works thereof, You may choose to offer,
168
+ and charge a fee for, acceptance of support, warranty, indemnity,
169
+ or other liability obligations and/or rights consistent with this
170
+ License. However, in accepting such obligations, You may act only
171
+ on Your own behalf and on Your sole responsibility, not on behalf
172
+ of any other Contributor, and only if You agree to indemnify,
173
+ defend, and hold each Contributor harmless for any liability
174
+ incurred by, or claims asserted against, such Contributor by reason
175
+ of your accepting any such warranty or additional liability.
176
+
177
+ END OF TERMS AND CONDITIONS
178
+
179
+ APPENDIX: How to apply the Apache License to your work.
180
+
181
+ To apply the Apache License to your work, attach the following
182
+ boilerplate notice, with the fields enclosed by brackets "[]"
183
+ replaced with your own identifying information. (Don't include
184
+ the brackets!) The text should be enclosed in the appropriate
185
+ comment syntax for the file format. We also recommend that a
186
+ file or class name and description of purpose be included on the
187
+ same "printed page" as the copyright notice for easier
188
+ identification within third-party archives.
189
+
190
+ Copyright [yyyy] [name of copyright owner]
191
+
192
+ Licensed under the Apache License, Version 2.0 (the "License");
193
+ you may not use this file except in compliance with the License.
194
+ You may obtain a copy of the License at
195
+
196
+ http://www.apache.org/licenses/LICENSE-2.0
197
+
198
+ Unless required by applicable law or agreed to in writing, software
199
+ distributed under the License is distributed on an "AS IS" BASIS,
200
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
201
+ See the License for the specific language governing permissions and
202
+ limitations under the License.
README.md ADDED
@@ -0,0 +1,184 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: apache-2.0
3
+ language:
4
+ - en
5
+ - pcm
6
+ - yo
7
+ - ig
8
+ - ha
9
+ base_model: FunAudioLLM/CosyVoice2-0.5B
10
+ pipeline_tag: text-to-speech
11
+ tags:
12
+ - text-to-speech
13
+ - code-switching
14
+ - nigerian-languages
15
+ - cosyvoice2
16
+ - research-preview
17
+ ---
18
+
19
+ # Dynamic Accent (Phase 2, research preview)
20
+
21
+ Nigerian multilingual, **code-switching** text-to-speech in one consistent voice: English, Nigerian Pidgin, Yoruba, Igbo and Hausa, mixed freely inside one sentence.
22
+
23
+ It is built on [FunAudioLLM/CosyVoice2-0.5B](https://huggingface.co/FunAudioLLM/CosyVoice2-0.5B) and adds:
24
+ - per-token **language embeddings**, a **speaker token** and an adversarial (GRL) speaker classifier on the LM, fine-tuned on about 120 h of Nigerian-language and code-switched speech with pause-normalised data (pause profile B);
25
+ - in-context **anchor prompts** at the LM and the flow, so prosody and pause style follow the anchor voice (WAXAL Yoruba speaker 4);
26
+ - a **flow-matching decoder fine-tuned on the anchor voice** (anchor SFT) for tone and pitch detail.
27
+
28
+ This repo is a **private research preview** for testing. It is not a production model.
29
+
30
+ Inference defaults to **prosody v2**: clause-level generation with anchor-matched pause gaps, and a 1.05 pitch-preserving speed-up. See the section below.
31
+
32
+ ## What is in the repo
33
+
34
+ | path | what it is |
35
+ |---|---|
36
+ | `checkpoints/p2_lm_B/best.pt` | Dynamic Accent LM (CosyVoice2 Qwen2 LM + language/speaker embeddings), pause profile B, 4,000 steps, best validation CE |
37
+ | `checkpoints/p2_lm_B/spk2id.json` | speaker-token map (the anchor is `waxal_yor_4`) |
38
+ | `checkpoints/p2_flow_sft/best.pt` | anchor-SFT flow-matching decoder (replaces the CosyVoice2 flow weights) |
39
+ | `prompts/clip{0..3}_*.wav`, `prompts/prompts.json` | the 4 anchor prompt clips (24 kHz) with their transcripts and S3 speech tokens. The final system uses clip 0 |
40
+ | `infer.py` | self-contained inference: the same path as the Phase 2 final samples |
41
+ | `code/` | the original training and synthesis code (`train_b.py`, `train_b2.py`, `da/`) for reference |
42
+ | `cosyvoice_requirements.txt` | CosyVoice's own requirements file, at the commit used |
43
+ | `samples/` | example renders |
44
+
45
+ Everything else comes from the stock base model **`FunAudioLLM/CosyVoice2-0.5B`** (Qwen tokenizer `CosyVoice-BlankEN`, `llm.pt`, `hift.pt` vocoder, `campplus.onnx`, `speech_tokenizer_v2.onnx`, `cosyvoice2.yaml`). Nothing in the base needs changing; `infer.py` downloads it on first run.
46
+
47
+ ## Setup (single GPU, e.g. Colab T4 or A100)
48
+
49
+ ```bash
50
+ # 1. CosyVoice code (the commit the model was built and tested on)
51
+ git clone --recursive https://github.com/FunAudioLLM/CosyVoice.git
52
+ cd CosyVoice && git checkout 074ca6dc9e80a2f424f1f74b48bdd7d3fea531cc && git submodule update --init --recursive && cd ..
53
+
54
+ # 2. This repo (private: log in with a token that can read Modularcomputing)
55
+ huggingface-cli login
56
+ huggingface-cli download Modularcomputing/dynamic-accent-preview --local-dir dynamic-accent-preview
57
+
58
+ # 3. Python deps (Python 3.10; on Colab keep the preinstalled torch/torchaudio)
59
+ pip install -r dynamic-accent-preview/requirements.txt
60
+ ```
61
+
62
+ The base model downloads to `dynamic-accent-preview/pretrained/CosyVoice2-0.5B` the first time you run `infer.py`. You can also point `--model_dir` (or `COSYVOICE2_DIR`) at an existing copy. Point `--cosyvoice_dir` (or `COSYVOICE_REPO`) at your CosyVoice clone if it is not inside the repo folder.
63
+
64
+ The 0.5B model should fit comfortably on a 16 GB T4. This build was tested on an H100 only, not yet on Colab.
65
+
66
+ ## Usage
67
+
68
+ ```bash
69
+ cd dynamic-accent-preview
70
+ python infer.py --cosyvoice_dir ../CosyVoice \
71
+ --text "<eng>this is dynamic accent,</eng> <pcm>e dey sound nice abi?</pcm>" \
72
+ --seed 0 --out out.wav # defaults: --pause_mode clause --speed 1.05
73
+ # Phase 2 final behaviour: add --pause_mode model --speed 1.0
74
+ ```
75
+
76
+ - **Language tags:** wrap each span in `<eng>`, `<pcm>`, `<yor>`, `<ibo>` or `<hau>`. Untagged text is read as English. Keep punctuation inside the span it belongs to.
77
+ - **`--seed`:** the final system is a single sample at seed 0. Try seeds 1 and 2 (or more) and keep the take you like. Best-of-N picking lowered CER by about a third in evaluation.
78
+ - **`--pause_mode clause`** (default, prosody v2): the text is cut at `, ; : . ? !` and at language switches. Each chunk is generated separately and its edge silence is trimmed (energy VAD, 30 ms kept). Chunks are then joined with set gaps taken from the real anchor's pause statistics: 0.40 s ± 0.05 at commas and `;:`, 0.60 s at sentence ends, and 0.12 s at an unpunctuated language switch, with 10 ms fades. A one-word chunk is merged into the next chunk of the same language.
79
+ - **`--speed`** (default 1.05): a pitch-preserving tempo factor (WSOLA via `audiotsm`) applied to the speech only, so the gaps stay as set. The allowed range is 1.0–1.2.
80
+ - **`--pause_mode model --speed 1.0`** reproduces the Phase 2 final system: model-generated pauses, sentence-by-sentence generation, internal silences capped at 0.6 s, and 0.6 s between sentences. Both modes use anchor prompt clip 0 at the LM and flow (`--prompt_clip 0`).
81
+ - **`--dtype`:** the LM ran under bf16 autocast. T4 has no bf16, so `auto` uses fp16 there. fp16 has not been tested yet; if a T4 run sounds wrong, compare against an A100 (bf16) run.
82
+ - **Determinism:** generation is not bit-reproducible across runs on GPU. Sampling under bf16 can pick a different token on near-ties, so the same seed can give a slightly different take.
83
+
84
+ From Python:
85
+
86
+ ```python
87
+ from infer import DynamicAccent
88
+ tts = DynamicAccent("pretrained/CosyVoice2-0.5B", "../CosyVoice",
89
+ "checkpoints/p2_lm_B/best.pt", "checkpoints/p2_flow_sft/best.pt", "prompts/prompts.json")
90
+ wav = tts.say("<hau>Ina kwana, yaya aiki?</hau> <eng>The meeting has been moved to Friday morning.</eng>",
91
+ seed=0, pause_mode="clause", speed=1.05)
92
+ import soundfile as sf; sf.write("hau_eng.wav", wav, tts.sr)
93
+ ```
94
+
95
+ ### Example code-switched inputs
96
+
97
+ ```text
98
+ <eng>this is dynamic accent,</eng> <pcm>e dey sound nice abi?</pcm>
99
+ <yor>Ẹ káàárọ̀ o, ṣé dáadáa ni?</yor> <eng>I will send you the report this afternoon.</eng>
100
+ <ibo>Kedu ka ị mere?</ibo> <pcm>Abeg no vex, I go call you for evening.</pcm>
101
+ <hau>Ina kwana, yaya aiki?</hau> <eng>The meeting has been moved to Friday morning.</eng>
102
+ <ibo>Ndewo nụ, ụmụnna m.</ibo> <pcm>Una well done o, I know say the road no easy.</pcm> <eng>We have three things to discuss this evening.</eng>
103
+ <hau>Barka da yamma, yaya kuke?</hau> <eng>The weather report says it will rain tomorrow morning.</eng> <pcm>Abeg no forget o, the rain fit heavy well well.</pcm>
104
+ ```
105
+
106
+ The Yoruba, Igbo and Hausa example lines are hand-written and have not been checked by native speakers.
107
+
108
+ ## Samples
109
+
110
+ - `samples/pcm_eng_dynamic_accent_seed{0,1,2}.mp3`: "this is dynamic accent, e dey sound nice abi?" (English then Pidgin), seeds 0, 1 and 2. These are Phase 2 final renders (`--pause_mode model --speed 1.0`).
111
+ - `samples/prosody_v2/*.mp3`: the same custom sentence plus `para_ibo_pcm_eng`, `para_yor_eng`, `para_hau_eng_pcm`, `ibo_000` and `yor_000`, rendered with the defaults (`--pause_mode clause --speed 1.05`, seed 0).
112
+
113
+ ## Prosody v2 (inference-time pause and pace fix)
114
+
115
+ **Speaking rate** is articulation rate: syllables per second of speech, with pauses of 0.15 s or more excluded. It uses the same syllable counter for every source. Reference speech is 150 clips per source. The system rate comes from the Phase 2 final renders of the eval set.
116
+
117
+ | | anchor (yor) | yor | ibo | hau | pcm | eng |
118
+ |---|---|---|---|---|---|---|
119
+ | reference speech (syl/s) | 4.45 | 4.78 (WAXAL, other speakers) | 4.73 | 5.71 | 3.87 | 4.53 (FLEURS) |
120
+ | Phase 2 final system (syl/s) | 4.20 (yor) | 4.20 | 4.50 | 3.98 | 3.63 | 4.77 |
121
+ | reference ÷ system | 1.06 | 1.14 | 1.05 | 1.43 | 1.06 | 0.95 |
122
+
123
+ The median ratio is 1.06, so the default `--speed` is **1.05** (rounded to 0.05 and clamped to 1.0–1.2). Hausa is the outlier. Its vowel-based syllable count is the roughest of the five.
124
+
125
+ **Before and after** on the six sample items (custom sentence, 3 paragraphs, ibo_000, yor_000), seed 0. Pauses are measured at forced-aligned word boundaries with the Phase 2 report harness. The anchor row is real WAXAL recordings. With only 6 items, read these as a direction, not a result.
126
+
127
+ | | comma pause (s) | sentence pause (s) | unpunctuated pause rate (>0.25 s) | articulation rate (syl/s) | para CER | para CER (no tones) | all-6 CER | SIM |
128
+ |---|---|---|---|---|---|---|---|---|
129
+ | real anchor | 0.417 | 0.543 | 0.137 | 4.45 | – | – | – | ceiling ≈0.84 |
130
+ | before: Phase 2 final (`model`, 1.0) | 0.339 | 0.488 | 0.057 | 4.26 | 0.175 | 0.124 | 0.175 | 0.814 |
131
+ | clause, speed 1.0 | 0.339 | 0.526 | 0.095 | 3.95 | 0.179 | 0.123 | 0.166 | 0.775 |
132
+ | **after: clause, speed 1.05 (default)** | 0.275 (≈0.37 *) | 0.550 | 0.086 | 4.18 | 0.193 | 0.143 | 0.207 | 0.750 |
133
+ | clause, speed 1.10 | 0.315 | 0.550 | 0.067 | 4.37 | 0.244 | 0.194 | 0.224 | 0.748 |
134
+ | model pauses, speed 1.05 | 0.235 | 0.441 | 0.029 | 4.35 | 0.189 | 0.149 | 0.188 | 0.788 |
135
+
136
+ \* At two commas that follow the one-letter Pidgin particle "o," the aligner moves the inserted gap to the boundary before "o". This reads as a 0.0 s comma plus one unpunctuated pause. The other comma gaps in clause mode measure 0.37–0.39 s, against the 0.40 s target.
137
+
138
+ - **Pauses:** clause mode moves comma and sentence pauses and the phrase-break rate toward the anchor's numbers. Sentence pauses go from 0.49 to 0.55 s against the anchor's 0.54 s.
139
+ - **Pace:** generating short chunks one at a time makes speech about 7% slower (4.26 to 3.95 syl/s). So 1.05 only brings the rate back to about where the final system was, and it is still below the anchor.
140
+ - **Cost:** WSOLA speed-up and chunking each lower ECAPA similarity (0.81 to 0.75–0.79). At 1.10, paragraph CER rises clearly (0.175 to 0.244). At 1.05 the rise is small (0.175 to 0.193), and mixed across paragraphs: Hausa got better, Igbo and Yoruba got worse. A blind listening check should decide between 1.0 and 1.05.
141
+
142
+ ## Evaluation (Phase 2 report)
143
+
144
+ CER is ASR round-trip: MMS-1b-all for ibo/hau/yor, Whisper-large-v3-turbo for eng and pcm, and min(MMS, Whisper) for code-switched (CS) items and paragraphs. "No tones" strips the diacritics. SIM is ECAPA cosine to the anchor centroid; the ceiling is about 0.84 on real held-out anchor clips. Pidgin CER is limited by the judge: real Pidgin recordings score 0.43. Full eval set: 30 items per language + 9 CS sentences + 3 paragraphs = 162 items, single sample (seed 0, anchor prompt).
145
+
146
+ | system | ibo CER | hau CER | yor CER (no tones) | pcm CER | eng CER | CS CER | para CER | SIM to anchor (mean of 5 langs) | SIM spread | CS half-vs-half SIM |
147
+ |---|---|---|---|---|---|---|---|---|---|---|
148
+ | Phase 1 Arm B (smoke) | 0.212 | 0.244 | 0.407 (0.294) | 0.412 | 0.035 | 0.201 | 0.195 | 0.804 | 0.030 | 0.649 |
149
+ | p2_lm (profile A) + SFT flow | 0.199 | 0.183 | 0.223 (0.140) | 0.190 | 0.038 | 0.184 | 0.176 | 0.769 | 0.069 | 0.634 |
150
+ | **p2_lm_B (profile B) + SFT flow (this release)** | 0.188 | 0.188 | 0.224 (0.137) | 0.174 | 0.034 | 0.167 | 0.173 | 0.782 | 0.039 | 0.642 |
151
+
152
+ Pause and pitch (energy VAD on forced-aligned word boundaries):
153
+
154
+ | system | int. pause p90 (s) | pauses / s | spurious pause rate | comma pause (s) | pause-length W1 vs anchor | para: sentence pause (s) | para: pause p90 (s) | F0 std (st) | F0 range (st) | yor tone-direction acc (pairs) |
155
+ |---|---|---|---|---|---|---|---|---|---|---|
156
+ | **real anchor recordings** (WAXAL val+test, raw) | 1.071 | 0.624 | 0.137 | 0.417 | 0 | 0.543 | – | 3.152 | 10.173 | 0.750 (2990) |
157
+ | Phase 1 Arm B (smoke) | 1.334 | 0.584 | 0.044 | 0.245 | 0.147 | 0.315 | 1.035 | 3.058 | 9.999 | 0.777 (251) |
158
+ | p2_lm (profile A) + SFT flow | 0.414 | 0.328 | 0.008 | 0.263 | 0.330 | 0.509 | 0.547 | 3.038 | 10.069 | 0.752 (254) |
159
+ | **p2_lm_B (profile B) + SFT flow (this release)** | 0.460 | 0.288 | 0.087 | 0.251 | 0.262 | 0.488 | 0.531 | 3.057 | 10.134 | 0.749 (255) |
160
+
161
+ Zero-shot baselines on the 62-item stage subset (for context): CosyVoice2 zero-shot ibo/hau/yor CER 0.689 / 0.281 / 0.852, and CosyVoice3 zero-shot 0.629 / 0.197 / 0.717, against 0.173 / 0.220 / 0.226 for Phase 2 (p2_lm + SFT flow).
162
+
163
+ ## Known limitations
164
+
165
+ - In `model` mode, comma pauses are short (0.25 s on the full eval set, against the anchor's 0.42 s). `clause` mode sets them directly (see Prosody v2).
166
+ - Speaker similarity is 0.78 against Phase 1's 0.80. English and Pidgin drift the most from the anchor.
167
+ - English after a Nigerian-language span keeps a strong local accent.
168
+ - Pidgin has only about 6 h of training data and no reliable ASR judge.
169
+ - Yoruba tone quality needs human listening; the automatic tone metric is saturated.
170
+ - Naturalness has not been rated by native speakers yet (no MOS / A/B).
171
+
172
+ ## Training data
173
+
174
+ The training data includes CC-BY-SA sources. Datasets used:
175
+ - **WAXAL** (Yoruba, Igbo, Hausa, Nigerian Pidgin; includes the anchor voice, Yoruba speaker 4), CC-BY-SA-4.0
176
+ - **BibleTTS** (Yoruba and Hausa), CC-BY-SA
177
+ - **FLEURS** (English, Hausa, Igbo, Yoruba)
178
+ - **OpenSLR SLR70** (Nigerian English)
179
+ - **OpenSLR SLR86** (Yoruba)
180
+ - 4,000 code-switched pairs built from the sets above
181
+
182
+ ## Licence
183
+
184
+ Apache-2.0 (see `LICENSE`). The base model, CosyVoice2-0.5B, is Apache-2.0.
code/da/__init__.py ADDED
File without changes
code/da/data.py ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Arm A data: memmapped Vocos mels + byte tokens with per-token language ids; frame-budget bucketing.
2
+ Beta-binomial alignment priors are computed in DataLoader workers."""
3
+ import json, os, random
4
+ import numpy as np
5
+ import torch
6
+ from torch.utils.data import Dataset, Sampler
7
+
8
+ from .text import encode, LANG2ID
9
+ from .model import beta_binomial_prior
10
+
11
+ N_MELS = 100
12
+
13
+
14
+ def load_meta(root, datasets, split="train", max_frames=1400, max_tokens=420):
15
+ items, mm = [], {}
16
+ for ds in datasets:
17
+ p = f"{root}/{ds}/meta.jsonl"
18
+ if not os.path.exists(p) or os.path.getsize(p) == 0:
19
+ continue
20
+ mm[ds] = np.memmap(f"{root}/{ds}/mels.f16", dtype=np.float16, mode="r").reshape(-1, N_MELS)
21
+ for l in open(p, encoding="utf-8"):
22
+ m = json.loads(l)
23
+ if m["split"] != split or m["nf"] > max_frames:
24
+ continue
25
+ if len(m["text"].encode("utf-8")) + 2 > max_tokens:
26
+ continue
27
+ items.append(m)
28
+ return items, mm
29
+
30
+
31
+ class TTSData(Dataset):
32
+ def __init__(self, items, mm, spk2id):
33
+ self.items, self.mm, self.spk2id = items, mm, spk2id
34
+
35
+ def __len__(self):
36
+ return len(self.items)
37
+
38
+ def __getitem__(self, i):
39
+ m = self.items[i]
40
+ mel = np.asarray(self.mm[m["ds"]][m["off"]: m["off"] + m["nf"]], dtype=np.float32)
41
+ tok, _ = encode(f"<{m['lang']}>{m['text']}</{m['lang']}>")
42
+ lang = [LANG2ID[m["lang"]]] * len(tok)
43
+ return dict(tok=tok, lang=lang, mel=mel, spk=self.spk2id[m["spk"]], id=m["id"])
44
+
45
+
46
+ def collate(batch):
47
+ B = len(batch)
48
+ N = max(len(b["tok"]) for b in batch)
49
+ T = max(len(b["mel"]) for b in batch)
50
+ tok = torch.zeros(B, N, dtype=torch.long)
51
+ lang = torch.zeros(B, N, dtype=torch.long)
52
+ mel = torch.zeros(B, T, N_MELS)
53
+ prior = torch.full((B, T, N), -1e4)
54
+ tl, ml = torch.zeros(B, dtype=torch.long), torch.zeros(B, dtype=torch.long)
55
+ for i, b in enumerate(batch):
56
+ n, t = len(b["tok"]), len(b["mel"])
57
+ tok[i, :n] = torch.tensor(b["tok"]); lang[i, :n] = torch.tensor(b["lang"])
58
+ mel[i, :t] = torch.from_numpy(b["mel"]); tl[i], ml[i] = n, t
59
+ prior[i, :t, :n] = beta_binomial_prior(n, t)
60
+ spk = torch.tensor([b["spk"] for b in batch])
61
+ return dict(tok=tok, lang=lang, tlens=tl, mel=mel, mlens=ml, spk=spk, prior=prior)
62
+
63
+
64
+ class FrameBucketSampler(Sampler):
65
+ """Batches of similar length under a padded-frame budget; dataset weights by repetition."""
66
+
67
+ def __init__(self, items, frame_budget=40000, weights=None, seed=0):
68
+ self.items, self.budget, self.seed, self.epoch = items, frame_budget, seed, 0
69
+ self.weights = weights or {}
70
+
71
+ def _batches(self):
72
+ rng = random.Random(self.seed + self.epoch)
73
+ idx = []
74
+ for i, m in enumerate(self.items):
75
+ w = self.weights.get(m["ds"], 1.0)
76
+ idx += [i] * int(w) + ([i] if rng.random() < (w - int(w)) else [])
77
+ idx.sort(key=lambda i: self.items[i]["nf"] + rng.random() * 30)
78
+ batches, cur, mx = [], [], 0
79
+ for i in idx:
80
+ nf = self.items[i]["nf"]
81
+ if cur and max(mx, nf) * (len(cur) + 1) > self.budget:
82
+ batches.append(cur); cur, mx = [], 0
83
+ cur.append(i); mx = max(mx, nf)
84
+ if cur:
85
+ batches.append(cur)
86
+ rng.shuffle(batches)
87
+ return batches
88
+
89
+ def __iter__(self):
90
+ b = self._batches()
91
+ self.epoch += 1
92
+ return iter(b)
93
+
94
+ def __len__(self):
95
+ return len(self._batches())
code/da/model.py ADDED
@@ -0,0 +1,368 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Dynamic Accent - Arm A, written from scratch in PyTorch.
2
+
3
+ Backbone: Parallel Tacotron 2 (Elias et al. 2021, arXiv 2103.14574): non-autoregressive, token durations,
4
+ differentiable learned upsampling (MLP over start/end offsets -> token->frame weights W), iterative mel loss.
5
+ Multilingual recipe: Zhang et al. 2019 (arXiv 1907.04448): separate speaker + language embeddings,
6
+ domain-adversarial speaker classifier (gradient reversal) on text-encoder outputs, residual VAE.
7
+
8
+ Documented deviation (alignment): PT2 trains durations end-to-end with Soft-DTW. Soft-DTW is O(T*N)-sequential and
9
+ too slow for a 2 h smoke budget, so we use the internal unsupervised alignment-learning objective of
10
+ Badlani et al. 2022 ("One TTS Alignment To Rule Them All", arXiv 2108.10447): a small text/mel attention trained with
11
+ a CTC forward-sum loss + beta-binomial prior, hardened with monotonic alignment search (MAS). The hard durations
12
+ supervise the duration predictor and drive the SAME differentiable PT2 upsampler at train time; predicted durations
13
+ drive it at inference. No external aligner is used.
14
+ """
15
+ import math
16
+ import numpy as np
17
+ import torch
18
+ import torch.nn as nn
19
+ import torch.nn.functional as F
20
+
21
+ from .text import VOCAB, LANGS
22
+
23
+ N_MELS = 100
24
+
25
+
26
+ # ---------------------------------------------------------------- utils
27
+ def seq_mask(lens, maxlen=None):
28
+ maxlen = maxlen or int(lens.max())
29
+ return torch.arange(maxlen, device=lens.device)[None] < lens[:, None] # True = valid
30
+
31
+
32
+ def sinusoid(T, d, device):
33
+ pos = torch.arange(T, device=device).float()[:, None]
34
+ i = torch.arange(0, d, 2, device=device).float()[None]
35
+ ang = pos / (10000 ** (i / d))
36
+ pe = torch.zeros(T, d, device=device)
37
+ pe[:, 0::2] = torch.sin(ang)
38
+ pe[:, 1::2] = torch.cos(ang)
39
+ return pe
40
+
41
+
42
+ class GradReverse(torch.autograd.Function):
43
+ """Gradient reversal. As in Zhang et al. 2019 the reversed gradient sent into the text encoder is clipped
44
+ (here: its L2 norm is capped at `clip`), which keeps the min-max game from blowing up the encoder."""
45
+
46
+ @staticmethod
47
+ def forward(ctx, x, lam, clip):
48
+ ctx.lam, ctx.clip = lam, clip
49
+ return x.view_as(x)
50
+
51
+ @staticmethod
52
+ def backward(ctx, g):
53
+ g = -ctx.lam * g
54
+ if ctx.clip is not None:
55
+ n = g.norm()
56
+ if n > ctx.clip:
57
+ g = g * (ctx.clip / (n + 1e-12))
58
+ return g, None, None
59
+
60
+
61
+ def grad_reverse(x, lam=1.0, clip=0.5):
62
+ return GradReverse.apply(x, lam, clip)
63
+
64
+
65
+ class ConvNorm(nn.Module):
66
+ def __init__(self, cin, cout, k=5, drop=0.1):
67
+ super().__init__()
68
+ self.conv = nn.Conv1d(cin, cout, k, padding=k // 2)
69
+ self.ln = nn.LayerNorm(cout)
70
+ self.drop = nn.Dropout(drop)
71
+
72
+ def forward(self, x, mask): # x [B,T,C], mask [B,T]
73
+ x = x.masked_fill(~mask[..., None], 0)
74
+ y = self.conv(x.transpose(1, 2)).transpose(1, 2)
75
+ return self.drop(F.relu(self.ln(y)))
76
+
77
+
78
+ class FFTBlock(nn.Module):
79
+ """Pre-LN self-attention + convolutional feed-forward (FastSpeech/PT2-style lightweight block)."""
80
+
81
+ def __init__(self, d, heads=4, ff=4, k=3, drop=0.1):
82
+ super().__init__()
83
+ self.ln1 = nn.LayerNorm(d)
84
+ self.att = nn.MultiheadAttention(d, heads, dropout=drop, batch_first=True)
85
+ self.ln2 = nn.LayerNorm(d)
86
+ self.c1 = nn.Conv1d(d, d * ff, k, padding=k // 2)
87
+ self.c2 = nn.Conv1d(d * ff, d, 1)
88
+ self.drop = nn.Dropout(drop)
89
+
90
+ def forward(self, x, mask):
91
+ h = self.ln1(x)
92
+ a, _ = self.att(h, h, h, key_padding_mask=~mask, need_weights=False)
93
+ x = x + self.drop(a)
94
+ h = self.ln2(x).masked_fill(~mask[..., None], 0).transpose(1, 2)
95
+ h = self.c2(F.gelu(self.c1(h))).transpose(1, 2)
96
+ x = x + self.drop(h)
97
+ return x.masked_fill(~mask[..., None], 0)
98
+
99
+
100
+ # ---------------------------------------------------------------- modules
101
+ class TextEncoder(nn.Module):
102
+ def __init__(self, d, n_lang, layers=6):
103
+ super().__init__()
104
+ self.emb = nn.Embedding(VOCAB, d, padding_idx=0)
105
+ self.lang_in = nn.Embedding(n_lang, d) # per-token language id at the input
106
+ self.convs = nn.ModuleList([ConvNorm(d, d, 5) for _ in range(3)])
107
+ self.blocks = nn.ModuleList([FFTBlock(d) for _ in range(layers)])
108
+
109
+ def forward(self, tok, lang, mask):
110
+ x = self.emb(tok) + self.lang_in(lang)
111
+ for c in self.convs:
112
+ x = x + c(x, mask)
113
+ x = x + sinusoid(x.shape[1], x.shape[2], x.device)[None]
114
+ for b in self.blocks:
115
+ x = b(x, mask)
116
+ return x
117
+
118
+
119
+ class ResidualVAE(nn.Module):
120
+ """Global residual (prosody) encoder: mel -> q(z|x); z broadcast to every token (Zhang 2019 / Hsu 2019)."""
121
+
122
+ def __init__(self, zdim=16, h=256):
123
+ super().__init__()
124
+ self.convs = nn.Sequential(
125
+ nn.Conv1d(N_MELS, h, 3, padding=1), nn.ReLU(),
126
+ nn.Conv1d(h, h, 3, stride=2, padding=1), nn.ReLU(),
127
+ nn.Conv1d(h, h, 3, stride=2, padding=1), nn.ReLU())
128
+ self.gru = nn.GRU(h, h // 2, batch_first=True, bidirectional=True)
129
+ self.mu = nn.Linear(h, zdim)
130
+ self.logvar = nn.Linear(h, zdim)
131
+
132
+ def forward(self, mel, mel_lens):
133
+ h = self.convs(mel.transpose(1, 2)).transpose(1, 2)
134
+ lens = torch.clamp((mel_lens + 3) // 4, min=1)
135
+ h, _ = self.gru(h)
136
+ m = seq_mask(lens, h.shape[1]).float()[..., None]
137
+ h = (h * m).sum(1) / m.sum(1)
138
+ mu, lv = self.mu(h), self.logvar(h).clamp(-8, 8)
139
+ z = mu + torch.randn_like(mu) * torch.exp(0.5 * lv) if self.training else mu
140
+ kl = 0.5 * (mu.pow(2) + lv.exp() - 1 - lv).sum(-1).mean()
141
+ return z, kl
142
+
143
+
144
+ class Aligner(nn.Module):
145
+ """Unsupervised text-mel alignment (Badlani et al. 2022)."""
146
+
147
+ def __init__(self, d, h=256, temp=0.0005):
148
+ super().__init__()
149
+ self.temp = temp
150
+ self.key = nn.Sequential(nn.Conv1d(d, h * 2, 3, padding=1), nn.ReLU(), nn.Conv1d(h * 2, 80, 1))
151
+ self.query = nn.Sequential(nn.Conv1d(N_MELS, h * 2, 3, padding=1), nn.ReLU(),
152
+ nn.Conv1d(h * 2, h, 1), nn.ReLU(), nn.Conv1d(h, 80, 1))
153
+
154
+ def forward(self, txt, mel, tmask, mmask, prior_log):
155
+ k = self.key(txt.transpose(1, 2)) # B,80,N
156
+ q = self.query(mel.transpose(1, 2)) # B,80,T
157
+ q, k = q.float(), k.float()
158
+ dist = (q.pow(2).sum(1)[:, :, None] + k.pow(2).sum(1)[:, None, :]
159
+ - 2 * torch.bmm(q.transpose(1, 2), k)).clamp_min(0) # B,T,N (memory-light ||q-k||^2)
160
+ logits = -self.temp * dist
161
+ logits = logits.masked_fill(~tmask[:, None, :], -1e4)
162
+ logp = F.log_softmax(logits, -1) + prior_log # prior in log domain
163
+ logp = logp.masked_fill(~tmask[:, None, :], -1e4)
164
+ soft = F.softmax(logp, -1) * mmask[..., None]
165
+ return logits, logp, soft
166
+
167
+
168
+ def forward_sum_loss(logits, tlens, mlens, blank_logprob=-1.0):
169
+ """CTC forward-sum: every token must be visited monotonically. logits [B,T,N] (pre-softmax, masked)."""
170
+ B, T, N = logits.shape
171
+ lp = F.pad(logits, (1, 0), value=blank_logprob)
172
+ lp = F.log_softmax(lp, -1).transpose(0, 1) # T,B,N+1
173
+ tgt = torch.arange(1, N + 1, device=logits.device)[None].expand(B, N)
174
+ return F.ctc_loss(lp.float(), tgt, mlens, tlens, blank=0, reduction="mean", zero_infinity=True)
175
+
176
+
177
+ def _mas_np(logp, N, T):
178
+ """Monotonic alignment search on one [T,N] log-prob matrix -> hard [T,N]."""
179
+ Q = np.full((T, N), -np.inf, dtype=np.float32)
180
+ Q[0, 0] = logp[0, 0]
181
+ for t in range(1, T):
182
+ for n in range(min(N, t + 1)):
183
+ stay = Q[t - 1, n]
184
+ move = Q[t - 1, n - 1] if n > 0 else -np.inf
185
+ Q[t, n] = max(stay, move) + logp[t, n]
186
+ path = np.zeros((T, N), dtype=np.float32)
187
+ n = N - 1
188
+ for t in range(T - 1, -1, -1):
189
+ path[t, n] = 1
190
+ if t > 0 and n > 0 and (n == t or Q[t - 1, n - 1] > Q[t - 1, n]):
191
+ n -= 1
192
+ return path
193
+
194
+
195
+ try:
196
+ import numba
197
+
198
+ _mas_np = numba.njit(cache=True)(_mas_np) # noqa: F811
199
+ except Exception: # numba optional (CPU unit tests)
200
+ pass
201
+
202
+
203
+ @torch.no_grad()
204
+ def mas(logp, tlens, mlens):
205
+ out = torch.zeros_like(logp)
206
+ lp = logp.float().cpu().numpy()
207
+ for b in range(lp.shape[0]):
208
+ T, N = int(mlens[b]), int(tlens[b])
209
+ out[b, :T, :N] = torch.from_numpy(_mas_np(np.ascontiguousarray(lp[b, :T, :N]), N, T))
210
+ return out
211
+
212
+
213
+ _prior_cache = {}
214
+
215
+
216
+ def beta_binomial_prior(N, T, scale=1.0):
217
+ from scipy.stats import betabinom
218
+ key = (N, T)
219
+ if key not in _prior_cache:
220
+ x = np.arange(N)[None]
221
+ t = np.arange(1, T + 1)[:, None]
222
+ pmf = betabinom.pmf(x, N - 1, scale * t, scale * (T + 1 - t))
223
+ _prior_cache[key] = torch.from_numpy(np.log(pmf + 1e-8)).float()
224
+ if len(_prior_cache) > 20000:
225
+ _prior_cache.clear()
226
+ return _prior_cache[key]
227
+
228
+
229
+ def batch_prior(tlens, mlens, N, T, device):
230
+ p = torch.full((len(tlens), T, N), -1e4)
231
+ for b in range(len(tlens)):
232
+ n, t = int(tlens[b]), int(mlens[b])
233
+ p[b, :t, :n] = beta_binomial_prior(n, t)
234
+ return p.to(device)
235
+
236
+
237
+ class DurationPredictor(nn.Module):
238
+ def __init__(self, d, h=256):
239
+ super().__init__()
240
+ self.convs = nn.ModuleList([ConvNorm(d, h, 3), ConvNorm(h, h, 3), ConvNorm(h, h, 3)])
241
+ self.out = nn.Linear(h, 1)
242
+
243
+ def forward(self, x, mask):
244
+ for c in self.convs:
245
+ x = c(x, mask)
246
+ return self.out(x).squeeze(-1).masked_fill(~mask, 0) # log(1 + d)
247
+
248
+
249
+ class LearnedUpsampler(nn.Module):
250
+ """PT2 differentiable upsampling: W[t,k] = softmax_k MLP(S_tk, E_tk, h_k); O = W H + proj(sum_k W C)."""
251
+
252
+ def __init__(self, d, hw=16, hc=8):
253
+ super().__init__()
254
+ self.se_w = nn.Linear(2, hw)
255
+ self.h_w = nn.Linear(d, hw)
256
+ self.w_out = nn.Linear(hw, 1)
257
+ self.se_c = nn.Linear(2, hc)
258
+ self.h_c = nn.Linear(d, hc)
259
+ self.c_out = nn.Linear(hc, d)
260
+
261
+ def forward(self, H, dur, tmask, T):
262
+ dur = dur.masked_fill(~tmask, 0)
263
+ e = torch.cumsum(dur, 1)
264
+ s = e - dur
265
+ t = torch.arange(T, device=H.device).float()[None, :, None] + 0.5
266
+ S = (t - s[:, None, :]) / 10.0
267
+ E = (e[:, None, :] - t) / 10.0
268
+ se = torch.stack([S, E], -1) # B,T,N,2
269
+ lw = self.w_out(F.silu(self.se_w(se) + self.h_w(H)[:, None])).squeeze(-1)
270
+ lw = lw.masked_fill(~tmask[:, None, :], -1e4)
271
+ W = torch.softmax(lw, -1) # B,T,N
272
+ C = F.silu(self.se_c(se) + self.h_c(H)[:, None]) # B,T,N,hc
273
+ out = torch.bmm(W, H) + self.c_out(torch.einsum("btn,btnc->btc", W, C))
274
+ return out, W
275
+
276
+
277
+ class MelDecoder(nn.Module):
278
+ def __init__(self, d, layers=6, n_iter=3):
279
+ super().__init__()
280
+ self.blocks = nn.ModuleList([FFTBlock(d) for _ in range(layers)])
281
+ self.n_iter = n_iter
282
+ self.outs = nn.ModuleList([nn.Linear(d, N_MELS) for _ in range(n_iter)])
283
+
284
+ def forward(self, x, mask):
285
+ x = x + sinusoid(x.shape[1], x.shape[2], x.device)[None]
286
+ mels = []
287
+ L = len(self.blocks)
288
+ for i, b in enumerate(self.blocks):
289
+ x = b(x, mask)
290
+ j = i - (L - self.n_iter)
291
+ if j >= 0:
292
+ mels.append(self.outs[j](x))
293
+ return mels
294
+
295
+
296
+ # ---------------------------------------------------------------- full model
297
+ class DynamicAccentTTS(nn.Module):
298
+ def __init__(self, n_spk, n_lang=len(LANGS), d=384, enc_layers=6, dec_layers=6, spk_dim=128, lang_dim=64,
299
+ zdim=16, adv_hidden=256, mel_mean=None, mel_std=None, align_temp=0.0005):
300
+ super().__init__()
301
+ self.n_spk = n_spk
302
+ self.encoder = TextEncoder(d, n_lang, enc_layers)
303
+ self.spk_emb = nn.Embedding(n_spk, spk_dim)
304
+ self.lang_out = nn.Embedding(n_lang, lang_dim) # per-token, concatenated to encoder outputs
305
+ self.vae = ResidualVAE(zdim)
306
+ self.cond = nn.Linear(d + spk_dim + lang_dim + zdim, d)
307
+ self.adv = nn.Sequential(nn.LayerNorm(d), nn.Linear(d, adv_hidden), nn.ReLU(), nn.Linear(adv_hidden, n_spk))
308
+ self.aligner = Aligner(d, temp=align_temp)
309
+ self.dur = DurationPredictor(d)
310
+ self.up = LearnedUpsampler(d)
311
+ self.dec_spk = nn.Linear(spk_dim, d)
312
+ self.decoder = MelDecoder(d, dec_layers)
313
+ self.register_buffer("mel_mean", torch.tensor(mel_mean if mel_mean is not None else -2.5))
314
+ self.register_buffer("mel_std", torch.tensor(mel_std if mel_std is not None else 2.2))
315
+
316
+ def _cond(self, Ht, spk, lang, z):
317
+ B, N, _ = Ht.shape
318
+ s = self.spk_emb(spk)[:, None].expand(B, N, -1)
319
+ zz = z[:, None].expand(B, N, -1)
320
+ return self.cond(torch.cat([Ht, s, self.lang_out(lang), zz], -1)), s
321
+
322
+ def forward(self, tok, lang, tlens, mel, mlens, spk, step=0, adv_lambda=1.0, bin_start=4000, prior=None):
323
+ tmask, mmask = seq_mask(tlens, tok.shape[1]), seq_mask(mlens, mel.shape[1])
324
+ meln = (mel - self.mel_mean) / self.mel_std
325
+ Ht = self.encoder(tok, lang, tmask)
326
+ # adversarial speaker classifier on text encodings (Zhang 2019): GRL removes speaker info from text path
327
+ adv_logits = self.adv(grad_reverse(Ht, adv_lambda))
328
+ adv_ce = F.cross_entropy(adv_logits[tmask], spk[:, None].expand_as(tok)[tmask])
329
+ adv_acc = (adv_logits[tmask].argmax(-1) == spk[:, None].expand_as(tok)[tmask]).float().mean()
330
+ z, kl = self.vae(meln, mlens)
331
+ H, _ = self._cond(Ht, spk, lang, z)
332
+ # alignment (internal, unsupervised)
333
+ if prior is None:
334
+ prior = batch_prior(tlens, mlens, tok.shape[1], mel.shape[1], tok.device)
335
+ logits, logp, soft = self.aligner(self.encoder.emb(tok) + Ht.detach(), meln, tmask, mmask, prior)
336
+ l_fs = forward_sum_loss(logits.masked_fill(~mmask[..., None], -1e4) + prior, tlens, mlens)
337
+ hard = mas(logp, tlens, mlens)
338
+ l_bin = -(hard * torch.log(soft.clamp_min(1e-8))).sum() / hard.sum() if step >= bin_start else soft.new_zeros(())
339
+ dur_t = hard.sum(1) # B,N frames
340
+ # durations
341
+ logd = self.dur(H.detach() if step < 1000 else H, tmask)
342
+ l_dur = F.mse_loss(logd[tmask], torch.log1p(dur_t[tmask]))
343
+ # upsample with target durations (train) -> decoder
344
+ up, W = self.up(H, dur_t, tmask, mel.shape[1])
345
+ x = up + self.dec_spk(self.spk_emb(spk))[:, None]
346
+ mels = self.decoder(x, mmask)
347
+ m = mmask[..., None].float()
348
+ l_mel = sum((F.l1_loss(o, meln, reduction="none") * m).sum() / (m.sum() * N_MELS) for o in mels) / len(mels)
349
+ return dict(mel=l_mel, dur=l_dur, fs=l_fs, bin=l_bin, kl=kl, adv=adv_ce, adv_acc=adv_acc.detach()), mels[-1]
350
+
351
+ @torch.no_grad()
352
+ def infer(self, tok, lang, spk, pace=1.0, z=None):
353
+ tlens = torch.full((tok.shape[0],), tok.shape[1], device=tok.device)
354
+ tmask = seq_mask(tlens, tok.shape[1])
355
+ Ht = self.encoder(tok, lang, tmask)
356
+ if z is None:
357
+ z = torch.zeros(tok.shape[0], self.vae.mu.out_features, device=tok.device)
358
+ H, _ = self._cond(Ht, spk, lang, z)
359
+ dur = (torch.expm1(self.dur(H, tmask)).clamp(min=0) * pace).masked_fill(~tmask, 0)
360
+ T = max(int(dur.sum(1).max().round().item()), 1)
361
+ up, W = self.up(H, dur, tmask, T)
362
+ x = up + self.dec_spk(self.spk_emb(spk))[:, None]
363
+ mel = self.decoder(x, torch.ones(tok.shape[0], T, dtype=torch.bool, device=tok.device))[-1]
364
+ return mel * self.mel_std + self.mel_mean, dur
365
+
366
+
367
+ def count_params(m):
368
+ return sum(p.numel() for p in m.parameters())
code/da/p2common.py ADDED
@@ -0,0 +1,308 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Phase 2 shared tools: romanisation for MMS forced alignment, chunked aligner, pause normalisation,
2
+ pause / F0 / Yoruba-tone metrics. Used by prep2.py (data) and eval2.py (metrics)."""
3
+ import re, unicodedata
4
+ import numpy as np
5
+
6
+ # ------------------------------------------------------------------ text
7
+ _MAP = {"ọ": "o", "ẹ": "e", "ṣ": "s", "ị": "i", "ụ": "u", "ṅ": "n", "ń": "n", "ǹ": "n", "ḿ": "m", "ɓ": "b", "ɗ": "d",
8
+ "ƙ": "k", "ƴ": "y", "ŋ": "n", "ɛ": "e", "ɔ": "o", "ß": "s", "æ": "ae", "œ": "oe", "ø": "o", "đ": "d", "ł": "l"}
9
+ TONE_MARK = {"\u0301": 2, "\u0300": 0, "\u0304": 1} # acute=H, grave=L, macron=M
10
+ SENT_END = set(".?!")
11
+ CLAUSE = set(";:")
12
+ COMMA = set(",")
13
+ _VOW = set("aeiou")
14
+
15
+
16
+ def roman_char(c):
17
+ """one NFC char -> (romanised latin string, tone or None)."""
18
+ lc = c.lower()
19
+ if lc in _MAP:
20
+ return _MAP[lc], None
21
+ d = unicodedata.normalize("NFD", lc)
22
+ base = "".join(x for x in d if unicodedata.category(x) != "Mn")
23
+ tone = None
24
+ for x in d:
25
+ if x in TONE_MARK:
26
+ tone = TONE_MARK[x]
27
+ base = "".join(_MAP.get(b, b) for b in base)
28
+ base = "".join(b for b in base if ("a" <= b <= "z") or b == "'")
29
+ return base, tone
30
+
31
+
32
+ def words_for_align(text):
33
+ """text -> list of dict(word, rom, punct_after, chars=[(rom_char, orig_index, tone)]).
34
+ Words that romanise to nothing (digits, symbols) are aligned with the '*' star token."""
35
+ out = []
36
+ for m in re.finditer(r"\S+", text):
37
+ w = m.group(0)
38
+ chars = []
39
+ for k, c in enumerate(w):
40
+ r, t = roman_char(c)
41
+ for j, rc in enumerate(r):
42
+ chars.append((rc, m.start() + k, t if j == len(r) - 1 else None))
43
+ trail = re.sub(r"[\w'’]+", "", w[-3:]) if w else ""
44
+ p = None
45
+ if any(c in SENT_END for c in trail):
46
+ p = "sent"
47
+ elif any(c in CLAUSE for c in trail):
48
+ p = "clause"
49
+ elif any(c in COMMA for c in trail):
50
+ p = "comma"
51
+ rom = "".join(c[0] for c in chars)
52
+ out.append(dict(word=w, rom=rom if rom else "*", punct=p, chars=chars))
53
+ return out
54
+
55
+
56
+ # ------------------------------------------------------------------ aligner
57
+ class Aligner:
58
+ """torchaudio MMS_FA (CC-BY-NC weights, used only as a data tool/judge) with chunked emissions for long audio."""
59
+
60
+ def __init__(self, device="cuda"):
61
+ import torch, torchaudio
62
+ self.torch = torch
63
+ self.F = torchaudio.functional
64
+ b = torchaudio.pipelines.MMS_FA
65
+ self.model = b.get_model().to(device).eval().half()
66
+ self.dict = b.get_dict()
67
+ self.dev = device
68
+ self.rate = 320 # samples per emission frame at 16 kHz (20 ms)
69
+
70
+ def emission(self, y16, chunk_s=30, ctx_s=2):
71
+ torch = self.torch
72
+ sr = 16000
73
+ x = torch.from_numpy(y16).float().to(self.dev)
74
+ if len(y16) <= (chunk_s + 2 * ctx_s) * sr:
75
+ with torch.inference_mode():
76
+ e, _ = self.model(x[None].half())
77
+ return e[0].float().log_softmax(-1)
78
+ outs = []
79
+ step, ctx = chunk_s * sr, ctx_s * sr
80
+ nfr_total = len(y16) // self.rate
81
+ for s in range(0, len(y16), step):
82
+ a, b = max(0, s - ctx), min(len(y16), s + step + ctx)
83
+ with torch.inference_mode():
84
+ e, _ = self.model(x[a:b][None].half())
85
+ e = e[0].float().log_softmax(-1)
86
+ f0 = (s - a) // self.rate
87
+ nfr = min(step, len(y16) - s) // self.rate
88
+ outs.append(e[f0:f0 + nfr])
89
+ e = torch.cat(outs, 0)
90
+ return e[:max(nfr_total, 1)]
91
+
92
+ def align(self, y16, words):
93
+ """-> per word (start_s, end_s, score) and per char spans [(start_s, end_s)], or None on failure."""
94
+ torch = self.torch
95
+ em = self.emission(y16)
96
+ toks, wid = [], []
97
+ for i, w in enumerate(words):
98
+ for c in w["rom"]:
99
+ if c in self.dict:
100
+ toks.append(self.dict[c]); wid.append(i)
101
+ if not toks or len(toks) * 1 > em.shape[0]:
102
+ return None
103
+ t = torch.tensor([toks], dtype=torch.int32, device=self.dev)
104
+ try:
105
+ ali, sc = self.F.forced_align(em[None], t, blank=0)
106
+ except Exception:
107
+ return None
108
+ ali, sc = ali[0].cpu().numpy(), sc[0].exp().cpu().numpy()
109
+ # token spans (merge repeats; blank=0)
110
+ spans, i, k = [], 0, 0
111
+ T = len(ali)
112
+ while i < T:
113
+ if ali[i] == 0:
114
+ i += 1; continue
115
+ j = i
116
+ while j + 1 < T and ali[j + 1] == ali[i] and not (j + 1 < T and False):
117
+ j += 1
118
+ spans.append((i, j + 1, float(sc[i:j + 1].mean())))
119
+ i = j + 1
120
+ # repeated identical consecutive tokens (e.g. "aa") collapse in the path above only if no blank between;
121
+ # forced_align always inserts a blank between identical tokens, so len(spans)==len(toks)
122
+ if len(spans) != len(toks):
123
+ return None
124
+ r = self.rate / 16000
125
+ W = [[None, None, []] for _ in words]
126
+ chars = [[] for _ in words]
127
+ for (a, b, s), w in zip(spans, wid):
128
+ if W[w][0] is None:
129
+ W[w][0] = a * r
130
+ W[w][1] = b * r; W[w][2].append(s); chars[w].append((a * r, b * r))
131
+ res = []
132
+ for i, (a, b, s) in enumerate(W):
133
+ if a is None:
134
+ return None
135
+ res.append(dict(start=a, end=b, score=float(np.mean(s)), chars=chars[i]))
136
+ return res
137
+
138
+
139
+ # ------------------------------------------------------------------ energy / pauses
140
+ def frame_db(y, sr, hop_s=0.01):
141
+ hop = int(sr * hop_s)
142
+ n = max(1, len(y) // hop)
143
+ fr = y[: n * hop].reshape(n, hop)
144
+ e = 10 * np.log10((fr.astype(np.float64) ** 2).mean(1) + 1e-10)
145
+ return e
146
+
147
+
148
+ def silence_mask(y, sr, rel_db=-40.0, hop_s=0.01):
149
+ e = frame_db(y, sr, hop_s)
150
+ ref = np.percentile(e, 98)
151
+ return e < ref + rel_db
152
+
153
+
154
+ def runs(mask):
155
+ """-> list of (start, end) index runs where mask is True."""
156
+ out, i, n = [], 0, len(mask)
157
+ while i < n:
158
+ if mask[i]:
159
+ j = i
160
+ while j < n and mask[j]:
161
+ j += 1
162
+ out.append((i, j)); i = j
163
+ else:
164
+ i += 1
165
+ return out
166
+
167
+
168
+ PAUSE_TARGET = { # (min, max) silence seconds after a word, by the punctuation that follows it
169
+ "sent": (0.30, 0.60),
170
+ "clause": (0.20, 0.50),
171
+ "comma": (0.00, 0.40),
172
+ None: (0.00, 0.20),
173
+ }
174
+ # profile B (Phase 2 v2): keeps natural phrase breaks where there is no punctuation (the real anchor pauses at
175
+ # ~14% of unpunctuated word boundaries), still removes hesitations; set P2PAUSE=B
176
+ import os as _os
177
+ if _os.environ.get("P2PAUSE") == "B":
178
+ PAUSE_TARGET = {"sent": (0.35, 0.70), "clause": (0.25, 0.55), "comma": (0.00, 0.50), None: (0.00, 0.35)}
179
+ LEAD_S, TAIL_S = 0.12, 0.18
180
+
181
+
182
+ def normalise_pauses(y, sr, words, ali, seg_a, seg_b, sil=None, lo_s=0.0, hi_s=None):
183
+ """Rebuild one segment [word seg_a .. seg_b] of y: edge silence fixed to LEAD_S/TAIL_S, every inter-word
184
+ silence clamped to PAUSE_TARGET[punct] (only truly silent frames are removed; silence is inserted, as a
185
+ copy of the quietest local frames, when a sentence/clause boundary has too little). -> (y_out, pauses)"""
186
+ hop = int(0.01 * sr)
187
+ if sil is None:
188
+ sil = silence_mask(y, sr)
189
+ hi_s = len(y) / sr if hi_s is None else hi_s
190
+ xf = int(0.005 * sr)
191
+ pieces, pauses = [], []
192
+ a0 = ali[seg_a]["start"]
193
+ start = int(max(lo_s, a0 - LEAD_S, 0.0) * sr)
194
+ cur = start
195
+ for i in range(seg_a, seg_b):
196
+ e_i, s_n = ali[i]["end"], ali[i + 1]["start"]
197
+ f_a, f_b = int(e_i / 0.01), int(s_n / 0.01) + 1
198
+ # longest silent run inside the gap (with 20 ms guard to the word edges)
199
+ best = None
200
+ for ra, rb in runs(sil[f_a:f_b]):
201
+ if best is None or rb - ra > best[1] - best[0]:
202
+ best = (ra, rb)
203
+ lo, hi = PAUSE_TARGET[words[i]["punct"]]
204
+ if best is None:
205
+ cur_sil = 0.0; mid = int((e_i + s_n) / 2 * sr)
206
+ else:
207
+ ra, rb = (f_a + best[0]) * hop, (f_a + best[1]) * hop
208
+ cur_sil = (rb - ra) / sr; mid = (ra + rb) // 2
209
+ if cur_sil > hi and best is not None: # shrink: cut the middle of the silent run
210
+ cut = int((cur_sil - hi) * sr)
211
+ c0 = mid - cut // 2
212
+ pieces.append(y[cur:c0]); cur = c0 + cut
213
+ pauses.append(hi)
214
+ elif cur_sil < lo: # insert quiet frames at the quietest point of the gap
215
+ add = int((lo - cur_sil) * sr)
216
+ g0, g1 = int(e_i * sr), max(int(s_n * sr), int(e_i * sr) + hop)
217
+ seg = y[g0:g1]
218
+ if len(seg) >= hop:
219
+ k = int(np.argmin(frame_db(seg, sr))) * hop
220
+ ins_at = g0 + k
221
+ q = y[ins_at:ins_at + hop]
222
+ else:
223
+ ins_at, q = mid, np.zeros(hop, dtype=y.dtype)
224
+ filler = np.tile(q, add // hop + 1)[:add] * 0.5
225
+ pieces.append(y[cur:ins_at]); pieces.append(filler); cur = ins_at
226
+ pauses.append(lo)
227
+ else:
228
+ pauses.append(cur_sil)
229
+ end = min(len(y), int(min(hi_s, ali[seg_b]["end"] + TAIL_S) * sr))
230
+ pieces.append(y[cur:end])
231
+ out = np.concatenate([p for p in pieces if len(p)]) if pieces else y[start:end]
232
+ # pad edges with silence if the source had less than LEAD/TAIL available
233
+ lead_have = a0 - start / sr
234
+ if lead_have < LEAD_S - 0.02:
235
+ out = np.concatenate([np.zeros(int((LEAD_S - lead_have) * sr), out.dtype), out])
236
+ tail_have = end / sr - ali[seg_b]["end"]
237
+ if tail_have < TAIL_S - 0.02:
238
+ out = np.concatenate([out, np.zeros(int((TAIL_S - tail_have) * sr), out.dtype)])
239
+ # tiny fades at the edges
240
+ f = min(xf, len(out) // 4)
241
+ if f > 0:
242
+ out[:f] *= np.linspace(0, 1, f); out[-f:] *= np.linspace(1, 0, f)
243
+ return out.astype(np.float32), pauses
244
+
245
+
246
+ # ------------------------------------------------------------------ metrics on audio
247
+ def pause_stats(y, sr, min_pause=0.15):
248
+ """VAD (energy, -40 dB vs 98th pct) pauses: leading/trailing silence and internal pauses >= min_pause."""
249
+ sil = silence_mask(y, sr)
250
+ R = runs(sil)
251
+ n = len(sil)
252
+ lead = R[0][1] * 0.01 if R and R[0][0] == 0 else 0.0
253
+ tail = (n - R[-1][0]) * 0.01 if R and R[-1][1] == n else 0.0
254
+ internal = [(b - a) * 0.01 for a, b in R if a > 0 and b < n and (b - a) * 0.01 >= min_pause]
255
+ speech = max(1e-3, n * 0.01 - lead - tail)
256
+ return dict(lead=round(lead, 3), tail=round(tail, 3), pauses=[round(p, 3) for p in internal],
257
+ n_pauses=len(internal), pause_rate=round(len(internal) / speech, 4),
258
+ pause_frac=round(sum(internal) / speech, 4), speech_s=round(speech, 3))
259
+
260
+
261
+ def f0_track(y16):
262
+ import pyworld as pw
263
+ x = y16.astype(np.float64)
264
+ f0, t = pw.dio(x, 16000, f0_floor=60, f0_ceil=500, frame_period=10)
265
+ return pw.stonemask(x, f0, t, 16000)
266
+
267
+
268
+ def f0_stats(y16):
269
+ f0 = f0_track(y16)
270
+ v = f0[f0 > 0]
271
+ if len(v) < 20:
272
+ return dict(voiced=float(len(v)), f0_med=None, st_std=None, st_range=None)
273
+ st = 12 * np.log2(v / np.median(v))
274
+ d = np.diff(st)
275
+ return dict(voiced_frac=round(len(v) / len(f0), 3), f0_med=round(float(np.median(v)), 1),
276
+ st_std=round(float(st.std()), 3), st_range=round(float(np.percentile(st, 95) - np.percentile(st, 5)), 3),
277
+ st_vel=round(float(np.abs(d[np.abs(d) < 3]).mean()), 4))
278
+
279
+
280
+ def tone_pairs(y16, words, ali):
281
+ """Yoruba tone-direction accuracy: for consecutive tone-bearing vowels whose tones differ, is the sign of the
282
+ median-F0 change (semitones) the same as the sign of the tone change? -> (n_pairs, n_correct)"""
283
+ f0 = f0_track(y16)
284
+ vals = []
285
+ for w, a in zip(words, ali):
286
+ k = 0
287
+ for (rc, oi, tone), (s, e) in zip([c for c in w["chars"] if c[0] in "abcdefghijklmnopqrstuvwxyz'"], a["chars"]):
288
+ if rc in _VOW or (rc in "nm" and tone is not None):
289
+ t = 1 if tone is None else tone
290
+ fr = f0[int(s / 0.01): int(e / 0.01) + 1]
291
+ fr = fr[fr > 0]
292
+ vals.append((t, float(np.median(12 * np.log2(fr))) if len(fr) else None))
293
+ n = c = 0
294
+ for (t1, f1), (t2, f2) in zip(vals, vals[1:]):
295
+ if t1 == t2 or f1 is None or f2 is None:
296
+ continue
297
+ n += 1
298
+ c += int(np.sign(f2 - f1) == np.sign(t2 - t1))
299
+ return n, c
300
+
301
+
302
+ def wasserstein1(a, b):
303
+ """1-D earth mover's distance between two samples."""
304
+ if not len(a) or not len(b):
305
+ return None
306
+ a, b = np.sort(a), np.sort(b)
307
+ q = np.linspace(0, 1, 101)
308
+ return float(np.mean(np.abs(np.quantile(a, q) - np.quantile(b, q))))
code/da/prep2.py ADDED
@@ -0,0 +1,259 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Phase 2 data prep (H100 node): every source -> MMS forced alignment -> sentence segmentation of long clips ->
2
+ punctuation-consistent pause normalisation -> 24 kHz int16 + meta. Output ~/data2/<ds>/{pcm24k.i16, meta.jsonl, stats.json}
3
+
4
+ python -m da.prep2 waxal bible_hau bible_yor slr86 slr70 fleurs
5
+ """
6
+ import os, sys, io, json, glob, csv, hashlib, time
7
+ import numpy as np, soundfile as sf, librosa
8
+ from multiprocessing import Pool
9
+ sys.path.insert(0, os.path.expanduser("~/da"))
10
+ from da.p2common import words_for_align, Aligner, normalise_pauses # noqa: E402
11
+
12
+ RAW = "/scratch/raw"
13
+ OUT = os.path.expanduser(os.environ.get("P2OUT", "~/data2"))
14
+ OLD = os.path.expanduser("~/data")
15
+ SR = 24000
16
+ BIBLE_HAU_CAP_H = 30.0
17
+ FLOW_OK = {"waxal", "bible_yor", "bible_hau", "slr86", "slr70"} # 48 kHz studio-ish sources (FLEURS is 16 kHz)
18
+
19
+
20
+ def clean_text(t):
21
+ import unicodedata, re
22
+ t = unicodedata.normalize("NFC", t or "")
23
+ t = t.replace("\u2019", "'").replace("\u2018", "'").replace("\u201c", '"').replace("\u201d", '"')
24
+ return re.sub(r"\s+", " ", t).strip()
25
+
26
+
27
+ def hsplit(i):
28
+ return "val" if int(hashlib.md5(i.encode()).hexdigest(), 16) % 100 == 0 else "train"
29
+
30
+
31
+ # ------------------------------------------------------------------ enumerators (yield meta + loader args)
32
+ def items_waxal():
33
+ import pyarrow.parquet as pq
34
+ for lang in ["yor", "ibo", "hau", "pcm"]:
35
+ files = sorted(glob.glob(f"{RAW}/waxal/data/TTS/{lang}/*.parquet"))
36
+ files = [f for f in files if not os.path.basename(f).startswith("yor_tts")]
37
+ for f in files:
38
+ bn = os.path.basename(f)
39
+ split = "test" if "test" in bn else ("val" if "validation" in bn else "train")
40
+ pf = pq.ParquetFile(f)
41
+ for b in pf.iter_batches(batch_size=8):
42
+ for r in b.to_pylist():
43
+ yield dict(id=f"waxal_{lang}_{r['id']}", ds="waxal", lang=lang,
44
+ spk=f"waxal_{lang}_{r.get('speaker_id') or 'spk'}", gender=r.get("gender") or "",
45
+ split=split, text=r["text"]), ("bytes", r["audio"]["bytes"])
46
+
47
+
48
+ FLEURS = {"hau": "ha_ng", "ibo": "ig_ng", "yor": "yo_ng", "eng": "en_us"}
49
+
50
+
51
+ def items_fleurs():
52
+ """raw FLEURS train audio (16 kHz); speaker ids = Phase 1 ECAPA pseudo-speakers from ~/data/fleurs/meta.jsonl"""
53
+ for l in open(f"{OLD}/fleurs/meta.jsonl", encoding="utf-8"):
54
+ m = json.loads(l)
55
+ fn = m["id"].split("_", 2)[2] + ".wav"
56
+ p = f"{RAW}/fleurs/data/{FLEURS[m['lang']]}/audio/train/{fn}"
57
+ yield dict(id=m["id"], ds="fleurs", lang=m["lang"], spk=m["spk"], gender=m.get("gender", ""),
58
+ split=hsplit(m["id"]), text=m["text"]), ("path", p)
59
+
60
+
61
+ def items_slr(num, lang, prefix):
62
+ for idx in sorted(glob.glob(f"{RAW}/slr{num}/**/line_index.tsv", recursive=True)):
63
+ d = os.path.dirname(idx)
64
+ for line in open(idx, encoding="utf-8"):
65
+ parts = line.rstrip("\n").split("\t")
66
+ if len(parts) < 2:
67
+ continue
68
+ fid, text = parts[0].strip(), parts[-1]
69
+ p = os.path.join(d, fid + ".wav")
70
+ if os.path.exists(p):
71
+ spk = "_".join(fid.split("_")[:2])
72
+ yield dict(id=f"{prefix}_{fid}", ds=prefix, lang=lang, spk=f"{prefix}_{spk}",
73
+ gender="female" if fid[2] == "f" else "male", split=hsplit(fid), text=text), ("path", p)
74
+
75
+
76
+ def items_bible(lang_dir, lang, ds, cap_h=None):
77
+ root = f"{RAW}/bible/{lang_dir}"
78
+ auds = sorted(glob.glob(f"{root}/**/*.flac", recursive=True) + glob.glob(f"{root}/**/*.wav", recursive=True))
79
+ if cap_h: # deterministic random subset of the train split, roughly cap_h hours (verse ~ 9 s on average)
80
+ auds.sort(key=lambda p: hashlib.md5(p.encode()).hexdigest())
81
+ tot = 0.0
82
+ for p in auds:
83
+ txt = os.path.splitext(p)[0] + ".txt"
84
+ if not os.path.exists(txt):
85
+ continue
86
+ split = "test" if "/test/" in p else ("val" if "/dev/" in p else "train")
87
+ if cap_h and split == "train":
88
+ if tot > cap_h * 3600:
89
+ continue
90
+ try:
91
+ tot += sf.info(p).duration
92
+ except Exception:
93
+ continue
94
+ yield dict(id=f"{ds}_{os.path.splitext(os.path.basename(p))[0]}", ds=ds, lang=lang, spk=ds, gender="",
95
+ split=split, text=open(txt, encoding="utf-8").read()), ("path", p)
96
+
97
+
98
+ def enum(ds):
99
+ return {"waxal": items_waxal, "fleurs": items_fleurs,
100
+ "slr86": lambda: items_slr(86, "yor", "slr86"), "slr70": lambda: items_slr(70, "eng", "slr70"),
101
+ "bible_yor": lambda: items_bible("yoruba", "yor", "bible_yor"),
102
+ "bible_hau": lambda: items_bible("hausa", "hau", "bible_hau", BIBLE_HAU_CAP_H)}[ds]()
103
+
104
+
105
+ # ------------------------------------------------------------------ workers
106
+ def load(arg):
107
+ meta, src = arg
108
+ try:
109
+ if src[0] == "bytes":
110
+ y, sr = sf.read(io.BytesIO(src[1]), dtype="float32", always_2d=True); y = y.mean(1)
111
+ elif src[0] == "path":
112
+ y, sr = sf.read(src[1], dtype="float32", always_2d=True); y = y.mean(1)
113
+ else:
114
+ pcm = np.memmap(src[1], dtype=np.int16, mode="r")
115
+ y, sr = pcm[src[2]:src[2] + src[3]].astype(np.float32) / 32768.0, 16000
116
+ meta["text"] = clean_text(meta["text"])
117
+ meta["sr_in"] = int(sr)
118
+ if not meta["text"] or len(y) < 0.5 * sr:
119
+ return meta, None, None, "empty"
120
+ y24 = librosa.resample(y, orig_sr=sr, target_sr=SR, res_type="soxr_hq") if sr != SR else y
121
+ y16 = librosa.resample(y, orig_sr=sr, target_sr=16000, res_type="soxr_hq") if sr != 16000 else y
122
+ return meta, y16, y24, None
123
+ except Exception as e: # noqa
124
+ return meta, None, None, f"err:{type(e).__name__}"
125
+
126
+
127
+ def plan_segments(words, ali, dur):
128
+ """word-index ranges [(a, b)] (inclusive). Short clips: one segment. Long clips: whole sentences grouped
129
+ into 3-14 s segments; an over-long sentence is cut at clause/comma marks."""
130
+ n = len(words)
131
+ if dur <= 20.0:
132
+ return [(0, n - 1)]
133
+ sents, a = [], 0
134
+ for i, w in enumerate(words):
135
+ if w["punct"] == "sent" or i == n - 1:
136
+ sents.append((a, i)); a = i + 1
137
+ pieces = []
138
+ for a, b in sents:
139
+ if ali[b]["end"] - ali[a]["start"] <= 16.0:
140
+ pieces.append((a, b)); continue
141
+ s = a
142
+ for i in range(a, b + 1):
143
+ if (words[i]["punct"] in ("clause", "comma") and ali[i]["end"] - ali[s]["start"] > 3.0) or i == b:
144
+ pieces.append((s, i)); s = i + 1
145
+ segs, cur = [], None
146
+ for a, b in pieces:
147
+ if cur is None:
148
+ cur = [a, b]; continue
149
+ if ali[b]["end"] - ali[cur[0]]["start"] <= 14.0 and ali[cur[1]]["end"] - ali[cur[0]]["start"] < 4.0:
150
+ cur[1] = b
151
+ else:
152
+ segs.append(tuple(cur)); cur = [a, b]
153
+ if cur:
154
+ segs.append(tuple(cur))
155
+ return segs
156
+
157
+
158
+ def post(arg):
159
+ meta, y24, words, ali = arg
160
+ out, why = [], {}
161
+ segs = plan_segments(words, ali, len(y24) / SR)
162
+ from da.p2common import silence_mask
163
+ sil = silence_mask(y24, SR)
164
+ for k, (a, b) in enumerate(segs):
165
+ sc = float(np.mean([ali[i]["score"] for i in range(a, b + 1)]))
166
+ maxw = max(ali[i]["end"] - ali[i]["start"] for i in range(a, b + 1))
167
+ text = " ".join(words[i]["word"] for i in range(a, b + 1))
168
+ if sc < 0.30:
169
+ why["low_score"] = why.get("low_score", 0) + 1; continue
170
+ if maxw > 2.5:
171
+ why["long_word"] = why.get("long_word", 0) + 1; continue
172
+ lo = ali[a - 1]["end"] + 0.02 if a > 0 else 0.0
173
+ hi = ali[b + 1]["start"] - 0.02 if b + 1 < len(ali) else None
174
+ y, pauses = normalise_pauses(y24, SR, words, ali, a, b, sil=sil, lo_s=lo, hi_s=hi)
175
+ dur = len(y) / SR
176
+ cps = len(text) / dur
177
+ if not (0.8 <= dur <= 20.0):
178
+ why["dur"] = why.get("dur", 0) + 1; continue
179
+ if not (4.0 <= cps <= 25.0):
180
+ why["cps"] = why.get("cps", 0) + 1; continue
181
+ rms = np.sqrt((y ** 2).mean() + 1e-12)
182
+ y = y * (0.07 / rms)
183
+ pk = np.abs(y).max()
184
+ if pk > 0.99:
185
+ y = y * (0.99 / pk)
186
+ m = dict(meta)
187
+ if len(segs) > 1:
188
+ m["id"] = f"{meta['id']}_s{k:03d}"; m["src_id"] = meta["id"]; m["longform"] = True
189
+ m.update(text=text, dur=round(dur, 3), ascore=round(sc, 3), cps=round(cps, 2),
190
+ pauses=[round(p, 2) for p in pauses],
191
+ puncts=[words[i]["punct"] for i in range(a, b)], flow_ok=meta["ds"] in FLOW_OK)
192
+ out.append((m, np.clip(y * 32767, -32768, 32767).astype(np.int16)))
193
+ return out, why
194
+
195
+
196
+ def run(ds):
197
+ od = f"{OUT}/{ds}"
198
+ os.makedirs(od, exist_ok=True)
199
+ al = Aligner()
200
+ stats = dict(src_items=0, src_hours=0.0, kept=0, hours=0.0, rej={}, longform_src=0, longform_hours_in=0.0,
201
+ longform_segments=0)
202
+ t0 = time.time(); off = 0
203
+ pending = []
204
+ with open(f"{od}/pcm24k.i16", "wb") as pf, open(f"{od}/meta.jsonl", "w", encoding="utf-8") as jf, \
205
+ Pool(20) as lp, Pool(6) as pp:
206
+ def drain(block=False):
207
+ nonlocal off
208
+ keep = []
209
+ for r in pending:
210
+ if block or r.ready():
211
+ segs, why = r.get()
212
+ for k, v in why.items():
213
+ stats["rej"][k] = stats["rej"].get(k, 0) + v
214
+ for m, pcm in segs:
215
+ m["off24"] = off; m["n24"] = int(len(pcm))
216
+ pf.write(pcm.tobytes()); off += len(pcm)
217
+ jf.write(json.dumps(m, ensure_ascii=False) + "\n")
218
+ stats["kept"] += 1; stats["hours"] += m["dur"] / 3600
219
+ if m.get("longform"):
220
+ stats["longform_segments"] += 1
221
+ else:
222
+ keep.append(r)
223
+ pending[:] = keep
224
+ import itertools
225
+ src = enum(ds)
226
+ if os.environ.get("LIMIT"):
227
+ src = itertools.islice(src, 0, None, int(os.environ.get("STRIDE", "1")))
228
+ src = itertools.islice(src, int(os.environ["LIMIT"]))
229
+ for meta, y16, y24, err in lp.imap(load, src, chunksize=2):
230
+ stats["src_items"] += 1
231
+ if err:
232
+ stats["rej"][err] = stats["rej"].get(err, 0) + 1; continue
233
+ d = len(y24) / SR
234
+ stats["src_hours"] += d / 3600
235
+ if d > 20:
236
+ stats["longform_src"] += 1; stats["longform_hours_in"] += d / 3600
237
+ if d > 2400:
238
+ stats["rej"]["too_long"] = stats["rej"].get("too_long", 0) + 1; continue
239
+ words = words_for_align(meta["text"])
240
+ ali = al.align(y16, words) if words else None
241
+ if ali is None:
242
+ stats["rej"]["align_fail"] = stats["rej"].get("align_fail", 0) + 1; continue
243
+ pending.append(pp.apply_async(post, ((meta, y24, words, ali),)))
244
+ if len(pending) > 64:
245
+ drain(block=False)
246
+ while len(pending) > 128:
247
+ time.sleep(0.05); drain(block=False)
248
+ if stats["src_items"] % 2000 == 0:
249
+ print(ds, stats["src_items"], "kept", stats["kept"], "h", round(stats["hours"], 2),
250
+ "rej", stats["rej"], round(time.time() - t0), "s", flush=True)
251
+ drain(block=True)
252
+ stats = {k: (round(v, 3) if isinstance(v, float) else v) for k, v in stats.items()}
253
+ json.dump(stats, open(f"{od}/stats.json", "w"), indent=1)
254
+ print(ds, "PREP2_DONE", json.dumps(stats), round(time.time() - t0), "s", flush=True)
255
+
256
+
257
+ if __name__ == "__main__":
258
+ for ds in sys.argv[1:]:
259
+ run(ds)
code/da/s3_2.py ADDED
@@ -0,0 +1,130 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Phase 2: S3 speech tokens (speech_tokenizer_v2.onnx, CUDA EP) + CAM++ speaker embeddings for ~/data2/<ds>,
2
+ plus code-switched training pairs (ds "cs_pairs").
3
+
4
+ python -m da.s3_2 tok waxal fleurs ... -> s3tok.i32, s3tok.jsonl, campplus.npy
5
+ python -m da.s3_2 cs 4000 -> ~/data2/cs_pairs/{pcm24k.i16, meta.jsonl} (+ run tok on it)
6
+ """
7
+ import os, sys, json, random, hashlib
8
+ import numpy as np, librosa, torch
9
+ from multiprocessing import Pool
10
+
11
+ D2 = os.path.expanduser(os.environ.get("P2OUT", "~/data2"))
12
+ MD = os.path.expanduser("~/models/CosyVoice2-0.5B")
13
+ _S = {}
14
+ _FB = torch.from_numpy(librosa.filters.mel(sr=16000, n_fft=400, n_mels=128)).float()
15
+ _WIN = torch.hann_window(400)
16
+
17
+
18
+ def log_mel_128(a):
19
+ st = torch.stft(a, 400, 160, window=_WIN, return_complex=True)
20
+ mag = st[..., :-1].abs() ** 2
21
+ lm = torch.clamp(_FB @ mag, min=1e-10).log10()
22
+ lm = torch.maximum(lm, lm.max() - 8.0)
23
+ return (lm + 4.0) / 4.0
24
+
25
+
26
+ def sess(name, gpu):
27
+ if name not in _S:
28
+ import onnxruntime as ort
29
+ torch.set_num_threads(1)
30
+ o = ort.SessionOptions(); o.intra_op_num_threads = 2; o.inter_op_num_threads = 1
31
+ prov = ["CUDAExecutionProvider", "CPUExecutionProvider"] if gpu else ["CPUExecutionProvider"]
32
+ _S[name] = ort.InferenceSession(f"{MD}/{name}", sess_options=o, providers=prov)
33
+ return _S[name]
34
+
35
+
36
+ def work(args):
37
+ path, off, n = args
38
+ import torchaudio.compliance.kaldi as kaldi
39
+ pcm = np.memmap(path, dtype=np.int16, mode="r")
40
+ y24 = pcm[off:off + n].astype(np.float32) / 32768.0
41
+ y16 = librosa.resample(y24, orig_sr=24000, target_sr=16000, res_type="soxr_hq")
42
+ a = torch.from_numpy(y16)
43
+ f = log_mel_128(a)[None]
44
+ s = sess("speech_tokenizer_v2.onnx", True)
45
+ ins = s.get_inputs()
46
+ tok = s.run(None, {ins[0].name: f.numpy(), ins[1].name: np.array([f.shape[2]], dtype=np.int32)})[0]
47
+ fb = kaldi.fbank(a[None], num_mel_bins=80, dither=0, sample_frequency=16000)
48
+ fb = fb - fb.mean(0, keepdim=True)
49
+ c = sess("campplus.onnx", False)
50
+ emb = c.run(None, {c.get_inputs()[0].name: fb[None].numpy()})[0].reshape(-1).astype(np.float32)
51
+ return np.asarray(tok).reshape(-1).astype(np.int32), emb
52
+
53
+
54
+ def tok(ds):
55
+ od = f"{D2}/{ds}"
56
+ meta = [json.loads(l) for l in open(f"{od}/meta.jsonl", encoding="utf-8")]
57
+ jobs = [(f"{od}/pcm24k.i16", m["off24"], m["n24"]) for m in meta]
58
+ toff, embs = 0, []
59
+ with open(f"{od}/s3tok.i32", "wb") as tf, open(f"{od}/s3tok.jsonl", "w") as jf, Pool(12) as pool:
60
+ for i, (m, (t, e)) in enumerate(zip(meta, pool.imap(work, jobs, chunksize=4))):
61
+ tf.write(t.tobytes()); embs.append(e)
62
+ jf.write(json.dumps({"id": m["id"], "toff": toff, "ntok": int(len(t))}) + "\n")
63
+ toff += len(t)
64
+ if i % 5000 == 0:
65
+ print(ds, i, len(meta), "tok/s", round(len(t) / max(m["dur"], 1e-3), 1), flush=True)
66
+ np.save(f"{od}/campplus.npy", np.stack(embs))
67
+ print(ds, "S3_DONE", toff, flush=True)
68
+
69
+
70
+ def build_cs(n_pairs=4000, seed=0):
71
+ """Join two train utterances in different languages (prefer same gender) into one code-switched item.
72
+ Junction silence follows the first clip's final punctuation (sentence 0.45 s, comma 0.25 s, else 0.20 s)."""
73
+ rng = random.Random(seed)
74
+ pools = {}
75
+ for ds in ["waxal", "slr70", "slr86", "fleurs"]:
76
+ p = f"{D2}/{ds}/meta.jsonl"
77
+ if not os.path.exists(p):
78
+ continue
79
+ for l in open(p, encoding="utf-8"):
80
+ m = json.loads(l)
81
+ if m["split"] != "train" or not (1.0 <= m["dur"] <= 6.5):
82
+ continue
83
+ if ds == "fleurs" and m["lang"] != "eng":
84
+ continue
85
+ pools.setdefault(m["lang"], []).append(m)
86
+ langs = sorted(pools)
87
+ print("cs pools", {l: len(v) for l, v in pools.items()})
88
+ od = f"{D2}/cs_pairs"; os.makedirs(od, exist_ok=True)
89
+ mm = {}
90
+ off = 0
91
+ with open(f"{od}/pcm24k.i16", "wb") as pf, open(f"{od}/meta.jsonl", "w", encoding="utf-8") as jf:
92
+ for k in range(n_pairs):
93
+ l1, l2 = rng.sample(langs, 2)
94
+ if rng.random() < 0.5 and "eng" in langs and "eng" not in (l1, l2):
95
+ l2 = "eng" # English is the most common partner in real Nigerian code-switching
96
+ a = rng.choice(pools[l1])
97
+ g = (a.get("gender") or "").lower()[:1]
98
+ cand = [b for b in rng.sample(pools[l2], min(50, len(pools[l2]))) if (b.get("gender") or "").lower()[:1] == g]
99
+ b = cand[0] if cand else rng.choice(pools[l2])
100
+ ys = []
101
+ for m in (a, b):
102
+ if m["ds"] not in mm:
103
+ mm[m["ds"]] = np.memmap(f"{D2}/{m['ds']}/pcm24k.i16", dtype=np.int16, mode="r")
104
+ ys.append(np.asarray(mm[m["ds"]][m["off24"]:m["off24"] + m["n24"]]))
105
+ last = a["text"].rstrip()[-1:]
106
+ junc = 0.45 if last in ".?!" else (0.25 if last in ",;:" else 0.20)
107
+ have = 0.18 + 0.12
108
+ y1 = ys[0]
109
+ if junc < have:
110
+ y1 = y1[: len(y1) - int((have - junc) * 24000)]
111
+ gap = np.zeros(0, np.int16)
112
+ else:
113
+ gap = np.zeros(int((junc - have) * 24000), np.int16)
114
+ y = np.concatenate([y1, gap, ys[1]])
115
+ text = f"<{l1}>{a['text']}</{l1}> <{l2}>{b['text']}</{l2}>"
116
+ m = dict(id=f"cs_{k:05d}", ds="cs_pairs", lang="cs", langs=[l1, l2], spk=a["spk"], gender=a.get("gender", ""),
117
+ split="train", text=text, dur=round(len(y) / 24000, 3), off24=off, n24=int(len(y)),
118
+ src=[a["id"], b["id"]], flow_ok=False)
119
+ pf.write(y.tobytes()); off += len(y)
120
+ jf.write(json.dumps(m, ensure_ascii=False) + "\n")
121
+ print("cs pairs", n_pairs, "hours", round(off / 24000 / 3600, 2), flush=True)
122
+
123
+
124
+ if __name__ == "__main__":
125
+ if sys.argv[1] == "tok":
126
+ for ds in sys.argv[2:]:
127
+ tok(ds)
128
+ elif sys.argv[1] == "cs":
129
+ build_cs(int(sys.argv[2]))
130
+ tok("cs_pairs")
code/da/text.py ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Text front-end: Unicode NFC, tone diacritics preserved, UTF-8 byte tokens with per-token language ids.
2
+
3
+ Code-switched input uses inline tags: "<yor>Mo fẹ́ lọ sí ọjà</yor> <eng>to buy some bread</eng>"
4
+ Untagged text takes the default language. Each byte inherits the language of the span it is in.
5
+ """
6
+ import re
7
+ import unicodedata
8
+
9
+ LANGS = ["eng", "ibo", "hau", "yor", "pcm"]
10
+ LANG2ID = {l: i for i, l in enumerate(LANGS)}
11
+ PAD, BOS, EOS = 0, 1, 2 # byte b -> token b + 3
12
+ VOCAB = 256 + 3
13
+ _TAG = re.compile(r"<(eng|ibo|hau|yor|pcm)>(.*?)</\1>", re.S)
14
+
15
+
16
+ def normalize(t: str) -> str:
17
+ t = unicodedata.normalize("NFC", t)
18
+ t = t.replace("\u2019", "'").replace("\u2018", "'").replace("\u201c", '"').replace("\u201d", '"')
19
+ return re.sub(r"\s+", " ", t).strip()
20
+
21
+
22
+ def segments(text: str, default_lang: str = "eng"):
23
+ """-> list of (lang, normalized_text) spans, order preserved."""
24
+ out, pos = [], 0
25
+ for m in _TAG.finditer(text):
26
+ pre = text[pos:m.start()]
27
+ if pre.strip():
28
+ out.append((default_lang, pre))
29
+ out.append((m.group(1), m.group(2)))
30
+ pos = m.end()
31
+ if text[pos:].strip():
32
+ out.append((default_lang, text[pos:]))
33
+ # keep a single space between spans
34
+ res = []
35
+ for i, (l, s) in enumerate(out):
36
+ s = normalize(s)
37
+ if not s:
38
+ continue
39
+ if res:
40
+ s = " " + s
41
+ res.append((l, s))
42
+ return res
43
+
44
+
45
+ def encode(text: str, default_lang: str = "eng"):
46
+ """-> (token ids, language ids) incl. BOS/EOS. BOS takes the first span's language, EOS the last."""
47
+ segs = segments(text, default_lang)
48
+ toks, langs = [BOS], [LANG2ID[segs[0][0]] if segs else LANG2ID[default_lang]]
49
+ for l, s in segs:
50
+ b = s.encode("utf-8")
51
+ toks += [x + 3 for x in b]
52
+ langs += [LANG2ID[l]] * len(b)
53
+ toks.append(EOS)
54
+ langs.append(langs[-1])
55
+ return toks, langs
56
+
57
+
58
+ def plain(text: str) -> str:
59
+ """Tagged text -> plain text (for ASR references)."""
60
+ return " ".join(s.strip() for _, s in segments(text))
code/train_b.py ADDED
@@ -0,0 +1,353 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Arm B: CosyVoice2-0.5B (Apache-2.0) fine-tune with the Zhang et al. 2019 recipe added to its text->speech-token LM.
2
+
3
+ Additions (new, ours):
4
+ * per-token language embedding added to the Qwen text-token embeddings (zero-init)
5
+ * learned speaker embedding inserted as one token AFTER the text, before the task token (zero-init), so text
6
+ positions cannot see it; speech positions attend to it (anchor-voice conditioning)
7
+ * domain-adversarial speaker classifier with gradient reversal on the LM hidden states at text positions
8
+ Flow-matching decoder + HiFT vocoder stay frozen; at inference they are prompted with an anchor-voice clip.
9
+
10
+ python train_b.py train --out ~/runs/b1 --minutes 30
11
+ python train_b.py synth --ckpt ~/runs/b1/last.pt --out ~/eval/sys/armB_ft
12
+ python train_b.py zeroshot --out ~/eval/sys/cosyvoice2_zeroshot (pretrained model, cross-lingual, no fine-tune)
13
+ """
14
+ import argparse, inspect, json, math, os, random, sys, time
15
+ import numpy as np, torch, torch.nn as nn, torch.nn.functional as F, soundfile as sf, librosa
16
+
17
+ HOME = os.path.expanduser("~")
18
+ sys.path[:0] = [f"{HOME}/CosyVoice", f"{HOME}/CosyVoice/third_party/Matcha-TTS"]
19
+ MODEL_DIR = f"{HOME}/models/CosyVoice2-0.5B"
20
+ LANGS = ["eng", "ibo", "hau", "yor", "pcm"]
21
+ L2I = {l: i for i, l in enumerate(LANGS)}
22
+ sys.path.insert(0, f"{HOME}/da")
23
+ from da.text import segments # noqa: E402
24
+
25
+ from da.model import grad_reverse # noqa: E402
26
+
27
+
28
+ def _sf_load_wav(wav, target_sr, min_sr=16000):
29
+ """soundfile replacement for cosyvoice load_wav (torchaudio>=2.9 load needs torchcodec)."""
30
+ y, sr = sf.read(wav, dtype="float32", always_2d=True)
31
+ y = y.mean(1)
32
+ if sr != target_sr:
33
+ y = librosa.resample(y, orig_sr=sr, target_sr=target_sr)
34
+ return torch.from_numpy(y)[None]
35
+
36
+
37
+ def load_cosy():
38
+ import cosyvoice.utils.file_utils as fu
39
+ fu.load_wav = _sf_load_wav
40
+ import cosyvoice.cli.frontend as cf
41
+ cf.load_wav = _sf_load_wav
42
+ from cosyvoice.cli.cosyvoice import CosyVoice2
43
+ kw = dict(load_jit=False, load_trt=False, fp16=False)
44
+ sig = inspect.signature(CosyVoice2.__init__).parameters
45
+ kw = {k: v for k, v in kw.items() if k in sig}
46
+ return CosyVoice2(MODEL_DIR, **kw)
47
+
48
+
49
+ def text_tokens(tok, text, default="eng"):
50
+ """tagged text -> (qwen ids, per-token lang ids)."""
51
+ ids, langs = [], []
52
+ for l, s in segments(text, default):
53
+ t = tok(s, add_special_tokens=False)["input_ids"]
54
+ ids += t; langs += [L2I[l]] * len(t)
55
+ return ids, langs
56
+
57
+
58
+ class DALM(nn.Module):
59
+ def __init__(self, qlm, n_spk, d=896):
60
+ super().__init__()
61
+ self.q = qlm # cosyvoice Qwen2LM
62
+ self.lang_emb = nn.Embedding(len(LANGS), d); nn.init.zeros_(self.lang_emb.weight)
63
+ self.spk_emb = nn.Embedding(n_spk, d); nn.init.normal_(self.spk_emb.weight, std=0.02)
64
+ self.adv = nn.Sequential(nn.Linear(d, 256), nn.ReLU(), nn.Linear(256, n_spk))
65
+ self.V = qlm.speech_token_size
66
+
67
+ def embed_text(self, ids, langs):
68
+ return self.q.llm.model.model.embed_tokens(ids) + self.lang_emb(langs)
69
+
70
+ def build(self, ids, langs, spk, speech=None):
71
+ """one sequence: [sos, text+lang, spk, task, speech...]"""
72
+ sos = self.q.llm_embedding.weight[self.q.sos][None]
73
+ task = self.q.llm_embedding.weight[self.q.task_id][None]
74
+ parts = [sos, self.embed_text(ids, langs), self.spk_emb(spk)[None], task]
75
+ if speech is not None:
76
+ parts.append(self.q.speech_embedding(speech))
77
+ return torch.cat(parts, 0)
78
+
79
+ def hidden(self, x, mask):
80
+ out = self.q.llm.model.model(inputs_embeds=x, attention_mask=mask, return_dict=True)
81
+ return out.last_hidden_state
82
+
83
+ def forward(self, batch, adv_lambda=1.0):
84
+ seqs, tgts, tpos = [], [], []
85
+ for ids, langs, spk, sp in batch:
86
+ x = self.build(ids, langs, spk, sp)
87
+ n_text = len(ids)
88
+ tgt = torch.full((x.shape[0],), -100, device=x.device, dtype=torch.long)
89
+ # position of task token = 1 + n_text + 1; it predicts speech[0]; last speech pos predicts EOS
90
+ start = 1 + n_text + 1
91
+ tgt[start: start + len(sp)] = sp
92
+ tgt[start + len(sp)] = self.V # EOS
93
+ seqs.append(x); tgts.append(tgt); tpos.append((1, 1 + n_text))
94
+ T = max(s.shape[0] for s in seqs) + 1
95
+ D = seqs[0].shape[1]
96
+ X = seqs[0].new_zeros(len(seqs), T, D)
97
+ Y = torch.full((len(seqs), T), -100, device=X.device, dtype=torch.long)
98
+ M = torch.zeros(len(seqs), T, device=X.device, dtype=torch.long)
99
+ for i, (s, t) in enumerate(zip(seqs, tgts)):
100
+ X[i, :len(s)] = s; Y[i, :len(t)] = t[:len(s)] if len(t) > len(s) else t; M[i, :len(s)] = 1
101
+ # targets were laid out on positions; shift is implicit (position p predicts tgt[p])
102
+ H = self.hidden(X, M)
103
+ logits = self.q.llm_decoder(H)
104
+ ce = F.cross_entropy(logits.float().reshape(-1, logits.shape[-1]), Y.reshape(-1), ignore_index=-100)
105
+ valid = Y != -100
106
+ acc = (logits.argmax(-1)[valid] == Y[valid]).float().mean()
107
+ # adversarial speaker classifier on text positions
108
+ hs, ys = [], []
109
+ for i, (a, b) in enumerate(tpos):
110
+ hs.append(H[i, a:b]); ys.append(torch.full((b - a,), int(batch[i][2]), device=X.device))
111
+ hs = torch.cat(hs); ys = torch.cat(ys)
112
+ al = self.adv(grad_reverse(hs.float(), adv_lambda))
113
+ adv = F.cross_entropy(al, ys)
114
+ adv_acc = (al.argmax(-1) == ys).float().mean()
115
+ return ce, acc, adv, adv_acc
116
+
117
+ @torch.no_grad()
118
+ def generate(self, ids, langs, spk, top_p=0.8, top_k=25, win=10, tau_r=0.1, seed=0):
119
+ g = torch.Generator(device=ids.device).manual_seed(seed)
120
+ x = self.build(ids, langs, spk)[None]
121
+ n = len(ids)
122
+ min_len, max_len = int(n * 2), int(n * 20)
123
+ past, out = None, []
124
+ inp = x
125
+ for i in range(max_len):
126
+ o = self.q.llm.model.model(inputs_embeds=inp, past_key_values=past, use_cache=True, return_dict=True)
127
+ past = o.past_key_values
128
+ logp = self.q.llm_decoder(o.last_hidden_state[:, -1]).float().log_softmax(-1)[0]
129
+ if i < min_len:
130
+ logp[self.V] = -float("inf")
131
+ logp[self.V + 1:] = -float("inf")
132
+ p = logp.exp()
133
+ sp, si = p.sort(descending=True)
134
+ keep = (sp.cumsum(0) - sp) < top_p
135
+ keep[top_k:] = False
136
+ cand_p, cand_i = sp[keep], si[keep]
137
+ tokid = int(cand_i[torch.multinomial(cand_p / cand_p.sum(), 1, generator=g)])
138
+ if out and sum(1 for t in out[-win:] if t == tokid) >= win * tau_r and tokid != self.V: # RAS
139
+ tokid = int(torch.multinomial(p, 1, generator=g))
140
+ if tokid >= self.V:
141
+ break
142
+ out.append(tokid)
143
+ inp = self.q.speech_embedding.weight[tokid][None, None]
144
+ return out
145
+
146
+
147
+ # ------------------------------------------------------------------ data
148
+ def load_items(root, datasets, split, max_tok=500):
149
+ items, mm = [], {}
150
+ for ds in datasets:
151
+ if not os.path.exists(f"{root}/{ds}/s3tok.jsonl"):
152
+ continue
153
+ mm[ds] = np.memmap(f"{root}/{ds}/s3tok.i32", dtype=np.int32, mode="r")
154
+ tk = {}
155
+ for l in open(f"{root}/{ds}/s3tok.jsonl"):
156
+ r = json.loads(l); tk[r["id"]] = (r["toff"], r["ntok"])
157
+ for l in open(f"{root}/{ds}/meta.jsonl", encoding="utf-8"):
158
+ m = json.loads(l)
159
+ if m["split"] != split or m["id"] not in tk:
160
+ continue
161
+ m["toff"], m["ntok"] = tk[m["id"]]
162
+ if 5 < m["ntok"] <= max_tok:
163
+ items.append(m)
164
+ return items, mm
165
+
166
+
167
+ def batches(items, budget, weights, rng):
168
+ idx = []
169
+ for i, m in enumerate(items):
170
+ w = weights.get(m["ds"], 1.0)
171
+ idx += [i] * int(w) + ([i] if rng.random() < w - int(w) else [])
172
+ idx.sort(key=lambda i: items[i]["ntok"] + rng.random() * 10)
173
+ out, cur, mx = [], [], 0
174
+ for i in idx:
175
+ L = items[i]["ntok"] + len(items[i]["text"]) // 2 + 4
176
+ if cur and max(mx, L) * (len(cur) + 1) > budget:
177
+ out.append(cur); cur, mx = [], 0
178
+ cur.append(i); mx = max(mx, L)
179
+ if cur:
180
+ out.append(cur)
181
+ rng.shuffle(out)
182
+ return out
183
+
184
+
185
+ def train(a):
186
+ from transformers import AutoTokenizer
187
+ tok = AutoTokenizer.from_pretrained(f"{MODEL_DIR}/CosyVoice-BlankEN")
188
+ cosy = load_cosy()
189
+ items, mm = load_items(a.data, a.datasets.split(","), "train")
190
+ val, _ = load_items(a.data, a.datasets.split(","), "val")
191
+ spks = sorted({m["spk"] for m in items}); spk2id = {s: i for i, s in enumerate(spks)}
192
+ val = [m for m in val if m["spk"] in spk2id][:200]
193
+ os.makedirs(a.out, exist_ok=True)
194
+ json.dump(spk2id, open(f"{a.out}/spk2id.json", "w"))
195
+ print("armB items", len(items), "hours", round(sum(m["dur"] for m in items) / 3600, 1), "spk", len(spks), flush=True)
196
+ model = DALM(cosy.model.llm, len(spks)).cuda().train()
197
+ for p in cosy.model.flow.parameters():
198
+ p.requires_grad = False
199
+ for p in cosy.model.hift.parameters():
200
+ p.requires_grad = False
201
+ new = list(model.lang_emb.parameters()) + list(model.spk_emb.parameters()) + list(model.adv.parameters())
202
+ newids = {id(p) for p in new}
203
+ old = [p for p in model.parameters() if id(p) not in newids and p.requires_grad]
204
+ opt = torch.optim.AdamW([{"params": old, "lr": a.lr}, {"params": new, "lr": a.lr_new}], weight_decay=0.01)
205
+ base = [a.lr, a.lr_new]
206
+ cache = {}
207
+
208
+ def enc(m):
209
+ if m["id"] not in cache:
210
+ cache[m["id"]] = text_tokens(tok, f"<{m['lang']}>{m['text']}</{m['lang']}>")
211
+ ids, langs = cache[m["id"]]
212
+ sp = np.asarray(mm[m["ds"]][m["toff"]: m["toff"] + m["ntok"]], dtype=np.int64)
213
+ return (torch.tensor(ids).cuda(), torch.tensor(langs).cuda(), torch.tensor(spk2id[m["spk"]]).cuda(),
214
+ torch.from_numpy(sp).cuda())
215
+
216
+ rng = random.Random(0); step = 0; nskip = 0; t0 = time.time(); logf = open(f"{a.out}/log.jsonl", "a")
217
+
218
+ def validate():
219
+ model.eval(); L = []
220
+ with torch.no_grad(), torch.autocast("cuda", dtype=torch.bfloat16):
221
+ for i in range(0, len(val), 16):
222
+ ce, acc, _, _ = model([enc(m) for m in val[i:i + 16]]); L.append(float(ce))
223
+ model.train(); return float(np.mean(L))
224
+
225
+ v0 = validate(); print(json.dumps({"step": 0, "val_ce": v0}), flush=True); logf.write(json.dumps({"step": 0, "val_ce": v0}) + "\n")
226
+ done = False
227
+ while not done:
228
+ for bi in batches(items, a.tokens, {"waxal": a.waxal_w}, rng):
229
+ lrm = min(1.0, (step + 1) / 200)
230
+ for g, b in zip(opt.param_groups, base):
231
+ g["lr"] = b * lrm
232
+ with torch.autocast("cuda", dtype=torch.bfloat16):
233
+ ce, acc, adv, adv_acc = model([enc(items[i]) for i in bi])
234
+ loss = ce + a.adv_w * adv
235
+ opt.zero_grad(set_to_none=True)
236
+ if not torch.isfinite(loss):
237
+ nskip += 1
238
+ print(json.dumps({"skip_nonfinite_fwd": step, "ce": float(ce), "adv": float(adv), "B": len(bi),
239
+ "maxtok": max(items[i]["ntok"] for i in bi)}), flush=True)
240
+ continue
241
+ loss.backward()
242
+ gn = torch.nn.utils.clip_grad_norm_([p for p in model.parameters() if p.requires_grad], 1.0)
243
+ if not torch.isfinite(gn):
244
+ nskip += 1; opt.zero_grad(set_to_none=True)
245
+ print(json.dumps({"skip_nonfinite_grad": step}), flush=True); continue
246
+ opt.step(); step += 1
247
+ if step % 25 == 0:
248
+ r = dict(step=step, ce=round(float(ce), 4), acc=round(float(acc), 4), adv=round(float(adv), 3),
249
+ adv_acc=round(float(adv_acc), 3), gn=round(float(gn), 2), B=len(bi), min=round((time.time() - t0) / 60, 2))
250
+ print(json.dumps(r), flush=True); logf.write(json.dumps(r) + "\n"); logf.flush()
251
+ if step % 500 == 0:
252
+ v = validate(); print(json.dumps({"step": step, "val_ce": v}), flush=True)
253
+ logf.write(json.dumps({"step": step, "val_ce": v}) + "\n"); logf.flush()
254
+ if (time.time() - t0) / 60 > a.minutes:
255
+ done = True; break
256
+ v = validate(); logf.write(json.dumps({"step": step, "val_ce": v, "final": True}) + "\n")
257
+ sd = {k: v for k, v in model.state_dict().items()}
258
+ torch.save(dict(model=sd, step=step, n_spk=len(spks)), f"{a.out}/last.pt")
259
+ print("TRAIN_B_DONE", step, v, flush=True)
260
+
261
+
262
+ # ------------------------------------------------------------------ inference
263
+ def prompt_feats(cosy, wav_path):
264
+ """flow prompt (speech tokens, 24k mel feat, campplus embedding) from an anchor clip."""
265
+ fe = cosy.frontend
266
+ tokn, tokl = fe._extract_speech_token(wav_path)
267
+ feat, featl = fe._extract_speech_feat(wav_path)
268
+ # CosyVoice2 aligns: feat frames = 2 * tokens
269
+ n = min(feat.shape[1] // 2, tokn.shape[1])
270
+ feat, tokn = feat[:, :2 * n], tokn[:, :n]
271
+ emb = fe._extract_spk_embedding(wav_path)
272
+ return tokn.cuda(), feat.cuda(), emb.cuda()
273
+
274
+
275
+ @torch.no_grad()
276
+ def token2wav(cosy, tokens, ptok, pfeat, emb):
277
+ flow = cosy.model.flow
278
+ t = torch.tensor([tokens], dtype=torch.int32).cuda()
279
+ kw = dict(token=t, token_len=torch.tensor([t.shape[1]], dtype=torch.int32).cuda(),
280
+ prompt_token=ptok, prompt_token_len=torch.tensor([ptok.shape[1]], dtype=torch.int32).cuda(),
281
+ prompt_feat=pfeat, prompt_feat_len=torch.tensor([pfeat.shape[1]], dtype=torch.int32).cuda(),
282
+ embedding=emb)
283
+ sig = inspect.signature(flow.inference).parameters
284
+ if "streaming" in sig:
285
+ kw["streaming"] = False
286
+ if "finalize" in sig:
287
+ kw["finalize"] = True
288
+ if "flow_cache" in sig:
289
+ kw["flow_cache"] = torch.zeros(1, 80, 0, 2).cuda()
290
+ mel = flow.inference(**kw)
291
+ mel = mel[0] if isinstance(mel, tuple) else mel
292
+ hs = inspect.signature(cosy.model.hift.inference).parameters
293
+ w = cosy.model.hift.inference(speech_feat=mel, **({"cache_source": torch.zeros(1, 1, 0).cuda()} if "cache_source" in hs else {}))
294
+ w = w[0] if isinstance(w, tuple) else w
295
+ return w.squeeze().float().cpu().numpy()
296
+
297
+
298
+ def eval_rows():
299
+ return [json.loads(l) for l in open(f"{HOME}/eval/eval_set.jsonl", encoding="utf-8")]
300
+
301
+
302
+ def synth(a):
303
+ from transformers import AutoTokenizer
304
+ tok = AutoTokenizer.from_pretrained(f"{MODEL_DIR}/CosyVoice-BlankEN")
305
+ cosy = load_cosy()
306
+ ck = torch.load(a.ckpt, map_location="cuda")
307
+ spk2id = json.load(open(os.path.join(os.path.dirname(a.ckpt), "spk2id.json")))
308
+ model = DALM(cosy.model.llm, ck["n_spk"]).cuda().eval()
309
+ model.load_state_dict(ck["model"])
310
+ anc = json.load(open(f"{HOME}/eval/anchor.json"))
311
+ ptok, pfeat, emb = prompt_feats(cosy, anc["ref_wavs"][0])
312
+ os.makedirs(a.out, exist_ok=True)
313
+ for r in eval_rows():
314
+ text = r["text"] if r["lang"] == "cs" else f"<{r['lang']}>{r['text']}</{r['lang']}>"
315
+ ids, langs = text_tokens(tok, text)
316
+ with torch.autocast("cuda", dtype=torch.bfloat16):
317
+ sp = model.generate(torch.tensor(ids).cuda(), torch.tensor(langs).cuda(), torch.tensor(spk2id[anc["spk"]]).cuda())
318
+ sf.write(f"{a.out}/{r['id']}.wav", token2wav(cosy, sp, ptok, pfeat, emb), cosy.sample_rate)
319
+ print("synth_b done", a.out, flush=True)
320
+
321
+
322
+ def zeroshot(a):
323
+ """Unmodified pretrained CosyVoice2, cross-lingual mode with the anchor prompt (reference point)."""
324
+ cosy = load_cosy()
325
+ anc = json.load(open(f"{HOME}/eval/anchor.json"))
326
+ from cosyvoice.utils.file_utils import load_wav
327
+ pw = anc["ref_wavs"][0]
328
+ sig = inspect.signature(cosy.inference_cross_lingual).parameters
329
+ os.makedirs(a.out, exist_ok=True)
330
+ for r in eval_rows():
331
+ text = " ".join(s.strip() for _, s in segments(r["text"])) if r["lang"] == "cs" else r["text"]
332
+ prompt = pw
333
+ outs = [o["tts_speech"] for o in cosy.inference_cross_lingual(text, prompt, stream=False)]
334
+ w = torch.cat(outs, 1).squeeze().cpu().numpy()
335
+ sf.write(f"{a.out}/{r['id']}.wav", w, cosy.sample_rate)
336
+ print("zeroshot done", flush=True)
337
+
338
+
339
+ if __name__ == "__main__":
340
+ ap = argparse.ArgumentParser()
341
+ ap.add_argument("cmd")
342
+ ap.add_argument("--data", default=f"{HOME}/data")
343
+ ap.add_argument("--datasets", default="waxal,fleurs,slr86,slr70,bible_yor")
344
+ ap.add_argument("--out", default=f"{HOME}/runs/b1")
345
+ ap.add_argument("--ckpt", default="")
346
+ ap.add_argument("--minutes", type=float, default=30)
347
+ ap.add_argument("--lr", type=float, default=1e-5)
348
+ ap.add_argument("--lr_new", type=float, default=1e-3)
349
+ ap.add_argument("--tokens", type=int, default=12000)
350
+ ap.add_argument("--waxal_w", type=float, default=2.0)
351
+ ap.add_argument("--adv_w", type=float, default=0.02)
352
+ a = ap.parse_args()
353
+ {"train": train, "synth": synth, "zeroshot": zeroshot}[a.cmd](a)
code/train_b2.py ADDED
@@ -0,0 +1,590 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Arm B, Phase 2. CosyVoice2-0.5B (Apache-2.0) + language embeddings + speaker token + GRL speaker classifier.
2
+
3
+ New in Phase 2
4
+ * data from ~/data2 (24 kHz, forced-aligned, pause-normalised; long WAXAL clips segmented; BibleTTS Hausa; CS pairs)
5
+ * in-context prompts during LM training (p=0.5): [sos, prompt_text+text, spk, task, prompt_speech, speech] with
6
+ loss on the target speech only -> at inference the anchor clip steers prosody and pause style
7
+ * keep-best on validation CE (every --val_every steps), cosine LR, early stop
8
+ * flow-matching decoder fine-tune on 24 kHz studio sources (tones / pitch / timing detail)
9
+ * synthesis: anchor prompt at LM and flow, sentence-level generation for multi-sentence text with anchor-matched
10
+ inter-sentence pauses, N candidates per text (seed + prompt clip vary)
11
+
12
+ python train_b2.py lm --out ~/runs/p2_lm --minutes 90
13
+ python train_b2.py flow --out ~/runs/p2_flow --minutes 60
14
+ python train_b2.py synth --ckpt ~/runs/p2_lm/best.pt [--flow ~/runs/p2_flow/best.pt] --out DIR --n 4
15
+ """
16
+ import argparse, inspect, json, math, os, random, re, sys, time
17
+ import numpy as np, torch, torch.nn as nn, torch.nn.functional as F, soundfile as sf, librosa
18
+
19
+ HOME = os.path.expanduser("~")
20
+ sys.path[:0] = [f"{HOME}/CosyVoice", f"{HOME}/CosyVoice/third_party/Matcha-TTS", f"{HOME}/da"]
21
+ from train_b import load_cosy, text_tokens, DALM, LANGS, L2I, MODEL_DIR, token2wav # noqa: E402
22
+ from da.model import grad_reverse # noqa: E402
23
+ from da.text import segments # noqa: E402
24
+
25
+ D2 = os.path.expanduser(os.environ.get("P2OUT", f"{HOME}/data2"))
26
+ ANCHOR = "waxal_yor_4"
27
+
28
+
29
+ # ------------------------------------------------------------------ model
30
+ class DALM2(DALM):
31
+ def build2(self, ids, langs, spk, sp=None, pids=None, plangs=None, psp=None):
32
+ """[sos, (prompt_text) text, spk, task, (prompt_speech) speech] -> embeddings, n_text_total, n_prompt_speech"""
33
+ sos = self.q.llm_embedding.weight[self.q.sos][None]
34
+ task = self.q.llm_embedding.weight[self.q.task_id][None]
35
+ if pids is not None:
36
+ ids = torch.cat([pids, ids]); langs = torch.cat([plangs, langs])
37
+ parts = [sos, self.embed_text(ids, langs), self.spk_emb(spk)[None], task]
38
+ npsp = 0
39
+ if psp is not None:
40
+ parts.append(self.q.speech_embedding(psp)); npsp = len(psp)
41
+ if sp is not None:
42
+ parts.append(self.q.speech_embedding(sp))
43
+ return torch.cat(parts, 0), len(ids), npsp
44
+
45
+ def forward2(self, batch, adv_lambda=1.0):
46
+ seqs, tgts, tpos, spks = [], [], [], []
47
+ for b in batch:
48
+ ids, langs, spk, sp = b[:4]
49
+ pr = b[4] if len(b) > 4 else None
50
+ x, nt, npsp = self.build2(ids, langs, spk, sp, *(pr if pr else (None, None, None)))
51
+ tgt = torch.full((x.shape[0] + 1,), -100, device=x.device, dtype=torch.long)
52
+ start = 1 + nt + 2 + npsp - 1 # position whose output predicts speech[0]
53
+ tgt[start: start + len(sp)] = sp
54
+ tgt[start + len(sp)] = self.V
55
+ seqs.append(x); tgts.append(tgt[:x.shape[0]]); tpos.append((1, 1 + nt)); spks.append(int(spk))
56
+ T = max(s.shape[0] for s in seqs)
57
+ D = seqs[0].shape[1]
58
+ X = seqs[0].new_zeros(len(seqs), T, D)
59
+ Y = torch.full((len(seqs), T), -100, device=X.device, dtype=torch.long)
60
+ M = torch.zeros(len(seqs), T, device=X.device, dtype=torch.long)
61
+ for i, (s, t) in enumerate(zip(seqs, tgts)):
62
+ X[i, :len(s)] = s; Y[i, :len(t)] = t; M[i, :len(s)] = 1
63
+ H = self.hidden(X, M)
64
+ logits = self.q.llm_decoder(H)
65
+ ce = F.cross_entropy(logits.float().reshape(-1, logits.shape[-1]), Y.reshape(-1), ignore_index=-100)
66
+ valid = Y != -100
67
+ acc = (logits.argmax(-1)[valid] == Y[valid]).float().mean()
68
+ hs, ys = [], []
69
+ for i, (a, b) in enumerate(tpos):
70
+ hs.append(H[i, a:b]); ys.append(torch.full((b - a,), spks[i], device=X.device))
71
+ hs = torch.cat(hs); ys = torch.cat(ys)
72
+ al = self.adv(grad_reverse(hs.float(), adv_lambda))
73
+ adv = F.cross_entropy(al, ys)
74
+ return ce, acc, adv, (al.argmax(-1) == ys).float().mean()
75
+
76
+ @torch.no_grad()
77
+ def generate2(self, ids, langs, spk, prompt=None, top_p=0.8, top_k=25, win=10, tau_r=0.1, seed=0,
78
+ min_ratio=2.0, max_ratio=20.0):
79
+ g = torch.Generator(device=ids.device).manual_seed(seed)
80
+ x, _, _ = self.build2(ids, langs, spk, None, *(prompt if prompt else (None, None, None)))
81
+ x = x[None]
82
+ n = len(ids)
83
+ min_len, max_len = int(n * min_ratio), int(n * max_ratio)
84
+ past, out, inp = None, [], x
85
+ for i in range(max_len):
86
+ o = self.q.llm.model.model(inputs_embeds=inp, past_key_values=past, use_cache=True, return_dict=True)
87
+ past = o.past_key_values
88
+ logp = self.q.llm_decoder(o.last_hidden_state[:, -1]).float().log_softmax(-1)[0]
89
+ if i < min_len:
90
+ logp[self.V] = -float("inf")
91
+ logp[self.V + 1:] = -float("inf")
92
+ p = logp.exp()
93
+ sp, si = p.sort(descending=True)
94
+ keep = (sp.cumsum(0) - sp) < top_p
95
+ keep[top_k:] = False
96
+ cp, ci = sp[keep], si[keep]
97
+ tokid = int(ci[torch.multinomial(cp / cp.sum(), 1, generator=g)])
98
+ if out and sum(1 for t in out[-win:] if t == tokid) >= win * tau_r and tokid != self.V:
99
+ tokid = int(torch.multinomial(p, 1, generator=g))
100
+ if tokid >= self.V:
101
+ break
102
+ out.append(tokid)
103
+ inp = self.q.speech_embedding.weight[tokid][None, None]
104
+ return out
105
+
106
+
107
+ # ------------------------------------------------------------------ data
108
+ def load_items(datasets, split, max_tok=500, flow=False):
109
+ items, mm = [], {}
110
+ for ds in datasets:
111
+ od = f"{D2}/{ds}"
112
+ if not os.path.exists(f"{od}/s3tok.jsonl"):
113
+ print("missing", ds, flush=True); continue
114
+ mm[ds] = np.memmap(f"{od}/s3tok.i32", dtype=np.int32, mode="r")
115
+ tk = [json.loads(l) for l in open(f"{od}/s3tok.jsonl")]
116
+ for k, l in enumerate(open(f"{od}/meta.jsonl", encoding="utf-8")):
117
+ m = json.loads(l)
118
+ t = tk[k]
119
+ assert t["id"] == m["id"]
120
+ if m["split"] != split:
121
+ continue
122
+ if flow and not m.get("flow_ok"):
123
+ continue
124
+ m["toff"], m["ntok"], m["row"] = t["toff"], t["ntok"], k
125
+ if 5 < m["ntok"] <= max_tok:
126
+ items.append(m)
127
+ return items, mm
128
+
129
+
130
+ def tagged(m):
131
+ return m["text"] if m["lang"] == "cs" else f"<{m['lang']}>{m['text']}</{m['lang']}>"
132
+
133
+
134
+ DS_W = {"waxal": 2.0, "bible_yor": 0.5, "bible_hau": 0.6, "fleurs": 1.0, "slr86": 1.0, "slr70": 1.0, "cs_pairs": 1.0}
135
+
136
+
137
+ def batches(items, budget, rng, extra_w=None, cost=None):
138
+ idx = []
139
+ for i, m in enumerate(items):
140
+ w = DS_W.get(m["ds"], 1.0) * (extra_w(m) if extra_w else 1.0)
141
+ idx += [i] * int(w) + ([i] if rng.random() < w - int(w) else [])
142
+ cost = cost or (lambda m: m["ntok"] + len(m["text"]) // 2 + 4)
143
+ idx.sort(key=lambda i: cost(items[i]) + rng.random() * 10)
144
+ out, cur, mx = [], [], 0
145
+ for i in idx:
146
+ L = cost(items[i])
147
+ if cur and max(mx, L) * (len(cur) + 1) > budget:
148
+ out.append(cur); cur, mx = [], 0
149
+ cur.append(i); mx = max(mx, L)
150
+ if cur:
151
+ out.append(cur)
152
+ rng.shuffle(out)
153
+ return out
154
+
155
+
156
+ # ------------------------------------------------------------------ LM training
157
+ def train_lm(a):
158
+ from transformers import AutoTokenizer
159
+ tok = AutoTokenizer.from_pretrained(f"{MODEL_DIR}/CosyVoice-BlankEN")
160
+ cosy = load_cosy()
161
+ dss = a.datasets.split(",")
162
+ items, mm = load_items(dss, "train")
163
+ val_all, _ = load_items(dss, "val")
164
+ if a.anchor_only_w > 0:
165
+ print("anchor items", sum(m["spk"] == ANCHOR for m in items), flush=True)
166
+ spks = sorted({m["spk"] for m in items})
167
+ if a.init:
168
+ ck = torch.load(a.init, map_location="cpu")
169
+ spk2id = json.load(open(os.path.join(os.path.dirname(a.init), "spk2id.json")))
170
+ for s in spks:
171
+ if s not in spk2id:
172
+ spk2id[s] = len(spk2id)
173
+ else:
174
+ spk2id = {s: i for i, s in enumerate(spks)}
175
+ n_spk = max(len(spk2id), ck["n_spk"]) if a.init else len(spk2id)
176
+ # balanced val: up to 60 per language (+ anchor), never more than 15 per speaker
177
+ rng = random.Random(1); rng.shuffle(val_all)
178
+ val, per_l, per_s = [], {}, {}
179
+ for m in val_all:
180
+ if m["spk"] not in spk2id or m["lang"] == "cs":
181
+ continue
182
+ L = "anchor" if m["spk"] == ANCHOR else m["lang"]
183
+ if per_l.get(L, 0) >= 60 or per_s.get(m["spk"], 0) >= 15:
184
+ continue
185
+ per_l[L] = per_l.get(L, 0) + 1; per_s[m["spk"]] = per_s.get(m["spk"], 0) + 1; val.append(m)
186
+ by_spk = {}
187
+ for m in items:
188
+ if m["ntok"] <= 160 and m["lang"] != "cs":
189
+ by_spk.setdefault(m["spk"], []).append(m)
190
+ os.makedirs(a.out, exist_ok=True)
191
+ json.dump(spk2id, open(f"{a.out}/spk2id.json", "w"))
192
+ print("lm items", len(items), "hours", round(sum(m["dur"] for m in items) / 3600, 1), "spk", len(spk2id),
193
+ "val", len(val), per_l, flush=True)
194
+ model = DALM2(cosy.model.llm, n_spk).cuda().train()
195
+ if a.init:
196
+ sd = ck["model"]
197
+ if sd["spk_emb.weight"].shape[0] < n_spk:
198
+ for k in ["spk_emb.weight"]:
199
+ w = model.state_dict()[k].clone(); w[:sd[k].shape[0]] = sd[k]; sd[k] = w
200
+ for k in ["adv.2.weight", "adv.2.bias"]:
201
+ w = model.state_dict()[k].clone(); w[:sd[k].shape[0]] = sd[k]; sd[k] = w
202
+ print(model.load_state_dict(sd, strict=False), flush=True)
203
+ for p in cosy.model.flow.parameters():
204
+ p.requires_grad = False
205
+ for p in cosy.model.hift.parameters():
206
+ p.requires_grad = False
207
+ new = list(model.lang_emb.parameters()) + list(model.spk_emb.parameters()) + list(model.adv.parameters())
208
+ newids = {id(p) for p in new}
209
+ old = [p for p in model.parameters() if id(p) not in newids and p.requires_grad]
210
+ opt = torch.optim.AdamW([{"params": old, "lr": a.lr}, {"params": new, "lr": a.lr_new}], weight_decay=0.01)
211
+ base = [a.lr, a.lr_new]
212
+ cache = {}
213
+
214
+ def tt(text, key):
215
+ if key not in cache:
216
+ cache[key] = text_tokens(tok, text)
217
+ return cache[key]
218
+
219
+ def enc(m, prng=None, force_prompt=None):
220
+ ids, langs = tt(tagged(m), m["id"])
221
+ sp = np.asarray(mm[m["ds"]][m["toff"]: m["toff"] + m["ntok"]], dtype=np.int64)
222
+ r = [torch.tensor(ids).cuda(), torch.tensor(langs).cuda(), torch.tensor(spk2id[m["spk"]]).cuda(),
223
+ torch.from_numpy(sp).cuda()]
224
+ use = force_prompt if force_prompt is not None else (prng is not None and prng.random() < a.p_prompt)
225
+ if use:
226
+ c = by_spk.get(m["spk"], [])
227
+ c = [x for x in (prng or random).sample(c, min(6, len(c))) if x["id"] != m["id"]] if c else []
228
+ if c and c[0]["ntok"] + m["ntok"] <= 600:
229
+ p = c[0]
230
+ pids, plangs = tt(tagged(p), p["id"])
231
+ psp = np.asarray(mm[p["ds"]][p["toff"]: p["toff"] + p["ntok"]], dtype=np.int64)
232
+ r.append((torch.tensor(pids).cuda(), torch.tensor(plangs).cuda(), torch.from_numpy(psp).cuda()))
233
+ return r
234
+
235
+ def validate():
236
+ model.eval(); out = {}
237
+ with torch.no_grad(), torch.autocast("cuda", dtype=torch.bfloat16):
238
+ for mode in (False, True):
239
+ vr = random.Random(7); L, W = [], []
240
+ for i in range(0, len(val), 16):
241
+ bt = val[i:i + 16]
242
+ ce, _, _, _ = model.forward2([enc(m, vr, force_prompt=mode) for m in bt])
243
+ L.append(float(ce) * len(bt)); W.append(len(bt))
244
+ out["p" if mode else "np"] = sum(L) / sum(W)
245
+ # anchor-only val
246
+ av = [m for m in val if m["spk"] == ANCHOR]
247
+ if av:
248
+ vr = random.Random(7)
249
+ ce, _, _, _ = model.forward2([enc(m, vr, force_prompt=True) for m in av[:32]])
250
+ out["anchor_p"] = float(ce)
251
+ model.train()
252
+ out["mean"] = (out["np"] + out["p"]) / 2
253
+ return out
254
+
255
+ extra = (lambda m: a.anchor_only_w if m["spk"] == ANCHOR else 1.0) if a.anchor_only_w > 0 else None
256
+ rng = random.Random(0); prng = random.Random(3)
257
+ step, nskip, t0 = 0, 0, time.time()
258
+ logf = open(f"{a.out}/log.jsonl", "a")
259
+ v = validate(); best = v["mean"] if not a.select_anchor else 0.5 * (v["mean"] + v.get("anchor_p", v["mean"])); bad = 0
260
+ logf.write(json.dumps({"step": 0, "val": v}) + "\n"); print(json.dumps({"step": 0, "val": v}), flush=True)
261
+ torch.save(dict(model=model.state_dict(), step=0, n_spk=n_spk, val=v), f"{a.out}/best.pt")
262
+ total = a.max_steps
263
+ done = False
264
+ while not done:
265
+ for bi in batches(items, a.tokens, rng, extra, cost=lambda m: m["ntok"] + len(m["text"]) // 2 + 4 + int(a.p_prompt * 200)):
266
+ lrm = min(1.0, (step + 1) / a.warmup) * (0.1 + 0.9 * 0.5 * (1 + math.cos(math.pi * min(1.0, step / total))))
267
+ for g, b in zip(opt.param_groups, base):
268
+ g["lr"] = b * lrm
269
+ batch = [enc(items[i], prng) for i in bi]
270
+ # keep the padded length inside the budget when prompts were added
271
+ with torch.autocast("cuda", dtype=torch.bfloat16):
272
+ ce, acc, adv, adv_acc = model.forward2(batch, adv_lambda=min(1.0, step / 1000))
273
+ loss = ce + a.adv_w * adv
274
+ opt.zero_grad(set_to_none=True)
275
+ if not torch.isfinite(loss):
276
+ nskip += 1; print(json.dumps({"skip_nonfinite_fwd": step, "ce": float(ce), "adv": float(adv)}), flush=True)
277
+ continue
278
+ loss.backward()
279
+ gn = torch.nn.utils.clip_grad_norm_([p for p in model.parameters() if p.requires_grad], 1.0)
280
+ if not torch.isfinite(gn):
281
+ nskip += 1; opt.zero_grad(set_to_none=True); print(json.dumps({"skip_nonfinite_grad": step}), flush=True)
282
+ continue
283
+ opt.step(); step += 1
284
+ if step % 25 == 0:
285
+ r = dict(step=step, ce=round(float(ce), 4), acc=round(float(acc), 4), adv=round(float(adv), 3),
286
+ adv_acc=round(float(adv_acc), 3), gn=round(float(gn), 2), B=len(bi), lr=round(lrm * a.lr, 9),
287
+ skips=nskip, min=round((time.time() - t0) / 60, 2))
288
+ print(json.dumps(r), flush=True); logf.write(json.dumps(r) + "\n"); logf.flush()
289
+ if step % a.val_every == 0:
290
+ v = validate()
291
+ score = v["mean"] if not a.select_anchor else 0.5 * (v["mean"] + v.get("anchor_p", v["mean"]))
292
+ imp = score < best - 1e-4
293
+ rec = {"step": step, "val": v, "best": imp}
294
+ print(json.dumps(rec), flush=True); logf.write(json.dumps(rec) + "\n"); logf.flush()
295
+ if imp:
296
+ best, bad = score, 0
297
+ torch.save(dict(model=model.state_dict(), step=step, n_spk=n_spk, val=v), f"{a.out}/best.pt")
298
+ else:
299
+ bad += 1
300
+ if bad >= a.patience:
301
+ print("EARLY_STOP", step, flush=True); done = True; break
302
+ if (time.time() - t0) / 60 > a.minutes or step >= total:
303
+ done = True; break
304
+ print("TRAIN_LM_DONE", step, "best", best, "skips", nskip, flush=True)
305
+
306
+
307
+ # ------------------------------------------------------------------ flow training
308
+ def train_flow(a):
309
+ cosy = load_cosy()
310
+ flow = cosy.model.flow
311
+ if a.flow_init:
312
+ flow.load_state_dict(torch.load(a.flow_init, map_location="cuda")["flow"])
313
+ flow.train()
314
+ for p in cosy.model.llm.parameters():
315
+ p.requires_grad = False
316
+ fe = cosy.frontend.feat_extractor
317
+ dss = a.datasets.split(",")
318
+ items, mm = load_items(dss, "train", max_tok=400, flow=True)
319
+ val, _ = load_items(dss, "val", max_tok=400, flow=True)
320
+ rng0 = random.Random(1); rng0.shuffle(val); val = val[:256]
321
+ pcm = {ds: np.memmap(f"{D2}/{ds}/pcm24k.i16", dtype=np.int16, mode="r") for ds in dss if os.path.exists(f"{D2}/{ds}/pcm24k.i16")}
322
+ emb = {ds: np.load(f"{D2}/{ds}/campplus.npy", mmap_mode="r") for ds in dss if os.path.exists(f"{D2}/{ds}/campplus.npy")}
323
+ os.makedirs(a.out, exist_ok=True)
324
+ print("flow items", len(items), "hours", round(sum(m["dur"] for m in items) / 3600, 1), "val", len(val), flush=True)
325
+ extra = (lambda m: a.anchor_only_w if m["spk"] == ANCHOR else 1.0) if a.anchor_only_w > 0 else None
326
+
327
+ def make(bt):
328
+ toks, feats, es = [], [], []
329
+ for m in bt:
330
+ y = torch.from_numpy(np.asarray(pcm[m["ds"]][m["off24"]: m["off24"] + m["n24"]], dtype=np.float32) / 32768.0)
331
+ n = int(math.ceil(len(y) / 960)) * 960
332
+ y = F.pad(y, (0, n - len(y)))[None].cuda()
333
+ ft = fe(y).squeeze(0).transpose(0, 1) # [T,80]
334
+ tk = torch.from_numpy(np.asarray(mm[m["ds"]][m["toff"]: m["toff"] + m["ntok"]], dtype=np.int64))
335
+ k = min(ft.shape[0] // 2, len(tk))
336
+ toks.append(tk[:k]); feats.append(ft[:2 * k]); es.append(torch.from_numpy(np.array(emb[m["ds"]][m["row"]])))
337
+ tl = torch.tensor([len(t) for t in toks], dtype=torch.int32)
338
+ fl = torch.tensor([len(f) for f in feats], dtype=torch.int32)
339
+ T = int(tl.max()); Fm = int(fl.max())
340
+ tok = torch.zeros(len(bt), T, dtype=torch.long); feat = torch.zeros(len(bt), Fm, 80)
341
+ for i, (t, f) in enumerate(zip(toks, feats)):
342
+ tok[i, :len(t)] = t; feat[i, :len(f)] = f.cpu()
343
+ return dict(speech_token=tok, speech_token_len=tl, speech_feat=feat, speech_feat_len=fl,
344
+ embedding=torch.stack(es).float())
345
+
346
+ params = [p for p in flow.parameters() if p.requires_grad]
347
+ opt = torch.optim.AdamW(params, lr=a.lr, weight_decay=0.0)
348
+ logf = open(f"{a.out}/log.jsonl", "a")
349
+
350
+ def validate():
351
+ flow.eval(); L = []
352
+ st = torch.random.get_rng_state(); cst = torch.cuda.get_rng_state(); pst = random.getstate()
353
+ torch.manual_seed(0); random.seed(0)
354
+ with torch.no_grad():
355
+ for i in range(0, len(val), 16):
356
+ L.append(float(flow(make(val[i:i + 16]), torch.device("cuda"))["loss"]))
357
+ torch.random.set_rng_state(st); torch.cuda.set_rng_state(cst); random.setstate(pst)
358
+ flow.train(); return float(np.mean(L))
359
+
360
+ v = validate(); best, bad = v, 0
361
+ print(json.dumps({"step": 0, "val": v}), flush=True); logf.write(json.dumps({"step": 0, "val": v}) + "\n")
362
+ torch.save(dict(flow=flow.state_dict(), step=0, val=v), f"{a.out}/best.pt")
363
+ rng = random.Random(0); step, t0, done = 0, time.time(), False
364
+ while not done:
365
+ for bi in batches(items, a.tokens, rng, extra, cost=lambda m: m["ntok"]):
366
+ lrm = min(1.0, (step + 1) / a.warmup)
367
+ for g in opt.param_groups:
368
+ g["lr"] = a.lr * lrm
369
+ with torch.autocast("cuda", dtype=torch.bfloat16):
370
+ loss = flow(make([items[i] for i in bi]), torch.device("cuda"))["loss"]
371
+ opt.zero_grad(set_to_none=True)
372
+ if not torch.isfinite(loss):
373
+ continue
374
+ loss.backward()
375
+ gn = torch.nn.utils.clip_grad_norm_(params, 5.0)
376
+ if not torch.isfinite(gn):
377
+ continue
378
+ opt.step(); step += 1
379
+ if step % 50 == 0:
380
+ r = dict(step=step, loss=round(float(loss), 4), gn=round(float(gn), 2), B=len(bi), min=round((time.time() - t0) / 60, 2))
381
+ print(json.dumps(r), flush=True); logf.write(json.dumps(r) + "\n"); logf.flush()
382
+ if step % a.val_every == 0:
383
+ v = validate(); imp = v < best - 1e-4
384
+ print(json.dumps({"step": step, "val": v, "best": imp}), flush=True)
385
+ logf.write(json.dumps({"step": step, "val": v, "best": imp}) + "\n"); logf.flush()
386
+ if imp:
387
+ best, bad = v, 0
388
+ torch.save(dict(flow=flow.state_dict(), step=step, val=v), f"{a.out}/best.pt")
389
+ else:
390
+ bad += 1
391
+ if bad >= a.patience:
392
+ done = True; break
393
+ if (time.time() - t0) / 60 > a.minutes or step >= a.max_steps:
394
+ done = True; break
395
+ print("TRAIN_FLOW_DONE", step, "best", best, flush=True)
396
+
397
+
398
+ # ------------------------------------------------------------------ synthesis
399
+ _SENT = re.compile(r"(?<=[.?!])\s+")
400
+
401
+
402
+ def split_sentences(text):
403
+ """tagged text -> list of tagged sentences. A sentence ends at . ? ! inside a span or at the end of a span;
404
+ language spans are cut there and re-tagged, so a sentence keeps its in-sentence switches."""
405
+ out, cur = [], []
406
+ for l, s in segments(text):
407
+ parts = _SENT.split(s.strip())
408
+ for k, p in enumerate(parts):
409
+ p = p.strip()
410
+ if p:
411
+ cur.append(f"<{l}>{p}</{l}>")
412
+ if (k < len(parts) - 1 or p[-1:] in ".?!") and cur:
413
+ out.append(" ".join(cur)); cur = []
414
+ if cur:
415
+ out.append(" ".join(cur))
416
+ return [o for o in out if o.strip()]
417
+
418
+
419
+ def trim_edges(w, sr, keep_lead=0.12, keep_tail=0.18):
420
+ from da.p2common import silence_mask
421
+ sil = silence_mask(w, sr)
422
+ sp = np.where(~sil)[0]
423
+ if not len(sp):
424
+ return w
425
+ a, b = sp[0] * 0.01, (sp[-1] + 1) * 0.01
426
+ out = w[int(max(0, a - keep_lead) * sr): int(min(len(w) / sr, b + keep_tail) * sr)]
427
+ lead = a - max(0, a - keep_lead)
428
+ if lead < keep_lead:
429
+ out = np.concatenate([np.zeros(int((keep_lead - lead) * sr), out.dtype), out])
430
+ return out
431
+
432
+
433
+ def cap_internal(w, sr, cap=0.6):
434
+ """shorten every internal silence longer than cap seconds to cap (the centre of the silent run is removed)."""
435
+ from da.p2common import silence_mask, runs
436
+ sil = silence_mask(w, sr)
437
+ hop = int(0.01 * sr)
438
+ R = [(a, b) for a, b in runs(sil) if a > 0 and b < len(sil) and (b - a) * 0.01 > cap]
439
+ if not R:
440
+ return w
441
+ keep, cur = [], 0
442
+ for a, b in R:
443
+ cut0 = a * hop + int(cap / 2 * sr)
444
+ cut1 = b * hop - int(cap / 2 * sr)
445
+ keep.append(w[cur:cut0]); cur = cut1
446
+ keep.append(w[cur:])
447
+ return np.concatenate(keep)
448
+
449
+
450
+ class Synth:
451
+ def __init__(self, ckpt, flow_ckpt=None, sent_pause=0.5):
452
+ from transformers import AutoTokenizer
453
+ self.tok = AutoTokenizer.from_pretrained(f"{MODEL_DIR}/CosyVoice-BlankEN")
454
+ self.cosy = load_cosy()
455
+ if flow_ckpt:
456
+ self.cosy.model.flow.load_state_dict(torch.load(flow_ckpt, map_location="cuda")["flow"])
457
+ ck = torch.load(ckpt, map_location="cuda")
458
+ self.spk2id = json.load(open(os.path.join(os.path.dirname(ckpt), "spk2id.json")))
459
+ self.m = DALM2(self.cosy.model.llm, ck["n_spk"]).cuda().eval()
460
+ self.m.load_state_dict(ck["model"])
461
+ self.sent_pause = sent_pause
462
+ self.cap = 0.6
463
+ # anchor prompts: normalised anchor val clips from data2 (never in train), 3-7 s, highest SNR first
464
+ meta = [json.loads(l) for l in open(f"{D2}/waxal/meta.jsonl", encoding="utf-8")]
465
+ tk = [json.loads(l) for l in open(f"{D2}/waxal/s3tok.jsonl")]
466
+ pcm = np.memmap(f"{D2}/waxal/pcm24k.i16", dtype=np.int16, mode="r")
467
+ s3 = np.memmap(f"{D2}/waxal/s3tok.i32", dtype=np.int32, mode="r")
468
+ cand = [(m, t) for m, t in zip(meta, tk) if m["spk"] == ANCHOR and m["split"] == "val" and 3.0 <= m["dur"] <= 7.0
469
+ and not m.get("longform")]
470
+ cand.sort(key=lambda x: -x[0]["ascore"])
471
+ self.prompts = []
472
+ os.makedirs(f"{HOME}/eval/prompts", exist_ok=True)
473
+ for m, t in cand[:4]:
474
+ y = np.asarray(pcm[m["off24"]: m["off24"] + m["n24"]], dtype=np.float32) / 32768.0
475
+ p = f"{HOME}/eval/prompts/{m['id']}.wav"; sf.write(p, y, 24000)
476
+ from train_b import prompt_feats
477
+ ptok, pfeat, pemb = prompt_feats(self.cosy, p)
478
+ ids, langs = text_tokens(self.tok, tagged(m))
479
+ lmp = (torch.tensor(ids).cuda(), torch.tensor(langs).cuda(),
480
+ torch.from_numpy(np.asarray(s3[t["toff"]: t["toff"] + t["ntok"]], dtype=np.int64)).cuda())
481
+ self.prompts.append(dict(id=m["id"], text=m["text"], lm=lmp, flow=(ptok, pfeat, pemb)))
482
+ print("anchor prompts", [p["id"] for p in self.prompts], flush=True)
483
+
484
+ def one(self, tagged_text, seed=0, prompt_k=0, use_lm_prompt=True):
485
+ ids, langs = text_tokens(self.tok, tagged_text)
486
+ P = self.prompts[prompt_k % len(self.prompts)]
487
+ with torch.autocast("cuda", dtype=torch.bfloat16):
488
+ sp = self.m.generate2(torch.tensor(ids).cuda(), torch.tensor(langs).cuda(),
489
+ torch.tensor(self.spk2id[ANCHOR]).cuda(), prompt=P["lm"] if use_lm_prompt else None,
490
+ seed=seed)
491
+ if len(sp) < 3:
492
+ sp = sp + [0] * 3
493
+ self.last_tokens.append(sp)
494
+ return token2wav(self.cosy, sp, *P["flow"])
495
+
496
+ def join(self, ws):
497
+ sr = self.cosy.sample_rate
498
+ if len(ws) == 1:
499
+ return ws[0]
500
+ out = []
501
+ for i, w in enumerate(ws):
502
+ if i:
503
+ out.append(np.zeros(max(0, int((self.sent_pause - 0.30) * sr)), np.float32))
504
+ out.append(w)
505
+ return np.concatenate(out)
506
+
507
+ def redecode(self, toks, prompt_k=0):
508
+ """same LM tokens, (possibly different) flow -> identical post-processing"""
509
+ sr = self.cosy.sample_rate
510
+ P = self.prompts[prompt_k % len(self.prompts)]
511
+ return self.join([cap_internal(trim_edges(token2wav(self.cosy, t, *P["flow"]), sr), sr, self.cap) for t in toks])
512
+
513
+ def say(self, tagged_text, seed=0, prompt_k=0, stitch=True, use_lm_prompt=True):
514
+ sr = self.cosy.sample_rate
515
+ self.last_tokens = []
516
+ sents = split_sentences(tagged_text) if stitch else [tagged_text]
517
+ ws = [cap_internal(trim_edges(self.one(s, seed=seed + 1000 * i, prompt_k=prompt_k, use_lm_prompt=use_lm_prompt), sr), sr, self.cap)
518
+ for i, s in enumerate(sents)]
519
+ # trailing 0.18 + leading 0.12 already present; join() tops up to the anchor's sentence pause
520
+ return self.join(ws)
521
+
522
+
523
+ def redecode(a):
524
+ """decode the LM tokens saved by synth (--src DIR) with another flow checkpoint -> --out"""
525
+ S = Synth(a.ckpt, a.flow or None, a.sent_pause)
526
+ os.makedirs(a.out, exist_ok=True)
527
+ import glob
528
+ for f in sorted(glob.glob(f"{a.src}/*.tok.json")):
529
+ d = json.load(open(f))
530
+ i = os.path.basename(f)[:-9]
531
+ sf.write(f"{a.out}/{i}.wav", S.redecode(d["tokens"], d["prompt_k"]), S.cosy.sample_rate)
532
+ print("REDECODE_DONE", a.out, flush=True)
533
+
534
+
535
+ def synth(a):
536
+ S = Synth(a.ckpt, a.flow or None, a.sent_pause)
537
+ rows = [json.loads(l) for l in open(a.eval_set, encoding="utf-8")]
538
+ if a.only:
539
+ keep = set(a.only.split(",")); rows = [r for r in rows if r["id"] in keep or r["lang"] in keep]
540
+ if a.nper:
541
+ cnt, sel = {}, []
542
+ for r in rows:
543
+ if r["lang"] in ("cs", "para") or cnt.get(r["lang"], 0) < a.nper:
544
+ sel.append(r); cnt[r["lang"]] = cnt.get(r["lang"], 0) + 1
545
+ rows = sel
546
+ ks = [a.k] if a.k >= 0 else list(range(a.n))
547
+ for k in ks:
548
+ od = a.out if len(ks) == 1 else f"{a.out}/cand{k}"
549
+ os.makedirs(od, exist_ok=True)
550
+ for r in rows:
551
+ text = r["text"] if r["lang"] in ("cs", "para") else f"<{r['lang']}>{r['text']}</{r['lang']}>"
552
+ w = S.say(text, seed=k, prompt_k=k, stitch=not a.no_stitch, use_lm_prompt=not a.no_lm_prompt)
553
+ sf.write(f"{od}/{r['id']}.wav", w, S.cosy.sample_rate)
554
+ json.dump(dict(tokens=S.last_tokens, prompt_k=k), open(f"{od}/{r['id']}.tok.json", "w"))
555
+ print("synth cand", k, "done", od, flush=True)
556
+ print("SYNTH2_DONE", a.out, flush=True)
557
+
558
+
559
+ if __name__ == "__main__":
560
+ ap = argparse.ArgumentParser()
561
+ ap.add_argument("cmd")
562
+ ap.add_argument("--datasets", default="waxal,fleurs,slr86,slr70,bible_yor,bible_hau,cs_pairs")
563
+ ap.add_argument("--out", default=f"{HOME}/runs/p2_lm")
564
+ ap.add_argument("--init", default="")
565
+ ap.add_argument("--flow_init", default="")
566
+ ap.add_argument("--ckpt", default="")
567
+ ap.add_argument("--flow", default="")
568
+ ap.add_argument("--minutes", type=float, default=90)
569
+ ap.add_argument("--max_steps", type=int, default=4000)
570
+ ap.add_argument("--lr", type=float, default=1e-5)
571
+ ap.add_argument("--lr_new", type=float, default=5e-4)
572
+ ap.add_argument("--warmup", type=int, default=300)
573
+ ap.add_argument("--tokens", type=int, default=16000)
574
+ ap.add_argument("--adv_w", type=float, default=0.02)
575
+ ap.add_argument("--p_prompt", type=float, default=0.5)
576
+ ap.add_argument("--val_every", type=int, default=200)
577
+ ap.add_argument("--patience", type=int, default=5)
578
+ ap.add_argument("--anchor_only_w", type=float, default=0.0)
579
+ ap.add_argument("--select_anchor", action="store_true")
580
+ ap.add_argument("--eval_set", default=f"{HOME}/eval/eval_set2.jsonl")
581
+ ap.add_argument("--n", type=int, default=1)
582
+ ap.add_argument("--nper", type=int, default=0)
583
+ ap.add_argument("--k", type=int, default=-1)
584
+ ap.add_argument("--src", default="")
585
+ ap.add_argument("--only", default="")
586
+ ap.add_argument("--sent_pause", type=float, default=0.6)
587
+ ap.add_argument("--no_stitch", action="store_true")
588
+ ap.add_argument("--no_lm_prompt", action="store_true")
589
+ a = ap.parse_args()
590
+ {"lm": train_lm, "flow": train_flow, "synth": synth, "redecode": redecode}[a.cmd](a)
cosyvoice_requirements.txt ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ --extra-index-url https://download.pytorch.org/whl/cu121
2
+ --extra-index-url https://aiinfra.pkgs.visualstudio.com/PublicPackages/_packaging/onnxruntime-cuda-12/pypi/simple/ # https://github.com/microsoft/onnxruntime/issues/21684
3
+ conformer==0.3.2
4
+ deepspeed==0.15.1; sys_platform == 'linux'
5
+ diffusers==0.29.0
6
+ fastapi==0.115.6
7
+ fastapi-cli==0.0.4
8
+ gdown==5.1.0
9
+ gradio==5.4.0
10
+ grpcio==1.57.0
11
+ grpcio-tools==1.57.0
12
+ hydra-core==1.3.2
13
+ HyperPyYAML==1.2.3
14
+ inflect==7.3.1
15
+ librosa==0.10.2
16
+ lightning==2.2.4
17
+ matplotlib==3.7.5
18
+ modelscope==1.20.0
19
+ networkx==3.1
20
+ numpy==1.26.4
21
+ omegaconf==2.3.0
22
+ onnx==1.16.0
23
+ onnxruntime-gpu==1.18.0; sys_platform == 'linux'
24
+ onnxruntime==1.18.0; sys_platform == 'darwin' or sys_platform == 'win32'
25
+ openai-whisper==20231117
26
+ protobuf==4.25
27
+ pyarrow==18.1.0
28
+ pydantic==2.7.0
29
+ pyworld==0.3.4
30
+ rich==13.7.1
31
+ soundfile==0.12.1
32
+ tensorboard==2.14.0
33
+ tensorrt-cu12==10.13.3.9; sys_platform == 'linux'
34
+ tensorrt-cu12-bindings==10.13.3.9; sys_platform == 'linux'
35
+ tensorrt-cu12-libs==10.13.3.9; sys_platform == 'linux'
36
+ torch==2.3.1
37
+ torchaudio==2.3.1
38
+ transformers==4.51.3
39
+ x-transformers==2.11.24
40
+ uvicorn==0.30.0
41
+ wetext==0.0.4
42
+ wget==3.2
infer.py ADDED
@@ -0,0 +1,418 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ """Dynamic Accent (Phase 2 preview): Nigerian multilingual, code-switching TTS on CosyVoice2-0.5B.
3
+
4
+ Same inference path as the Phase 2 final samples: LM `p2_lm_B` (pause profile B) + anchor-SFT flow,
5
+ anchor prompt clip 0 at the LM and the flow, single sample, sentence-level stitching, edge trim and
6
+ internal-silence cap (0.6 s), 0.6 s between sentences.
7
+
8
+ python infer.py --text "<eng>this is dynamic accent,</eng> <pcm>e dey sound nice abi?</pcm>" --seed 0 --out out.wav
9
+
10
+ Prosody v2 (default, `--pause_mode clause`): the text is cut at , ; : . ? ! and at language-switch points; each chunk
11
+ is generated with the same LM/flow/prompt, its edge silence is trimmed (energy VAD, 30 ms kept), its speech is sped
12
+ up pitch-preserving by `--speed` (WSOLA, default 1.05), and chunks are joined with set gaps from the real anchor's pause statistics:
13
+ comma/;/: 0.40 s (+-0.05 jitter), sentence end 0.60 s, unpunctuated language switch 0.12 s, with 10 ms fades.
14
+ `--pause_mode model --speed 1.0` reproduces the Phase 2 final samples (model-generated pauses, sentence stitching).
15
+
16
+ Text: wrap each language span in <eng>, <pcm>, <yor>, <ibo> or <hau> tags. Untagged text is treated as English.
17
+ """
18
+ import argparse, inspect, json, os, re, sys, unicodedata
19
+
20
+ import numpy as np
21
+ import soundfile as sf
22
+ import librosa
23
+ import torch
24
+ import torch.nn as nn
25
+
26
+ HERE = os.path.dirname(os.path.abspath(__file__))
27
+ LANGS = ["eng", "ibo", "hau", "yor", "pcm"]
28
+ L2I = {l: i for i, l in enumerate(LANGS)}
29
+ ANCHOR = "waxal_yor_4"
30
+ DEFAULT_SPEED = 1.05 # from the speaking-rate measurement (README, "Prosody v2"): median ref/system ratio 1.06
31
+
32
+
33
+ # ------------------------------------------------------------------ text front-end (tags -> spans)
34
+ _TAG = re.compile(r"<(eng|ibo|hau|yor|pcm)>(.*?)</\1>", re.S)
35
+ _SENT = re.compile(r"(?<=[.?!])\s+")
36
+
37
+
38
+ def normalize(t):
39
+ t = unicodedata.normalize("NFC", t)
40
+ t = t.replace("\u2019", "'").replace("\u2018", "'").replace("\u201c", '"').replace("\u201d", '"')
41
+ return re.sub(r"\s+", " ", t).strip()
42
+
43
+
44
+ def segments(text, default_lang="eng"):
45
+ out, pos = [], 0
46
+ for m in _TAG.finditer(text):
47
+ pre = text[pos:m.start()]
48
+ if pre.strip():
49
+ out.append((default_lang, pre))
50
+ out.append((m.group(1), m.group(2)))
51
+ pos = m.end()
52
+ if text[pos:].strip():
53
+ out.append((default_lang, text[pos:]))
54
+ res = []
55
+ for l, s in out:
56
+ s = normalize(s)
57
+ if not s:
58
+ continue
59
+ if res:
60
+ s = " " + s
61
+ res.append((l, s))
62
+ return res
63
+
64
+
65
+ def text_tokens(tok, text, default="eng"):
66
+ ids, langs = [], []
67
+ for l, s in segments(text, default):
68
+ t = tok(s, add_special_tokens=False)["input_ids"]
69
+ ids += t; langs += [L2I[l]] * len(t)
70
+ return ids, langs
71
+
72
+
73
+ def split_sentences(text):
74
+ out, cur = [], []
75
+ for l, s in segments(text):
76
+ parts = _SENT.split(s.strip())
77
+ for k, p in enumerate(parts):
78
+ p = p.strip()
79
+ if p:
80
+ cur.append(f"<{l}>{p}</{l}>")
81
+ if (k < len(parts) - 1 or p[-1:] in ".?!") and cur:
82
+ out.append(" ".join(cur)); cur = []
83
+ if cur:
84
+ out.append(" ".join(cur))
85
+ return [o for o in out if o.strip()]
86
+
87
+
88
+ # ------------------------------------------------------------------ silence post-processing
89
+ def frame_db(y, sr, hop_s=0.01):
90
+ hop = int(sr * hop_s)
91
+ n = max(1, len(y) // hop)
92
+ fr = y[: n * hop].reshape(n, hop)
93
+ return 10 * np.log10((fr.astype(np.float64) ** 2).mean(1) + 1e-10)
94
+
95
+
96
+ def silence_mask(y, sr, rel_db=-40.0, hop_s=0.01):
97
+ e = frame_db(y, sr, hop_s)
98
+ return e < np.percentile(e, 98) + rel_db
99
+
100
+
101
+ def runs(mask):
102
+ out, i, n = [], 0, len(mask)
103
+ while i < n:
104
+ if mask[i]:
105
+ j = i
106
+ while j < n and mask[j]:
107
+ j += 1
108
+ out.append((i, j)); i = j
109
+ else:
110
+ i += 1
111
+ return out
112
+
113
+
114
+ def trim_edges(w, sr, keep_lead=0.12, keep_tail=0.18):
115
+ sil = silence_mask(w, sr)
116
+ sp = np.where(~sil)[0]
117
+ if not len(sp):
118
+ return w
119
+ a, b = sp[0] * 0.01, (sp[-1] + 1) * 0.01
120
+ out = w[int(max(0, a - keep_lead) * sr): int(min(len(w) / sr, b + keep_tail) * sr)]
121
+ lead = a - max(0, a - keep_lead)
122
+ if lead < keep_lead:
123
+ out = np.concatenate([np.zeros(int((keep_lead - lead) * sr), out.dtype), out])
124
+ return out
125
+
126
+
127
+ def cap_internal(w, sr, cap=0.6):
128
+ sil = silence_mask(w, sr)
129
+ hop = int(0.01 * sr)
130
+ R = [(a, b) for a, b in runs(sil) if a > 0 and b < len(sil) and (b - a) * 0.01 > cap]
131
+ if not R:
132
+ return w
133
+ keep, cur = [], 0
134
+ for a, b in R:
135
+ keep.append(w[cur: a * hop + int(cap / 2 * sr)]); cur = b * hop - int(cap / 2 * sr)
136
+ keep.append(w[cur:])
137
+ return np.concatenate(keep)
138
+
139
+
140
+
141
+ # ------------------------------------------------------------------ prosody v2: clause chunks, set gaps, pitch-preserving speed
142
+ _PUNCT = re.compile(r"(?<=[,;:.?!])\s+")
143
+ GAPS = {"comma": 0.40, "sent": 0.60, "switch": 0.12}
144
+ JITTER = {"comma": 0.05, "sent": 0.03, "switch": 0.02}
145
+
146
+
147
+ def clause_chunks(text):
148
+ """tagged text -> [(lang, chunk_text, boundary_after)] with boundary in comma|sent|switch|None (None = end).
149
+ Chunks are cut after , ; : . ? ! and at language switches; a one-word chunk is merged into the next chunk of the
150
+ same language (one-word LM calls are unstable), keeping its comma inside."""
151
+ raw = []
152
+ for l, s in segments(text):
153
+ for p in _PUNCT.split(s.strip()):
154
+ if p.strip():
155
+ raw.append([l, p.strip()])
156
+ merged = []
157
+ for c in raw:
158
+ if merged and merged[-1][0] == c[0] and len(merged[-1][1].split()) < 2:
159
+ merged[-1][1] = merged[-1][1] + " " + c[1]
160
+ else:
161
+ merged.append(c)
162
+ out = []
163
+ for i, (l, t) in enumerate(merged):
164
+ if i == len(merged) - 1:
165
+ b = None
166
+ elif t[-1] in ".?!":
167
+ b = "sent"
168
+ elif t[-1] in ",;:":
169
+ b = "comma"
170
+ else:
171
+ b = "switch"
172
+ out.append((l, t, b))
173
+ return out
174
+
175
+
176
+ def trim_tight(w, sr, keep=0.03):
177
+ sil = silence_mask(w, sr)
178
+ sp = np.where(~sil)[0]
179
+ if not len(sp):
180
+ return w
181
+ a, b = sp[0] * 0.01, (sp[-1] + 1) * 0.01
182
+ return w[int(max(0.0, a - keep) * sr): int(min(len(w) / sr, b + keep) * sr)]
183
+
184
+
185
+ def time_stretch(w, sr, speed):
186
+ """pitch-preserving tempo change (speed > 1 = faster). WSOLA via audiotsm, else librosa's phase vocoder."""
187
+ if abs(speed - 1.0) < 1e-3:
188
+ return w
189
+ try:
190
+ from audiotsm import wsola
191
+ from audiotsm.io.array import ArrayReader, ArrayWriter
192
+ r, wr = ArrayReader(w[None].astype(np.float32)), ArrayWriter(1)
193
+ wsola(1, speed=speed, frame_length=int(0.040 * sr), synthesis_hop=int(0.010 * sr)).run(r, wr)
194
+ return wr.data[0].astype(np.float32)
195
+ except ImportError:
196
+ return librosa.effects.time_stretch(w.astype(np.float32), rate=speed)
197
+
198
+
199
+ def fade(w, sr, ms=10):
200
+ n = min(len(w) // 2, int(sr * ms / 1000))
201
+ if n > 0:
202
+ w = w.copy(); r = np.linspace(0, 1, n, dtype=np.float32)
203
+ w[:n] *= r; w[-n:] *= r[::-1]
204
+ return w
205
+
206
+ # ------------------------------------------------------------------ CosyVoice2 base
207
+ def _sf_load_wav(wav, target_sr, min_sr=16000):
208
+ y, sr = sf.read(wav, dtype="float32", always_2d=True)
209
+ y = y.mean(1)
210
+ if sr != target_sr:
211
+ y = librosa.resample(y, orig_sr=sr, target_sr=target_sr)
212
+ return torch.from_numpy(y)[None]
213
+
214
+
215
+ def load_cosy(model_dir, cosyvoice_dir):
216
+ sys.path[:0] = [cosyvoice_dir, os.path.join(cosyvoice_dir, "third_party", "Matcha-TTS")]
217
+ import cosyvoice.utils.file_utils as fu
218
+ fu.load_wav = _sf_load_wav
219
+ import cosyvoice.cli.frontend as cf
220
+ cf.load_wav = _sf_load_wav
221
+ from cosyvoice.cli.cosyvoice import CosyVoice2
222
+ kw = dict(load_jit=False, load_trt=False, fp16=False)
223
+ sig = inspect.signature(CosyVoice2.__init__).parameters
224
+ return CosyVoice2(model_dir, **{k: v for k, v in kw.items() if k in sig})
225
+
226
+
227
+ def prompt_feats(cosy, wav_path):
228
+ fe = cosy.frontend
229
+ tokn, _ = fe._extract_speech_token(wav_path)
230
+ feat, _ = fe._extract_speech_feat(wav_path)
231
+ n = min(feat.shape[1] // 2, tokn.shape[1])
232
+ feat, tokn = feat[:, :2 * n], tokn[:, :n]
233
+ emb = fe._extract_spk_embedding(wav_path)
234
+ return tokn.cuda(), feat.cuda(), emb.cuda()
235
+
236
+
237
+ @torch.no_grad()
238
+ def token2wav(cosy, tokens, ptok, pfeat, emb):
239
+ flow = cosy.model.flow
240
+ t = torch.tensor([tokens], dtype=torch.int32).cuda()
241
+ kw = dict(token=t, token_len=torch.tensor([t.shape[1]], dtype=torch.int32).cuda(),
242
+ prompt_token=ptok, prompt_token_len=torch.tensor([ptok.shape[1]], dtype=torch.int32).cuda(),
243
+ prompt_feat=pfeat, prompt_feat_len=torch.tensor([pfeat.shape[1]], dtype=torch.int32).cuda(),
244
+ embedding=emb)
245
+ sig = inspect.signature(flow.inference).parameters
246
+ if "streaming" in sig:
247
+ kw["streaming"] = False
248
+ if "finalize" in sig:
249
+ kw["finalize"] = True
250
+ if "flow_cache" in sig:
251
+ kw["flow_cache"] = torch.zeros(1, 80, 0, 2).cuda()
252
+ mel = flow.inference(**kw)
253
+ mel = mel[0] if isinstance(mel, tuple) else mel
254
+ hs = inspect.signature(cosy.model.hift.inference).parameters
255
+ w = cosy.model.hift.inference(speech_feat=mel, **({"cache_source": torch.zeros(1, 1, 0).cuda()} if "cache_source" in hs else {}))
256
+ w = w[0] if isinstance(w, tuple) else w
257
+ return w.squeeze().float().cpu().numpy()
258
+
259
+
260
+ # ------------------------------------------------------------------ Dynamic Accent LM (Qwen2LM + language + speaker embeddings)
261
+ class DALM2(nn.Module):
262
+ def __init__(self, qlm, n_spk, d=896):
263
+ super().__init__()
264
+ self.q = qlm
265
+ self.lang_emb = nn.Embedding(len(LANGS), d)
266
+ self.spk_emb = nn.Embedding(n_spk, d)
267
+ self.adv = nn.Sequential(nn.Linear(d, 256), nn.ReLU(), nn.Linear(256, n_spk)) # training-only head
268
+ self.V = qlm.speech_token_size
269
+
270
+ def embed_text(self, ids, langs):
271
+ return self.q.llm.model.model.embed_tokens(ids) + self.lang_emb(langs)
272
+
273
+ def build2(self, ids, langs, spk, pids=None, plangs=None, psp=None):
274
+ sos = self.q.llm_embedding.weight[self.q.sos][None]
275
+ task = self.q.llm_embedding.weight[self.q.task_id][None]
276
+ if pids is not None:
277
+ ids = torch.cat([pids, ids]); langs = torch.cat([plangs, langs])
278
+ parts = [sos, self.embed_text(ids, langs), self.spk_emb(spk)[None], task]
279
+ if psp is not None:
280
+ parts.append(self.q.speech_embedding(psp))
281
+ return torch.cat(parts, 0)
282
+
283
+ @torch.no_grad()
284
+ def generate2(self, ids, langs, spk, prompt=None, top_p=0.8, top_k=25, win=10, tau_r=0.1, seed=0,
285
+ min_ratio=2.0, max_ratio=20.0):
286
+ g = torch.Generator(device=ids.device).manual_seed(seed)
287
+ x = self.build2(ids, langs, spk, *(prompt if prompt else (None, None, None)))[None]
288
+ n = len(ids)
289
+ min_len, max_len = int(n * min_ratio), int(n * max_ratio)
290
+ past, out, inp = None, [], x
291
+ for i in range(max_len):
292
+ o = self.q.llm.model.model(inputs_embeds=inp, past_key_values=past, use_cache=True, return_dict=True)
293
+ past = o.past_key_values
294
+ logp = self.q.llm_decoder(o.last_hidden_state[:, -1]).float().log_softmax(-1)[0]
295
+ if i < min_len:
296
+ logp[self.V] = -float("inf")
297
+ logp[self.V + 1:] = -float("inf")
298
+ p = logp.exp()
299
+ sp, si = p.sort(descending=True)
300
+ keep = (sp.cumsum(0) - sp) < top_p
301
+ keep[top_k:] = False
302
+ cp, ci = sp[keep], si[keep]
303
+ tokid = int(ci[torch.multinomial(cp / cp.sum(), 1, generator=g)])
304
+ if out and sum(1 for t in out[-win:] if t == tokid) >= win * tau_r and tokid != self.V: # RAS
305
+ tokid = int(torch.multinomial(p, 1, generator=g))
306
+ if tokid >= self.V:
307
+ break
308
+ out.append(tokid)
309
+ inp = self.q.speech_embedding.weight[tokid][None, None]
310
+ return out
311
+
312
+
313
+ class DynamicAccent:
314
+ def __init__(self, model_dir, cosyvoice_dir, lm_ckpt, flow_ckpt, prompts_json, dtype="auto", sent_pause=0.6, cap=0.6):
315
+ from transformers import AutoTokenizer
316
+ self.tok = AutoTokenizer.from_pretrained(os.path.join(model_dir, "CosyVoice-BlankEN"))
317
+ self.cosy = load_cosy(model_dir, cosyvoice_dir)
318
+ self.sr = self.cosy.sample_rate
319
+ self.cosy.model.flow.load_state_dict(torch.load(flow_ckpt, map_location="cuda")["flow"])
320
+ ck = torch.load(lm_ckpt, map_location="cuda")
321
+ self.spk2id = json.load(open(os.path.join(os.path.dirname(lm_ckpt), "spk2id.json")))
322
+ self.m = DALM2(self.cosy.model.llm, ck["n_spk"]).cuda().eval()
323
+ self.m.load_state_dict(ck["model"])
324
+ if dtype == "auto":
325
+ dtype = "bf16" if torch.cuda.is_bf16_supported() else "fp16"
326
+ self.dtype = torch.bfloat16 if dtype == "bf16" else torch.float16
327
+ self.sent_pause, self.cap = sent_pause, cap
328
+ meta = json.load(open(prompts_json, encoding="utf-8"))
329
+ root = os.path.dirname(os.path.dirname(os.path.abspath(prompts_json)))
330
+ self.prompts = []
331
+ for p in meta["prompts"]:
332
+ ids, langs = text_tokens(self.tok, p["tagged"])
333
+ lmp = (torch.tensor(ids).cuda(), torch.tensor(langs).cuda(), torch.tensor(p["s3_tokens"], dtype=torch.int64).cuda())
334
+ self.prompts.append(dict(id=p["id"], lm=lmp, flow=prompt_feats(self.cosy, os.path.join(root, p["wav"]))))
335
+
336
+ def one(self, tagged_text, seed, prompt_k, use_lm_prompt=True):
337
+ ids, langs = text_tokens(self.tok, tagged_text)
338
+ P = self.prompts[prompt_k % len(self.prompts)]
339
+ with torch.autocast("cuda", dtype=self.dtype):
340
+ sp = self.m.generate2(torch.tensor(ids).cuda(), torch.tensor(langs).cuda(),
341
+ torch.tensor(self.spk2id[ANCHOR]).cuda(), prompt=P["lm"] if use_lm_prompt else None, seed=seed)
342
+ if len(sp) < 3:
343
+ sp = sp + [0] * 3
344
+ return token2wav(self.cosy, sp, *P["flow"])
345
+
346
+ def say(self, tagged_text, seed=0, prompt_k=0, stitch=True, pause_mode="clause", speed=1.10, return_info=False):
347
+ if pause_mode == "clause":
348
+ return self.say_clause(tagged_text, seed, prompt_k, speed, return_info)
349
+ w = self.say_model(tagged_text, seed, prompt_k, stitch)
350
+ w = time_stretch(w, self.sr, speed)
351
+ return (w, None) if return_info else w
352
+
353
+ def say_clause(self, tagged_text, seed=0, prompt_k=0, speed=1.10, return_info=False):
354
+ rng = np.random.default_rng(seed)
355
+ chunks = clause_chunks(tagged_text)
356
+ out, info, lead = [], [], 0.03
357
+ for i, (l, t, b) in enumerate(chunks):
358
+ w = self.one(f"<{l}>{t}</{l}>", seed + 1000 * i, prompt_k)
359
+ w = cap_internal(trim_tight(w, self.sr), self.sr, self.cap)
360
+ w = fade(time_stretch(w, self.sr, speed), self.sr)
361
+ if i == 0:
362
+ out.append(np.zeros(int(0.12 * self.sr), np.float32)) # same 0.12 s lead as model mode
363
+ out.append(w)
364
+ if b is not None:
365
+ g = GAPS[b] + rng.uniform(-JITTER[b], JITTER[b])
366
+ out.append(np.zeros(max(0, int((g - 2 * lead) * self.sr)), np.float32)) # 30 ms kept each side
367
+ else:
368
+ out.append(np.zeros(int(0.15 * self.sr), np.float32))
369
+ info.append(dict(lang=l, text=t, boundary=b))
370
+ w = np.concatenate(out)
371
+ return (w, info) if return_info else w
372
+
373
+ def say_model(self, tagged_text, seed=0, prompt_k=0, stitch=True):
374
+ sents = split_sentences(tagged_text) if stitch else [tagged_text]
375
+ ws = [cap_internal(trim_edges(self.one(s, seed + 1000 * i, prompt_k), self.sr), self.sr, self.cap)
376
+ for i, s in enumerate(sents)]
377
+ if len(ws) == 1:
378
+ return ws[0]
379
+ out = []
380
+ for i, w in enumerate(ws):
381
+ if i:
382
+ out.append(np.zeros(max(0, int((self.sent_pause - 0.30) * self.sr)), np.float32))
383
+ out.append(w)
384
+ return np.concatenate(out)
385
+
386
+
387
+ def main():
388
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
389
+ ap.add_argument("--text", required=True, help="tagged text, e.g. '<eng>Good morning,</eng> <pcm>how body?</pcm>'")
390
+ ap.add_argument("--seed", type=int, default=0)
391
+ ap.add_argument("--out", default="out.wav")
392
+ ap.add_argument("--prompt_clip", type=int, default=0, help="anchor prompt clip 0-3 (final system: 0)")
393
+ ap.add_argument("--model_dir", default=os.environ.get("COSYVOICE2_DIR", os.path.join(HERE, "pretrained", "CosyVoice2-0.5B")),
394
+ help="stock FunAudioLLM/CosyVoice2-0.5B snapshot")
395
+ ap.add_argument("--cosyvoice_dir", default=os.environ.get("COSYVOICE_REPO", os.path.join(HERE, "CosyVoice")),
396
+ help="clone of github.com/FunAudioLLM/CosyVoice (with submodules)")
397
+ ap.add_argument("--lm", default=os.path.join(HERE, "checkpoints", "p2_lm_B", "best.pt"))
398
+ ap.add_argument("--flow", default=os.path.join(HERE, "checkpoints", "p2_flow_sft", "best.pt"))
399
+ ap.add_argument("--prompts", default=os.path.join(HERE, "prompts", "prompts.json"))
400
+ ap.add_argument("--dtype", default="auto", choices=["auto", "bf16", "fp16"],
401
+ help="LM autocast dtype. Samples were made with bf16; T4 has no bf16, so auto picks fp16 there")
402
+ ap.add_argument("--no_stitch", action="store_true", help="generate multi-sentence text in one pass")
403
+ ap.add_argument("--pause_mode", default="clause", choices=["clause", "model"],
404
+ help="clause: clause-level chunks joined with anchor-matched gaps (default); model: Phase 2 final behaviour")
405
+ ap.add_argument("--speed", type=float, default=DEFAULT_SPEED,
406
+ help="pitch-preserving tempo factor applied to speech (gaps are kept as set). 1.0 = Phase 2 final pace")
407
+ a = ap.parse_args()
408
+ if not os.path.isdir(a.model_dir):
409
+ from huggingface_hub import snapshot_download
410
+ snapshot_download("FunAudioLLM/CosyVoice2-0.5B", local_dir=a.model_dir)
411
+ S = DynamicAccent(a.model_dir, a.cosyvoice_dir, a.lm, a.flow, a.prompts, a.dtype)
412
+ w = S.say(a.text, seed=a.seed, prompt_k=a.prompt_clip, stitch=not a.no_stitch, pause_mode=a.pause_mode, speed=a.speed)
413
+ sf.write(a.out, w, S.sr)
414
+ print(f"wrote {a.out} ({len(w) / S.sr:.2f} s, {S.sr} Hz)")
415
+
416
+
417
+ if __name__ == "__main__":
418
+ main()
prompts/clip0_waxal_yor_yor_1258.wav ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:e7e368595eb4c267f124164c7d8a83b03462d549c5efd235dd8ec6eb557dc418
3
+ size 285164
prompts/clip1_waxal_yor_yor_1205.wav ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:d8731a9032a1e84a88a2494b65a576794f732cf5c8555c1b94af1a5d370ad7c9
3
+ size 314924
prompts/clip2_waxal_yor_yor_1263.wav ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:0cf10737379673b123cee008f78006f4fa2fdabc779563d466f85f0772b9cec5
3
+ size 194924
prompts/clip3_waxal_yor_yor_1218.wav ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:5a0e00a437713a35429fe9423c3acf4ec668038e418fc7b85391bec53cb2368a
3
+ size 262124
prompts/prompts.json ADDED
@@ -0,0 +1,602 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "anchor_speaker": "waxal_yor_4",
3
+ "source": "WAXAL (CC-BY-SA-4.0), anchor val clips, pause profile B (~/data3)",
4
+ "prompts": [
5
+ {
6
+ "index": 0,
7
+ "id": "waxal_yor_yor_1258",
8
+ "wav": "prompts/clip0_waxal_yor_yor_1258.wav",
9
+ "lang": "yor",
10
+ "text": "Púpọ̀ jùlọ àwọn olókìkí ní ìlú Èkó ń gbé ní erékùṣù náà.",
11
+ "tagged": "<yor>Púpọ̀ jùlọ àwọn olókìkí ní ìlú Èkó ń gbé ní erékùṣù náà.</yor>",
12
+ "dur": 5.94,
13
+ "s3_tokens": [
14
+ 3677,
15
+ 4137,
16
+ 4218,
17
+ 2166,
18
+ 1452,
19
+ 5830,
20
+ 2175,
21
+ 2112,
22
+ 696,
23
+ 5027,
24
+ 4834,
25
+ 2344,
26
+ 1860,
27
+ 5973,
28
+ 1939,
29
+ 1862,
30
+ 1558,
31
+ 3880,
32
+ 316,
33
+ 2621,
34
+ 2864,
35
+ 2864,
36
+ 2891,
37
+ 5051,
38
+ 3412,
39
+ 3888,
40
+ 3888,
41
+ 1458,
42
+ 1701,
43
+ 1950,
44
+ 1973,
45
+ 1946,
46
+ 3890,
47
+ 4133,
48
+ 692,
49
+ 3617,
50
+ 4346,
51
+ 3617,
52
+ 5030,
53
+ 4955,
54
+ 2768,
55
+ 4955,
56
+ 2789,
57
+ 2006,
58
+ 2903,
59
+ 3395,
60
+ 3771,
61
+ 6013,
62
+ 3853,
63
+ 5096,
64
+ 5095,
65
+ 1350,
66
+ 978,
67
+ 2298,
68
+ 5586,
69
+ 1122,
70
+ 1788,
71
+ 2598,
72
+ 295,
73
+ 566,
74
+ 51,
75
+ 3669,
76
+ 3748,
77
+ 2938,
78
+ 4751,
79
+ 4750,
80
+ 4741,
81
+ 2006,
82
+ 1349,
83
+ 1835,
84
+ 1510,
85
+ 1588,
86
+ 2180,
87
+ 5581,
88
+ 4606,
89
+ 2330,
90
+ 1127,
91
+ 573,
92
+ 660,
93
+ 1364,
94
+ 617,
95
+ 2290,
96
+ 5209,
97
+ 2077,
98
+ 2076,
99
+ 2184,
100
+ 967,
101
+ 4616,
102
+ 4607,
103
+ 4526,
104
+ 4769,
105
+ 2591,
106
+ 4526,
107
+ 4579,
108
+ 2446,
109
+ 4137,
110
+ 4137,
111
+ 4137,
112
+ 1959,
113
+ 3670,
114
+ 3671,
115
+ 3751,
116
+ 1479,
117
+ 4769,
118
+ 5021,
119
+ 4994,
120
+ 4751,
121
+ 5561,
122
+ 5561,
123
+ 5236,
124
+ 3255,
125
+ 4218,
126
+ 4218,
127
+ 4218,
128
+ 4137,
129
+ 5352,
130
+ 6081,
131
+ 5379,
132
+ 1006,
133
+ 1034,
134
+ 4679,
135
+ 2960,
136
+ 2411,
137
+ 1207,
138
+ 860,
139
+ 4760,
140
+ 233,
141
+ 1407,
142
+ 654,
143
+ 3567,
144
+ 5826,
145
+ 6310,
146
+ 5481,
147
+ 5563,
148
+ 3348,
149
+ 6556,
150
+ 2102,
151
+ 886,
152
+ 1563,
153
+ 1479,
154
+ 2300,
155
+ 4547,
156
+ 4546,
157
+ 2522,
158
+ 263,
159
+ 263,
160
+ 1225,
161
+ 1953,
162
+ 1947
163
+ ]
164
+ },
165
+ {
166
+ "index": 1,
167
+ "id": "waxal_yor_yor_1205",
168
+ "wav": "prompts/clip1_waxal_yor_yor_1205.wav",
169
+ "lang": "yor",
170
+ "text": "Mejìlá ní ìsọdipúpọ̀ nípasẹ̀ mẹ́rin dọ́gba mejì-mejìlá.",
171
+ "tagged": "<yor>Mejìlá ní ìsọdipúpọ̀ nípasẹ̀ mẹ́rin dọ́gba mejì-mejìlá.</yor>",
172
+ "dur": 6.56,
173
+ "s3_tokens": [
174
+ 3677,
175
+ 1968,
176
+ 1804,
177
+ 1884,
178
+ 1883,
179
+ 2312,
180
+ 4579,
181
+ 3021,
182
+ 1041,
183
+ 5250,
184
+ 4858,
185
+ 80,
186
+ 3799,
187
+ 1532,
188
+ 731,
189
+ 6158,
190
+ 4486,
191
+ 3669,
192
+ 5938,
193
+ 3748,
194
+ 4742,
195
+ 4751,
196
+ 4751,
197
+ 4751,
198
+ 4714,
199
+ 5605,
200
+ 1951,
201
+ 2031,
202
+ 1978,
203
+ 3481,
204
+ 1268,
205
+ 3721,
206
+ 4672,
207
+ 4999,
208
+ 4752,
209
+ 586,
210
+ 2894,
211
+ 2413,
212
+ 1608,
213
+ 2347,
214
+ 1940,
215
+ 1410,
216
+ 2139,
217
+ 6558,
218
+ 5829,
219
+ 2148,
220
+ 2112,
221
+ 1410,
222
+ 5099,
223
+ 5081,
224
+ 2894,
225
+ 707,
226
+ 518,
227
+ 1308,
228
+ 1950,
229
+ 4131,
230
+ 3645,
231
+ 1707,
232
+ 1959,
233
+ 1987,
234
+ 1753,
235
+ 1484,
236
+ 5857,
237
+ 1482,
238
+ 4571,
239
+ 3637,
240
+ 4299,
241
+ 1383,
242
+ 3574,
243
+ 4214,
244
+ 3647,
245
+ 56,
246
+ 5401,
247
+ 6374,
248
+ 1035,
249
+ 32,
250
+ 2303,
251
+ 2303,
252
+ 227,
253
+ 2127,
254
+ 6502,
255
+ 1882,
256
+ 4742,
257
+ 4490,
258
+ 4579,
259
+ 4550,
260
+ 3016,
261
+ 1724,
262
+ 377,
263
+ 377,
264
+ 2456,
265
+ 2456,
266
+ 25,
267
+ 3903,
268
+ 3975,
269
+ 4137,
270
+ 4218,
271
+ 4137,
272
+ 4137,
273
+ 6324,
274
+ 6324,
275
+ 6324,
276
+ 1968,
277
+ 3913,
278
+ 1727,
279
+ 1807,
280
+ 1110,
281
+ 2666,
282
+ 5078,
283
+ 5078,
284
+ 1125,
285
+ 2157,
286
+ 1452,
287
+ 389,
288
+ 5186,
289
+ 2999,
290
+ 2513,
291
+ 2270,
292
+ 3242,
293
+ 5429,
294
+ 5105,
295
+ 5103,
296
+ 2922,
297
+ 1464,
298
+ 1950,
299
+ 4218,
300
+ 1987,
301
+ 1987,
302
+ 1987,
303
+ 1804,
304
+ 6256,
305
+ 1639,
306
+ 2573,
307
+ 4849,
308
+ 4696,
309
+ 1509,
310
+ 1779,
311
+ 4440,
312
+ 5911,
313
+ 4857,
314
+ 4777,
315
+ 323,
316
+ 323,
317
+ 2445,
318
+ 978,
319
+ 1950,
320
+ 1968,
321
+ 1996,
322
+ 1885,
323
+ 1882,
324
+ 4760,
325
+ 4696,
326
+ 3966,
327
+ 5658,
328
+ 4431,
329
+ 5506,
330
+ 78,
331
+ 5986,
332
+ 74,
333
+ 1460,
334
+ 1460,
335
+ 982,
336
+ 1701,
337
+ 1947
338
+ ]
339
+ },
340
+ {
341
+ "index": 2,
342
+ "id": "waxal_yor_yor_1263",
343
+ "wav": "prompts/clip2_waxal_yor_yor_1263.wav",
344
+ "lang": "yor",
345
+ "text": "Ọmọ ọdún méji ni Nóla ní oṣù kẹwàá.",
346
+ "tagged": "<yor>Ọmọ ọdún méji ni Nóla ní oṣù kẹwàá.</yor>",
347
+ "dur": 4.06,
348
+ "s3_tokens": [
349
+ 3677,
350
+ 4218,
351
+ 2057,
352
+ 4217,
353
+ 2762,
354
+ 2127,
355
+ 4314,
356
+ 2852,
357
+ 2771,
358
+ 572,
359
+ 4835,
360
+ 963,
361
+ 1914,
362
+ 954,
363
+ 6554,
364
+ 5041,
365
+ 6502,
366
+ 4068,
367
+ 2420,
368
+ 4858,
369
+ 2265,
370
+ 1995,
371
+ 2022,
372
+ 4440,
373
+ 5262,
374
+ 5506,
375
+ 323,
376
+ 753,
377
+ 1563,
378
+ 750,
379
+ 323,
380
+ 2267,
381
+ 2591,
382
+ 2321,
383
+ 2240,
384
+ 4670,
385
+ 2455,
386
+ 4389,
387
+ 2922,
388
+ 3648,
389
+ 4137,
390
+ 1950,
391
+ 1987,
392
+ 1753,
393
+ 1481,
394
+ 1480,
395
+ 3745,
396
+ 6012,
397
+ 1073,
398
+ 4949,
399
+ 5054,
400
+ 2297,
401
+ 5905,
402
+ 5986,
403
+ 2216,
404
+ 5915,
405
+ 5186,
406
+ 5186,
407
+ 4469,
408
+ 2916,
409
+ 3645,
410
+ 1458,
411
+ 1464,
412
+ 4218,
413
+ 4137,
414
+ 1725,
415
+ 3670,
416
+ 3831,
417
+ 2941,
418
+ 5102,
419
+ 5561,
420
+ 6074,
421
+ 5234,
422
+ 2012,
423
+ 2006,
424
+ 2096,
425
+ 2912,
426
+ 5828,
427
+ 2990,
428
+ 6051,
429
+ 6054,
430
+ 1588,
431
+ 2072,
432
+ 2139,
433
+ 1734,
434
+ 2355,
435
+ 2528,
436
+ 4571,
437
+ 2384,
438
+ 881,
439
+ 644,
440
+ 1456,
441
+ 2185,
442
+ 707,
443
+ 1946,
444
+ 812,
445
+ 3242,
446
+ 5914,
447
+ 4545,
448
+ 1711,
449
+ 2031,
450
+ 6405
451
+ ]
452
+ },
453
+ {
454
+ "index": 3,
455
+ "id": "waxal_yor_yor_1218",
456
+ "wav": "prompts/clip3_waxal_yor_yor_1218.wav",
457
+ "lang": "yor",
458
+ "text": "Ìdajì àti ọ̀kan kẹ́fà dọ́gba méji kẹ́ta.",
459
+ "tagged": "<yor>Ìdajì àti ọ̀kan kẹ́fà dọ́gba méji kẹ́ta.</yor>",
460
+ "dur": 5.46,
461
+ "s3_tokens": [
462
+ 3707,
463
+ 2031,
464
+ 566,
465
+ 566,
466
+ 4778,
467
+ 861,
468
+ 1770,
469
+ 68,
470
+ 3083,
471
+ 5996,
472
+ 4570,
473
+ 777,
474
+ 3939,
475
+ 4683,
476
+ 5169,
477
+ 5911,
478
+ 3239,
479
+ 1052,
480
+ 286,
481
+ 259,
482
+ 5109,
483
+ 2922,
484
+ 2922,
485
+ 3894,
486
+ 3651,
487
+ 2031,
488
+ 1976,
489
+ 3161,
490
+ 3890,
491
+ 3890,
492
+ 2219,
493
+ 1527,
494
+ 2085,
495
+ 6380,
496
+ 6131,
497
+ 5401,
498
+ 1038,
499
+ 2267,
500
+ 4697,
501
+ 4697,
502
+ 4778,
503
+ 4697,
504
+ 4778,
505
+ 5507,
506
+ 5507,
507
+ 5507,
508
+ 3050,
509
+ 3255,
510
+ 3894,
511
+ 4218,
512
+ 4137,
513
+ 1979,
514
+ 1304,
515
+ 1385,
516
+ 2897,
517
+ 601,
518
+ 4299,
519
+ 2031,
520
+ 2754,
521
+ 4869,
522
+ 5030,
523
+ 5030,
524
+ 665,
525
+ 497,
526
+ 1723,
527
+ 2112,
528
+ 762,
529
+ 2271,
530
+ 2213,
531
+ 4523,
532
+ 4853,
533
+ 6372,
534
+ 6534,
535
+ 6534,
536
+ 688,
537
+ 650,
538
+ 1055,
539
+ 1784,
540
+ 3161,
541
+ 2917,
542
+ 2916,
543
+ 1458,
544
+ 1458,
545
+ 1461,
546
+ 2031,
547
+ 1807,
548
+ 1726,
549
+ 1806,
550
+ 1806,
551
+ 873,
552
+ 2657,
553
+ 5081,
554
+ 4852,
555
+ 1941,
556
+ 1888,
557
+ 2181,
558
+ 218,
559
+ 3242,
560
+ 2999,
561
+ 2999,
562
+ 3242,
563
+ 3170,
564
+ 2926,
565
+ 3645,
566
+ 3645,
567
+ 1701,
568
+ 2031,
569
+ 1959,
570
+ 1727,
571
+ 1727,
572
+ 3994,
573
+ 6256,
574
+ 1883,
575
+ 4841,
576
+ 4848,
577
+ 2346,
578
+ 1779,
579
+ 1284,
580
+ 4509,
581
+ 5181,
582
+ 1943,
583
+ 1923,
584
+ 1707,
585
+ 168,
586
+ 4751,
587
+ 2330,
588
+ 62,
589
+ 67,
590
+ 1707,
591
+ 2058,
592
+ 54,
593
+ 2189,
594
+ 3242,
595
+ 2927,
596
+ 2925,
597
+ 1944,
598
+ 4218
599
+ ]
600
+ }
601
+ ]
602
+ }
requirements.txt ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python 3.10. Install torch/torchaudio for your CUDA first (Colab already has them), then:
2
+ # pip install -r requirements.txt
3
+ # and clone CosyVoice (see README). Versions below are the ones the samples were rendered with
4
+ # (pins loosened where Colab's preinstalled versions also work).
5
+ torch>=2.3
6
+ torchaudio>=2.3
7
+ transformers==4.51.3
8
+ huggingface_hub>=0.23
9
+ numpy==1.26.4
10
+ soundfile>=0.12
11
+ librosa>=0.10
12
+ onnxruntime-gpu>=1.18
13
+ HyperPyYAML>=1.2.2
14
+ modelscope>=1.20
15
+ conformer==0.3.2
16
+ diffusers==0.29.0
17
+ lightning>=2.2.4
18
+ omegaconf>=2.3.0
19
+ hydra-core==1.3.2
20
+ inflect>=7.3
21
+ x-transformers>=2.11
22
+ openai-whisper>=20231117
23
+ einops>=0.8
24
+ pyworld>=0.3.4
25
+ pyarrow>=18
26
+ wget==3.2
27
+ gdown>=5.1
28
+ rich>=13.7
29
+ matplotlib>=3.7
30
+ protobuf>=4.25
31
+ pydantic>=2.7
32
+ wetext==0.0.4
33
+ audiotsm==0.1.2 # WSOLA time-stretch for --speed (falls back to librosa if missing)
samples/pcm_eng_dynamic_accent_seed0.mp3 ADDED
Binary file (62.6 kB). View file
 
samples/pcm_eng_dynamic_accent_seed1.mp3 ADDED
Binary file (51.1 kB). View file
 
samples/pcm_eng_dynamic_accent_seed2.mp3 ADDED
Binary file (56.5 kB). View file
 
samples/prosody_v2/custom_pcm_eng.mp3 ADDED
Binary file (63.4 kB). View file
 
samples/prosody_v2/ibo_000.mp3 ADDED
Binary file (84.9 kB). View file
 
samples/prosody_v2/para_hau_eng_pcm.mp3 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:ce6ab62b8647c8325ffb8ba499ea2b9ec3db623aa3e68aaafe5667225751abcb
3
+ size 289964
samples/prosody_v2/para_ibo_pcm_eng.mp3 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:fa85fb1f58a0778a7cc0027b4d9919ed36537f518054b7f9f8b3f9a9a5cff232
3
+ size 291116
samples/prosody_v2/para_yor_eng.mp3 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:e2a0f0280ac149d9691975ba6f1e8c763c9d91a365632202d5b1a42b675e36c3
3
+ size 241196
samples/prosody_v2/yor_000.mp3 ADDED
Binary file (32.7 kB). View file