Release Ava 82M v0.1
Browse filesListener-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 +4 -34
- .gitignore +10 -0
- CITATIONS.md +52 -0
- LICENSE +201 -0
- NOTICE.md +22 -0
- README.md +203 -0
- artifact_manifest.json +55 -0
- ava_tts-0.1.0-py3-none-any.whl +0 -0
- config.json +150 -0
- inference.py +10 -0
- model.pth +3 -0
- pronunciations.json +6 -0
- pyproject.toml +50 -0
- requirements.txt +8 -0
- samples/README.md +14 -0
- samples/ava-fa-release-smoke.wav +3 -0
- samples/ava-fa-sample.wav +3 -0
- samples/ava-fa-wheel-smoke.wav +3 -0
- src/ava_tts/__init__.py +7 -0
- src/ava_tts/audio.py +84 -0
- src/ava_tts/cli.py +48 -0
- src/ava_tts/frontend.py +60 -0
- src/ava_tts/g2p.py +96 -0
- src/ava_tts/model.py +252 -0
- src/ava_tts/pronunciation.py +84 -0
- src/ava_tts/text.py +175 -0
- tests/test_audio.py +33 -0
- tests/test_frontend.py +28 -0
- tests/test_model.py +24 -0
- tests/test_pronunciation.py +33 -0
- verify_release.py +43 -0
- voice.pt +3 -0
.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 |
-
*.
|
| 25 |
-
*.
|
| 26 |
-
|
| 27 |
-
|
| 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
|