arbtok 0.0.0a2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. arbtok-0.0.0a2/PKG-INFO +93 -0
  2. arbtok-0.0.0a2/README.md +60 -0
  3. arbtok-0.0.0a2/arbtok/__init__.py +13 -0
  4. arbtok-0.0.0a2/arbtok/constants.py +87 -0
  5. arbtok-0.0.0a2/arbtok/dialects.py +111 -0
  6. arbtok-0.0.0a2/arbtok/espeak_wrapper.py +237 -0
  7. arbtok-0.0.0a2/arbtok/num2words.py +170 -0
  8. arbtok-0.0.0a2/arbtok/plugin.py +118 -0
  9. arbtok-0.0.0a2/arbtok/pyarabic/__init__.py +13 -0
  10. arbtok-0.0.0a2/arbtok/pyarabic/arabrepr.py +64 -0
  11. arbtok-0.0.0a2/arbtok/pyarabic/araby.py +1647 -0
  12. arbtok-0.0.0a2/arbtok/pyarabic/named_const.py +227 -0
  13. arbtok-0.0.0a2/arbtok/pyarabic/normalize.py +161 -0
  14. arbtok-0.0.0a2/arbtok/pyarabic/number.py +826 -0
  15. arbtok-0.0.0a2/arbtok/pyarabic/number_const.py +1704 -0
  16. arbtok-0.0.0a2/arbtok/pyarabic/stack.py +52 -0
  17. arbtok-0.0.0a2/arbtok/pyarabic/trans.py +517 -0
  18. arbtok-0.0.0a2/arbtok/tashkeel.py +55 -0
  19. arbtok-0.0.0a2/arbtok/test.py +343 -0
  20. arbtok-0.0.0a2/arbtok/tokenizer.py +497 -0
  21. arbtok-0.0.0a2/arbtok/unicode_symbol2label.py +4173 -0
  22. arbtok-0.0.0a2/arbtok/util.py +778 -0
  23. arbtok-0.0.0a2/arbtok/version.py +10 -0
  24. arbtok-0.0.0a2/arbtok.egg-info/PKG-INFO +93 -0
  25. arbtok-0.0.0a2/arbtok.egg-info/SOURCES.txt +34 -0
  26. arbtok-0.0.0a2/arbtok.egg-info/dependency_links.txt +1 -0
  27. arbtok-0.0.0a2/arbtok.egg-info/requires.txt +11 -0
  28. arbtok-0.0.0a2/arbtok.egg-info/top_level.txt +1 -0
  29. arbtok-0.0.0a2/pyproject.toml +40 -0
  30. arbtok-0.0.0a2/requirements.txt +7 -0
  31. arbtok-0.0.0a2/setup.cfg +4 -0
  32. arbtok-0.0.0a2/tests/test_ipa.py +74 -0
  33. arbtok-0.0.0a2/tests/test_ipa_fuzzy.py +252 -0
  34. arbtok-0.0.0a2/tests/test_normalization.py +53 -0
  35. arbtok-0.0.0a2/tests/test_plugin.py +104 -0
  36. arbtok-0.0.0a2/tests/test_tokens.py +298 -0
