xmanii commited on
Commit
bb3a349
·
verified ·
1 Parent(s): 6dfe122

Release Ava 82M v0.1

Browse files

Listener-selected Persian Kokoro Stage-2 Epoch-2 checkpoint with a one-call Python API, CLI, Persian frontend, default boundary cleanup, model card, licenses, hashes, and samples.

.gitattributes CHANGED
@@ -1,35 +1,5 @@
1
- *.7z filter=lfs diff=lfs merge=lfs -text
2
- *.arrow filter=lfs diff=lfs merge=lfs -text
3
- *.bin filter=lfs diff=lfs merge=lfs -text
4
- *.bz2 filter=lfs diff=lfs merge=lfs -text
5
- *.ckpt filter=lfs diff=lfs merge=lfs -text
6
- *.ftz filter=lfs diff=lfs merge=lfs -text
7
- *.gz filter=lfs diff=lfs merge=lfs -text
8
- *.h5 filter=lfs diff=lfs merge=lfs -text
9
- *.joblib filter=lfs diff=lfs merge=lfs -text
10
- *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
- *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
- *.model filter=lfs diff=lfs merge=lfs -text
13
- *.msgpack filter=lfs diff=lfs merge=lfs -text
14
- *.npy filter=lfs diff=lfs merge=lfs -text
15
- *.npz filter=lfs diff=lfs merge=lfs -text
16
- *.onnx filter=lfs diff=lfs merge=lfs -text
17
- *.ot filter=lfs diff=lfs merge=lfs -text
18
- *.parquet filter=lfs diff=lfs merge=lfs -text
19
- *.pb filter=lfs diff=lfs merge=lfs -text
20
- *.pickle filter=lfs diff=lfs merge=lfs -text
21
- *.pkl filter=lfs diff=lfs merge=lfs -text
22
- *.pt filter=lfs diff=lfs merge=lfs -text
23
  *.pth filter=lfs diff=lfs merge=lfs -text
