KemeTone v1.0 — Egyptian Arabic TTS
Browse files- .gitattributes +2 -0
- README.md +186 -0
- assets/logo-card.png +3 -0
- assets/logo.png +3 -0
- config.json +237 -0
- example.py +47 -0
- kemetone.pth +3 -0
- kemetone/__init__.py +3 -0
- kemetone/arabic.py +42 -0
- kemetone/g2p.py +229 -0
- kemetone/lexicons/ث.tsv +105 -0
- kemetone/lexicons/ذ.tsv +9 -0
- kemetone/lexicons/ظ.tsv +6 -0
- kemetone/lexicons/ق.tsv +59 -0
- kemetone/normalize_tashkeel.py +48 -0
- kemetone/runtime.py +45 -0
- requirements.txt +6 -0
- voices/kemetone.pt +3 -0
.gitattributes
CHANGED
|
@@ -33,3 +33,5 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
assets/logo-card.png filter=lfs diff=lfs merge=lfs -text
|
| 37 |
+
assets/logo.png filter=lfs diff=lfs merge=lfs -text
|
README.md
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
license: apache-2.0
|
| 3 |
+
language:
|
| 4 |
+
- ar
|
| 5 |
+
pipeline_tag: text-to-speech
|
| 6 |
+
library_name: kokoro
|
| 7 |
+
tags:
|
| 8 |
+
- text-to-speech
|
| 9 |
+
- tts
|
| 10 |
+
- arabic
|
| 11 |
+
- egyptian-arabic
|
| 12 |
+
- masri
|
| 13 |
+
- dialect
|
| 14 |
+
- speech-synthesis
|
| 15 |
+
- kokoro
|
| 16 |
+
---
|
| 17 |
+
|
| 18 |
+
<div align="center">
|
| 19 |
+
<img src="assets/logo-card.png" alt="KemeTone" width="460">
|
| 20 |
+
|
| 21 |
+
### Egyptian Arabic that sounds Egyptian.
|
| 22 |
+
|
| 23 |
+
**82M parameters · 24 kHz · female voice · runs on a CPU**
|
| 24 |
+
</div>
|
| 25 |
+
|
| 26 |
+
---
|
| 27 |
+
|
| 28 |
+
## The problem with Arabic TTS
|
| 29 |
+
|
| 30 |
+
Almost every Arabic voice you can buy or download speaks **Modern Standard
|
| 31 |
+
Arabic** — the language of news broadcasts and textbooks. Roughly no one speaks
|
| 32 |
+
it at home.
|
| 33 |
+
|
| 34 |
+
Point one of those models at Egyptian text and it does not fail loudly. It
|
| 35 |
+
fails *politely*: it reads your words with textbook consonants and textbook
|
| 36 |
+
vowels, and the result lands somewhere between a newsreader and a foreigner.
|
| 37 |
+
`جَمِيل` comes out **/ʤamiːl/** when a hundred million people say **/ɡamiːl/**.
|
| 38 |
+
`دِلْوَقْتِي` comes out **/dilwaqti/** when the street says **/dilwaʔti/**.
|
| 39 |
+
|
| 40 |
+
KemeTone is built for the dialect, not adapted to it.
|
| 41 |
+
|
| 42 |
+
| | Standard Arabic TTS | **KemeTone** |
|
| 43 |
+
|---|---|---|
|
| 44 |
+
| ج | /ʤ/ — *gemeel* | **/ɡ/ — *gameel*** |
|
| 45 |
+
| ق | /q/ — *dilwaqti* | **/ʔ/ — *dilwa'ti*** |
|
| 46 |
+
| ث | /θ/ — *thalatha* | **/t/ — *talata*** |
|
| 47 |
+
| ذ | /ð/ — *dhahab* | **/z/ — *zahab*** |
|
| 48 |
+
| ظ | /ðˤ/ | **/zˤ/** |
|
| 49 |
+
| Register | broadcast | **conversational** |
|
| 50 |
+
|
| 51 |
+
And it does this without becoming naive about it. Egyptians do not flatten
|
| 52 |
+
*every* ق to a glottal stop — `الْقُرْآن` keeps its /q/, `ثَقَافَة` keeps its
|
| 53 |
+
/s/, and a model that applies the rule everywhere sounds like a caricature.
|
| 54 |
+
KemeTone ships curated exception lexicons for exactly the learned and Qur'anic
|
| 55 |
+
vocabulary that resists the shift, so the rules fire where a Cairene speaker
|
| 56 |
+
fires them and stay quiet where they don't.
|
| 57 |
+
|
| 58 |
+
## Listen for
|
| 59 |
+
|
| 60 |
+
- **A natural female voice**, warm and conversational — not a formal announcer.
|
| 61 |
+
- **Long-form in a single pass.** 15–20 seconds of continuous speech with
|
| 62 |
+
coherent phrasing, no chunk-and-stitch seams.
|
| 63 |
+
- **Correct dialect consonants**, with the learned-vocabulary exceptions intact.
|
| 64 |
+
- **ع and ح that are actually pronounced** — the two pharyngeals that
|
| 65 |
+
general-purpose phonemisers silently drop.
|
| 66 |
+
|
| 67 |
+
## Quick start
|
| 68 |
+
|
| 69 |
+
```bash
|
| 70 |
+
pip install -r requirements.txt
|
| 71 |
+
```
|
| 72 |
+
|
| 73 |
+
You also need **espeak-ng** available to the phonemiser
|
| 74 |
+
(`apt install espeak-ng`, `brew install espeak-ng`, or set
|
| 75 |
+
`KEMETONE_ESPEAK_LIB` to a build of `libespeak-ng.so`).
|
| 76 |
+
|
| 77 |
+
```python
|
| 78 |
+
import torch, soundfile as sf
|
| 79 |
+
from kokoro import KModel
|
| 80 |
+
from kemetone import EgyptianG2P
|
| 81 |
+
|
| 82 |
+
model = KModel(repo_id="Rabe3/kemetone",
|
| 83 |
+
config="config.json", model="kemetone.pth").eval()
|
| 84 |
+
voice = torch.load("voices/kemetone.pt")
|
| 85 |
+
|
| 86 |
+
ipa = EgyptianG2P()("النَّهَارْدَه الْجَوّ حِلْو أَوِي")
|
| 87 |
+
audio = model(ipa, voice[len(ipa) - 1])
|
| 88 |
+
|
| 89 |
+
sf.write("out.wav", audio.numpy(), 24000)
|
| 90 |
+
```
|
| 91 |
+
|
| 92 |
+
Or straight from the command line:
|
| 93 |
+
|
| 94 |
+
```bash
|
| 95 |
+
python example.py "النَّهَارْدَه الْجَوّ حِلْو أَوِي" out.wav
|
| 96 |
+
```
|
| 97 |
+
|
| 98 |
+
### Give it diacritics
|
| 99 |
+
|
| 100 |
+
Arabic script does not write short vowels. When they are missing, the
|
| 101 |
+
phonemiser has to guess them — and it guesses the **Standard Arabic** way,
|
| 102 |
+
which is precisely the accent KemeTone exists to avoid. The consonants will
|
| 103 |
+
still be Egyptian; the vowels will not.
|
| 104 |
+
|
| 105 |
+
```
|
| 106 |
+
حلو → vowels guessed, MSA-flavoured
|
| 107 |
+
حِلْو → vowels read from the text ✓
|
| 108 |
+
```
|
| 109 |
+
|
| 110 |
+
Undiacritized input works and is often acceptable. Diacritized input is
|
| 111 |
+
noticeably better. If your text is bare, run it through an Arabic diacritizer
|
| 112 |
+
first — the quality gain is larger than anything else you can do at inference
|
| 113 |
+
time.
|
| 114 |
+
|
| 115 |
+
## Specifications
|
| 116 |
+
|
| 117 |
+
| | |
|
| 118 |
+
|---|---|
|
| 119 |
+
| Architecture | StyleTTS2 / Kokoro (ISTFTNet decoder) |
|
| 120 |
+
| Parameters | 81.8 M |
|
| 121 |
+
| Sample rate | 24 kHz mono |
|
| 122 |
+
| Voice | single female speaker |
|
| 123 |
+
| Language | Egyptian (Cairene) Arabic |
|
| 124 |
+
| Input | diacritized Arabic text |
|
| 125 |
+
| Weights | `kemetone.pth` (327 MB, fp32) |
|
| 126 |
+
| Voice embedding | `voices/kemetone.pt` |
|
| 127 |
+
| Hardware | CPU or any CUDA GPU; ~300 MB of VRAM |
|
| 128 |
+
| Licence | Apache 2.0 |
|
| 129 |
+
|
| 130 |
+
### What ships here
|
| 131 |
+
|
| 132 |
+
```
|
| 133 |
+
kemetone.pth model weights
|
| 134 |
+
config.json architecture + 178-symbol vocabulary
|
| 135 |
+
voices/kemetone.pt the voice embedding
|
| 136 |
+
kemetone/ Egyptian G2P front-end and its exception lexicons
|
| 137 |
+
example.py end-to-end synthesis
|
| 138 |
+
```
|
| 139 |
+
|
| 140 |
+
The front-end is part of the model, not a convenience wrapper. The weights were
|
| 141 |
+
trained on the phoneme strings this G2P produces; feeding them phonemes from a
|
| 142 |
+
generic Arabic phonemiser will degrade output in ways that are subtle enough to
|
| 143 |
+
miss and consistent enough to matter.
|
| 144 |
+
|
| 145 |
+
## Limitations
|
| 146 |
+
|
| 147 |
+
- **One voice.** No multi-speaker support, no voice cloning, no style prompts.
|
| 148 |
+
- **Cairene.** Not Sa'idi, not Alexandrian, and not any other Arabic dialect.
|
| 149 |
+
- **Diacritics carry the vowels.** See above.
|
| 150 |
+
- **Latin script, digits, and code-switched English** are not handled — convert
|
| 151 |
+
numbers to Arabic words before synthesis.
|
| 152 |
+
- **Very long inputs** must be split at sentence boundaries; the context window
|
| 153 |
+
is 510 tokens.
|
| 154 |
+
- Emotional range is conversational-neutral. There is no shouting, whispering,
|
| 155 |
+
or singing in this model.
|
| 156 |
+
|
| 157 |
+
## Responsible use
|
| 158 |
+
|
| 159 |
+
This voice is modelled on a real person. Please treat it accordingly:
|
| 160 |
+
|
| 161 |
+
- **Do not** use it to impersonate anyone, to put words in a real person's
|
| 162 |
+
mouth, or to produce audio that a listener could mistake for a genuine
|
| 163 |
+
recording of someone.
|
| 164 |
+
- **Do not** use it for fraud, harassment, political disinformation, or to
|
| 165 |
+
defeat voice-based authentication.
|
| 166 |
+
- **Do** disclose synthetic speech when the audience might otherwise assume it
|
| 167 |
+
is human — accessibility tools, narration, learning material, and assistants
|
| 168 |
+
are all better with a label on them.
|
| 169 |
+
|
| 170 |
+
Downstream users are responsible for complying with the laws on synthetic media
|
| 171 |
+
and personality rights in their own jurisdiction.
|
| 172 |
+
|
| 173 |
+
## Licence and credits
|
| 174 |
+
|
| 175 |
+
Released under **Apache 2.0**, inheriting the licence of the Kokoro
|
| 176 |
+
architecture it builds on.
|
| 177 |
+
|
| 178 |
+
Built on [Kokoro-82M](https://huggingface.co/hexgrad/Kokoro-82M) and
|
| 179 |
+
[Nabra-82M](https://huggingface.co/oddadmix/Nabra-82M-v0.1), with
|
| 180 |
+
phonemisation by [espeak-ng](https://github.com/espeak-ng/espeak-ng).
|
| 181 |
+
|
| 182 |
+
---
|
| 183 |
+
|
| 184 |
+
<div align="center">
|
| 185 |
+
<sub><b>KemeTone</b> — from <i>Kemet</i>, the old name for Egypt: <i>the black land</i>.</sub>
|
| 186 |
+
</div>
|
assets/logo-card.png
ADDED
|
Git LFS Details
|
assets/logo.png
ADDED
|
Git LFS Details
|
config.json
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"istftnet": {
|
| 3 |
+
"upsample_kernel_sizes": [
|
| 4 |
+
20,
|
| 5 |
+
12
|
| 6 |
+
],
|
| 7 |
+
"upsample_rates": [
|
| 8 |
+
10,
|
| 9 |
+
6
|
| 10 |
+
],
|
| 11 |
+
"gen_istft_hop_size": 5,
|
| 12 |
+
"gen_istft_n_fft": 20,
|
| 13 |
+
"resblock_dilation_sizes": [
|
| 14 |
+
[
|
| 15 |
+
1,
|
| 16 |
+
3,
|
| 17 |
+
5
|
| 18 |
+
],
|
| 19 |
+
[
|
| 20 |
+
1,
|
| 21 |
+
3,
|
| 22 |
+
5
|
| 23 |
+
],
|
| 24 |
+
[
|
| 25 |
+
1,
|
| 26 |
+
3,
|
| 27 |
+
5
|
| 28 |
+
]
|
| 29 |
+
],
|
| 30 |
+
"resblock_kernel_sizes": [
|
| 31 |
+
3,
|
| 32 |
+
7,
|
| 33 |
+
11
|
| 34 |
+
],
|
| 35 |
+
"upsample_initial_channel": 512
|
| 36 |
+
},
|
| 37 |
+
"dim_in": 64,
|
| 38 |
+
"dropout": 0.2,
|
| 39 |
+
"hidden_dim": 512,
|
| 40 |
+
"max_conv_dim": 512,
|
| 41 |
+
"max_dur": 50,
|
| 42 |
+
"multispeaker": true,
|
| 43 |
+
"n_layer": 3,
|
| 44 |
+
"n_mels": 80,
|
| 45 |
+
"n_token": 178,
|
| 46 |
+
"style_dim": 128,
|
| 47 |
+
"text_encoder_kernel_size": 5,
|
| 48 |
+
"plbert": {
|
| 49 |
+
"hidden_size": 768,
|
| 50 |
+
"num_attention_heads": 12,
|
| 51 |
+
"intermediate_size": 2048,
|
| 52 |
+
"max_position_embeddings": 512,
|
| 53 |
+
"num_hidden_layers": 12,
|
| 54 |
+
"dropout": 0.1
|
| 55 |
+
},
|
| 56 |
+
"sample_rate": 24000,
|
| 57 |
+
"vocab": {
|
| 58 |
+
"$": 0,
|
| 59 |
+
";": 1,
|
| 60 |
+
":": 2,
|
| 61 |
+
",": 3,
|
| 62 |
+
".": 4,
|
| 63 |
+
"!": 5,
|
| 64 |
+
"?": 6,
|
| 65 |
+
"ʕ": 7,
|
| 66 |
+
"ħ": 8,
|
| 67 |
+
"—": 9,
|
| 68 |
+
"…": 10,
|
| 69 |
+
"\"": 11,
|
| 70 |
+
"(": 12,
|
| 71 |
+
")": 13,
|
| 72 |
+
"“": 14,
|
| 73 |
+
"”": 15,
|
| 74 |
+
" ": 16,
|
| 75 |
+
"̃": 17,
|
| 76 |
+
"ʣ": 18,
|
| 77 |
+
"ʥ": 19,
|
| 78 |
+
"ʦ": 20,
|
| 79 |
+
"ʨ": 21,
|
| 80 |
+
"ᵝ": 22,
|
| 81 |
+
"ꭧ": 23,
|
| 82 |
+
"A": 24,
|
| 83 |
+
"I": 25,
|
| 84 |
+
"": 26,
|
| 85 |
+
"": 27,
|
| 86 |
+
"": 28,
|
| 87 |
+
"": 29,
|
| 88 |
+
"": 30,
|
| 89 |
+
"O": 31,
|
| 90 |
+
"": 32,
|
| 91 |
+
"Q": 33,
|
| 92 |
+
"": 34,
|
| 93 |
+
"S": 35,
|
| 94 |
+
"T": 36,
|
| 95 |
+
"": 37,
|
| 96 |
+
"": 38,
|
| 97 |
+
"W": 39,
|
| 98 |
+
"": 40,
|
| 99 |
+
"Y": 41,
|
| 100 |
+
"ᵊ": 42,
|
| 101 |
+
"a": 43,
|
| 102 |
+
"b": 44,
|
| 103 |
+
"c": 45,
|
| 104 |
+
"d": 46,
|
| 105 |
+
"e": 47,
|
| 106 |
+
"f": 48,
|
| 107 |
+
"": 49,
|
| 108 |
+
"h": 50,
|
| 109 |
+
"i": 51,
|
| 110 |
+
"j": 52,
|
| 111 |
+
"k": 53,
|
| 112 |
+
"l": 54,
|
| 113 |
+
"m": 55,
|
| 114 |
+
"n": 56,
|
| 115 |
+
"o": 57,
|
| 116 |
+
"p": 58,
|
| 117 |
+
"q": 59,
|
| 118 |
+
"r": 60,
|
| 119 |
+
"s": 61,
|
| 120 |
+
"t": 62,
|
| 121 |
+
"u": 63,
|
| 122 |
+
"v": 64,
|
| 123 |
+
"w": 65,
|
| 124 |
+
"x": 66,
|
| 125 |
+
"y": 67,
|
| 126 |
+
"z": 68,
|
| 127 |
+
"ɑ": 69,
|
| 128 |
+
"ɐ": 70,
|
| 129 |
+
"ɒ": 71,
|
| 130 |
+
"æ": 72,
|
| 131 |
+
"": 73,
|
| 132 |
+
"": 74,
|
| 133 |
+
"β": 75,
|
| 134 |
+
"ɔ": 76,
|
| 135 |
+
"ɕ": 77,
|
| 136 |
+
"ç": 78,
|
| 137 |
+
"": 79,
|
| 138 |
+
"ɖ": 80,
|
| 139 |
+
"ð": 81,
|
| 140 |
+
"ʤ": 82,
|
| 141 |
+
"ə": 83,
|
| 142 |
+
"": 84,
|
| 143 |
+
"ɚ": 85,
|
| 144 |
+
"ɛ": 86,
|
| 145 |
+
"ɜ": 87,
|
| 146 |
+
"": 88,
|
| 147 |
+
"": 89,
|
| 148 |
+
"ɟ": 90,
|
| 149 |
+
"": 91,
|
| 150 |
+
"ɡ": 92,
|
| 151 |
+
"": 93,
|
| 152 |
+
"": 94,
|
| 153 |
+
"": 95,
|
| 154 |
+
"": 96,
|
| 155 |
+
"": 97,
|
| 156 |
+
"": 98,
|
| 157 |
+
"ɥ": 99,
|
| 158 |
+
"": 100,
|
| 159 |
+
"ɨ": 101,
|
| 160 |
+
"ɪ": 102,
|
| 161 |
+
"ʝ": 103,
|
| 162 |
+
"": 104,
|
| 163 |
+
"": 105,
|
| 164 |
+
"": 106,
|
| 165 |
+
"": 107,
|
| 166 |
+
"": 108,
|
| 167 |
+
"": 109,
|
| 168 |
+
"ɯ": 110,
|
| 169 |
+
"ɰ": 111,
|
| 170 |
+
"ŋ": 112,
|
| 171 |
+
"ɳ": 113,
|
| 172 |
+
"ɲ": 114,
|
| 173 |
+
"ɴ": 115,
|
| 174 |
+
"ø": 116,
|
| 175 |
+
"": 117,
|
| 176 |
+
"ɸ": 118,
|
| 177 |
+
"θ": 119,
|
| 178 |
+
"œ": 120,
|
| 179 |
+
"": 121,
|
| 180 |
+
"": 122,
|
| 181 |
+
"ɹ": 123,
|
| 182 |
+
"": 124,
|
| 183 |
+
"ɾ": 125,
|
| 184 |
+
"ɻ": 126,
|
| 185 |
+
"": 127,
|
| 186 |
+
"ʁ": 128,
|
| 187 |
+
"ɽ": 129,
|
| 188 |
+
"ʂ": 130,
|
| 189 |
+
"ʃ": 131,
|
| 190 |
+
"ʈ": 132,
|
| 191 |
+
"ʧ": 133,
|
| 192 |
+
"": 134,
|
| 193 |
+
"ʊ": 135,
|
| 194 |
+
"ʋ": 136,
|
| 195 |
+
"": 137,
|
| 196 |
+
"ʌ": 138,
|
| 197 |
+
"ɣ": 139,
|
| 198 |
+
"ɤ": 140,
|
| 199 |
+
"": 141,
|
| 200 |
+
"χ": 142,
|
| 201 |
+
"ʎ": 143,
|
| 202 |
+
"": 144,
|
| 203 |
+
"": 145,
|
| 204 |
+
"": 146,
|
| 205 |
+
"ʒ": 147,
|
| 206 |
+
"ʔ": 148,
|
| 207 |
+
"": 149,
|
| 208 |
+
"": 150,
|
| 209 |
+
"": 151,
|
| 210 |
+
"": 152,
|
| 211 |
+
"": 153,
|
| 212 |
+
"": 154,
|
| 213 |
+
"": 155,
|
| 214 |
+
"ˈ": 156,
|
| 215 |
+
"ˌ": 157,
|
| 216 |
+
"ː": 158,
|
| 217 |
+
"": 159,
|
| 218 |
+
"": 160,
|
| 219 |
+
"": 161,
|
| 220 |
+
"ʰ": 162,
|
| 221 |
+
"": 163,
|
| 222 |
+
"ʲ": 164,
|
| 223 |
+
"": 165,
|
| 224 |
+
"": 166,
|
| 225 |
+
"": 167,
|
| 226 |
+
"": 168,
|
| 227 |
+
"↓": 169,
|
| 228 |
+
"": 170,
|
| 229 |
+
"→": 171,
|
| 230 |
+
"↗": 172,
|
| 231 |
+
"↘": 173,
|
| 232 |
+
"": 174,
|
| 233 |
+
"": 175,
|
| 234 |
+
"": 176,
|
| 235 |
+
"ᵻ": 177
|
| 236 |
+
}
|
| 237 |
+
}
|
example.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Synthesise Egyptian Arabic with KemeTone.
|
| 3 |
+
|
| 4 |
+
python example.py "النَّهَارْدَه الْجَوّ حِلْو أَوِي" out.wav
|
| 5 |
+
|
| 6 |
+
Input should be diacritized. Undiacritized Arabic still synthesises, but short
|
| 7 |
+
vowels are then guessed by the phonemiser rather than read from the text, and
|
| 8 |
+
the guesses follow Modern Standard patterns — which is audibly wrong in
|
| 9 |
+
Egyptian. See the model card.
|
| 10 |
+
|
| 11 |
+
If espeak-ng is not on the default library path, point KemeTone at it:
|
| 12 |
+
|
| 13 |
+
export KEMETONE_ESPEAK_LIB=/path/to/libespeak-ng.so
|
| 14 |
+
export KEMETONE_ESPEAK_DATA=/path/to/espeak-ng-data
|
| 15 |
+
"""
|
| 16 |
+
import sys
|
| 17 |
+
import torch
|
| 18 |
+
import soundfile as sf
|
| 19 |
+
from kokoro import KModel
|
| 20 |
+
from kemetone import EgyptianG2P
|
| 21 |
+
|
| 22 |
+
SR = 24000
|
| 23 |
+
REPO = "Rabe3/kemetone"
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def main() -> int:
|
| 27 |
+
text = sys.argv[1] if len(sys.argv) > 1 else "النَّهَارْدَه الْجَوّ حِلْو أَوِي"
|
| 28 |
+
out = sys.argv[2] if len(sys.argv) > 2 else "out.wav"
|
| 29 |
+
device = "cuda" if torch.cuda.is_available() else "cpu"
|
| 30 |
+
|
| 31 |
+
model = KModel(repo_id=REPO, config="config.json",
|
| 32 |
+
model="kemetone.pth").to(device).eval()
|
| 33 |
+
voice = torch.load("voices/kemetone.pt", map_location=device)
|
| 34 |
+
|
| 35 |
+
ipa = EgyptianG2P()(text)
|
| 36 |
+
print("phonemes:", ipa)
|
| 37 |
+
|
| 38 |
+
with torch.no_grad():
|
| 39 |
+
audio = model(ipa, voice[len(ipa) - 1])
|
| 40 |
+
|
| 41 |
+
sf.write(out, audio.cpu().numpy(), SR)
|
| 42 |
+
print(f"wrote {out} {len(audio) / SR:.1f}s")
|
| 43 |
+
return 0
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
if __name__ == "__main__":
|
| 47 |
+
raise SystemExit(main())
|
kemetone.pth
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:7880f9db23c5b9adb46d26ea166181ccef208db812322256ff004ab83420c5f0
|
| 3 |
+
size 327220427
|
kemetone/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""KemeTone — Egyptian Arabic text-to-speech."""
|
| 2 |
+
from .g2p import EgyptianG2P # noqa: F401
|
| 3 |
+
__version__ = "1.0.0"
|
kemetone/arabic.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Arabic orthography helpers used by the front-end."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
import unicodedata
|
| 4 |
+
|
| 5 |
+
SHADDA = "ّ"
|
| 6 |
+
FINAL_MARKS = frozenset(["ً", "ٌ", "ٍ", "َ", "ُ", "ِ", "ْ", "ٰ"])
|
| 7 |
+
ALL_HARAKAT = FINAL_MARKS | {SHADDA}
|
| 8 |
+
KEPT_FINAL = frozenset([SHADDA, "ْ"])
|
| 9 |
+
ALEF_VARIANTS = str.maketrans({"أ": "ا", "إ": "ا", "آ": "ا", "ٱ": "ا", "ة": "ه", "ى": "ي"})
|
| 10 |
+
ARABIC_LETTERS = frozenset("ابتثجحخدذرزسشصضطظعغفقكلمنهويءأإآئؤةى")
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def is_mark(ch: str) -> bool:
|
| 14 |
+
return unicodedata.category(ch) == "Mn"
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def letters(word: str) -> str:
|
| 18 |
+
return "".join(c for c in word if not is_mark(c))
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def skeleton(word: str) -> str:
|
| 22 |
+
"""Lookup key: Arabic letters only, orthography normalised."""
|
| 23 |
+
return "".join(
|
| 24 |
+
c for c in letters(word).translate(ALEF_VARIANTS) if c in ARABIC_LETTERS
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def strip_irab(text: str) -> str:
|
| 29 |
+
"""Drop word-final case endings, keeping final sukun and shadda.
|
| 30 |
+
|
| 31 |
+
Egyptian does not pronounce i'rab, and leaving it in leads the phonemiser to
|
| 32 |
+
voice a final vowel that is not there. Applied to every input.
|
| 33 |
+
|
| 34 |
+
رَجُلٌ -> رَجُل حَقٌّ -> حَقّ مِنْ -> مِنْ
|
| 35 |
+
"""
|
| 36 |
+
out = []
|
| 37 |
+
for word in text.split(" "):
|
| 38 |
+
i = len(word)
|
| 39 |
+
while i > 0 and word[i - 1] in ALL_HARAKAT:
|
| 40 |
+
i -= 1
|
| 41 |
+
out.append(word[:i] + "".join(c for c in word[i:] if c in KEPT_FINAL))
|
| 42 |
+
return " ".join(out)
|
kemetone/g2p.py
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Egyptian (Cairene) Arabic grapheme-to-phoneme front-end.
|
| 2 |
+
|
| 3 |
+
Turns diacritized Egyptian text into the IPA symbol set the model was trained
|
| 4 |
+
on. Modern Standard Arabic phonemisation will not do: five consonants are
|
| 5 |
+
realised differently in Cairene, and a model fed MSA phonemes produces MSA
|
| 6 |
+
pronunciation regardless of how the text was written.
|
| 7 |
+
|
| 8 |
+
ج -> /ɡ/ unconditional
|
| 9 |
+
ق -> /ʔ/ except in Qur'anic and learned vocabulary, which keeps /q/
|
| 10 |
+
ث -> /t/ except in learned vocabulary, which takes /s/
|
| 11 |
+
ذ -> /z/ except in a small inherited set, which takes /d/
|
| 12 |
+
ظ -> /zˤ/ except in a small inherited set, which takes /dˤ/
|
| 13 |
+
|
| 14 |
+
The exceptions are lexical, not rule-governed, so each lives in a word list
|
| 15 |
+
under `lexicons/`. Lookup strips fused proclitics (و ف ب ك ل ال …) because a
|
| 16 |
+
proclitic never changes how the stem's consonants are realised.
|
| 17 |
+
|
| 18 |
+
Two implementation notes that the code depends on:
|
| 19 |
+
|
| 20 |
+
* espeak-ng emits **ð for both ذ and ظ** — it produces no ðˤ — so those two
|
| 21 |
+
rules cannot be told apart on the phoneme string and are decided from the
|
| 22 |
+
word's letters instead. A word carrying both letters is left alone.
|
| 23 |
+
* Rewrites run **before** the phoneme fixups, which strip the pharyngealisation
|
| 24 |
+
marker and the dental bridge. Running them after would collapse distinctions
|
| 25 |
+
the rules still need.
|
| 26 |
+
|
| 27 |
+
Usage:
|
| 28 |
+
from kemetone import EgyptianG2P
|
| 29 |
+
g2p = EgyptianG2P()
|
| 30 |
+
g2p("قَالَ لِي جَمِيل") # -> 'ʔˈaːla liː ɡˈamiːl'
|
| 31 |
+
"""
|
| 32 |
+
from __future__ import annotations
|
| 33 |
+
|
| 34 |
+
import re
|
| 35 |
+
from collections import Counter
|
| 36 |
+
from pathlib import Path
|
| 37 |
+
|
| 38 |
+
from .arabic import skeleton, strip_irab
|
| 39 |
+
from .normalize_tashkeel import normalize as normalize_marks
|
| 40 |
+
|
| 41 |
+
LEXDIR = Path(__file__).resolve().parent / "lexicons"
|
| 42 |
+
|
| 43 |
+
# Symbols outside the model's inventory, mapped or dropped.
|
| 44 |
+
PHONEME_FIXUPS = {
|
| 45 |
+
"̪": "", # combining bridge below (dental)
|
| 46 |
+
"ˤ": "", # pharyngealisation marker
|
| 47 |
+
"[": "", "]": "", "{": "", "}": "",
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
_SYLLABLE_DOT = re.compile(r"(?<=\S)\.(?=\S)")
|
| 51 |
+
_LATIN_RUN = re.compile(r"[A-Za-z]+")
|
| 52 |
+
_CITATION = re.compile(r"\[[^\]]*\]")
|
| 53 |
+
_WS = re.compile(r"\s+")
|
| 54 |
+
|
| 55 |
+
# What espeak-ng's Arabic voice emits for each letter we rewrite. ج is a single
|
| 56 |
+
# codepoint (U+02A4), not a d+ʒ tie bar, so a plain replace is safe.
|
| 57 |
+
PH_JEEM, PH_QAF, PH_THEH, PH_DHAL = "ʤ", "q", "θ", "ð"
|
| 58 |
+
|
| 59 |
+
# A word that makes the NEXT word a Qur'anic proper noun, which keeps /q/.
|
| 60 |
+
# Sura names cannot go in the flat lexicon: most double as ordinary words said
|
| 61 |
+
# with /ʔ/ — البقرة is "the cow", القصص is "stories".
|
| 62 |
+
Q_NEXT_TRIGGERS = {"سوره", "سور"}
|
| 63 |
+
|
| 64 |
+
_PROCLITICS = ("وال", "فال", "بال", "كال", "لل", "ال", "و", "ف", "ب", "ك", "ل")
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def load_lexicon(letter: str, path: str | Path | None = None) -> set[str]:
|
| 68 |
+
p = Path(path) if path else LEXDIR / f"{letter}.tsv"
|
| 69 |
+
if not p.exists():
|
| 70 |
+
return set()
|
| 71 |
+
out = set()
|
| 72 |
+
for line in p.read_text(encoding="utf-8").splitlines():
|
| 73 |
+
line = line.split("#", 1)[0].strip()
|
| 74 |
+
if line:
|
| 75 |
+
out.add(line.split("\t", 1)[0])
|
| 76 |
+
return out
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def lexicon_lookup(skel: str, lex: set[str]) -> bool:
|
| 80 |
+
"""Membership test that tolerates fused proclitics."""
|
| 81 |
+
if skel in lex:
|
| 82 |
+
return True
|
| 83 |
+
seen, frontier = {skel}, [skel]
|
| 84 |
+
for _ in range(2):
|
| 85 |
+
nxt = []
|
| 86 |
+
for w in frontier:
|
| 87 |
+
for p in _PROCLITICS:
|
| 88 |
+
if w.startswith(p) and len(w) - len(p) >= 3:
|
| 89 |
+
s = w[len(p):]
|
| 90 |
+
if s in lex:
|
| 91 |
+
return True
|
| 92 |
+
if s not in seen:
|
| 93 |
+
seen.add(s)
|
| 94 |
+
nxt.append(s)
|
| 95 |
+
frontier = nxt
|
| 96 |
+
return False
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def normalize_text(text: str) -> tuple[str, int]:
|
| 100 |
+
text = _CITATION.sub(" ", text)
|
| 101 |
+
# Tatweel has no phonetic value, but espeak reads it BY NAME when bare —
|
| 102 |
+
# "الـ" comes out as the spoken word تطويل.
|
| 103 |
+
text = text.replace("ـ", "")
|
| 104 |
+
latin = _LATIN_RUN.findall(text)
|
| 105 |
+
if latin:
|
| 106 |
+
text = _LATIN_RUN.sub(" ", text)
|
| 107 |
+
return _WS.sub(" ", text).strip(), len(latin)
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def clean_phonemes(ph: str) -> str:
|
| 111 |
+
ph = _SYLLABLE_DOT.sub("", ph)
|
| 112 |
+
for old, new in PHONEME_FIXUPS.items():
|
| 113 |
+
ph = ph.replace(old, new)
|
| 114 |
+
return ph
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
class EgyptianG2P:
|
| 118 |
+
"""Diacritized Egyptian text -> IPA.
|
| 119 |
+
|
| 120 |
+
Args:
|
| 121 |
+
dialect: apply the Egyptian rewrites. False gives plain MSA output.
|
| 122 |
+
pausal: drop word-final case endings before phonemising. On by default,
|
| 123 |
+
and applied here so that every input is treated identically.
|
| 124 |
+
"""
|
| 125 |
+
|
| 126 |
+
def __init__(self, dialect: bool = True, lexicon_dir: str | Path | None = None,
|
| 127 |
+
pausal: bool = True):
|
| 128 |
+
from misaki import espeak
|
| 129 |
+
|
| 130 |
+
from .runtime import init_espeak
|
| 131 |
+
init_espeak()
|
| 132 |
+
|
| 133 |
+
self._g2p = espeak.EspeakG2P(language="ar")
|
| 134 |
+
self.dialect = dialect
|
| 135 |
+
self.pausal = pausal
|
| 136 |
+
d = Path(lexicon_dir) if lexicon_dir else LEXDIR
|
| 137 |
+
self.lex = {ch: load_lexicon(ch, d / f"{ch}.tsv") for ch in ("ق", "ث", "ذ", "ظ")}
|
| 138 |
+
|
| 139 |
+
self.n_words = 0
|
| 140 |
+
self.n_rule = Counter()
|
| 141 |
+
self.n_exception = Counter()
|
| 142 |
+
self.n_ambiguous = 0
|
| 143 |
+
self.oov = Counter()
|
| 144 |
+
|
| 145 |
+
def _rewrite_word(self, ph: str, word: str, prev: str | None = None) -> str:
|
| 146 |
+
skel = skeleton(word)
|
| 147 |
+
|
| 148 |
+
if PH_JEEM in ph:
|
| 149 |
+
self.n_rule["ج>ɡ"] += 1
|
| 150 |
+
ph = ph.replace(PH_JEEM, "ɡ")
|
| 151 |
+
|
| 152 |
+
if PH_QAF in ph:
|
| 153 |
+
keep_q = lexicon_lookup(skel, self.lex["ق"]) or (
|
| 154 |
+
prev is not None and skeleton(prev) in Q_NEXT_TRIGGERS
|
| 155 |
+
)
|
| 156 |
+
if keep_q:
|
| 157 |
+
self.n_exception["ق>q"] += 1
|
| 158 |
+
else:
|
| 159 |
+
self.n_rule["ق>ʔ"] += 1
|
| 160 |
+
ph = ph.replace(PH_QAF, "ʔ")
|
| 161 |
+
|
| 162 |
+
if PH_THEH in ph:
|
| 163 |
+
if lexicon_lookup(skel, self.lex["ث"]):
|
| 164 |
+
self.n_exception["ث>s"] += 1
|
| 165 |
+
ph = ph.replace(PH_THEH, "s")
|
| 166 |
+
else:
|
| 167 |
+
self.n_rule["ث>t"] += 1
|
| 168 |
+
ph = ph.replace(PH_THEH, "t")
|
| 169 |
+
|
| 170 |
+
# ð is both ذ and ظ — decide from the spelling.
|
| 171 |
+
if PH_DHAL in ph:
|
| 172 |
+
has_dhal, has_zah = "ذ" in skel, "ظ" in skel
|
| 173 |
+
if has_dhal and has_zah:
|
| 174 |
+
self.n_ambiguous += 1
|
| 175 |
+
elif has_zah:
|
| 176 |
+
if lexicon_lookup(skel, self.lex["ظ"]):
|
| 177 |
+
self.n_exception["ظ>d"] += 1
|
| 178 |
+
ph = ph.replace(PH_DHAL, "dˤ")
|
| 179 |
+
else:
|
| 180 |
+
self.n_rule["ظ>z"] += 1
|
| 181 |
+
ph = ph.replace(PH_DHAL, "zˤ")
|
| 182 |
+
elif has_dhal:
|
| 183 |
+
if lexicon_lookup(skel, self.lex["ذ"]):
|
| 184 |
+
self.n_exception["ذ>d"] += 1
|
| 185 |
+
ph = ph.replace(PH_DHAL, "d")
|
| 186 |
+
else:
|
| 187 |
+
self.n_rule["ذ>z"] += 1
|
| 188 |
+
ph = ph.replace(PH_DHAL, "z")
|
| 189 |
+
return ph
|
| 190 |
+
|
| 191 |
+
def phonemize(self, text: str) -> str:
|
| 192 |
+
text, _ = normalize_text(text)
|
| 193 |
+
if not text:
|
| 194 |
+
return ""
|
| 195 |
+
# A vowel written before a shadda makes espeak drop it silently, and
|
| 196 |
+
# both orders occur in real text — canonicalise on the way in.
|
| 197 |
+
text = normalize_marks(text)
|
| 198 |
+
if self.pausal:
|
| 199 |
+
text = strip_irab(text)
|
| 200 |
+
|
| 201 |
+
raw, _ = self._g2p(text)
|
| 202 |
+
if not self.dialect:
|
| 203 |
+
return clean_phonemes(raw)
|
| 204 |
+
|
| 205 |
+
words, parts = text.split(" "), raw.split(" ")
|
| 206 |
+
self.n_words += len(words)
|
| 207 |
+
if len(parts) == len(words):
|
| 208 |
+
out = " ".join(
|
| 209 |
+
self._rewrite_word(p, w, words[i - 1] if i else None)
|
| 210 |
+
for i, (p, w) in enumerate(zip(parts, words))
|
| 211 |
+
)
|
| 212 |
+
else:
|
| 213 |
+
# espeak merged or split something; phonemise word by word so the
|
| 214 |
+
# rules stay word-scoped.
|
| 215 |
+
out = " ".join(
|
| 216 |
+
self._rewrite_word(self._g2p(w)[0], w, words[i - 1] if i else None)
|
| 217 |
+
for i, w in enumerate(words)
|
| 218 |
+
)
|
| 219 |
+
return clean_phonemes(out)
|
| 220 |
+
|
| 221 |
+
__call__ = phonemize
|
| 222 |
+
|
| 223 |
+
def stats(self) -> dict:
|
| 224 |
+
return {
|
| 225 |
+
"words": self.n_words,
|
| 226 |
+
"rules_fired": dict(self.n_rule),
|
| 227 |
+
"lexicon_exceptions": dict(self.n_exception),
|
| 228 |
+
"ambiguous_words": self.n_ambiguous,
|
| 229 |
+
}
|
kemetone/lexicons/ث.tsv
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Words taking the exception reading for ث.
|
| 2 |
+
ابحاث
|
| 3 |
+
اثار
|
| 4 |
+
اثبات
|
| 5 |
+
اثر
|
| 6 |
+
اثري
|
| 7 |
+
اثرياء
|
| 8 |
+
اثناء
|
| 9 |
+
احاديث
|
| 10 |
+
اضغاث
|
| 11 |
+
الاثار
|
| 12 |
+
الاثر
|
| 13 |
+
الاثناء
|
| 14 |
+
الاثير
|
| 15 |
+
الاضغاث
|
| 16 |
+
الباحث
|
| 17 |
+
البث
|
| 18 |
+
البحث
|
| 19 |
+
البراغيث
|
| 20 |
+
التاثير
|
| 21 |
+
التلوث
|
| 22 |
+
الثابت
|
| 23 |
+
الثانويه
|
| 24 |
+
الثروه
|
| 25 |
+
الثقافه
|
| 26 |
+
الثقالبه
|
| 27 |
+
الثقه
|
| 28 |
+
الثوره
|
| 29 |
+
الجثه
|
| 30 |
+
الحادث
|
| 31 |
+
الحادثه
|
| 32 |
+
الحديث
|
| 33 |
+
الحديثه
|
| 34 |
+
الحوادث
|
| 35 |
+
الكارثه
|
| 36 |
+
المثال
|
| 37 |
+
المثقفين
|
| 38 |
+
المثلث
|
| 39 |
+
المستثمرين
|
| 40 |
+
الميثين
|
| 41 |
+
الميراث
|
| 42 |
+
الوثائق
|
| 43 |
+
الوثيقه
|
| 44 |
+
الوراثه
|
| 45 |
+
امثله
|
| 46 |
+
باحث
|
| 47 |
+
باحثين
|
| 48 |
+
بحث
|
| 49 |
+
بحوث
|
| 50 |
+
تؤثر
|
| 51 |
+
تاثير
|
| 52 |
+
تثير
|
| 53 |
+
تحديث
|
| 54 |
+
تلوث
|
| 55 |
+
تندثر
|
| 56 |
+
ثابت
|
| 57 |
+
ثانوي
|
| 58 |
+
ثانويه
|
| 59 |
+
ثبات
|
| 60 |
+
ثروه
|
| 61 |
+
ثري
|
| 62 |
+
ثقافه
|
| 63 |
+
ثقافي
|
| 64 |
+
ثقافيه
|
| 65 |
+
ثقه
|
| 66 |
+
ثنائي
|
| 67 |
+
ثنائيه
|
| 68 |
+
ثورات
|
| 69 |
+
ثوره
|
| 70 |
+
ثوري
|
| 71 |
+
ثوريه
|
| 72 |
+
جثث
|
| 73 |
+
جثه
|
| 74 |
+
حادث
|
| 75 |
+
حديث
|
| 76 |
+
حديثه
|
| 77 |
+
حوادث
|
| 78 |
+
حيث
|
| 79 |
+
فثم
|
| 80 |
+
كارثه
|
| 81 |
+
لوث
|
| 82 |
+
مؤثر
|
| 83 |
+
متاثر
|
| 84 |
+
مثال
|
| 85 |
+
مثبت
|
| 86 |
+
مثقال
|
| 87 |
+
مثقف
|
| 88 |
+
مثل
|
| 89 |
+
مثلا
|
| 90 |
+
مثلث
|
| 91 |
+
مثير
|
| 92 |
+
مثيره
|
| 93 |
+
ملوث
|
| 94 |
+
ميثان
|
| 95 |
+
ميراث
|
| 96 |
+
نموذج
|
| 97 |
+
واثق
|
| 98 |
+
وثائق
|
| 99 |
+
وثيقه
|
| 100 |
+
وراثه
|
| 101 |
+
وراثي
|
| 102 |
+
يؤثر
|
| 103 |
+
يثبت
|
| 104 |
+
يثير
|
| 105 |
+
يلبثوا
|
kemetone/lexicons/ذ.tsv
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Words taking the exception reading for ذ.
|
| 2 |
+
الذهب
|
| 3 |
+
بالذهب
|
| 4 |
+
تاخذ
|
| 5 |
+
ذراعه
|
| 6 |
+
ذكر
|
| 7 |
+
ذهب
|
| 8 |
+
ياخذ
|
| 9 |
+
يذوب
|
kemetone/lexicons/ظ.tsv
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Words taking the exception reading for ظ.
|
| 2 |
+
الظهر
|
| 3 |
+
بالظبط
|
| 4 |
+
نظاره
|
| 5 |
+
نظيف
|
| 6 |
+
نظيفه
|
kemetone/lexicons/ق.tsv
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Words taking the exception reading for ق.
|
| 2 |
+
اقتصاد
|
| 3 |
+
اقتصادي
|
| 4 |
+
اقتصاديه
|
| 5 |
+
الاقتصاد
|
| 6 |
+
الانشقاق
|
| 7 |
+
التقوي
|
| 8 |
+
الثقافه
|
| 9 |
+
العلق
|
| 10 |
+
الفرقان
|
| 11 |
+
الفقه
|
| 12 |
+
الفقهاء
|
| 13 |
+
الفلق
|
| 14 |
+
القارعه
|
| 15 |
+
القانون
|
| 16 |
+
القبله
|
| 17 |
+
القدر
|
| 18 |
+
القدس
|
| 19 |
+
القران
|
| 20 |
+
القوانين
|
| 21 |
+
القوميه
|
| 22 |
+
القيامه
|
| 23 |
+
المقرئ
|
| 24 |
+
المنافقون
|
| 25 |
+
المنطق
|
| 26 |
+
الموسيقي
|
| 27 |
+
الوثائق
|
| 28 |
+
الوثيقه
|
| 29 |
+
بالقران
|
| 30 |
+
تقدير
|
| 31 |
+
تقوي
|
| 32 |
+
ثقافه
|
| 33 |
+
ثقافي
|
| 34 |
+
ثقافيه
|
| 35 |
+
فقه
|
| 36 |
+
فقهاء
|
| 37 |
+
فقهي
|
| 38 |
+
قانون
|
| 39 |
+
قانوني
|
| 40 |
+
قانونيه
|
| 41 |
+
قبله
|
| 42 |
+
قدر
|
| 43 |
+
قران
|
| 44 |
+
قرانا
|
| 45 |
+
قراني
|
| 46 |
+
قرانيه
|
| 47 |
+
قوانين
|
| 48 |
+
قوميه
|
| 49 |
+
للقران
|
| 50 |
+
مقرئ
|
| 51 |
+
منطق
|
| 52 |
+
منطقي
|
| 53 |
+
منطقيه
|
| 54 |
+
موسيقي
|
| 55 |
+
موسيقيه
|
| 56 |
+
موسيقيين
|
| 57 |
+
والقران
|
| 58 |
+
وثائق
|
| 59 |
+
وثيقه
|
kemetone/normalize_tashkeel.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Canonical ordering for Arabic combining marks.
|
| 4 |
+
|
| 5 |
+
Unicode allows a vowel mark either side of a shadda, and both orderings occur
|
| 6 |
+
in real text. espeak-ng only honours the canonical one:
|
| 7 |
+
|
| 8 |
+
consonant + shadda + harakat -> الشَّمْس ʔaʃʃˈams (correct)
|
| 9 |
+
consonant + harakat + shadda -> الشَّمْس ʔaʃʃms (vowel SILENTLY dropped)
|
| 10 |
+
|
| 11 |
+
The wrong order does not error, it just deletes a vowel from the phoneme
|
| 12 |
+
string. Applied across a corpus that is a systematic pronunciation defect that
|
| 13 |
+
no symbol-map assertion would catch, so normalize before phonemizing.
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
import re
|
| 17 |
+
|
| 18 |
+
SHADDA = "ّ"
|
| 19 |
+
HARAKAT = "ً-ْٰ"
|
| 20 |
+
|
| 21 |
+
_SWAP = re.compile(f"([{HARAKAT}])({SHADDA})")
|
| 22 |
+
_DUP = re.compile(f"([{HARAKAT}])\\1+")
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def normalize(text: str) -> str:
|
| 26 |
+
"""Reorder harakat+shadda -> shadda+harakat and collapse duplicated marks."""
|
| 27 |
+
prev = None
|
| 28 |
+
while prev != text: # a mark may need to migrate past several neighbours
|
| 29 |
+
prev = text
|
| 30 |
+
text = _SWAP.sub(r"\2\1", text)
|
| 31 |
+
return _DUP.sub(r"\1", text)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def count_misordered(text: str) -> int:
|
| 35 |
+
return len(_SWAP.findall(text))
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
if __name__ == "__main__":
|
| 39 |
+
import sys
|
| 40 |
+
|
| 41 |
+
for path in sys.argv[1:]:
|
| 42 |
+
with open(path, encoding="utf-8") as fh:
|
| 43 |
+
src = fh.read()
|
| 44 |
+
n = count_misordered(src)
|
| 45 |
+
out = normalize(src)
|
| 46 |
+
with open(path, "w", encoding="utf-8") as fh:
|
| 47 |
+
fh.write(out)
|
| 48 |
+
print(f"{path}: reordered {n} harakat+shadda pairs")
|
kemetone/runtime.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""espeak-ng wiring.
|
| 2 |
+
|
| 3 |
+
The front-end phonemises through espeak-ng's Arabic voice. Some environments
|
| 4 |
+
ship a `libespeak-ng.so` that will not load (an older glibc than the wheel was
|
| 5 |
+
built against is the usual cause). Point `KEMETONE_ESPEAK_LIB` at a working
|
| 6 |
+
build to override:
|
| 7 |
+
|
| 8 |
+
export KEMETONE_ESPEAK_LIB=/path/to/libespeak-ng.so
|
| 9 |
+
|
| 10 |
+
The override must be applied AFTER `misaki.espeak` is imported, because that
|
| 11 |
+
module sets the library path at import time and phonemizer reads its value
|
| 12 |
+
before consulting the environment. `init_espeak()` handles the ordering.
|
| 13 |
+
"""
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
import os
|
| 16 |
+
|
| 17 |
+
_done = False
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def init_espeak(library: str | None = None) -> str | None:
|
| 21 |
+
"""Prepare espeak-ng. Safe to call repeatedly."""
|
| 22 |
+
global _done
|
| 23 |
+
if _done:
|
| 24 |
+
return os.environ.get("PHONEMIZER_ESPEAK_LIBRARY")
|
| 25 |
+
|
| 26 |
+
import misaki.espeak # noqa: F401 — sets its own path at import time
|
| 27 |
+
|
| 28 |
+
lib = library or os.environ.get("KEMETONE_ESPEAK_LIB")
|
| 29 |
+
if lib:
|
| 30 |
+
from phonemizer.backend.espeak.wrapper import EspeakWrapper
|
| 31 |
+
|
| 32 |
+
EspeakWrapper.set_library(lib) # after misaki, so ours wins
|
| 33 |
+
os.environ["PHONEMIZER_ESPEAK_LIBRARY"] = lib
|
| 34 |
+
data = os.environ.get("KEMETONE_ESPEAK_DATA")
|
| 35 |
+
if data:
|
| 36 |
+
EspeakWrapper.set_data_path(data)
|
| 37 |
+
_done = True
|
| 38 |
+
return os.environ.get("PHONEMIZER_ESPEAK_LIBRARY")
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def espeak_version() -> tuple:
|
| 42 |
+
init_espeak()
|
| 43 |
+
from phonemizer.backend import EspeakBackend
|
| 44 |
+
|
| 45 |
+
return EspeakBackend.version()
|
requirements.txt
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# KemeTone needs the Kokoro runtime for the model class, and espeak-ng for the
|
| 2 |
+
# Arabic phonemiser. `kokoro` pulls in misaki and phonemizer-fork itself.
|
| 3 |
+
kokoro>=0.9.4
|
| 4 |
+
torch>=2.1
|
| 5 |
+
soundfile
|
| 6 |
+
numpy
|
voices/kemetone.pt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:5f8c27cf931b2b7852cd4dc90f07c9f1189a18edf32d3c9973a33f14f8575d38
|
| 3 |
+
size 523824
|