@@ -0,0 +1,93 @@
1
+ Metadata-Version: 2.4
2
+ Name: arbtok
3
+ Version: 0.0.0a2
4
+ Summary: Rule-based Arabic (MSA) text-to-IPA with tashkeel diacritization — an orthography2ipa G2P plugin
5
+ License: Apache-2.0
6
+ Project-URL: Homepage, https://github.com/TigreGotico/arbtok
7
+ Project-URL: Issues, https://github.com/TigreGotico/arbtok/issues
8
+ Keywords: arabic,ipa,g2p,phonemizer,tashkeel,diacritization,tts
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Intended Audience :: Science/Research
12
+ Classifier: Topic :: Text Processing :: Linguistic
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.9
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: License :: OSI Approved :: Apache Software License
20
+ Classifier: Operating System :: OS Independent
21
+ Requires-Python: >=3.9
22
+ Description-Content-Type: text/markdown
23
+ Requires-Dist: numpy
24
+ Requires-Dist: quebra-frases
25
+ Requires-Dist: langcodes
26
+ Requires-Dist: ovos-number-parser>=0.4.0
27
+ Requires-Dist: ovos-date-parser>=0.6.4a1
28
+ Requires-Dist: orthography2ipa>=0.3.0a1
29
+ Requires-Dist: text2tashkeel>=0.1.0
30
+ Provides-Extra: test
31
+ Requires-Dist: pytest; extra == "test"
32
+ Requires-Dist: pytest-timeout; extra == "test"
33
+
34
+ # arbtok
35
+
36
+ Rule-based **Arabic (MSA) text→IPA** with **tashkeel** diacritization — a
37
+ downstream Arabic engine built on
38
+ [orthography2ipa](https://github.com/TigreGotico/orthography2ipa).
39
+
40
+ A context-sensitive token tree (sentence → words → characters) implements the
41
+ morpho-phonological rules that table lookups cannot express: sun-letter
42
+ assimilation, hamzat al-waṣl elision, tanwīn pausal forms, tāʾ marbūṭa,
43
+ mater-lectionis vowel lengthening, definite-article waṣl, and idgham/iqlab
44
+ nasal assimilation. Bare (undiacritized) text is diacritized first via
45
+ [text2tashkeel](https://github.com/TigreGotico/text2tashkeel) — a model
46
+ picker over bundled ONNX diacritization models.
47
+
48
+ > Honesty note: the gold IPA reference set was LLM-generated and has not been
49
+ > validated by a native MSA speaker. If you speak MSA, pull requests are very
50
+ > welcome.
51
+
52
+ ## Installation
53
+
54
+ ```bash
55
+ pip install arbtok
56
+ ```
57
+
58
+ ## Usage
59
+
60
+ arbtok is built **on** [orthography2ipa](https://github.com/TigreGotico/orthography2ipa)
61
+ (spec data and the shared `G2PPlugin`/`WordContext` base types) and owns the
62
+ Arabic pipeline — orthography2ipa stays the language-agnostic base library.
63
+
64
+ ### Engine class
65
+
66
+ ```python
67
+ from arbtok.tokenizer import Sentence
68
+
69
+ Sentence("اَلسَّلَامُ عَلَيْكُمْ").ipa
70
+ ```
71
+
72
+ Bare text is handled by diacritizing first:
73
+
74
+ ```python
75
+ from arbtok.plugin import ArbtokG2PPlugin
76
+
77
+ plugin = ArbtokG2PPlugin()
78
+ plugin.transcribe("كتاب جميل") # auto-tashkeel + IPA
79
+ ```
80
+
81
+ ### Diacritization only
82
+
83
+ ```python
84
+ from arbtok.tashkeel import TashkeelDiacritizer # wraps text2tashkeel
85
+
86
+ TashkeelDiacritizer().diacritize("كتاب جميل")
87
+ ```
88
+
89
+ ## Quality benchmarks
90
+
91
+ The test suite pins a gold sentence set (CER target ≤ 5% against the
92
+ reference transcriptions) and benchmarks against espeak-ng. See
93
+ `tests/test_ipa_fuzzy.py` and `docs/` for details.
@@ -0,0 +1,60 @@
1
+ # arbtok
2
+
3
+ Rule-based **Arabic (MSA) text→IPA** with **tashkeel** diacritization — a
4
+ downstream Arabic engine built on
5
+ [orthography2ipa](https://github.com/TigreGotico/orthography2ipa).
6
+
7
+ A context-sensitive token tree (sentence → words → characters) implements the
8
+ morpho-phonological rules that table lookups cannot express: sun-letter
9
+ assimilation, hamzat al-waṣl elision, tanwīn pausal forms, tāʾ marbūṭa,
10
+ mater-lectionis vowel lengthening, definite-article waṣl, and idgham/iqlab
11
+ nasal assimilation. Bare (undiacritized) text is diacritized first via
12
+ [text2tashkeel](https://github.com/TigreGotico/text2tashkeel) — a model
13
+ picker over bundled ONNX diacritization models.
14
+
15
+ > Honesty note: the gold IPA reference set was LLM-generated and has not been
16
+ > validated by a native MSA speaker. If you speak MSA, pull requests are very
17
+ > welcome.
18
+
19
+ ## Installation
20
+
21
+ ```bash
22
+ pip install arbtok
23
+ ```
24
+
25
+ ## Usage
26
+
27
+ arbtok is built **on** [orthography2ipa](https://github.com/TigreGotico/orthography2ipa)
28
+ (spec data and the shared `G2PPlugin`/`WordContext` base types) and owns the
29
+ Arabic pipeline — orthography2ipa stays the language-agnostic base library.
30
+
31
+ ### Engine class
32
+
33
+ ```python
34
+ from arbtok.tokenizer import Sentence
35
+
36
+ Sentence("اَلسَّلَامُ عَلَيْكُمْ").ipa
37
+ ```
38
+
39
+ Bare text is handled by diacritizing first:
40
+
41
+ ```python
42
+ from arbtok.plugin import ArbtokG2PPlugin
43
+
44
+ plugin = ArbtokG2PPlugin()
45
+ plugin.transcribe("كتاب جميل") # auto-tashkeel + IPA
46
+ ```
47
+
48
+ ### Diacritization only
49
+
50
+ ```python
51
+ from arbtok.tashkeel import TashkeelDiacritizer # wraps text2tashkeel
52
+
53
+ TashkeelDiacritizer().diacritize("كتاب جميل")
54
+ ```
55
+
56
+ ## Quality benchmarks
57
+
58
+ The test suite pins a gold sentence set (CER target ≤ 5% against the
59
+ reference transcriptions) and benchmarks against espeak-ng. See
60
+ `tests/test_ipa_fuzzy.py` and `docs/` for details.
@@ -0,0 +1,13 @@
1
+ """
2
+ I'm just a dude trying to create TTS voices to help the visually impaired arabic community.
3
+
4
+ I am not an arabic speaker, I never had any previous interaction with the arabic script, this might be a pointless experiment powered by LLM hallucinations.
5
+
6
+ You have been warned.
7
+
8
+ if you speak MSA pull requests welcome!
9
+
10
+ - test.py contains a reference dataset to benchmark the code against, IPA transcriptions have been LLM generated
11
+ - main logic is in tokenizer.py
12
+ - see tests folder for some benchmarks and comparison against pre existing (low quality) datasets
13
+ """
@@ -0,0 +1,87 @@
1
+ import string
2
+ from typing import Set
3
+
4
+ # ==============================================================================
5
+ # ARABIC CONSTANTS
6
+ # ==============================================================================
7
+
8
+ # --- Diacritics (Tashkeel) ---
9
+ FATHA = '\u064E' # َ (short a)
10
+ DAMMA = '\u064F' # ُ (short u)
11
+ KASRA = '\u0650' # ِ (short i)
12
+ SHADDA = '\u0651' # ّ (consonant doubling/gemination)
13
+ SUKUN = '\u0652' # ْ (no vowel/silence)
14
+ TANWIN_FATH = '\u064B' # ً (an)
15
+ TANWIN_DAMM = '\u064C' # ٌ (un)
16
+ TANWIN_KASR = '\u064D' # ٍ (in)
17
+ DAGGER_ALIF = '\u0670' # ٰ (superscript alef, long a)
18
+ MADD = '\u0653' # ٓ (madda, vowel elongation)
19
+
20
+ # --- Letters & Variants ---
21
+ HAMZA = '\u0621' # ء (standalone hamza)
22
+ ALEF_MADDA = '\u0622' # آ (alef with madda)
23
+ ALEF_HAMZA_ABOVE = '\u0623' # أ (alef with hamza above)
24
+ WAW_HAMZA = '\u0624' # ؤ (waw with hamza)
25
+ ALEF_HAMZA_BELOW = '\u0625' # إ (alef with hamza below)
26
+ YA_HAMZA = '\u0626' # ئ (ya with hamza)
27
+ ALIF = '\u0627' # ا (bare alef)
28
+ TA_MARBUTA = '\u0629' # ة (tied ta, fem. marker)
29
+ WAW = '\u0648' # و
30
+ ALIF_MAKSURA = '\u0649' # ى (broken alef)
31
+ YA = '\u064A' # ي
32
+ LAM = '\u0644' # ل
33
+ HAMZAT_AL_WASL = '\u0671' # ٱ (wasl, connecting hamza)
34
+
35
+ ALEF_VARIANTS = {ALIF, ALEF_MADDA, ALEF_HAMZA_ABOVE, ALEF_HAMZA_BELOW, HAMZAT_AL_WASL}
36
+
37
+ # --- Punctuation ---
38
+ ARABIC_PUNCT = "،؛؟ـ…" # Comma, Semicolon, Question mark, Tatweel, Ellipsis
39
+ PUNCT = string.punctuation + ARABIC_PUNCT
40
+
41
+ # --- Sun Letters (Shamsiyya) ---
42
+ # These consonants assimilate the 'l' of the definite article 'al-'.
43
+ # e.g., al-shams -> ash-shams.
44
+ SUN_LETTERS: Set[str] = {
45
+ '\u062A', # ت (t)
46
+ '\u062B', # ث (th)
47
+ '\u062F', # د (d)
48
+ '\u0630', # ذ (dh)
49
+ '\u0631', # ر (r)
50
+ '\u0632', # ز (z)
51
+ '\u0633', # س (s)
52
+ '\u0634', # ش (sh)
53
+ '\u0635', # ص (sad)
54
+ '\u0636', # ض (dad)
55
+ '\u0637', # ط (ta)
56
+ '\u0638', # ظ (dha)
57
+ '\u0644', # ل (l)
58
+ '\u0646', # ن (n)
59
+ }
60
+
61
+ # --- Proclitics ---
62
+ # Short prepositions/conjunctions that attach to the following word in writing.
63
+ CLITIC_BASES = {
64
+ WAW, # و (and)
65
+ '\u0641', # ف (so/then)
66
+ '\u0628', # ب (by/with)
67
+ '\u0643', # ك (like/as)
68
+ LAM, # ل (for/to)
69
+ '\u0633' # س (future marker 'sa-')
70
+ }
71
+
72
+ # --- IPA Mappings ---
73
+ # for easy unambiguous phoneme reference in the code when the unicode maps to a specific IPA sound
74
+ B = '\u0628'
75
+ R = '\u0631'
76
+ Z = '\u0632'
77
+ S = '\u0633'
78
+ F = '\u0641'
79
+ Q = '\u0642'
80
+ K = '\u0643'
81
+ M = '\u0645'
82
+ N = '\u0646'
83
+ H = '\u0647'
84
+ T = '\u062A'
85
+ DJ = '\u062C'
86
+ X = '\u062E'
87
+ D = '\u062F'
@@ -0,0 +1,111 @@
1
+ """
2
+ TODO: to be extended with different phoneme realizations per dialect.
3
+ logic in tokenizer might use ArabicDialect enum if needed for further contextual rules
4
+
5
+ currently only MSA is targeted
6
+ """
7
+ from enum import Enum
8
+ from arbtok.constants import (B, T, DJ, X, D, R, Z, S, F, Q, K, M, N, H, LAM, WAW, YA,
9
+ FATHA, DAMMA, KASRA, DAGGER_ALIF, MADD,
10
+ HAMZA, ALEF_HAMZA_ABOVE, ALEF_HAMZA_BELOW, WAW_HAMZA, YA_HAMZA,
11
+ TANWIN_FATH, TANWIN_KASR, TANWIN_DAMM,
12
+ ALIF_MAKSURA)
13
+
14
+
15
+ class ArabicDialect(str, Enum):
16
+ CLA = "CLA"
17
+ MSA = "MSA"
18
+
19
+
20
+ ARABIC_TO_IPA_CONSONANTS = {
21
+ HAMZA: "ʔ", ALEF_HAMZA_ABOVE: "ʔ", ALEF_HAMZA_BELOW: "ʔ", WAW_HAMZA: "ʔ", YA_HAMZA: "ʔ",
22
+ B: 'b',
23
+ T: 't',
24
+ '\u062B': 'θ',
25
+ DJ: 'dʒ', # DJ: 'ʤ',
26
+ '\u062D': 'ħ',
27
+ X: 'x',
28
+ D: 'd',
29
+ '\u0630': 'ð',
30
+ R: 'r',
31
+ Z: 'z',
32
+ S: 's',
33
+ '\u0634': 'ʃ',
34
+ '\u0635': 'sˤ',
35
+ '\u0636': 'dˤ',
36
+ '\u0637': 'tˤ',
37
+ '\u0638': 'ðˤ',
38
+ '\u0639': 'ʕ',
39
+ '\u063A': 'ɣ',
40
+ F: 'f',
41
+ Q: 'q',
42
+ K: 'k',
43
+ M: 'm',
44
+ N: 'n',
45
+ H: 'h',
46
+ LAM: 'l',
47
+ WAW: 'w',
48
+ YA: 'j',
49
+ # Other unicode variations
50
+ "ﻻ": "laː"
51
+ }
52
+
53
+ DIACRITIC_TO_IPA = {
54
+ FATHA: 'a',
55
+ DAMMA: 'u',
56
+ KASRA: 'i',
57
+ DAGGER_ALIF: 'aː',
58
+ MADD: 'aː'
59
+ }
60
+ TANWIN_TO_IPA = {
61
+ TANWIN_FATH: "an",
62
+ TANWIN_DAMM: "un",
63
+ TANWIN_KASR: "in"
64
+ }
65
+
66
+ VOWEL_MAP = {**DIACRITIC_TO_IPA,
67
+ **TANWIN_TO_IPA,
68
+ MADD: ":",
69
+ ALIF_MAKSURA: 'aː'}
70
+
71
+ # --- Word Exceptions ---
72
+ # TODO - LLM generated, needs validation from native speaker
73
+ # Dictionary for words with irregular orthography vs pronunciation.
74
+ # These words have "deficient" or "historical" spelling where vowels
75
+ # are pronounced but not written, or written letters are silent.
76
+ WORD_EXCEPTIONS = {
77
+ # === Unicode / orthographic variants ===
78
+
79
+ # Demonstratives with "Dagger Alif" (pronounced long /a:/ but written short or omitted)
80
+ "هَٰذَا": "haːðaː", # ha-dha (this, m.)
81
+ "هٰذَا": "haːðaː", # variant
82
+ "هَذَا": "haːðaː", # common deficient spelling
83
+ "هَٰذِهِ": "haːðihi", # ha-dhi-hi (this, f.)
84
+ "هٰذِهِ": "haːðihi", # variant
85
+ "ذَٰلِكَ": "ðaːlika", # dha-li-ka (that)
86
+ "ذٰلِكَ": "ðaːlika", # variant
87
+ "أُولَٰئِكَ": "ʔulaːʔika", # u-la-i-ka (those) - note medial hamza logic is complex, hardcoded here
88
+
89
+ # Particles
90
+ "لَٰكِن": "laːkin", # la-kin (but) - unwritten medial alif
91
+ "لَكِن": "laːkin", # deficient spelling
92
+ "لَٰكِنَّ": "laːkinna", # la-kin-na (but...)
93
+
94
+ # Divine Names
95
+ "ﷲ": "allaːh",
96
+ "اللّٰه": "allaːh", # Allah - heavy L, unwritten alif
97
+ "اللَّه": "allaːh", # variant
98
+ "إِلَٰه": "ʔilaːh", # ilah (god)
99
+ "الرَّحْمَٰن": "arraħmaːn", # Ar-Rahman
100
+
101
+ # Irregular pronunciations
102
+ "مِائَة": "miʔa", # mi'a (hundred) - silent extra Alif in spelling
103
+ "عَمْرٌو": "ʕamrun", # 'Amr - final Waw is silent (differentiates from 'Umar)
104
+ "أُولِي": "ʔuliː", # 'uli (owners of) - silent Waw
105
+ "أُولُو": "ʔuluː", # 'ulu - silent Waw
106
+
107
+ # TODO - why are these irregular?
108
+ "مُسْتَشْفَى": "mustashfaː",
109
+ "مَدْرَسَة": "madrasah"
110
+ }
111
+
@@ -0,0 +1,237 @@
1
+ import os
2
+ import re
3
+ import string
4
+ import subprocess
5
+ from typing import List, Optional
6
+ from typing import Tuple
7
+
8
+ import numpy as np
9
+ import onnxruntime
10
+ import requests
11
+ from quebra_frases import sentence_tokenize
12
+
13
+ from arbtok.tashkeel import TashkeelDiacritizer
14
+ from arbtok.util import normalize, match_lang
15
+
16
+ # list of (substring, terminator, end_of_sentence) tuples.
17
+ TextChunks = List[Tuple[str, str, bool]]
18
+ # list of (phonemes, terminator, end_of_sentence) tuples.
19
+ RawPhonemizedChunks = List[Tuple[str, str, bool]]
20
+
21
+ PhonemizedChunks = list[list[str]]
22
+
23
+
24
+ class EspeakError(Exception):
25
+ """Custom exception for espeak-ng related errors."""
26
+ pass
27
+
28
+
29
+ class EspeakPhonemizer:
30
+ """
31
+ A phonemizer class that uses the espeak-ng command-line tool to convert text into phonemes.
32
+ It segments the input text heuristically based on punctuation to mimic clause-by-clause processing.
33
+ """
34
+ ESPEAK_LANGS = ['es-419', 'ca', 'qya', 'ga', 'et', 'ky', 'io', 'fa-latn', 'en-gb', 'fo', 'haw', 'kl',
35
+ 'ta', 'ml', 'gd', 'sd', 'es', 'hy', 'ur', 'ro', 'hi', 'or', 'ti', 'ca-va', 'om', 'tr', 'pa',
36
+ 'smj', 'mk', 'bg', 'cv', "fr", 'fi', 'en-gb-x-rp', 'ru', 'mt', 'an', 'mr', 'pap', 'vi', 'id',
37
+ 'fr-be', 'ltg', 'my', 'nl', 'shn', 'ba', 'az', 'cmn', 'da', 'as', 'sw',
38
+ 'piqd', 'en-us', 'hr', 'it', 'ug', 'th', 'mi', 'cy', 'ru-lv', 'ia', 'tt', 'hu', 'xex', 'te', 'ne',
39
+ 'eu', 'ja', 'bpy', 'hak', 'cs', 'en-gb-scotland', 'hyw', 'uk', 'pt', 'bn', 'mto', 'yue',
40
+ 'be', 'gu', 'sv', 'sl', 'cmn-latn-pinyin', 'lfn', 'lv', 'fa', 'sjn', 'nog', 'ms',
41
+ 'vi-vn-x-central', 'lt', 'kn', 'he', 'qu', 'ca-ba', 'quc', 'nb', 'sk', 'tn', 'py', 'si', 'de',
42
+ 'ar', 'en-gb-x-gbcwmd', 'bs', 'qdb', 'sq', 'sr', 'tk', 'en-029', 'ht', 'ru-cl', 'af', 'pt-br',
43
+ 'fr-ch', 'ka', 'en-gb-x-gbclan', 'ko', 'is', 'ca-nw', 'gn', 'kok', 'la', 'lb', 'am', 'kk', 'ku',
44
+ 'kaa', 'jbo', 'eo', 'uz', 'nci', 'vi-vn-x-south', 'el', 'pl', 'grc', ]
45
+
46
+ def __init__(self, pausal: bool = True):
47
+ self.pausal = pausal # arabic only
48
+ self._tashkeel: Optional[TashkeelDiacritizer] = None
49
+
50
+ @classmethod
51
+ def get_lang(cls, target_lang: str) -> str:
52
+ """
53
+ Validates and returns the closest supported language code.
54
+
55
+ Args:
56
+ target_lang (str): The language code to validate.
57
+
58
+ Returns:
59
+ str: The validated language code.
60
+
61
+ Raises:
62
+ ValueError: If the language code is unsupported.
63
+ """
64
+ if target_lang.lower() == "en-gb":
65
+ return "en-gb-x-rp"
66
+ if target_lang in cls.ESPEAK_LANGS:
67
+ return target_lang
68
+ if target_lang.lower().split("-")[0] in cls.ESPEAK_LANGS:
69
+ return target_lang.lower().split("-")[0]
70
+ return cls.match_lang(target_lang, cls.ESPEAK_LANGS)
71
+
72
+ @property
73
+ def tashkeel(self) -> TashkeelDiacritizer:
74
+ if self._tashkeel is None:
75
+ self._tashkeel = TashkeelDiacritizer()
76
+ return self._tashkeel
77
+
78
+ @staticmethod
79
+ def _run_espeak_command(args: List[str], input_text: str = None, check: bool = True) -> str:
80
+ """
81
+ Helper function to run espeak-ng commands via subprocess.
82
+ Executes 'espeak-ng' with the given arguments and input text.
83
+ Captures stdout and stderr, and raises EspeakError on failure.
84
+
85
+ Args:
86
+ args (List[str]): A list of command-line arguments for espeak-ng.
87
+ input_text (str, optional): The text to pass to espeak-ng's stdin. Defaults to None.
88
+ check (bool, optional): If True, raises a CalledProcessError if the command returns a non-zero exit code. Defaults to True.
89
+
90
+ Returns:
91
+ str: The stripped standard output from the espeak-ng command.
92
+
93
+ Raises:
94
+ EspeakError: If espeak-ng command is not found, or if the subprocess call fails.
95
+ """
96
+ command: List[str] = ['espeak-ng'] + args
97
+
98
+ # Standard arguments for subprocess.run
99
+ subprocess_args = {
100
+ 'input': input_text,
101
+ 'capture_output': True,
102
+ 'text': True,
103
+ 'check': check,
104
+ 'encoding': 'utf-8',
105
+ 'errors': 'replace' # Replaces unencodable characters with a placeholder
106
+ }
107
+
108
+ # Add 'creationflags' to hide the terminal window on Windows
109
+ if os.name == 'nt':
110
+ subprocess_args['creationflags'] = 0x08000000
111
+
112
+ try:
113
+ process: subprocess.CompletedProcess = subprocess.run(
114
+ command,
115
+ **subprocess_args # Use the dynamic arguments
116
+ )
117
+ return process.stdout.strip()
118
+ except FileNotFoundError:
119
+ raise EspeakError(
120
+ "espeak-ng command not found. Please ensure espeak-ng is installed "
121
+ "and available in your system's PATH."
122
+ )
123
+ except subprocess.CalledProcessError as e:
124
+ raise EspeakError(
125
+ f"espeak-ng command failed with error code {e.returncode}:\n"
126
+ f"STDOUT: {e.stdout}\n"
127
+ f"STDERR: {e.stderr}"
128
+ )
129
+ except Exception as e:
130
+ raise EspeakError(f"An unexpected error occurred while running espeak-ng: {e}")
131
+
132
+ def phonemize_string(self, text: str, lang: str) -> str:
133
+ lang = self.get_lang(lang)
134
+ return self._run_espeak_command(
135
+ ['-q', '-x', '--ipa', '-v', lang],
136
+ input_text=text
137
+ )
138
+
139
+ def phonemize_to_list(self, text: str, lang: str = "ar") -> List[str]:
140
+ return list(self.phonemize_string(text, lang))
141
+
142
+ def add_diacritics(self, text: str, lang: str= "ar") -> str:
143
+ if lang.startswith("ar"):
144
+ return self.tashkeel.diacritize(text, pausal=self.pausal)
145
+ return text
146
+
147
+ def phonemize(self, text: str, lang: str= "ar") -> PhonemizedChunks:
148
+ if not text:
149
+ return [('', '', True)]
150
+ results: RawPhonemizedChunks = []
151
+ text = normalize(text, lang)
152
+ for chunk, punct, eos in self.chunk_text(text):
153
+ phoneme_str = self.phonemize_string(self.remove_punctuation(chunk), lang)
154
+ results += [(phoneme_str, punct, True)]
155
+ return self._process_phones(results)
156
+
157
+ @staticmethod
158
+ def _process_phones(raw_phones: RawPhonemizedChunks) -> PhonemizedChunks:
159
+ """Text to phonemes grouped by sentence."""
160
+ all_phonemes: list[list[str]] = []
161
+ sentence_phonemes: list[str] = []
162
+ for phonemes_str, terminator_str, end_of_sentence in raw_phones:
163
+ # Filter out (lang) switch (flags).
164
+ # These surround words from languages other than the current voice.
165
+ phonemes_str = re.sub(r"\([^)]+\)", "", phonemes_str)
166
+ sentence_phonemes.extend(list(phonemes_str))
167
+ if end_of_sentence:
168
+ all_phonemes.append(sentence_phonemes)
169
+ sentence_phonemes = []
170
+ if sentence_phonemes:
171
+ all_phonemes.append(sentence_phonemes)
172
+ return all_phonemes
173
+
174
+ @staticmethod
175
+ def match_lang(target_lang: str, valid_langs: List[str]) -> str:
176
+ """
177
+ Validates and returns the closest supported language code.
178
+
179
+ Args:
180
+ target_lang (str): The language code to validate.
181
+
182
+ Returns:
183
+ str: The validated language code.
184
+
185
+ Raises:
186
+ ValueError: If the language code is unsupported.
187
+ """
188
+ lang, score = match_lang(target_lang, valid_langs)
189
+ if score > 10:
190
+ # raise an error for unsupported language
191
+ raise ValueError(f"unsupported language code: {target_lang}")
192
+ return lang
193
+
194
+ @staticmethod
195
+ def remove_punctuation(text):
196
+ """
197
+ Removes all punctuation characters from a string.
198
+ Punctuation characters are defined by string.punctuation.
199
+ """
200
+ # Create a regex pattern that matches any character in string.punctuation
201
+ punctuation_pattern = r"[" + re.escape(string.punctuation) + r"]"
202
+ return re.sub(punctuation_pattern, '', text).strip()
203
+
204
+ @staticmethod
205
+ def chunk_text(text: str, delimiters: Optional[List[str]] = None) -> TextChunks:
206
+ if not text:
207
+ return [('', '', True)]
208
+
209
+ results: TextChunks = []
210
+ delimiters = delimiters or [":", ";", "...", "|"]
211
+
212
+ # Create a regex pattern that matches any of the delimiters
213
+ delimiter_pattern = re.escape(delimiters[0])
214
+ for delimiter in delimiters[1:]:
215
+ delimiter_pattern += f"|{re.escape(delimiter)}"
216
+
217
+ for sentence in sentence_tokenize(text):
218
+ # Default punctuation if no specific punctuation found
219
+ default_punc = sentence[-1] if sentence and sentence[-1] in string.punctuation else "."
220
+
221
+ # Use regex to split the sentence by any of the delimiters
222
+ parts = re.split(f'({delimiter_pattern})', sentence)
223
+
224
+ # Group parts into chunks (text + delimiter)
225
+ chunks = []
226
+ for i in range(0, len(parts), 2):
227
+ # If there's a delimiter after the text, use it
228
+ delimiter = parts[i + 1] if i + 1 < len(parts) else default_punc
229
+
230
+ # Last chunk is marked as complete
231
+ is_last = (i + 2 >= len(parts))
232
+
233
+ chunks.append((parts[i].strip(), delimiter.strip(), is_last))
234
+
235
+ results.extend(chunks)
236
+
237
+ return results