Rabe3 commited on
Commit
6bb3339
·
verified ·
1 Parent(s): 59e0a25

KemeTone v1.0 — Egyptian Arabic TTS

Browse files
.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

  • SHA256: fd8cf620e9032fcdfade68daadad19a72129a32d4b2c81bcfe70123383348ef5
  • Pointer size: 131 Bytes
  • Size of remote file: 891 kB
assets/logo.png ADDED

Git LFS Details

  • SHA256: 7192ef2976046973cad80faf373d30d2893d9e8a379458add0880e50851daec7
  • Pointer size: 131 Bytes
  • Size of remote file: 956 kB
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