24
- *.rar filter=lfs diff=lfs merge=lfs -text
25
- *.safetensors filter=lfs diff=lfs merge=lfs -text
26
- saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
- *.tar.* filter=lfs diff=lfs merge=lfs -text
28
- *.tar filter=lfs diff=lfs merge=lfs -text
29
- *.tflite filter=lfs diff=lfs merge=lfs -text
30
- *.tgz filter=lfs diff=lfs merge=lfs -text
31
- *.wasm filter=lfs diff=lfs merge=lfs -text
32
- *.xz 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
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  *.pth filter=lfs diff=lfs merge=lfs -text
2
+ *.pt filter=lfs diff=lfs merge=lfs -text
3
+ *.wav filter=lfs diff=lfs merge=lfs -text
4
+ *.whl filter=lfs diff=lfs merge=lfs -text
5
+
 
 
 
 
 
 
 
 
.gitignore ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.py[cod]
3
+ .pytest_cache/
4
+ .venv/
5
+ .uv-cache/
6
+ .cache/
7
+ dist/
8
+ outputs/
9
+ ava.wav
10
+
CITATIONS.md ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Citations
2
+
3
+ ## Mana-TTS
4
+
5
+ ```bibtex
6
+ @inproceedings{qharabagh-etal-2025-manatts,
7
+ title = {{M}ana{TTS} {P}ersian: A Recipe for Creating {TTS} Datasets for Lower-Resource Languages},
8
+ author = {Qharabagh, Mahta Fetrat and Dehghanian, Zahra and Rabiee, Hamid R.},
9
+ booktitle = {Proceedings of the 2025 Conference of the North American Chapter of the Association for Computational Linguistics},
10
+ year = {2025},
11
+ publisher = {Association for Computational Linguistics},
12
+ pages = {9177--9206},
13
+ url = {https://aclanthology.org/2025.naacl-long.464/}
14
+ }
15
+ ```
16
+
17
+ ## Homo-GE2PE
18
+
19
+ ```bibtex
20
+ @misc{qharabagh2025fastfancyrethinkingg2p,
21
+ title = {Fast, Not Fancy: Rethinking G2P with Rich Data and Rule-Based Models},
22
+ author = {Mahta Fetrat Qharabagh and Zahra Dehghanian and Hamid R. Rabiee},
23
+ year = {2025},
24
+ eprint = {2505.12973},
25
+ archivePrefix = {arXiv},
26
+ primaryClass = {cs.CL},
27
+ url = {https://arxiv.org/abs/2505.12973}
28
+ }
29
+ ```
30
+
31
+ ## StyleTTS2
32
+
33
+ ```bibtex
34
+ @article{li2023styletts2,
35
+ title = {StyleTTS 2: Towards Human-Level Text-to-Speech through Style Diffusion and Adversarial Training with Large Speech Language Models},
36
+ author = {Li, Yinghao Aaron and Han, Cong and Raghavan, Vinay and Mischler, Gavin and Mesgarani, Nima},
37
+ journal = {arXiv preprint arXiv:2306.07691},
38
+ year = {2023}
39
+ }
40
+ ```
41
+
42
+ ## iSTFTNet
43
+
44
+ ```bibtex
45
+ @inproceedings{kaneko2022istftnet,
46
+ title = {iSTFTNet: Fast and Lightweight Mel-Spectrogram Vocoder Incorporating Inverse Short-Time Fourier Transform},
47
+ author = {Kaneko, Takuhiro and Tanaka, Kou and Kameoka, Hirokazu and Seki, Shogo},
48
+ booktitle = {ICASSP},
49
+ year = {2022}
50
+ }
51
+ ```
52
+
LICENSE ADDED
@@ -0,0 +1,201 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright [yyyy] [name of copyright owner]
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
NOTICE.md ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Ava 82M notices
2
+
3
+ Ava 82M is a Persian fine-tune of
4
+ [hexgrad/Kokoro-82M](https://huggingface.co/hexgrad/Kokoro-82M).
5
+ Kokoro's weights and inference code are distributed under Apache License 2.0.
6
+
7
+ The inference package pins the compatible
8
+ [semidark/kokoro](https://github.com/semidark/kokoro) runtime at commit
9
+ `b96fef95e6a746495f92443fac7c688f90fc57fc`.
10
+
11
+ Fine-tuning audio was selected from
12
+ [MahtaFetrat/Mana-TTS](https://huggingface.co/datasets/MahtaFetrat/Mana-TTS),
13
+ released under CC0 1.0. The dataset creators request ethical use and expressly
14
+ warn against voice impersonation, identity theft, and fraud.
15
+
16
+ Persian grapheme-to-phoneme conversion uses
17
+ [MahtaFetrat/Homo-GE2PE-Persian](https://huggingface.co/MahtaFetrat/Homo-GE2PE-Persian),
18
+ released under the MIT License.
19
+
20
+ Ava is released by Nimruz. “Ava” (آوا) means voice or sound in Persian.
21
+ No affiliation with the upstream Kokoro or Mana-TTS authors is implied.
22
+
README.md ADDED
@@ -0,0 +1,203 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ language:
3
+ - fa
4
+ license: apache-2.0
5
+ library_name: kokoro
6
+ pipeline_tag: text-to-speech
7
+ base_model: hexgrad/Kokoro-82M
8
+ base_model_relation: finetune
9
+ datasets:
10
+ - MahtaFetrat/Mana-TTS
11
+ tags:
12
+ - persian
13
+ - farsi
14
+ - tts
15
+ - speech-synthesis
16
+ - kokoro
17
+ - styletts2
18
+ - accessibility
19
+ ---
20
+
21
+ # Ava 82M (آوا)
22
+
23
+ **Ava** is a lightweight, open-weight Persian text-to-speech model released by
24
+ Nimruz. It is a Persian adaptation of
25
+ [Kokoro-82M](https://huggingface.co/hexgrad/Kokoro-82M), fine-tuned on an
26
+ approximately 10-hour curated subset of
27
+ [Mana-TTS](https://huggingface.co/datasets/MahtaFetrat/Mana-TTS).
28
+
29
+ Ava produces 24 kHz, single-speaker Persian speech. The release includes a
30
+ Persian text frontend, number/date normalization, contextual grapheme-to-phoneme
31
+ conversion, pronunciation overrides, automatic long-text splitting, and
32
+ conservative cleanup of leading and trailing synthesis artifacts.
33
+
34
+ > **Status:** v0.1 research release. Manual listening found the model clear and
35
+ > stable on conversational text, numbers, dates, and prices, but no formal MOS
36
+ > or Persian intelligibility benchmark has been completed yet.
37
+
38
+ ## Quick start
39
+
40
+ Python 3.11–3.13 and Git are required. Install the small Ava package directly
41
+ from this repository:
42
+
43
+ ```bash
44
+ python -m pip install \
45
+ "https://huggingface.co/xmanii/Ava-82M/resolve/main/ava_tts-0.1.0-py3-none-any.whl"
46
+ ```
47
+
48
+ Then generate speech:
49
+
50
+ ```python
51
+ from ava_tts import Ava
52
+
53
+ tts = Ava()
54
+ tts.save("سلام! من آوا هستم.", "ava.wav")
55
+ ```
56
+
57
+ The first run downloads the 82M-parameter acoustic model and the pinned Persian
58
+ G2P frontend. Later runs use the local cache.
59
+
60
+ ### Command line
61
+
62
+ ```bash
63
+ ava "قیمت این محصول سه میلیون و چهارصد هزار تومان است." -o price.wav
64
+ ```
65
+
66
+ Adjust speaking rate:
67
+
68
+ ```bash
69
+ ava "امروز بیست و ششم تیر است." --speed 0.95 -o slower.wav
70
+ ```
71
+
72
+ Boundary cleanup is enabled by default because listening tests found that it
73
+ removes the short robotic noise that can occur before or after speech. To
74
+ inspect the unprocessed model output:
75
+
76
+ ```bash
77
+ ava "سلام، این خروجی خام مدل است." --raw-boundaries -o raw.wav
78
+ ```
79
+
80
+ ### Reuse one model for multiple utterances
81
+
82
+ Loading the model once is faster:
83
+
84
+ ```python
85
+ from ava_tts import Ava
86
+
87
+ tts = Ava.from_pretrained("xmanii/Ava-82M")
88
+ tts.save("این جملهٔ اول است.", "first.wav")
89
+ tts.save("و این جملهٔ دوم است.", "second.wav", speed=1.05)
90
+ ```
91
+
92
+ On the tested MacBook Air M4, CPU inference generated approximately 4.5 seconds
93
+ of speech in roughly 0.9 seconds after loading. CPU is therefore selected
94
+ automatically on Apple Silicon.
95
+
96
+ ## What is included
97
+
98
+ | File | Purpose |
99
+ |---|---|
100
+ | `model.pth` | Selected Stage-2 Epoch-2 acoustic checkpoint |
101
+ | `voice.pt` | Persian single-speaker style/voice tensor |
102
+ | `config.json` | Kokoro architecture and phoneme vocabulary |
103
+ | `pronunciations.json` | Reviewed pronunciation correction layer |
104
+ | `ava_tts-0.1.0-py3-none-any.whl` | Friendly Python API and `ava` command |
105
+ | `samples/ava-fa-sample.wav` | Boundary-cleaned example output |
106
+ | `artifact_manifest.json` | File sizes, hashes, and provenance |
107
+
108
+ The package pins the exact Kokoro runtime revision used during local and cloud
109
+ validation. The public `kokoro==0.9.4` decoder is not weight-compatible with
110
+ this checkpoint.
111
+
112
+ ## Architecture
113
+
114
+ Ava retains Kokoro's compact StyleTTS2/iSTFTNet-derived inference architecture:
115
+
116
+ 1. Persian text is normalized, including Persian/Arabic digits, dates, times,
117
+ percentages, and common numeric forms.
118
+ 2. The pinned Homo-GE2PE model converts contextual Persian writing into
119
+ phonemes.
120
+ 3. Ava maps those phonemes into Kokoro's IPA vocabulary.
121
+ 4. The duration and prosody predictor estimates timing and pitch.
122
+ 5. The decoder/vocoder generates a 24 kHz waveform directly.
123
+ 6. Boundary-only cleanup trims synthesis artifacts without modifying internal
124
+ pauses.
125
+
126
+ The acoustic network has **81,763,410 inference parameters**. The separate G2P
127
+ frontend is not included in that parameter count.
128
+
129
+ ## Training and checkpoint selection
130
+
131
+ - Base model: `hexgrad/Kokoro-82M`
132
+ - Fine-tuning data: approximately 10 curated hours from `MahtaFetrat/Mana-TTS`
133
+ - Training recipe: StyleTTS2/Kokoro two-stage fine-tuning
134
+ - Released checkpoint: Stage 2, Epoch 2
135
+ - Sample rate: 24 kHz
136
+ - Voice count: one
137
+ - Validation loss recorded at the selected checkpoint: `0.48536252975463867`
138
+
139
+ The later joint-training experiment became numerically unstable and its
140
+ listener-evaluated output was more robotic, so it was deliberately excluded.
141
+ The released checkpoint is the best listener-approved artifact, not simply the
142
+ last checkpoint produced.
143
+
144
+ ## Evaluation
145
+
146
+ Evaluation for v0.1 is primarily structured human listening:
147
+
148
+ - Pronunciation was substantially more intelligible than the untuned Kokoro
149
+ baseline.
150
+ - Persian accent and phrasing improved over the short pilot.
151
+ - Numbers, dates, and prices were intelligible after normalization.
152
+ - Stage-2 Epoch 2 reduced most of the robotic tone heard in Stage 1.
153
+ - Boundary cleanup removed the brief synthetic onset/ending artifact without
154
+ changing internal pauses.
155
+ - Local output closely matched the cloud evaluation path after pinning the
156
+ exact runtime.
157
+
158
+ Formal MOS, speaker-similarity, real-time-factor across platforms, and Persian
159
+ phoneme-error-rate evaluations remain future work.
160
+
161
+ ## Limitations
162
+
163
+ - Ava v0.1 has one voice and may preserve characteristics of the Mana-TTS
164
+ narrator.
165
+ - Some uncommon names, loanwords, homographs, and specialized terminology can
166
+ still be mispronounced.
167
+ - Tone can occasionally sound synthetic, and pace can vary.
168
+ - Very long text is split automatically and may have noticeable joins.
169
+ - The Persian frontend is a separate model, so first-run download and memory
170
+ use are larger than the 82M acoustic parameter count suggests.
171
+ - The model has been tested most thoroughly on contemporary Iranian Persian.
172
+ - This research release is not guaranteed for safety-critical use.
173
+
174
+ ## Responsible use
175
+
176
+ Use Ava for ethical speech and accessibility applications. Do not use it for
177
+ impersonation, identity theft, fraud, deception, harassment, or misleading
178
+ claims that generated speech came from a real person. Clearly disclose
179
+ synthetic speech where a listener could reasonably mistake it for authentic
180
+ recorded speech.
181
+
182
+ The Mana-TTS maintainers specifically prohibit impersonation, identity theft,
183
+ and fraudulent use in their ethical-use notice.
184
+
185
+ ## License and attribution
186
+
187
+ Ava code and weights are released under Apache License 2.0. See
188
+ [`LICENSE`](./LICENSE) and [`NOTICE.md`](./NOTICE.md).
189
+
190
+ This release depends on:
191
+
192
+ - [Kokoro-82M](https://huggingface.co/hexgrad/Kokoro-82M), Apache-2.0.
193
+ - [Mana-TTS](https://huggingface.co/datasets/MahtaFetrat/Mana-TTS), CC0-1.0.
194
+ - [Homo-GE2PE-Persian](https://huggingface.co/MahtaFetrat/Homo-GE2PE-Persian),
195
+ MIT.
196
+ - [StyleTTS2](https://github.com/yl4579/StyleTTS2).
197
+
198
+ ## Citation
199
+
200
+ If you use Ava, please cite this repository as well as Kokoro, StyleTTS2,
201
+ Mana-TTS, and Homo-GE2PE. Full upstream citations are included in
202
+ [`CITATIONS.md`](./CITATIONS.md).
203
+
artifact_manifest.json ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "release": "Ava-82M-v0.1",
3
+ "repository": "xmanii/Ava-82M",
4
+ "publisher": "Nimruz",
5
+ "selected_checkpoint": "Stage-2 Epoch 2",
6
+ "source_checkpoint": "/output/pilot-10h-3plus3/epoch_2nd_00001.pth",
7
+ "source_epoch": 1,
8
+ "source_validation_loss": 0.48536252975463867,
9
+ "inference_parameters": 81763410,
10
+ "sample_rate": 24000,
11
+ "training_data_hours_approximate": 10,
12
+ "frontend": {
13
+ "normalizer": "ava-fa-v0.1",
14
+ "g2p_repository": "MahtaFetrat/Homo-GE2PE-Persian",
15
+ "g2p_revision": "9587f1e7966e41eaa0c514453cff7fb89db3473e",
16
+ "kokoro_runtime": "https://github.com/semidark/kokoro.git",
17
+ "kokoro_runtime_revision": "b96fef95e6a746495f92443fac7c688f90fc57fc"
18
+ },
19
+ "base_model": "hexgrad/Kokoro-82M",
20
+ "dataset": "MahtaFetrat/Mana-TTS",
21
+ "artifacts": {
22
+ "model.pth": {
23
+ "bytes": 327231582,
24
+ "sha256": "d60347fe3d8e47431c8eec2c0d44656fa19aa1b9a9360bbe0be3133fa4b6666d"
25
+ },
26
+ "voice.pt": {
27
+ "bytes": 523475,
28
+ "sha256": "15558df28cf358198b194b5ccb55241ff6a318badf11adf32346c5b3a08ca591"
29
+ },
30
+ "config.json": {
31
+ "bytes": 2351,
32
+ "sha256": "5abb01e2403b072bf03d04fde160443e209d7a0dad49a423be15196b9b43c17f"
33
+ },
34
+ "pronunciations.json": {
35
+ "bytes": 103,
36
+ "sha256": "0878f283f1b0340e6173fd51b7600a9bade6e22231feeef654ddc84151f7124e"
37
+ },
38
+ "ava_tts-0.1.0-py3-none-any.whl": {
39
+ "bytes": 20385,
40
+ "sha256": "258f7d533060ddbe4e408563037c491d91444f1070b3b216809f1b42c2c7454c"
41
+ },
42
+ "samples/ava-fa-sample.wav": {
43
+ "bytes": 191564,
44
+ "sha256": "8a90dd4a9e79d3c6babf38ad2f1999748735ceb96ac628c11b341e81e814c2d7"
45
+ },
46
+ "samples/ava-fa-release-smoke.wav": {
47
+ "bytes": 167084,
48
+ "sha256": "570d4dc43e261ca6f289cde3d680679ff3dfa467b8361be41dd0460d0d52e755"
49
+ },
50
+ "samples/ava-fa-wheel-smoke.wav": {
51
+ "bytes": 110444,
52
+ "sha256": "4dad33b0579731cd7e7dc19bf573074e6beb04f4e576802f0922fd1b591650df"
53
+ }
54
+ }
55
+ }
ava_tts-0.1.0-py3-none-any.whl ADDED
Binary file (20.4 kB). View file
 
config.json ADDED
@@ -0,0 +1,150 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "istftnet": {
3
+ "upsample_kernel_sizes": [20, 12],
4
+ "upsample_rates": [10, 6],
5
+ "gen_istft_hop_size": 5,
6
+ "gen_istft_n_fft": 20,
7
+ "resblock_dilation_sizes": [
8
+ [1, 3, 5],
9
+ [1, 3, 5],
10
+ [1, 3, 5]
11
+ ],
12
+ "resblock_kernel_sizes": [3, 7, 11],
13
+ "upsample_initial_channel": 512
14
+ },
15
+ "dim_in": 64,
16
+ "dropout": 0.2,
17
+ "hidden_dim": 512,
18
+ "max_conv_dim": 512,
19
+ "max_dur": 50,
20
+ "multispeaker": true,
21
+ "n_layer": 3,
22
+ "n_mels": 80,
23
+ "n_token": 178,
24
+ "style_dim": 128,
25
+ "text_encoder_kernel_size": 5,
26
+ "plbert": {
27
+ "hidden_size": 768,
28
+ "num_attention_heads": 12,
29
+ "intermediate_size": 2048,
30
+ "max_position_embeddings": 512,
31
+ "num_hidden_layers": 12,
32
+ "dropout": 0.1
33
+ },
34
+ "vocab": {
35
+ ";": 1,
36
+ ":": 2,
37
+ ",": 3,
38
+ ".": 4,
39
+ "!": 5,
40
+ "?": 6,
41
+ "—": 9,
42
+ "…": 10,
43
+ "\"": 11,
44
+ "(": 12,
45
+ ")": 13,
46
+ "“": 14,
47
+ "”": 15,
48
+ " ": 16,
49
+ "\u0303": 17,
50
+ "ʣ": 18,
51
+ "ʥ": 19,
52
+ "ʦ": 20,
53
+ "ʨ": 21,
54
+ "ᵝ": 22,
55
+ "\uAB67": 23,
56
+ "A": 24,
57
+ "I": 25,
58
+ "O": 31,
59
+ "Q": 33,
60
+ "S": 35,
61
+ "T": 36,
62
+ "W": 39,
63
+ "Y": 41,
64
+ "ᵊ": 42,
65
+ "a": 43,
66
+ "b": 44,
67
+ "c": 45,
68
+ "d": 46,
69
+ "e": 47,
70
+ "f": 48,
71
+ "h": 50,
72
+ "i": 51,
73
+ "j": 52,
74
+ "k": 53,
75
+ "l": 54,
76
+ "m": 55,
77
+ "n": 56,
78
+ "o": 57,
79
+ "p": 58,
80
+ "q": 59,
81
+ "r": 60,
82
+ "s": 61,
83
+ "t": 62,
84
+ "u": 63,
85
+ "v": 64,
86
+ "w": 65,
87
+ "x": 66,
88
+ "y": 67,
89
+ "z": 68,
90
+ "ɑ": 69,
91
+ "ɐ": 70,
92
+ "ɒ": 71,
93
+ "æ": 72,
94
+ "β": 75,
95
+ "ɔ": 76,
96
+ "ɕ": 77,
97
+ "ç": 78,
98
+ "ɖ": 80,
99
+ "ð": 81,
100
+ "ʤ": 82,
101
+ "ə": 83,
102
+ "ɚ": 85,
103
+ "ɛ": 86,
104
+ "ɜ": 87,
105
+ "ɟ": 90,
106
+ "ɡ": 92,
107
+ "ɥ": 99,
108
+ "ɨ": 101,
109
+ "ɪ": 102,
110
+ "ʝ": 103,
111
+ "ɯ": 110,
112
+ "ɰ": 111,
113
+ "ŋ": 112,
114
+ "ɳ": 113,
115
+ "ɲ": 114,
116
+ "ɴ": 115,
117
+ "ø": 116,
118
+ "ɸ": 118,
119
+ "θ": 119,
120
+ "œ": 120,
121
+ "ɹ": 123,
122
+ "ɾ": 125,
123
+ "ɻ": 126,
124
+ "ʁ": 128,
125
+ "ɽ": 129,
126
+ "ʂ": 130,
127
+ "ʃ": 131,
128
+ "ʈ": 132,
129
+ "ʧ": 133,
130
+ "ʊ": 135,
131
+ "ʋ": 136,
132
+ "ʌ": 138,
133
+ "ɣ": 139,
134
+ "ɤ": 140,
135
+ "χ": 142,
136
+ "ʎ": 143,
137
+ "ʒ": 147,
138
+ "ʔ": 148,
139
+ "ˈ": 156,
140
+ "ˌ": 157,
141
+ "ː": 158,
142
+ "ʰ": 162,
143
+ "ʲ": 164,
144
+ "↓": 169,
145
+ "→": 171,
146
+ "↗": 172,
147
+ "↘": 173,
148
+ "ᵻ": 177
149
+ }
150
+ }
inference.py ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ """Minimal local example after installing the Ava wheel."""
2
+
3
+ from ava_tts import Ava
4
+
5
+ tts = Ava.from_pretrained("xmanii/Ava-82M")
6
+ tts.save(
7
+ "سلام! من آوا هستم، یک مدل سبک برای تبدیل متن فارسی به گفتار.",
8
+ "ava.wav",
9
+ )
10
+
model.pth ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:d60347fe3d8e47431c8eec2c0d44656fa19aa1b9a9360bbe0be3133fa4b6666d
3
+ size 327231582
pronunciations.json ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ {
2
+ "version": 1,
3
+ "word_overrides": {},
4
+ "full_text_overrides": {},
5
+ "phoneme_substitutions": {}
6
+ }
pyproject.toml ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [project]
2
+ name = "ava-tts"
3
+ version = "0.1.0"
4
+ description = "Ava: lightweight open Persian text-to-speech"
5
+ readme = "README.md"
6
+ license = "Apache-2.0"
7
+ requires-python = ">=3.11,<3.14"
8
+ authors = [{ name = "Nimruz" }]
9
+ keywords = ["tts", "persian", "farsi", "speech", "kokoro"]
10
+ classifiers = [
11
+ "Development Status :: 3 - Alpha",
12
+ "License :: OSI Approved :: Apache Software License",
13
+ "Programming Language :: Python :: 3",
14
+ "Programming Language :: Python :: 3.11",
15
+ "Programming Language :: Python :: 3.12",
16
+ "Programming Language :: Python :: 3.13",
17
+ ]
18
+ dependencies = [
19
+ "huggingface-hub>=0.34,<2",
20
+ "kokoro @ git+https://github.com/semidark/kokoro.git@b96fef95e6a746495f92443fac7c688f90fc57fc",
21
+ "numpy>=1.26,<3",
22
+ "sentencepiece>=0.2,<1",
23
+ "soundfile>=0.13,<1",
24
+ "torch>=2.6,<3",
25
+ "transformers>=4.51,<5",
26
+ ]
27
+
28
+ [project.urls]
29
+ Homepage = "https://huggingface.co/xmanii/Ava-82M"
30
+ Repository = "https://huggingface.co/xmanii/Ava-82M"
31
+
32
+ [project.scripts]
33
+ ava = "ava_tts.cli:main"
34
+
35
+ [build-system]
36
+ requires = ["hatchling"]
37
+ build-backend = "hatchling.build"
38
+
39
+ [tool.hatch.build.targets.wheel]
40
+ packages = ["src/ava_tts"]
41
+
42
+ [tool.hatch.metadata]
43
+ allow-direct-references = true
44
+
45
+ [dependency-groups]
46
+ dev = ["pytest>=8,<10"]
47
+
48
+ [tool.pytest.ini_options]
49
+ pythonpath = ["src"]
50
+ testpaths = ["tests"]
requirements.txt ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ huggingface-hub>=0.34,<2
2
+ kokoro @ git+https://github.com/semidark/kokoro.git@b96fef95e6a746495f92443fac7c688f90fc57fc
3
+ numpy>=1.26,<3
4
+ sentencepiece>=0.2,<1
5
+ soundfile>=0.13,<1
6
+ torch>=2.6,<3
7
+ transformers>=4.51,<5
8
+
samples/README.md ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Ava v0.1 samples
2
+
3
+ All samples are 24 kHz mono PCM-16 WAV files with the default boundary cleanup.
4
+
5
+ | File | Persian text |
6
+ |---|---|
7
+ | `ava-fa-sample.wav` | سلام، امروز می‌خواهم کیفیت مدل گفتار فارسی را بررسی کنم. |
8
+ | `ava-fa-release-smoke.wav` | قیمت این کتاب سیصد و چهل و هشت هزار تومان است. |
9
+ | `ava-fa-wheel-smoke.wav` | سلام از نسخهٔ نصب‌شدهٔ آوا. |
10
+
11
+ The second and third files were generated through the final `Ava` API. The
12
+ third was generated after unpacking the built wheel, exercising the same code
13
+ users install from Hugging Face.
14
+
samples/ava-fa-release-smoke.wav ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:570d4dc43e261ca6f289cde3d680679ff3dfa467b8361be41dd0460d0d52e755
3
+ size 167084
samples/ava-fa-sample.wav ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:8a90dd4a9e79d3c6babf38ad2f1999748735ceb96ac628c11b341e81e814c2d7
3
+ size 191564
samples/ava-fa-wheel-smoke.wav ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:4dad33b0579731cd7e7dc19bf573074e6beb04f4e576802f0922fd1b591650df
3
+ size 110444
src/ava_tts/__init__.py ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ """Ava: lightweight open Persian text-to-speech."""
2
+
3
+ from .model import Ava, Generation
4
+
5
+ __all__ = ["Ava", "Generation"]
6
+ __version__ = "0.1.0"
7
+
src/ava_tts/audio.py ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Optional boundary-only cleanup for generated waveforms."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ import numpy as np
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class BoundaryCleanup:
12
+ audio: np.ndarray
13
+ trimmed_start_ms: float
14
+ trimmed_end_ms: float
15
+ threshold_db: float
16
+
17
+
18
+ def _sustained_regions(mask: np.ndarray, required: int) -> tuple[int, int] | None:
19
+ if len(mask) < required:
20
+ return None
21
+ run = np.convolve(mask.astype(np.int16), np.ones(required, dtype=np.int16), mode="valid")
22
+ starts = np.flatnonzero(run == required)
23
+ if not len(starts):
24
+ return None
25
+ first = int(starts[0])
26
+ last = int(starts[-1] + required)
27
+ return first, last
28
+
29
+
30
+ def clean_boundaries(
31
+ audio: np.ndarray,
32
+ sample_rate: int,
33
+ *,
34
+ threshold_db: float = -28.0,
35
+ frame_ms: float = 10.0,
36
+ hop_ms: float = 5.0,
37
+ sustained_ms: float = 20.0,
38
+ keep_ms: float = 70.0,
39
+ fade_ms: float = 12.0,
40
+ ) -> BoundaryCleanup:
41
+ """Trim only utterance edges; internal pauses are never modified."""
42
+
43
+ waveform = np.asarray(audio, dtype=np.float32).reshape(-1)
44
+ frame = max(1, round(sample_rate * frame_ms / 1000))
45
+ hop = max(1, round(sample_rate * hop_ms / 1000))
46
+ if len(waveform) < frame * 3:
47
+ return BoundaryCleanup(waveform.copy(), 0.0, 0.0, threshold_db)
48
+
49
+ starts = np.arange(0, len(waveform) - frame + 1, hop)
50
+ rms = np.array(
51
+ [
52
+ np.sqrt(np.mean(waveform[start : start + frame] ** 2) + 1e-12)
53
+ for start in starts
54
+ ],
55
+ dtype=np.float64,
56
+ )
57
+ peak = float(rms.max())
58
+ if peak <= 1e-8:
59
+ return BoundaryCleanup(waveform.copy(), 0.0, 0.0, threshold_db)
60
+ relative_db = 20 * np.log10(np.maximum(rms, 1e-12) / peak)
61
+ required = max(1, round(sustained_ms / hop_ms))
62
+ region = _sustained_regions(relative_db >= threshold_db, required)
63
+ if region is None:
64
+ return BoundaryCleanup(waveform.copy(), 0.0, 0.0, threshold_db)
65
+
66
+ first_frame, last_frame = region
67
+ keep = round(sample_rate * keep_ms / 1000)
68
+ start_sample = max(0, int(starts[first_frame]) - keep)
69
+ end_sample = min(len(waveform), int(starts[last_frame - 1]) + frame + keep)
70
+ if end_sample <= start_sample:
71
+ return BoundaryCleanup(waveform.copy(), 0.0, 0.0, threshold_db)
72
+
73
+ cleaned = waveform[start_sample:end_sample].copy()
74
+ fade = min(round(sample_rate * fade_ms / 1000), len(cleaned) // 4)
75
+ if fade:
76
+ ramp = np.sin(np.linspace(0, np.pi / 2, fade, dtype=np.float32)) ** 2
77
+ cleaned[:fade] *= ramp
78
+ cleaned[-fade:] *= ramp[::-1]
79
+ return BoundaryCleanup(
80
+ cleaned,
81
+ trimmed_start_ms=1000 * start_sample / sample_rate,
82
+ trimmed_end_ms=1000 * (len(waveform) - end_sample) / sample_rate,
83
+ threshold_db=threshold_db,
84
+ )
src/ava_tts/cli.py ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Command-line interface for Ava Persian TTS."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ from pathlib import Path
7
+
8
+ from .model import Ava
9
+
10
+
11
+ def main() -> None:
12
+ parser = argparse.ArgumentParser(description="Generate Persian speech with Ava")
13
+ parser.add_argument("text", nargs="?", help="Persian text to speak")
14
+ parser.add_argument("--text-file", type=Path, help="read Persian text from a UTF-8 file")
15
+ parser.add_argument("-o", "--output", type=Path, default=Path("ava.wav"))
16
+ parser.add_argument("--speed", type=float, default=1.0)
17
+ parser.add_argument(
18
+ "--device",
19
+ choices=("auto", "cpu", "mps", "cuda"),
20
+ default="auto",
21
+ )
22
+ parser.add_argument(
23
+ "--raw-boundaries",
24
+ action="store_true",
25
+ help="disable the recommended leading/trailing artifact cleanup",
26
+ )
27
+ args = parser.parse_args()
28
+
29
+ text = args.text
30
+ if args.text_file:
31
+ text = args.text_file.read_text(encoding="utf-8")
32
+ if not text:
33
+ parser.error("provide Persian text or --text-file")
34
+
35
+ tts = Ava(device=args.device)
36
+ result = tts.generate(
37
+ text,
38
+ speed=args.speed,
39
+ cleanup=not args.raw_boundaries,
40
+ )
41
+
42
+ import soundfile as sf
43
+
44
+ args.output.parent.mkdir(parents=True, exist_ok=True)
45
+ sf.write(args.output, result.audio, result.sample_rate, subtype="PCM_16")
46
+ print(f"Saved {result.duration:.2f}s of speech to {args.output.resolve()}")
47
+ print(f"Device: {result.device}; segments: {len(result.phonemes)}")
48
+
src/ava_tts/frontend.py ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pinned Homo-GE2PE to Kokoro phoneme conversion."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import unicodedata
7
+
8
+ _GE2PE_TO_IPA = {
9
+ "/": "æ",
10
+ "@": "ʔ",
11
+ "$": "ʃ",
12
+ "?": "ʔ",
13
+ "A": "ɒ",
14
+ "a": "ɒ",
15
+ "C": "ʧ",
16
+ "c": "ʧ",
17
+ "g": "ɡ",
18
+ "j": "ʤ",
19
+ "q": "ɣ",
20
+ "r": "ɾ",
21
+ "S": "ʃ",
22
+ "y": "j",
23
+ "Z": "ʒ",
24
+ "1": "",
25
+ "،": ",",
26
+ "؛": ";",
27
+ "؟": "?",
28
+ "ـ": "",
29
+ "\u200c": "",
30
+ "\u200f": "",
31
+ "\ufeff": "",
32
+ "-": " ",
33
+ }
34
+ _GE2PE_DIGRAPHS = {
35
+ "sh": "ʃ",
36
+ "zh": "ʒ",
37
+ "ch": "ʧ",
38
+ "kh": "x",
39
+ "gh": "ɣ",
40
+ }
41
+
42
+
43
+ def ge2pe_to_kokoro_ipa(value: str) -> str:
44
+ value = unicodedata.normalize("NFC", value.strip())
45
+ for digraph, ipa in _GE2PE_DIGRAPHS.items():
46
+ value = value.replace(digraph, ipa)
47
+ converted = "".join(_GE2PE_TO_IPA.get(character, character) for character in value)
48
+ return re.sub(r"\s+", " ", converted).strip()
49
+
50
+
51
+ def validate_phonemes(phonemes: str, vocab: dict[str, int]) -> str:
52
+ if not phonemes.strip():
53
+ raise ValueError("phoneme sequence is empty")
54
+ if len(phonemes) > 510:
55
+ raise ValueError(f"phoneme sequence is {len(phonemes)} characters; maximum is 510")
56
+ unknown = sorted(set(phonemes) - set(vocab), key=ord)
57
+ if unknown:
58
+ detail = ", ".join(f"{item!r} U+{ord(item):04X}" for item in unknown)
59
+ raise ValueError(f"unsupported Kokoro phoneme symbols: {detail}")
60
+ return phonemes
src/ava_tts/g2p.py ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pinned Persian grapheme-to-phoneme inference used by Ava."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import zipfile
6
+ from pathlib import Path
7
+
8
+ REPO_ID = "MahtaFetrat/Homo-GE2PE-Persian"
9
+ REVISION = "9587f1e7966e41eaa0c514453cff7fb89db3473e"
10
+ ARCHIVE = "model-weights/homo-ge2pe.zip"
11
+
12
+
13
+ def _find_model(root: Path) -> Path:
14
+ candidates = sorted(
15
+ config.parent
16
+ for config in root.rglob("config.json")
17
+ if (config.parent / "tokenizer_config.json").exists()
18
+ or (config.parent / "spiece.model").exists()
19
+ )
20
+ if len(candidates) != 1:
21
+ raise RuntimeError(f"expected one G2P checkpoint under {root}, found {candidates}")
22
+ return candidates[0]
23
+
24
+
25
+ def ensure_model(cache_dir: Path) -> Path:
26
+ """Download and safely extract the pinned G2P checkpoint once."""
27
+
28
+ from huggingface_hub import hf_hub_download
29
+
30
+ extracted = cache_dir / "homo-ge2pe" / REVISION
31
+ marker = extracted / ".complete"
32
+ if marker.exists():
33
+ return _find_model(extracted)
34
+ archive = Path(
35
+ hf_hub_download(
36
+ repo_id=REPO_ID,
37
+ filename=ARCHIVE,
38
+ revision=REVISION,
39
+ cache_dir=cache_dir / "huggingface",
40
+ )
41
+ )
42
+ extracted.mkdir(parents=True, exist_ok=True)
43
+ with zipfile.ZipFile(archive) as handle:
44
+ root = extracted.resolve()
45
+ for member in handle.infolist():
46
+ destination = (extracted / member.filename).resolve()
47
+ if not destination.is_relative_to(root):
48
+ raise RuntimeError(f"unsafe G2P archive path: {member.filename}")
49
+ handle.extractall(extracted)
50
+ model = _find_model(extracted)
51
+ marker.write_text(str(model.relative_to(extracted)) + "\n", encoding="utf-8")
52
+ return model
53
+
54
+
55
+ class PersianG2P:
56
+ """Lazy, reusable Homo-GE2PE frontend."""
57
+
58
+ def __init__(self, cache_dir: Path):
59
+ self.cache_dir = cache_dir
60
+ self._tokenizer = None
61
+ self._model = None
62
+
63
+ def _load(self) -> None:
64
+ if self._model is not None:
65
+ return
66
+ import torch
67
+ from transformers import AutoTokenizer, T5ForConditionalGeneration
68
+
69
+ model_dir = ensure_model(self.cache_dir)
70
+ self._tokenizer = AutoTokenizer.from_pretrained(model_dir)
71
+ self._model = T5ForConditionalGeneration.from_pretrained(
72
+ model_dir,
73
+ torch_dtype=torch.float32,
74
+ ).to("cpu").eval()
75
+
76
+ def __call__(self, text: str) -> str:
77
+ import torch
78
+
79
+ self._load()
80
+ encoded = self._tokenizer(
81
+ [text],
82
+ padding=True,
83
+ add_special_tokens=False,
84
+ return_attention_mask=True,
85
+ return_tensors="pt",
86
+ )
87
+ with torch.inference_mode():
88
+ output = self._model.generate(
89
+ **encoded,
90
+ num_beams=5,
91
+ min_length=1,
92
+ max_length=512,
93
+ early_stopping=True,
94
+ )
95
+ return self._tokenizer.batch_decode(output, skip_special_tokens=True)[0]
96
+
src/ava_tts/model.py ADDED
@@ -0,0 +1,252 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """High-level Persian text-to-speech API for Ava."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import platform
8
+ import re
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+ from typing import Literal
12
+
13
+ import numpy as np
14
+
15
+ from .audio import clean_boundaries
16
+ from .frontend import ge2pe_to_kokoro_ipa, validate_phonemes
17
+ from .g2p import PersianG2P
18
+ from .pronunciation import apply_lexicon, load_lexicon
19
+ from .text import normalize_persian
20
+
21
+ DEFAULT_REPO_ID = "xmanii/Ava-82M"
22
+ SAMPLE_RATE = 24_000
23
+ MAX_PHONEMES = 510
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class Generation:
28
+ """Generated audio and the frontend decisions used to produce it."""
29
+
30
+ audio: np.ndarray
31
+ sample_rate: int
32
+ normalized_text: str
33
+ phonemes: tuple[str, ...]
34
+ device: str
35
+
36
+ @property
37
+ def duration(self) -> float:
38
+ return len(self.audio) / self.sample_rate
39
+
40
+
41
+ def _select_device(requested: str) -> str:
42
+ import torch
43
+
44
+ if requested != "auto":
45
+ if requested == "mps" and not torch.backends.mps.is_available():
46
+ raise RuntimeError("MPS is not available")
47
+ if requested == "cuda" and not torch.cuda.is_available():
48
+ raise RuntimeError("CUDA is not available")
49
+ return requested
50
+ # The validated Kokoro decoder contains MPS fallbacks. On the tested M4,
51
+ # native CPU was around 13x faster than the MPS path.
52
+ if platform.system() == "Darwin" and platform.machine() == "arm64":
53
+ return "cpu"
54
+ if torch.cuda.is_available():
55
+ return "cuda"
56
+ return "cpu"
57
+
58
+
59
+ def _default_cache() -> Path:
60
+ configured = os.environ.get("AVA_CACHE_DIR")
61
+ return Path(configured).expanduser() if configured else Path.home() / ".cache" / "ava-tts"
62
+
63
+
64
+ class Ava:
65
+ """One-object interface for Persian text-to-speech."""
66
+
67
+ def __init__(
68
+ self,
69
+ repo_id: str = DEFAULT_REPO_ID,
70
+ *,
71
+ device: Literal["auto", "cpu", "mps", "cuda"] = "auto",
72
+ cache_dir: str | Path | None = None,
73
+ local_dir: str | Path | None = None,
74
+ ):
75
+ os.environ.setdefault("PYTORCH_ENABLE_MPS_FALLBACK", "1")
76
+ self.repo_id = repo_id
77
+ self.cache_dir = Path(cache_dir) if cache_dir else _default_cache()
78
+ self.cache_dir.mkdir(parents=True, exist_ok=True)
79
+ self.device = _select_device(device)
80
+ self._g2p = PersianG2P(self.cache_dir)
81
+
82
+ paths = self._resolve_artifacts(Path(local_dir) if local_dir else None)
83
+ self._config = json.loads(paths["config"].read_text(encoding="utf-8"))
84
+ self._lexicon = load_lexicon(paths["lexicon"])
85
+
86
+ import torch
87
+ from kokoro import KModel
88
+
89
+ self._model = KModel(
90
+ repo_id="hexgrad/Kokoro-82M",
91
+ config=str(paths["config"]),
92
+ model=str(paths["model"]),
93
+ disable_complex=self.device == "mps",
94
+ ).to(self.device).eval()
95
+ self._voice = torch.load(
96
+ paths["voice"],
97
+ map_location="cpu",
98
+ weights_only=True,
99
+ )
100
+
101
+ @classmethod
102
+ def from_pretrained(
103
+ cls,
104
+ repo_id: str = DEFAULT_REPO_ID,
105
+ **kwargs,
106
+ ) -> "Ava":
107
+ """Load Ava from Hugging Face."""
108
+
109
+ return cls(repo_id=repo_id, **kwargs)
110
+
111
+ def _resolve_artifacts(self, local_dir: Path | None) -> dict[str, Path]:
112
+ filenames = {
113
+ "model": "model.pth",
114
+ "voice": "voice.pt",
115
+ "config": "config.json",
116
+ "lexicon": "pronunciations.json",
117
+ }
118
+ if local_dir:
119
+ paths = {key: local_dir / filename for key, filename in filenames.items()}
120
+ else:
121
+ from huggingface_hub import hf_hub_download
122
+
123
+ paths = {
124
+ key: Path(
125
+ hf_hub_download(
126
+ repo_id=self.repo_id,
127
+ filename=filename,
128
+ cache_dir=self.cache_dir / "hub",
129
+ )
130
+ )
131
+ for key, filename in filenames.items()
132
+ }
133
+ missing = [str(path) for path in paths.values() if not path.exists()]
134
+ if missing:
135
+ raise FileNotFoundError(f"missing Ava release artifacts: {missing}")
136
+ return paths
137
+
138
+ def _phonemize_normalized(self, normalized: str) -> str:
139
+ ge2pe = self._g2p(normalized)
140
+ result = apply_lexicon(normalized, ge2pe, self._lexicon)
141
+ return result.phonemes
142
+
143
+ @staticmethod
144
+ def _split_point(text: str) -> int:
145
+ """Choose a natural split close to the middle of a long utterance."""
146
+
147
+ middle = len(text) // 2
148
+ punctuation = [match.end() for match in re.finditer(r"[.!؟?!؛;،,]", text)]
149
+ spaces = [match.start() for match in re.finditer(r"\s+", text)]
150
+ candidates = [
151
+ point
152
+ for point in punctuation
153
+ if len(text) * 0.2 <= point <= len(text) * 0.8
154
+ ]
155
+ if not candidates:
156
+ candidates = [
157
+ point
158
+ for point in spaces
159
+ if len(text) * 0.2 <= point <= len(text) * 0.8
160
+ ]
161
+ return min(candidates, key=lambda point: abs(point - middle)) if candidates else -1
162
+
163
+ def phonemize(self, text: str) -> tuple[str, tuple[str, ...]]:
164
+ """Normalize and phonemize text, automatically splitting long input."""
165
+
166
+ normalized = normalize_persian(text, spoken=True)
167
+ if not normalized:
168
+ raise ValueError("text is empty")
169
+ pending = [normalized]
170
+ completed: list[str] = []
171
+ while pending:
172
+ piece = pending.pop(0).strip()
173
+ # Split conservatively before G2P generation can approach its own
174
+ # output limit. A later phoneme-length check remains authoritative.
175
+ if len(piece) > 350:
176
+ split = self._split_point(piece)
177
+ if split < 1:
178
+ raise ValueError(
179
+ "one text segment is too long and has no safe punctuation "
180
+ "or word boundary"
181
+ )
182
+ pending[0:0] = [piece[:split], piece[split:]]
183
+ continue
184
+ phonemes = self._phonemize_normalized(piece)
185
+ if len(phonemes) <= MAX_PHONEMES:
186
+ completed.append(validate_phonemes(phonemes, self._config["vocab"]))
187
+ continue
188
+ split = self._split_point(piece)
189
+ if split < 1:
190
+ raise ValueError(
191
+ "one text segment exceeds Ava's 510-phoneme context window "
192
+ "and has no safe punctuation or word boundary"
193
+ )
194
+ pending[0:0] = [piece[:split], piece[split:]]
195
+ return normalized, tuple(completed)
196
+
197
+ def generate(
198
+ self,
199
+ text: str,
200
+ *,
201
+ speed: float = 1.0,
202
+ cleanup: bool = True,
203
+ pause_ms: float = 120.0,
204
+ ) -> Generation:
205
+ """Generate 24 kHz mono audio from Persian text."""
206
+
207
+ if speed <= 0:
208
+ raise ValueError("speed must be greater than zero")
209
+ import torch
210
+
211
+ normalized, segments = self.phonemize(text)
212
+ waveforms: list[np.ndarray] = []
213
+ silence = np.zeros(round(SAMPLE_RATE * pause_ms / 1000), dtype=np.float32)
214
+ for index, phonemes in enumerate(segments):
215
+ reference = self._voice[len(phonemes) - 1].to(self.device)
216
+ with torch.inference_mode():
217
+ audio = self._model(phonemes, reference, speed=speed).numpy()
218
+ audio = np.asarray(audio, dtype=np.float32)
219
+ if cleanup:
220
+ audio = clean_boundaries(audio, SAMPLE_RATE).audio
221
+ if index:
222
+ waveforms.append(silence)
223
+ waveforms.append(audio)
224
+ joined = np.concatenate(waveforms) if waveforms else np.empty(0, dtype=np.float32)
225
+ return Generation(
226
+ audio=np.clip(joined, -1.0, 1.0),
227
+ sample_rate=SAMPLE_RATE,
228
+ normalized_text=normalized,
229
+ phonemes=segments,
230
+ device=self.device,
231
+ )
232
+
233
+ def save(
234
+ self,
235
+ text: str,
236
+ output: str | Path = "ava.wav",
237
+ **kwargs,
238
+ ) -> Path:
239
+ """Generate speech and save a PCM-16 WAV file."""
240
+
241
+ import soundfile as sf
242
+
243
+ destination = Path(output)
244
+ destination.parent.mkdir(parents=True, exist_ok=True)
245
+ generation = self.generate(text, **kwargs)
246
+ sf.write(
247
+ destination,
248
+ generation.audio,
249
+ generation.sample_rate,
250
+ subtype="PCM_16",
251
+ )
252
+ return destination.resolve()
src/ava_tts/pronunciation.py ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Reviewed pronunciation overrides layered on top of the pinned G2P."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+
10
+ from .frontend import ge2pe_to_kokoro_ipa
11
+
12
+ _EDGE_PUNCTUATION = "\"'()[]{}،؛:؟!,.…"
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class PronunciationResult:
17
+ phonemes: str
18
+ base_phonemes: str
19
+ applied: tuple[str, ...]
20
+
21
+
22
+ def load_lexicon(path: Path) -> dict:
23
+ data = json.loads(path.read_text(encoding="utf-8"))
24
+ if data.get("version") != 1:
25
+ raise ValueError(f"unsupported pronunciation lexicon version in {path}")
26
+ for key in ("word_overrides", "full_text_overrides", "phoneme_substitutions"):
27
+ if not isinstance(data.get(key), dict):
28
+ raise ValueError(f"{key} must be an object in {path}")
29
+ return data
30
+
31
+
32
+ def _word_key(token: str) -> str:
33
+ return token.strip(_EDGE_PUNCTUATION)
34
+
35
+
36
+ def _terminal_suffix(text_token: str, ipa_token: str) -> str:
37
+ if text_token.endswith(("؟", "?")) and ipa_token.endswith("ʔ"):
38
+ return "ʔ"
39
+ if text_token.endswith(("!", ".", "،", ",", "؛", ";", "…")):
40
+ match = re.search(r"([!.,;…])$", ipa_token)
41
+ return match.group(1) if match else ""
42
+ return ""
43
+
44
+
45
+ def apply_lexicon(normalized_text: str, ge2pe: str, lexicon: dict) -> PronunciationResult:
46
+ base = ge2pe_to_kokoro_ipa(ge2pe)
47
+ full_override = lexicon["full_text_overrides"].get(normalized_text)
48
+ if full_override is not None:
49
+ return PronunciationResult(
50
+ phonemes=str(full_override),
51
+ base_phonemes=base,
52
+ applied=(f"full_text:{normalized_text}",),
53
+ )
54
+
55
+ text_tokens = normalized_text.split()
56
+ ge2pe_tokens = ge2pe.split()
57
+ ipa_tokens = [ge2pe_to_kokoro_ipa(token) for token in ge2pe_tokens]
58
+ applied: list[str] = []
59
+ word_overrides = lexicon["word_overrides"]
60
+ requested_words = {_word_key(token) for token in text_tokens} & set(word_overrides)
61
+ if requested_words and len(text_tokens) != len(ipa_tokens):
62
+ raise ValueError(
63
+ "cannot safely apply word pronunciation overrides because Persian "
64
+ f"and G2P token counts differ ({len(text_tokens)} != {len(ipa_tokens)})"
65
+ )
66
+ if len(text_tokens) == len(ipa_tokens):
67
+ for index, (text_token, ipa_token) in enumerate(zip(text_tokens, ipa_tokens, strict=True)):
68
+ key = _word_key(text_token)
69
+ if key not in word_overrides:
70
+ continue
71
+ suffix = _terminal_suffix(text_token, ipa_token)
72
+ ipa_tokens[index] = f"{word_overrides[key]}{suffix}"
73
+ applied.append(f"word:{key}")
74
+ phonemes = " ".join(ipa_tokens) if ipa_tokens else base
75
+
76
+ for old, new in lexicon["phoneme_substitutions"].items():
77
+ if old in phonemes:
78
+ phonemes = phonemes.replace(old, new)
79
+ applied.append(f"phoneme:{old}->{new}")
80
+ return PronunciationResult(
81
+ phonemes=phonemes,
82
+ base_phonemes=base,
83
+ applied=tuple(applied),
84
+ )
src/ava_tts/text.py ADDED
@@ -0,0 +1,175 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Conservative Persian normalization used by the v0.1 training frontend."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import unicodedata
7
+
8
+ _CHAR_TRANSLATION = str.maketrans(
9
+ {
10
+ "ي": "ی",
11
+ "ى": "ی",
12
+ "ك": "ک",
13
+ "ۀ": "هٔ",
14
+ "ة": "ه",
15
+ "ـ": "",
16
+ "\u200b": "",
17
+ "\u200d": "",
18
+ "\ufeff": "",
19
+ "\u2066": "",
20
+ "\u2067": "",
21
+ "\u2068": "",
22
+ "\u2069": "",
23
+ "\u202a": "",
24
+ "\u202b": "",
25
+ "\u202c": "",
26
+ "\u202d": "",
27
+ "\u202e": "",
28
+ }
29
+ )
30
+ _DIGIT_TRANSLATION = str.maketrans(
31
+ "۰۱۲۳۴۵۶۷۸۹٠١٢٣٤٥٦٧٨٩",
32
+ "01234567890123456789",
33
+ )
34
+ _ONES = [
35
+ "صفر",
36
+ "یک",
37
+ "دو",
38
+ "سه",
39
+ "چهار",
40
+ "پنج",
41
+ "شش",
42
+ "هفت",
43
+ "هشت",
44
+ "نه",
45
+ ]
46
+ _TEENS = [
47
+ "ده",
48
+ "یازده",
49
+ "دوازده",
50
+ "سیزده",
51
+ "چهارده",
52
+ "پانزده",
53
+ "شانزده",
54
+ "هفده",
55
+ "هجده",
56
+ "نوزده",
57
+ ]
58
+ _TENS = ["", "", "بیست", "سی", "چهل", "پنجاه", "شصت", "هفتاد", "هشتاد", "نود"]
59
+ _HUNDREDS = [
60
+ "",
61
+ "صد",
62
+ "دویست",
63
+ "سیصد",
64
+ "چهارصد",
65
+ "پانصد",
66
+ "ششصد",
67
+ "هفتصد",
68
+ "هشتصد",
69
+ "نهصد",
70
+ ]
71
+ _SCALES = ["", "هزار", "میلیون", "میلیارد", "تریلیون", "کوادریلیون"]
72
+ _NUMBER_RE = re.compile(
73
+ r"(?<![\w\u200c])(?P<sign>[+\-−])?"
74
+ r"(?P<number>\d[\d,٬]*(?:[./٫]\d+)?)(?P<percent>\s*[%٪])?"
75
+ )
76
+ _DATE_RE = re.compile(r"(?<!\d)(\d{2,4})[/-](\d{1,2})[/-](\d{1,2})(?!\d)")
77
+ _TIME_RE = re.compile(r"(?<!\d)(?:ساعت\s*)?([01]?\d|2[0-3])\s*[::]\s*([0-5]\d)(?!\d)")
78
+ _LONG_DIGIT_SEQUENCE_RE = re.compile(r"(?<!\d)\d{7,17}(?!\d)")
79
+
80
+
81
+ def _under_thousand(value: int) -> str:
82
+ parts: list[str] = []
83
+ hundreds, rest = divmod(value, 100)
84
+ if hundreds:
85
+ parts.append(_HUNDREDS[hundreds])
86
+ if rest < 10:
87
+ if rest:
88
+ parts.append(_ONES[rest])
89
+ elif rest < 20:
90
+ parts.append(_TEENS[rest - 10])
91
+ else:
92
+ tens, ones = divmod(rest, 10)
93
+ parts.append(_TENS[tens])
94
+ if ones:
95
+ parts.append(_ONES[ones])
96
+ return " و ".join(parts)
97
+
98
+
99
+ def number_to_words(value: int) -> str:
100
+ if value == 0:
101
+ return _ONES[0]
102
+ if value < 0:
103
+ return f"منفی {number_to_words(-value)}"
104
+ if value >= 1000 ** len(_SCALES):
105
+ return " ".join(_ONES[int(digit)] for digit in str(value))
106
+ groups: list[str] = []
107
+ scale_index = 0
108
+ while value:
109
+ value, group = divmod(value, 1000)
110
+ if group:
111
+ spoken = _under_thousand(group)
112
+ scale = _SCALES[scale_index]
113
+ if scale_index == 1 and group == 1:
114
+ spoken = ""
115
+ groups.append(f"{spoken} {scale}".strip())
116
+ scale_index += 1
117
+ return " و ".join(reversed(groups))
118
+
119
+
120
+ def _expand_numeric(match: re.Match[str]) -> str:
121
+ sign = match.group("sign")
122
+ token = match.group("number").translate(_DIGIT_TRANSLATION)
123
+ percent = bool(match.group("percent"))
124
+ token = token.replace(",", "").replace("٬", "")
125
+ separator = "/" if "/" in token else ("٫" if "٫" in token else ("." if "." in token else None))
126
+ if separator:
127
+ left, right = token.split(separator, 1)
128
+ right_words = (
129
+ number_to_words(int(right))
130
+ if separator == "/"
131
+ else " ".join(_ONES[int(digit)] for digit in right)
132
+ )
133
+ words = f"{number_to_words(int(left))} ممیز {right_words}"
134
+ else:
135
+ words = number_to_words(int(token))
136
+ if sign in {"-", "−"}:
137
+ words = f"منفی {words}"
138
+ elif sign == "+":
139
+ words = f"مثبت {words}"
140
+ return f"{words} درصد" if percent else words
141
+
142
+
143
+ def normalize_persian(text: str, *, spoken: bool = True) -> str:
144
+ value = unicodedata.normalize("NFC", text).translate(_CHAR_TRANSLATION)
145
+ value = re.sub(r"[ \t\f\v]+", " ", value)
146
+ value = re.sub(r"\s+([،؛:؟!,.])", r"\1", value)
147
+ value = re.sub(r"([،؛:؟!])(?=\S)", r"\1 ", value)
148
+ if not spoken:
149
+ return value.strip()
150
+ value = value.translate(_DIGIT_TRANSLATION)
151
+ value = _DATE_RE.sub(
152
+ lambda match: (
153
+ f"سال {number_to_words(int(match.group(1)))}، "
154
+ f"ماه {number_to_words(int(match.group(2)))}، "
155
+ f"روز {number_to_words(int(match.group(3)))}"
156
+ ),
157
+ value,
158
+ )
159
+ value = _TIME_RE.sub(
160
+ lambda match: (
161
+ f"ساعت {number_to_words(int(match.group(1)))}"
162
+ + (
163
+ f" و {number_to_words(int(match.group(2)))} دقیقه"
164
+ if int(match.group(2))
165
+ else ""
166
+ )
167
+ ),
168
+ value,
169
+ )
170
+ value = _LONG_DIGIT_SEQUENCE_RE.sub(
171
+ lambda match: " ".join(_ONES[int(digit)] for digit in match.group()),
172
+ value,
173
+ )
174
+ value = _NUMBER_RE.sub(_expand_numeric, value)
175
+ return value.strip()
tests/test_audio.py ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import numpy as np
2
+
3
+ from ava_tts.audio import clean_boundaries
4
+
5
+
6
+ def test_cleanup_only_removes_edges_and_preserves_internal_pause():
7
+ sample_rate = 1000
8
+ edge_noise = np.full(200, 0.01, dtype=np.float32)
9
+ speech = np.full(300, 0.5, dtype=np.float32)
10
+ internal_pause = np.zeros(100, dtype=np.float32)
11
+ waveform = np.concatenate(
12
+ [edge_noise, speech, internal_pause, speech, edge_noise]
13
+ )
14
+ cleaned = clean_boundaries(
15
+ waveform,
16
+ sample_rate,
17
+ threshold_db=-20,
18
+ keep_ms=50,
19
+ fade_ms=10,
20
+ )
21
+ assert cleaned.trimmed_start_ms > 0
22
+ assert cleaned.trimmed_end_ms > 0
23
+ assert np.count_nonzero(cleaned.audio == 0) >= len(internal_pause)
24
+ assert len(cleaned.audio) < len(waveform)
25
+
26
+
27
+ def test_cleanup_leaves_silence_unchanged():
28
+ silence = np.zeros(1000, dtype=np.float32)
29
+ cleaned = clean_boundaries(silence, 1000)
30
+ assert cleaned.trimmed_start_ms == 0
31
+ assert cleaned.trimmed_end_ms == 0
32
+ assert np.array_equal(cleaned.audio, silence)
33
+
tests/test_frontend.py ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ from pathlib import Path
3
+
4
+ from ava_tts.frontend import ge2pe_to_kokoro_ipa, validate_phonemes
5
+ from ava_tts.text import normalize_persian
6
+
7
+
8
+ ROOT = Path(__file__).resolve().parents[1]
9
+
10
+
11
+ def test_training_frontend_conversion():
12
+ assert ge2pe_to_kokoro_ipa("s/lAm") == "sælɒm"
13
+
14
+
15
+ def test_spoken_normalization():
16
+ assert normalize_persian("قیمت ۱۲۳ تومان است.") == "قیمت صد و بیست و سه تومان است."
17
+
18
+
19
+ def test_date_and_time_normalization():
20
+ normalized = normalize_persian("جلسه در ۱۴۰۵/۰۵/۰۴ ساعت 09:30 است.")
21
+ assert "سال هزار و چهارصد و پنج" in normalized
22
+ assert "ساعت نه و سی دقیقه" in normalized
23
+
24
+
25
+ def test_config_accepts_persian_phonemes():
26
+ vocab = json.loads((ROOT / "config.json").read_text())["vocab"]
27
+ assert validate_phonemes("sælɒm", vocab) == "sælɒm"
28
+
tests/test_model.py ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from ava_tts.model import Ava
2
+
3
+
4
+ def test_split_prefers_punctuation_near_middle():
5
+ text = "این بخش اول جمله است، و این بخش دوم جمله است."
6
+ split = Ava._split_point(text)
7
+ assert text[split - 1] == "،"
8
+
9
+
10
+ def test_split_falls_back_to_word_boundary():
11
+ text = "یک متن بسیار طولانی برای آزمایش شکستن خودکار جمله"
12
+ split = Ava._split_point(text)
13
+ assert 0 < split < len(text)
14
+ assert text[split].isspace()
15
+
16
+
17
+ def test_long_text_is_split_before_frontend_limit():
18
+ ava = object.__new__(Ava)
19
+ ava._config = {"vocab": {"a": 1}}
20
+ ava._phonemize_normalized = lambda text: "a" * len(text)
21
+ normalized, segments = ava.phonemize("واژه " * 120)
22
+ assert normalized
23
+ assert len(segments) > 1
24
+ assert all(len(segment) <= 510 for segment in segments)
tests/test_pronunciation.py ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pytest
2
+
3
+ from ava_tts.pronunciation import apply_lexicon
4
+
5
+
6
+ def lexicon(**updates):
7
+ value = {
8
+ "version": 1,
9
+ "word_overrides": {},
10
+ "full_text_overrides": {},
11
+ "phoneme_substitutions": {},
12
+ }
13
+ value.update(updates)
14
+ return value
15
+
16
+
17
+ def test_word_override_preserves_question_boundary():
18
+ result = apply_lexicon(
19
+ "سلام خوبی؟",
20
+ "s/lam khubi@",
21
+ lexicon(word_overrides={"خوبی": "xubi"}),
22
+ )
23
+ assert result.phonemes == "sælɒm xubiʔ"
24
+
25
+
26
+ def test_ambiguous_alignment_fails_when_override_is_requested():
27
+ with pytest.raises(ValueError, match="cannot safely"):
28
+ apply_lexicon(
29
+ "سلام خوبی",
30
+ "s/lamkhubi",
31
+ lexicon(word_overrides={"خوبی": "xubi"}),
32
+ )
33
+
verify_release.py ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Verify the Ava release against artifact_manifest.json."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import hashlib
7
+ import json
8
+ from pathlib import Path
9
+
10
+
11
+ ROOT = Path(__file__).resolve().parent
12
+
13
+
14
+ def sha256(path: Path) -> str:
15
+ digest = hashlib.sha256()
16
+ with path.open("rb") as handle:
17
+ for chunk in iter(lambda: handle.read(1024 * 1024), b""):
18
+ digest.update(chunk)
19
+ return digest.hexdigest()
20
+
21
+
22
+ def main() -> None:
23
+ manifest = json.loads((ROOT / "artifact_manifest.json").read_text())
24
+ for filename, expected in manifest["artifacts"].items():
25
+ path = ROOT / filename
26
+ if not path.is_file():
27
+ raise SystemExit(f"missing: {filename}")
28
+ actual_size = path.stat().st_size
29
+ actual_hash = sha256(path)
30
+ if actual_size != expected["bytes"]:
31
+ raise SystemExit(
32
+ f"size mismatch for {filename}: {actual_size} != {expected['bytes']}"
33
+ )
34
+ if actual_hash != expected["sha256"]:
35
+ raise SystemExit(
36
+ f"hash mismatch for {filename}: {actual_hash} != {expected['sha256']}"
37
+ )
38
+ print(f"ok {filename} {actual_hash}")
39
+
40
+
41
+ if __name__ == "__main__":
42
+ main()
43
+
voice.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:15558df28cf358198b194b5ccb55241ff6a318badf11adf32346c5b3a08ca591
3
+ size 523475