scriptconv 0.0.3a10__tar.gz → 0.0.3a12__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.
- {scriptconv-0.0.3a10 → scriptconv-0.0.3a12}/PKG-INFO +58 -2
- {scriptconv-0.0.3a10 → scriptconv-0.0.3a12}/README.md +1 -1
- {scriptconv-0.0.3a10 → scriptconv-0.0.3a12}/pyproject.toml +19 -1
- {scriptconv-0.0.3a10 → scriptconv-0.0.3a12}/scriptconv/notation.py +45 -12
- scriptconv-0.0.3a12/scriptconv/phonemizers/__init__.py +40 -0
- scriptconv-0.0.3a12/scriptconv/phonemizers/base.py +332 -0
- scriptconv-0.0.3a12/scriptconv/phonemizers/enums.py +79 -0
- scriptconv-0.0.3a12/scriptconv/phonemizers/eu.py +172 -0
- scriptconv-0.0.3a12/scriptconv/phonemizers/fa.py +35 -0
- scriptconv-0.0.3a12/scriptconv/phonemizers/gl.py +77 -0
- scriptconv-0.0.3a12/scriptconv/phonemizers/he.py +33 -0
- scriptconv-0.0.3a12/scriptconv/phonemizers/mul.py +1419 -0
- scriptconv-0.0.3a12/scriptconv/phonemizers/mwl.py +58 -0
- scriptconv-0.0.3a12/scriptconv/phonemizers/o2ipa.py +80 -0
- scriptconv-0.0.3a12/scriptconv/phonemizers/pt.py +87 -0
- scriptconv-0.0.3a12/scriptconv/phonemizers/registry.py +160 -0
- scriptconv-0.0.3a12/scriptconv/phonemizers/vi.py +33 -0
- {scriptconv-0.0.3a10 → scriptconv-0.0.3a12}/scriptconv/version.py +1 -1
- {scriptconv-0.0.3a10 → scriptconv-0.0.3a12}/scriptconv.egg-info/PKG-INFO +58 -2
- {scriptconv-0.0.3a10 → scriptconv-0.0.3a12}/scriptconv.egg-info/SOURCES.txt +15 -0
- scriptconv-0.0.3a12/scriptconv.egg-info/requires.txt +85 -0
- scriptconv-0.0.3a12/tests/test_arpa_stress.py +88 -0
- scriptconv-0.0.3a12/tests/test_phonemizers_base.py +132 -0
- scriptconv-0.0.3a10/scriptconv.egg-info/requires.txt +0 -12
- {scriptconv-0.0.3a10 → scriptconv-0.0.3a12}/LICENSE +0 -0
- {scriptconv-0.0.3a10 → scriptconv-0.0.3a12}/requirements.txt +0 -0
- {scriptconv-0.0.3a10 → scriptconv-0.0.3a12}/scriptconv/__init__.py +0 -0
- {scriptconv-0.0.3a10 → scriptconv-0.0.3a12}/scriptconv/__main__.py +0 -0
- {scriptconv-0.0.3a10 → scriptconv-0.0.3a12}/scriptconv/cangjie.py +0 -0
- {scriptconv-0.0.3a10 → scriptconv-0.0.3a12}/scriptconv/conventions.py +0 -0
- {scriptconv-0.0.3a10 → scriptconv-0.0.3a12}/scriptconv/data/__init__.py +0 -0
- {scriptconv-0.0.3a10 → scriptconv-0.0.3a12}/scriptconv/data/cangjie5_tc.tsv.gz +0 -0
- {scriptconv-0.0.3a10 → scriptconv-0.0.3a12}/scriptconv/graph.py +0 -0
- {scriptconv-0.0.3a10 → scriptconv-0.0.3a12}/scriptconv/py.typed +0 -0
- {scriptconv-0.0.3a10 → scriptconv-0.0.3a12}/scriptconv/readings.py +0 -0
- {scriptconv-0.0.3a10 → scriptconv-0.0.3a12}/scriptconv/scripts.py +0 -0
- {scriptconv-0.0.3a10 → scriptconv-0.0.3a12}/scriptconv/translit.py +0 -0
- {scriptconv-0.0.3a10 → scriptconv-0.0.3a12}/scriptconv.egg-info/dependency_links.txt +0 -0
- {scriptconv-0.0.3a10 → scriptconv-0.0.3a12}/scriptconv.egg-info/top_level.txt +0 -0
- {scriptconv-0.0.3a10 → scriptconv-0.0.3a12}/setup.cfg +0 -0
- {scriptconv-0.0.3a10 → scriptconv-0.0.3a12}/tests/test_cangjie.py +0 -0
- {scriptconv-0.0.3a10 → scriptconv-0.0.3a12}/tests/test_cli.py +0 -0
- {scriptconv-0.0.3a10 → scriptconv-0.0.3a12}/tests/test_conventions.py +0 -0
- {scriptconv-0.0.3a10 → scriptconv-0.0.3a12}/tests/test_errors_policy.py +0 -0
- {scriptconv-0.0.3a10 → scriptconv-0.0.3a12}/tests/test_examples.py +0 -0
- {scriptconv-0.0.3a10 → scriptconv-0.0.3a12}/tests/test_graph.py +0 -0
- {scriptconv-0.0.3a10 → scriptconv-0.0.3a12}/tests/test_notation.py +0 -0
- {scriptconv-0.0.3a10 → scriptconv-0.0.3a12}/tests/test_readings.py +0 -0
- {scriptconv-0.0.3a10 → scriptconv-0.0.3a12}/tests/test_readings_zh.py +0 -0
- {scriptconv-0.0.3a10 → scriptconv-0.0.3a12}/tests/test_scripts.py +0 -0
- {scriptconv-0.0.3a10 → scriptconv-0.0.3a12}/tests/test_scripts_stressonnx_compat.py +0 -0
- {scriptconv-0.0.3a10 → scriptconv-0.0.3a12}/tests/test_translit.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: scriptconv
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.3a12
|
|
4
4
|
Summary: Zero-dependency script & phoneme-notation core — ISO-15924 detection & metadata, IPA↔ARPABET/X-SAMPA/Lexique/Kirshenbaum/Cotovía/RFE, Buckwalter↔Arabic, Hangul→jamo, kana
|
|
5
5
|
License: Apache-2.0
|
|
6
6
|
Project-URL: Homepage, https://github.com/TigreGotico/scriptconv
|
|
@@ -24,11 +24,67 @@ Provides-Extra: ja
|
|
|
24
24
|
Requires-Dist: pykakasi<3,>=2.3; extra == "ja"
|
|
25
25
|
Provides-Extra: zh
|
|
26
26
|
Requires-Dist: pypinyin<1,>=0.50; extra == "zh"
|
|
27
|
+
Provides-Extra: phonemizers
|
|
28
|
+
Requires-Dist: quebra-frases; extra == "phonemizers"
|
|
29
|
+
Requires-Dist: langcodes; extra == "phonemizers"
|
|
30
|
+
Provides-Extra: espeak
|
|
31
|
+
Requires-Dist: scriptconv[phonemizers]; extra == "espeak"
|
|
32
|
+
Requires-Dist: espyak; extra == "espeak"
|
|
33
|
+
Provides-Extra: gruut
|
|
34
|
+
Requires-Dist: scriptconv[phonemizers]; extra == "gruut"
|
|
35
|
+
Requires-Dist: gruut; extra == "gruut"
|
|
36
|
+
Provides-Extra: goruut
|
|
37
|
+
Requires-Dist: scriptconv[phonemizers]; extra == "goruut"
|
|
38
|
+
Requires-Dist: pygoruut; extra == "goruut"
|
|
39
|
+
Provides-Extra: epitran
|
|
40
|
+
Requires-Dist: scriptconv[phonemizers]; extra == "epitran"
|
|
41
|
+
Requires-Dist: epitran; extra == "epitran"
|
|
42
|
+
Provides-Extra: transphone
|
|
43
|
+
Requires-Dist: scriptconv[phonemizers]; extra == "transphone"
|
|
44
|
+
Requires-Dist: transphone; extra == "transphone"
|
|
45
|
+
Provides-Extra: misaki
|
|
46
|
+
Requires-Dist: scriptconv[phonemizers]; extra == "misaki"
|
|
47
|
+
Requires-Dist: misaki; extra == "misaki"
|
|
48
|
+
Provides-Extra: byt5
|
|
49
|
+
Requires-Dist: scriptconv[phonemizers]; extra == "byt5"
|
|
50
|
+
Requires-Dist: onnxruntime; extra == "byt5"
|
|
51
|
+
Requires-Dist: numpy; extra == "byt5"
|
|
52
|
+
Provides-Extra: o2i
|
|
53
|
+
Requires-Dist: scriptconv[phonemizers]; extra == "o2i"
|
|
54
|
+
Requires-Dist: orthography2ipa; extra == "o2i"
|
|
55
|
+
Provides-Extra: eu
|
|
56
|
+
Requires-Dist: scriptconv[phonemizers]; extra == "eu"
|
|
57
|
+
Requires-Dist: euskaphone; extra == "eu"
|
|
58
|
+
Requires-Dist: ahotts_g2p; extra == "eu"
|
|
59
|
+
Provides-Extra: pt-phonemizers
|
|
60
|
+
Requires-Dist: scriptconv[phonemizers]; extra == "pt-phonemizers"
|
|
61
|
+
Requires-Dist: tugaphone; extra == "pt-phonemizers"
|
|
62
|
+
Requires-Dist: g2p_barranquenho; extra == "pt-phonemizers"
|
|
63
|
+
Provides-Extra: mwl
|
|
64
|
+
Requires-Dist: scriptconv[phonemizers]; extra == "mwl"
|
|
65
|
+
Requires-Dist: mwl_phonemizer; extra == "mwl"
|
|
66
|
+
Provides-Extra: gl
|
|
67
|
+
Requires-Dist: scriptconv[phonemizers]; extra == "gl"
|
|
68
|
+
Requires-Dist: pycotovia; extra == "gl"
|
|
69
|
+
Provides-Extra: he
|
|
70
|
+
Requires-Dist: scriptconv[phonemizers]; extra == "he"
|
|
71
|
+
Requires-Dist: phonikud-onnx; extra == "he"
|
|
72
|
+
Provides-Extra: fa
|
|
73
|
+
Requires-Dist: scriptconv[phonemizers]; extra == "fa"
|
|
74
|
+
Requires-Dist: persian_phonemizer; extra == "fa"
|
|
75
|
+
Provides-Extra: vi
|
|
76
|
+
Requires-Dist: scriptconv[phonemizers]; extra == "vi"
|
|
77
|
+
Requires-Dist: viphoneme; extra == "vi"
|
|
78
|
+
Provides-Extra: tashkeel
|
|
79
|
+
Requires-Dist: scriptconv[phonemizers]; extra == "tashkeel"
|
|
80
|
+
Requires-Dist: text2tashkeel; extra == "tashkeel"
|
|
27
81
|
Provides-Extra: test
|
|
28
82
|
Requires-Dist: pytest; extra == "test"
|
|
29
83
|
Requires-Dist: pytest-timeout; extra == "test"
|
|
30
84
|
Requires-Dist: pykakasi<3,>=2.3; extra == "test"
|
|
31
85
|
Requires-Dist: pypinyin<1,>=0.50; extra == "test"
|
|
86
|
+
Requires-Dist: quebra-frases; extra == "test"
|
|
87
|
+
Requires-Dist: langcodes; extra == "test"
|
|
32
88
|
Dynamic: license-file
|
|
33
89
|
|
|
34
90
|
# scriptconv
|
|
@@ -210,7 +266,7 @@ below describes the per-converter default.
|
|
|
210
266
|
|
|
211
267
|
| Notation | `to_ipa` → `from_ipa` round-trip | `from_ipa` → `to_ipa` round-trip | Unknown-token behaviour |
|
|
212
268
|
|----------|----------------------------------|----------------------------------|-------------------------|
|
|
213
|
-
| **ARPABET** |
|
|
269
|
+
| **ARPABET** | **Lossless with `stress=True`** (digits ↔ IPA `ˈ`/`ˌ` before the stressed vowel; residues: extended-ARPABET `AX` normalises to CMUdict's `AH0` spelling, and `AH0 R` fuses to the r-colored `AXR0` — stable from the IPA side). Default `stress=False` drops digits and merges `AH0`↔`AX` | **Lossy** — ARPABET is an English-only inventory, so any IPA symbol outside it becomes the *unknown* placeholder | `arpa_to_ipa`: passed through unchanged. `ipa_to_arpa`: diacritics and suprasegmentals are dropped; other out-of-inventory symbols follow `errors=` (default `"replace"` → `?`) |
|
|
214
270
|
| **X-SAMPA** | Exact for all canonical symbols | Exact except aliases (`f\`→ɸ, `&`→æ) normalise to their canonical spelling | Passed through unchanged |
|
|
215
271
|
| **Buckwalter ↔ Arabic** | Exact (the `^` shadda alias normalises to canonical `~`; precomposed lam-alef ligatures decompose to two chars, visually identical) | Exact | Passed through unchanged |
|
|
216
272
|
| **Lexique ↔ IPA** | Exact except the `°`/`3` schwa pair (both → `ə`; reverse always → `°`) | Exact | Passed through unchanged |
|
|
@@ -177,7 +177,7 @@ below describes the per-converter default.
|
|
|
177
177
|
|
|
178
178
|
| Notation | `to_ipa` → `from_ipa` round-trip | `from_ipa` → `to_ipa` round-trip | Unknown-token behaviour |
|
|
179
179
|
|----------|----------------------------------|----------------------------------|-------------------------|
|
|
180
|
-
| **ARPABET** |
|
|
180
|
+
| **ARPABET** | **Lossless with `stress=True`** (digits ↔ IPA `ˈ`/`ˌ` before the stressed vowel; residues: extended-ARPABET `AX` normalises to CMUdict's `AH0` spelling, and `AH0 R` fuses to the r-colored `AXR0` — stable from the IPA side). Default `stress=False` drops digits and merges `AH0`↔`AX` | **Lossy** — ARPABET is an English-only inventory, so any IPA symbol outside it becomes the *unknown* placeholder | `arpa_to_ipa`: passed through unchanged. `ipa_to_arpa`: diacritics and suprasegmentals are dropped; other out-of-inventory symbols follow `errors=` (default `"replace"` → `?`) |
|
|
181
181
|
| **X-SAMPA** | Exact for all canonical symbols | Exact except aliases (`f\`→ɸ, `&`→æ) normalise to their canonical spelling | Passed through unchanged |
|
|
182
182
|
| **Buckwalter ↔ Arabic** | Exact (the `^` shadda alias normalises to canonical `~`; precomposed lam-alef ligatures decompose to two chars, visually identical) | Exact | Passed through unchanged |
|
|
183
183
|
| **Lexique ↔ IPA** | Exact except the `°`/`3` schwa pair (both → `ə`; reverse always → `°`) | Exact | Passed through unchanged |
|
|
@@ -35,7 +35,25 @@ dependencies = {file = ["requirements.txt"]}
|
|
|
35
35
|
[project.optional-dependencies]
|
|
36
36
|
ja = ["pykakasi>=2.3,<3"]
|
|
37
37
|
zh = ["pypinyin>=0.50,<1"]
|
|
38
|
-
|
|
38
|
+
# --- phonemizer wrappers (scriptconv.phonemizers) ---
|
|
39
|
+
phonemizers = ["quebra-frases", "langcodes"]
|
|
40
|
+
espeak = ["scriptconv[phonemizers]", "espyak"]
|
|
41
|
+
gruut = ["scriptconv[phonemizers]", "gruut"]
|
|
42
|
+
goruut = ["scriptconv[phonemizers]", "pygoruut"]
|
|
43
|
+
epitran = ["scriptconv[phonemizers]", "epitran"]
|
|
44
|
+
transphone = ["scriptconv[phonemizers]", "transphone"]
|
|
45
|
+
misaki = ["scriptconv[phonemizers]", "misaki"]
|
|
46
|
+
byt5 = ["scriptconv[phonemizers]", "onnxruntime", "numpy"]
|
|
47
|
+
o2i = ["scriptconv[phonemizers]", "orthography2ipa"]
|
|
48
|
+
eu = ["scriptconv[phonemizers]", "euskaphone", "ahotts_g2p"]
|
|
49
|
+
pt-phonemizers = ["scriptconv[phonemizers]", "tugaphone", "g2p_barranquenho"]
|
|
50
|
+
mwl = ["scriptconv[phonemizers]", "mwl_phonemizer"]
|
|
51
|
+
gl = ["scriptconv[phonemizers]", "pycotovia"]
|
|
52
|
+
he = ["scriptconv[phonemizers]", "phonikud-onnx"]
|
|
53
|
+
fa = ["scriptconv[phonemizers]", "persian_phonemizer"]
|
|
54
|
+
vi = ["scriptconv[phonemizers]", "viphoneme"]
|
|
55
|
+
tashkeel = ["scriptconv[phonemizers]", "text2tashkeel"]
|
|
56
|
+
test = ["pytest", "pytest-timeout", "pykakasi>=2.3,<3", "pypinyin>=0.50,<1", "quebra-frases", "langcodes"]
|
|
39
57
|
|
|
40
58
|
[project.urls]
|
|
41
59
|
Homepage = "https://github.com/TigreGotico/scriptconv"
|
|
@@ -157,6 +157,11 @@ _IPA_TO_ARPA.setdefault("g", "G")
|
|
|
157
157
|
_IPA_ARPA_KEYS_SORTED = sorted(_IPA_TO_ARPA.keys(), key=len, reverse=True)
|
|
158
158
|
_IPA_ARPA_RE = re.compile("|".join(re.escape(s) for s in _IPA_ARPA_KEYS_SORTED))
|
|
159
159
|
|
|
160
|
+
# ARPABET vowel class — the symbols that carry stress digits.
|
|
161
|
+
_ARPA_VOWELS = {"AO", "AA", "IY", "UW", "EH", "IH", "UH", "AH", "AE", "AX",
|
|
162
|
+
"EY", "AY", "OW", "AW", "OY", "ER", "AXR"}
|
|
163
|
+
_STRESS_MARKS = {"ˈ": "1", "ˌ": "2"} # IPA primary/secondary stress
|
|
164
|
+
|
|
160
165
|
|
|
161
166
|
|
|
162
167
|
class UnknownSymbolError(ValueError):
|
|
@@ -187,12 +192,18 @@ def _unknown(symbol: str, position: int, notation: str, errors: str,
|
|
|
187
192
|
raise ValueError(f"errors must be one of {_ERROR_POLICIES}, not {errors!r}")
|
|
188
193
|
|
|
189
194
|
|
|
190
|
-
def arpa_to_ipa(arpa_sequence: str, errors: str = "pass"
|
|
195
|
+
def arpa_to_ipa(arpa_sequence: str, errors: str = "pass",
|
|
196
|
+
stress: bool = False) -> str:
|
|
191
197
|
"""Convert a space-separated ARPABET sequence to an IPA string.
|
|
192
198
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
``
|
|
199
|
+
With ``stress=True``, ARPABET stress digits are preserved as IPA stress
|
|
200
|
+
marks placed immediately before the stressed vowel's symbol: ``AH1`` →
|
|
201
|
+
``ˈʌ``, ``AH2`` → ``ˌʌ``, ``AH0`` → unmarked (a purely notational,
|
|
202
|
+
reversible convention — no syllabification is performed). The default
|
|
203
|
+
``stress=False`` keeps the historical behavior of stripping the digits.
|
|
204
|
+
``errors`` selects the unknown-token policy: ``"pass"`` (default) keeps
|
|
205
|
+
the token, ``"replace"`` substitutes ``?``, ``"ignore"`` drops it,
|
|
206
|
+
``"strict"`` raises :class:`UnknownSymbolError`.
|
|
196
207
|
|
|
197
208
|
Examples
|
|
198
209
|
--------
|
|
@@ -202,6 +213,9 @@ def arpa_to_ipa(arpa_sequence: str, errors: str = "pass") -> str:
|
|
|
202
213
|
tokens = arpa_sequence.strip().split()
|
|
203
214
|
result = []
|
|
204
215
|
for i, tok in enumerate(tokens):
|
|
216
|
+
mark = ""
|
|
217
|
+
if stress and tok[-1:] in ("1", "2") and tok[:-1] in _ARPA_VOWELS:
|
|
218
|
+
mark = "ˈ" if tok[-1] == "1" else "ˌ"
|
|
205
219
|
ipa = _ARPA_TO_IPA.get(tok)
|
|
206
220
|
if ipa is None:
|
|
207
221
|
# strip trailing digit and retry
|
|
@@ -209,19 +223,24 @@ def arpa_to_ipa(arpa_sequence: str, errors: str = "pass") -> str:
|
|
|
209
223
|
ipa = _ARPA_TO_IPA.get(stripped)
|
|
210
224
|
if ipa is None:
|
|
211
225
|
ipa = _unknown(tok, i, "arpa", errors)
|
|
212
|
-
|
|
226
|
+
mark = ""
|
|
227
|
+
result.append(mark + ipa)
|
|
213
228
|
return "".join(result)
|
|
214
229
|
|
|
215
230
|
|
|
216
231
|
def ipa_to_arpa(ipa_string: str, unknown: str = "?",
|
|
217
|
-
errors: str = "replace") -> str:
|
|
232
|
+
errors: str = "replace", stress: bool = False) -> str:
|
|
218
233
|
"""Convert an IPA string to a space-separated ARPABET sequence.
|
|
219
234
|
|
|
220
|
-
Matches longest IPA symbol first. ``
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
235
|
+
Matches longest IPA symbol first. With ``stress=True``, IPA stress
|
|
236
|
+
marks (``ˈ``/``ˌ``) preceding a vowel-class symbol are emitted as that
|
|
237
|
+
vowel's stress digit (``1``/``2``) and unmarked vowels receive ``0`` —
|
|
238
|
+
the reverse of :func:`arpa_to_ipa`'s ``stress=True`` convention.
|
|
239
|
+
``errors`` selects the unknown-symbol policy (default ``"replace"``,
|
|
240
|
+
preserving the historical behavior of substituting *unknown*, ``"?"``);
|
|
241
|
+
``unknown=""`` still drops silently. Combining marks and modifier
|
|
242
|
+
letters qualify the preceding phoneme and are always dropped rather
|
|
243
|
+
than treated as unknowns.
|
|
225
244
|
|
|
226
245
|
Examples
|
|
227
246
|
--------
|
|
@@ -231,11 +250,25 @@ def ipa_to_arpa(ipa_string: str, unknown: str = "?",
|
|
|
231
250
|
'?'
|
|
232
251
|
"""
|
|
233
252
|
tokens = []
|
|
253
|
+
pending_stress = ""
|
|
234
254
|
pos = 0
|
|
235
255
|
while pos < len(ipa_string):
|
|
256
|
+
if stress and ipa_string[pos] in _STRESS_MARKS:
|
|
257
|
+
pending_stress = _STRESS_MARKS[ipa_string[pos]]
|
|
258
|
+
pos += 1
|
|
259
|
+
continue
|
|
236
260
|
m = _IPA_ARPA_RE.match(ipa_string, pos)
|
|
237
261
|
if m:
|
|
238
|
-
|
|
262
|
+
tok = _IPA_TO_ARPA[m.group(0)]
|
|
263
|
+
if stress and tok in _ARPA_VOWELS:
|
|
264
|
+
# CMUdict spells schwa as AH0 (it has no AX), so stress-aware
|
|
265
|
+
# output uses AH for exact round-trips; extended-ARPABET AX
|
|
266
|
+
# input still normalizes to AH0 (documented residue).
|
|
267
|
+
if m.group(0) == "ə":
|
|
268
|
+
tok = "AH"
|
|
269
|
+
tok += pending_stress or "0"
|
|
270
|
+
pending_stress = ""
|
|
271
|
+
tokens.append(tok)
|
|
239
272
|
pos = m.end()
|
|
240
273
|
else:
|
|
241
274
|
ch = ipa_string[pos]
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Phonemizer wrappers — see :mod:`scriptconv.phonemizers.base` for the thesis.
|
|
2
|
+
|
|
3
|
+
Public surface: the :class:`BasePhonemizer` contract, the ``Alphabet``/
|
|
4
|
+
``Phonemizer`` enums, the lazy registry (``get_phonemizer``,
|
|
5
|
+
``phonemizer_for_lang``), and the per-language wrapper modules. Backing
|
|
6
|
+
engines are optional extras; a missing one raises :class:`ImportError`
|
|
7
|
+
naming the extra to install.
|
|
8
|
+
"""
|
|
9
|
+
from scriptconv.phonemizers.enums import Alphabet, Phonemizer
|
|
10
|
+
from scriptconv.phonemizers.base import (
|
|
11
|
+
BasePhonemizer,
|
|
12
|
+
GraphemePhonemizer,
|
|
13
|
+
UnicodeCodepointPhonemizer,
|
|
14
|
+
TextChunks,
|
|
15
|
+
RawPhonemizedChunks,
|
|
16
|
+
PhonemizedChunks,
|
|
17
|
+
)
|
|
18
|
+
from scriptconv.phonemizers.registry import (
|
|
19
|
+
PHONEMIZER_REGISTRY,
|
|
20
|
+
LANG_DEFAULTS,
|
|
21
|
+
get_phonemizer,
|
|
22
|
+
get_phonemizer_class,
|
|
23
|
+
phonemizer_for_lang,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"Alphabet",
|
|
28
|
+
"Phonemizer",
|
|
29
|
+
"BasePhonemizer",
|
|
30
|
+
"GraphemePhonemizer",
|
|
31
|
+
"UnicodeCodepointPhonemizer",
|
|
32
|
+
"TextChunks",
|
|
33
|
+
"RawPhonemizedChunks",
|
|
34
|
+
"PhonemizedChunks",
|
|
35
|
+
"PHONEMIZER_REGISTRY",
|
|
36
|
+
"LANG_DEFAULTS",
|
|
37
|
+
"get_phonemizer",
|
|
38
|
+
"get_phonemizer_class",
|
|
39
|
+
"phonemizer_for_lang",
|
|
40
|
+
]
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
"""Phonemizer contract and base implementations.
|
|
2
|
+
|
|
3
|
+
Phonemization (orthography → sound) is out of scope for scriptconv's
|
|
4
|
+
zero-dependency core; this subpackage holds *wrappers* around external
|
|
5
|
+
phonemizer engines behind optional extras — the same boundary as
|
|
6
|
+
:mod:`scriptconv.readings` wrapping reading dictionaries. No
|
|
7
|
+
grapheme-to-phoneme rules are implemented in the core; in-tree
|
|
8
|
+
implementations live under ``_thirdparty`` with their provenance, installed
|
|
9
|
+
only with the ``phonemizers`` extras.
|
|
10
|
+
|
|
11
|
+
The ``phonemizers`` base extra provides sentence chunking (``quebra_frases``)
|
|
12
|
+
and language matching (``langcodes``).
|
|
13
|
+
|
|
14
|
+
**Normalization is injectable, not built in.** TTS stacks normalize numbers,
|
|
15
|
+
dates and abbreviations before phonemizing; that requires language resources
|
|
16
|
+
scriptconv does not ship. Pass ``normalizer=`` (a ``(text, lang) -> str``
|
|
17
|
+
callable) at construction to run it inside ``phonemize_lazy``; without it the
|
|
18
|
+
raw text is phonemized as-is. Downstream TTS injects its own normalizer, so
|
|
19
|
+
calling a wrapper through scriptconv directly and through a TTS stack can
|
|
20
|
+
legitimately differ on text containing digits.
|
|
21
|
+
"""
|
|
22
|
+
import abc
|
|
23
|
+
import re
|
|
24
|
+
import string
|
|
25
|
+
import unicodedata
|
|
26
|
+
from typing import Callable, Iterator, List, Optional, Tuple, Literal, Union
|
|
27
|
+
|
|
28
|
+
from scriptconv.phonemizers.enums import Alphabet
|
|
29
|
+
|
|
30
|
+
# list of (substring, terminator, end_of_sentence) tuples.
|
|
31
|
+
TextChunks = List[Tuple[str, str, bool]]
|
|
32
|
+
# list of (phonemes, terminator, end_of_sentence) tuples.
|
|
33
|
+
RawPhonemizedChunks = List[Tuple[str, str, bool]]
|
|
34
|
+
|
|
35
|
+
PhonemizedChunks = list[list[str]]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class BasePhonemizer(metaclass=abc.ABCMeta):
|
|
39
|
+
def __init__(self, alphabet: Alphabet = Alphabet.UNICODE,
|
|
40
|
+
diacritizer_model: str = "rawi-ensemble",
|
|
41
|
+
normalizer: Optional[Callable[[str, str], str]] = None,
|
|
42
|
+
phonikud_model: Optional[str] = None):
|
|
43
|
+
super().__init__()
|
|
44
|
+
self.alphabet = alphabet
|
|
45
|
+
# optional (text, lang) -> str hook run before chunking; see module
|
|
46
|
+
# docstring — scriptconv performs no normalization of its own
|
|
47
|
+
self.normalizer = normalizer
|
|
48
|
+
# local path to a phonikud ONNX model (Hebrew diacritization);
|
|
49
|
+
# scriptconv never downloads — the consumer resolves the file
|
|
50
|
+
self.phonikud_model = phonikud_model
|
|
51
|
+
|
|
52
|
+
# diacritizer model name, for languages that need one. Arabic uses
|
|
53
|
+
# text2tashkeel; the default "rawi-ensemble" restores hamza and the dagger
|
|
54
|
+
# alef in addition to the standard marks.
|
|
55
|
+
self.diacritizer_model = diacritizer_model
|
|
56
|
+
self._phonikud = None # hebrew only
|
|
57
|
+
self._tashkeel: dict = {} # model name -> text2tashkeel Diacritizer
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def phonikud(self):
|
|
61
|
+
if self._phonikud is None:
|
|
62
|
+
if not self.phonikud_model:
|
|
63
|
+
raise ValueError(
|
|
64
|
+
"Hebrew diacritization needs a local phonikud ONNX model: "
|
|
65
|
+
"pass phonikud_model=<path> (scriptconv never downloads "
|
|
66
|
+
"models; obtain one from the phonikud-onnx release)")
|
|
67
|
+
try:
|
|
68
|
+
from phonikud_onnx import Phonikud
|
|
69
|
+
except ImportError:
|
|
70
|
+
raise ImportError(
|
|
71
|
+
"Hebrew diacritization needs phonikud-onnx — install "
|
|
72
|
+
"with `pip install scriptconv[he]`") from None
|
|
73
|
+
self._phonikud = Phonikud(self.phonikud_model)
|
|
74
|
+
return self._phonikud
|
|
75
|
+
|
|
76
|
+
def tashkeel(self, model: Optional[str] = None):
|
|
77
|
+
"""Lazily build (and cache) the text2tashkeel Diacritizer used for Arabic.
|
|
78
|
+
|
|
79
|
+
text2tashkeel is a dependency of the ``[ar]`` extra; it restores hamza and the
|
|
80
|
+
dagger alef in addition to the standard marks. Install with
|
|
81
|
+
``pip install scriptconv[tashkeel]`` (or ``pip install text2tashkeel``)."""
|
|
82
|
+
model = model or self.diacritizer_model
|
|
83
|
+
if model not in self._tashkeel:
|
|
84
|
+
try:
|
|
85
|
+
from text2tashkeel import Diacritizer
|
|
86
|
+
except ImportError as e:
|
|
87
|
+
raise ImportError(
|
|
88
|
+
"Arabic diacritization requires the text2tashkeel package: "
|
|
89
|
+
"pip install scriptconv[tashkeel] (or pip install text2tashkeel)"
|
|
90
|
+
) from e
|
|
91
|
+
self._tashkeel[model] = Diacritizer(model)
|
|
92
|
+
return self._tashkeel[model]
|
|
93
|
+
|
|
94
|
+
@abc.abstractmethod
|
|
95
|
+
def phonemize_string(self, text: str, lang: str) -> str:
|
|
96
|
+
raise NotImplementedError
|
|
97
|
+
|
|
98
|
+
def phonemize_to_list(self, text: str, lang: str) -> List[str]:
|
|
99
|
+
return list(self.phonemize_string(text, lang))
|
|
100
|
+
|
|
101
|
+
def add_diacritics(self, text: str, lang: str,
|
|
102
|
+
model: Optional[str] = None) -> str:
|
|
103
|
+
if lang.startswith("he"):
|
|
104
|
+
return self.phonikud.add_diacritics(text)
|
|
105
|
+
elif lang.startswith("ar"):
|
|
106
|
+
return self.tashkeel(model).diacritize(text)
|
|
107
|
+
return text
|
|
108
|
+
|
|
109
|
+
def phonemize(self, text: str, lang: str) -> PhonemizedChunks:
|
|
110
|
+
# PhonemizedChunks is list[list[str]]; empty text yields no
|
|
111
|
+
# sentences. (Returning the raw (str, str, bool) tuple form here
|
|
112
|
+
# corrupted the type and broke callers that mutate each sentence,
|
|
113
|
+
# e.g. inline ``[[phoneme]]`` blocks in TTSVoice.phonemize.)
|
|
114
|
+
return list(self.phonemize_lazy(text, lang))
|
|
115
|
+
|
|
116
|
+
def phonemize_lazy(self, text: str, lang: str) -> Iterator[List[str]]:
|
|
117
|
+
"""Lazy, per-sentence variant of :meth:`phonemize`.
|
|
118
|
+
|
|
119
|
+
Yields one phoneme list per sentence-level chunk, invoking the
|
|
120
|
+
(potentially expensive) ``phonemize_string`` only as each sentence is
|
|
121
|
+
pulled from the generator. This lets a caller start synthesizing
|
|
122
|
+
sentence 1 before sentence 2 has been phonemized, cutting
|
|
123
|
+
time-to-first-audio.
|
|
124
|
+
|
|
125
|
+
Normalization and chunking still run over the *whole* text up front (both
|
|
126
|
+
are cheap and order-sensitive), so ``list(self.phonemize_lazy(text)) ==
|
|
127
|
+
self.phonemize(text)`` holds for any input.
|
|
128
|
+
"""
|
|
129
|
+
if not text:
|
|
130
|
+
return
|
|
131
|
+
if self.normalizer is not None:
|
|
132
|
+
text = self.normalizer(text, lang)
|
|
133
|
+
for chunk, punct, eos in self.chunk_text(text):
|
|
134
|
+
phoneme_str = self.phonemize_string(self.remove_punctuation(chunk), lang)
|
|
135
|
+
# Filter out (lang) switch (flags) that surround words from languages
|
|
136
|
+
# other than the current voice — mirrors _process_phones().
|
|
137
|
+
phoneme_str = re.sub(r"\([^)]+\)", "", phoneme_str)
|
|
138
|
+
# phonemize() marks every chunk as end-of-sentence, so each chunk is
|
|
139
|
+
# its own sentence.
|
|
140
|
+
yield list(phoneme_str)
|
|
141
|
+
|
|
142
|
+
@staticmethod
|
|
143
|
+
def _process_phones(raw_phones: RawPhonemizedChunks) -> PhonemizedChunks:
|
|
144
|
+
"""Text to phonemes grouped by sentence."""
|
|
145
|
+
all_phonemes: list[list[str]] = []
|
|
146
|
+
sentence_phonemes: list[str] = []
|
|
147
|
+
for phonemes_str, terminator_str, end_of_sentence in raw_phones:
|
|
148
|
+
# Filter out (lang) switch (flags).
|
|
149
|
+
# These surround words from languages other than the current voice.
|
|
150
|
+
phonemes_str = re.sub(r"\([^)]+\)", "", phonemes_str)
|
|
151
|
+
sentence_phonemes.extend(list(phonemes_str))
|
|
152
|
+
if end_of_sentence:
|
|
153
|
+
all_phonemes.append(sentence_phonemes)
|
|
154
|
+
sentence_phonemes = []
|
|
155
|
+
if sentence_phonemes:
|
|
156
|
+
all_phonemes.append(sentence_phonemes)
|
|
157
|
+
return all_phonemes
|
|
158
|
+
|
|
159
|
+
@staticmethod
|
|
160
|
+
def _match_lang(target_lang: str, valid_langs: Union[str, List[str]]):
|
|
161
|
+
"""Closest supported language and its distance (langcodes tag_distance)."""
|
|
162
|
+
from langcodes import tag_distance
|
|
163
|
+
if isinstance(valid_langs, str):
|
|
164
|
+
valid_langs = [valid_langs]
|
|
165
|
+
if target_lang in valid_langs:
|
|
166
|
+
return target_lang, 0
|
|
167
|
+
best_lang, best_distance = "und", 10000000
|
|
168
|
+
for l in valid_langs:
|
|
169
|
+
try:
|
|
170
|
+
distance = tag_distance(l, target_lang)
|
|
171
|
+
except Exception:
|
|
172
|
+
try:
|
|
173
|
+
distance = tag_distance(l.split("-")[0], target_lang)
|
|
174
|
+
except Exception:
|
|
175
|
+
continue
|
|
176
|
+
if distance < best_distance:
|
|
177
|
+
best_lang, best_distance = l, distance
|
|
178
|
+
if best_distance <= 10:
|
|
179
|
+
return best_lang, best_distance
|
|
180
|
+
return "und", 10000
|
|
181
|
+
|
|
182
|
+
@staticmethod
|
|
183
|
+
def match_lang(target_lang: str, valid_langs: List[str]) -> str:
|
|
184
|
+
"""
|
|
185
|
+
Validates and returns the closest supported language code.
|
|
186
|
+
|
|
187
|
+
Args:
|
|
188
|
+
target_lang (str): The language code to validate.
|
|
189
|
+
|
|
190
|
+
Returns:
|
|
191
|
+
str: The validated language code.
|
|
192
|
+
|
|
193
|
+
Raises:
|
|
194
|
+
ValueError: If the language code is unsupported.
|
|
195
|
+
"""
|
|
196
|
+
lang, score = BasePhonemizer._match_lang(target_lang, valid_langs)
|
|
197
|
+
if score > 10:
|
|
198
|
+
# raise an error for unsupported language
|
|
199
|
+
raise ValueError(f"unsupported language code: {target_lang}")
|
|
200
|
+
return lang
|
|
201
|
+
|
|
202
|
+
@staticmethod
|
|
203
|
+
def remove_punctuation(text):
|
|
204
|
+
"""
|
|
205
|
+
Removes punctuation characters from a string, based on unicode punctuation
|
|
206
|
+
categories (P*) rather than ASCII-only ``string.punctuation``. This also strips
|
|
207
|
+
non-ASCII punctuation such as Arabic ``،``/``؟`` and curly quotes.
|
|
208
|
+
|
|
209
|
+
Apostrophes and hyphens sandwiched between letters (e.g. "don't", "well-known")
|
|
210
|
+
are preserved so contractions and compounds aren't broken apart.
|
|
211
|
+
"""
|
|
212
|
+
out = []
|
|
213
|
+
chars = list(text)
|
|
214
|
+
for i, c in enumerate(chars):
|
|
215
|
+
if c in ("'", "’", "-") and 0 < i < len(chars) - 1 \
|
|
216
|
+
and chars[i - 1].isalpha() and chars[i + 1].isalpha():
|
|
217
|
+
out.append(c)
|
|
218
|
+
continue
|
|
219
|
+
if unicodedata.category(c).startswith("P"):
|
|
220
|
+
continue
|
|
221
|
+
out.append(c)
|
|
222
|
+
return "".join(out).strip()
|
|
223
|
+
|
|
224
|
+
@staticmethod
|
|
225
|
+
def chunk_text(text: str, delimiters: Optional[List[str]] = None) -> TextChunks:
|
|
226
|
+
"""
|
|
227
|
+
Split input text into sentence-aware chunks using sentence tokenization and optional intra-sentence delimiters.
|
|
228
|
+
|
|
229
|
+
Parameters:
|
|
230
|
+
text (str): Input text to split. If empty, returns a single empty chunk.
|
|
231
|
+
delimiters (Optional[List[str]]): List of substring delimiters to split sentences by (defaults to [":", ";", "...", "|"]).
|
|
232
|
+
|
|
233
|
+
Returns:
|
|
234
|
+
TextChunks: A list of tuples (substring, terminator, end_of_sentence) where:
|
|
235
|
+
- `substring` is the chunk text with surrounding whitespace removed,
|
|
236
|
+
- `terminator` is the delimiter that followed the substring or the sentence-final punctuation if none matched,
|
|
237
|
+
- `end_of_sentence` is `True` for the final chunk of a tokenized sentence, `False` otherwise.
|
|
238
|
+
"""
|
|
239
|
+
if not text:
|
|
240
|
+
return [('', '', True)]
|
|
241
|
+
|
|
242
|
+
try:
|
|
243
|
+
from quebra_frases import sentence_tokenize
|
|
244
|
+
except ImportError:
|
|
245
|
+
raise ImportError(
|
|
246
|
+
"sentence chunking needs quebra-frases — install with "
|
|
247
|
+
"`pip install scriptconv[phonemizers]`") from None
|
|
248
|
+
|
|
249
|
+
results: TextChunks = []
|
|
250
|
+
delimiters = delimiters or [":", ";", "...", "|"]
|
|
251
|
+
|
|
252
|
+
# Create a regex pattern that matches any of the delimiters
|
|
253
|
+
delimiter_pattern = re.escape(delimiters[0])
|
|
254
|
+
for delimiter in delimiters[1:]:
|
|
255
|
+
delimiter_pattern += f"|{re.escape(delimiter)}"
|
|
256
|
+
|
|
257
|
+
for sentence in sentence_tokenize(text):
|
|
258
|
+
# Default punctuation if no specific punctuation found
|
|
259
|
+
default_punc = sentence[-1] if sentence and sentence[-1] in string.punctuation else "."
|
|
260
|
+
|
|
261
|
+
# Use regex to split the sentence by any of the delimiters
|
|
262
|
+
parts = re.split(f'({delimiter_pattern})', sentence)
|
|
263
|
+
|
|
264
|
+
# Group parts into chunks (text + delimiter)
|
|
265
|
+
chunks = []
|
|
266
|
+
for i in range(0, len(parts), 2):
|
|
267
|
+
# If there's a delimiter after the text, use it
|
|
268
|
+
delimiter = parts[i + 1] if i + 1 < len(parts) else default_punc
|
|
269
|
+
|
|
270
|
+
# Last chunk is marked as complete
|
|
271
|
+
is_last = (i + 2 >= len(parts))
|
|
272
|
+
|
|
273
|
+
chunks.append((parts[i].strip(), delimiter.strip(), is_last))
|
|
274
|
+
|
|
275
|
+
results.extend(chunks)
|
|
276
|
+
|
|
277
|
+
return results
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
class GraphemePhonemizer(BasePhonemizer):
|
|
281
|
+
"""
|
|
282
|
+
A phonemizer class that treats input text as graphemes (characters).
|
|
283
|
+
It performs text normalization and returns the normalized text as a string
|
|
284
|
+
of characters.
|
|
285
|
+
"""
|
|
286
|
+
# Regular expression matching whitespace:
|
|
287
|
+
whitespace_re = re.compile(r"\s+")
|
|
288
|
+
|
|
289
|
+
def phonemize_string(self, text: str, lang: str) -> str:
|
|
290
|
+
"""
|
|
291
|
+
Normalizes input text by applying a series of transformations
|
|
292
|
+
and returns it as a sequence of graphemes.
|
|
293
|
+
|
|
294
|
+
Parameters:
|
|
295
|
+
text (str): Input text to be converted to graphemes.
|
|
296
|
+
lang (str): The language code (ignored for grapheme phonemization,
|
|
297
|
+
but required by BasePhonemizer).
|
|
298
|
+
|
|
299
|
+
Returns:
|
|
300
|
+
str: A normalized string of graphemes.
|
|
301
|
+
"""
|
|
302
|
+
text = text.lower()
|
|
303
|
+
text = text.replace(";", ",")
|
|
304
|
+
text = text.replace("-", " ")
|
|
305
|
+
text = text.replace(":", ",")
|
|
306
|
+
text = re.sub(r"[\<\>\(\)\[\]\"]+", "", text)
|
|
307
|
+
text = re.sub(self.whitespace_re, " ", text).strip()
|
|
308
|
+
return text
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
class UnicodeCodepointPhonemizer(BasePhonemizer):
|
|
312
|
+
"""Phonemes = codepoints
|
|
313
|
+
normalization also splits accents and punctuation into it's own codepoints
|
|
314
|
+
"""
|
|
315
|
+
|
|
316
|
+
def __init__(self, form: Literal["NFC", "NFD", "NFKC", "NFKD"] = "NFD"):
|
|
317
|
+
self.form = form
|
|
318
|
+
super().__init__(Alphabet.UNICODE)
|
|
319
|
+
|
|
320
|
+
def phonemize_string(self, text: str, lang: str) -> str:
|
|
321
|
+
# Phonemes = codepoints
|
|
322
|
+
"""
|
|
323
|
+
Normalize the input text to Unicode NFD so phonemes correspond to individual Unicode codepoints.
|
|
324
|
+
|
|
325
|
+
Parameters:
|
|
326
|
+
text (str): The input string to normalize.
|
|
327
|
+
lang (str): Language code (ignored by this implementation).
|
|
328
|
+
|
|
329
|
+
Returns:
|
|
330
|
+
str: The input text normalized to Unicode NFD, with combining marks separated into distinct codepoints.
|
|
331
|
+
"""
|
|
332
|
+
return unicodedata.normalize(self.form, text)
|