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.
Files changed (52) hide show
  1. {scriptconv-0.0.3a10 → scriptconv-0.0.3a12}/PKG-INFO +58 -2
  2. {scriptconv-0.0.3a10 → scriptconv-0.0.3a12}/README.md +1 -1
  3. {scriptconv-0.0.3a10 → scriptconv-0.0.3a12}/pyproject.toml +19 -1
  4. {scriptconv-0.0.3a10 → scriptconv-0.0.3a12}/scriptconv/notation.py +45 -12
  5. scriptconv-0.0.3a12/scriptconv/phonemizers/__init__.py +40 -0
  6. scriptconv-0.0.3a12/scriptconv/phonemizers/base.py +332 -0
  7. scriptconv-0.0.3a12/scriptconv/phonemizers/enums.py +79 -0
  8. scriptconv-0.0.3a12/scriptconv/phonemizers/eu.py +172 -0
  9. scriptconv-0.0.3a12/scriptconv/phonemizers/fa.py +35 -0
  10. scriptconv-0.0.3a12/scriptconv/phonemizers/gl.py +77 -0
  11. scriptconv-0.0.3a12/scriptconv/phonemizers/he.py +33 -0
  12. scriptconv-0.0.3a12/scriptconv/phonemizers/mul.py +1419 -0
  13. scriptconv-0.0.3a12/scriptconv/phonemizers/mwl.py +58 -0
  14. scriptconv-0.0.3a12/scriptconv/phonemizers/o2ipa.py +80 -0
  15. scriptconv-0.0.3a12/scriptconv/phonemizers/pt.py +87 -0
  16. scriptconv-0.0.3a12/scriptconv/phonemizers/registry.py +160 -0
  17. scriptconv-0.0.3a12/scriptconv/phonemizers/vi.py +33 -0
  18. {scriptconv-0.0.3a10 → scriptconv-0.0.3a12}/scriptconv/version.py +1 -1
  19. {scriptconv-0.0.3a10 → scriptconv-0.0.3a12}/scriptconv.egg-info/PKG-INFO +58 -2
  20. {scriptconv-0.0.3a10 → scriptconv-0.0.3a12}/scriptconv.egg-info/SOURCES.txt +15 -0
  21. scriptconv-0.0.3a12/scriptconv.egg-info/requires.txt +85 -0
  22. scriptconv-0.0.3a12/tests/test_arpa_stress.py +88 -0
  23. scriptconv-0.0.3a12/tests/test_phonemizers_base.py +132 -0
  24. scriptconv-0.0.3a10/scriptconv.egg-info/requires.txt +0 -12
  25. {scriptconv-0.0.3a10 → scriptconv-0.0.3a12}/LICENSE +0 -0
  26. {scriptconv-0.0.3a10 → scriptconv-0.0.3a12}/requirements.txt +0 -0
  27. {scriptconv-0.0.3a10 → scriptconv-0.0.3a12}/scriptconv/__init__.py +0 -0
  28. {scriptconv-0.0.3a10 → scriptconv-0.0.3a12}/scriptconv/__main__.py +0 -0
  29. {scriptconv-0.0.3a10 → scriptconv-0.0.3a12}/scriptconv/cangjie.py +0 -0
  30. {scriptconv-0.0.3a10 → scriptconv-0.0.3a12}/scriptconv/conventions.py +0 -0
  31. {scriptconv-0.0.3a10 → scriptconv-0.0.3a12}/scriptconv/data/__init__.py +0 -0
  32. {scriptconv-0.0.3a10 → scriptconv-0.0.3a12}/scriptconv/data/cangjie5_tc.tsv.gz +0 -0
  33. {scriptconv-0.0.3a10 → scriptconv-0.0.3a12}/scriptconv/graph.py +0 -0
  34. {scriptconv-0.0.3a10 → scriptconv-0.0.3a12}/scriptconv/py.typed +0 -0
  35. {scriptconv-0.0.3a10 → scriptconv-0.0.3a12}/scriptconv/readings.py +0 -0
  36. {scriptconv-0.0.3a10 → scriptconv-0.0.3a12}/scriptconv/scripts.py +0 -0
  37. {scriptconv-0.0.3a10 → scriptconv-0.0.3a12}/scriptconv/translit.py +0 -0
  38. {scriptconv-0.0.3a10 → scriptconv-0.0.3a12}/scriptconv.egg-info/dependency_links.txt +0 -0
  39. {scriptconv-0.0.3a10 → scriptconv-0.0.3a12}/scriptconv.egg-info/top_level.txt +0 -0
  40. {scriptconv-0.0.3a10 → scriptconv-0.0.3a12}/setup.cfg +0 -0
  41. {scriptconv-0.0.3a10 → scriptconv-0.0.3a12}/tests/test_cangjie.py +0 -0
  42. {scriptconv-0.0.3a10 → scriptconv-0.0.3a12}/tests/test_cli.py +0 -0
  43. {scriptconv-0.0.3a10 → scriptconv-0.0.3a12}/tests/test_conventions.py +0 -0
  44. {scriptconv-0.0.3a10 → scriptconv-0.0.3a12}/tests/test_errors_policy.py +0 -0
  45. {scriptconv-0.0.3a10 → scriptconv-0.0.3a12}/tests/test_examples.py +0 -0
  46. {scriptconv-0.0.3a10 → scriptconv-0.0.3a12}/tests/test_graph.py +0 -0
  47. {scriptconv-0.0.3a10 → scriptconv-0.0.3a12}/tests/test_notation.py +0 -0
  48. {scriptconv-0.0.3a10 → scriptconv-0.0.3a12}/tests/test_readings.py +0 -0
  49. {scriptconv-0.0.3a10 → scriptconv-0.0.3a12}/tests/test_readings_zh.py +0 -0
  50. {scriptconv-0.0.3a10 → scriptconv-0.0.3a12}/tests/test_scripts.py +0 -0
  51. {scriptconv-0.0.3a10 → scriptconv-0.0.3a12}/tests/test_scripts_stressonnx_compat.py +0 -0
  52. {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.3a10
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** | Exact for base symbols; **stress digits are dropped** and `AH0`↔`AX` (schwa) is not distinguished | **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 (combining marks, length/stress modifiers) are dropped; other out-of-inventory symbols are replaced with `?` by default (`unknown=` param; `unknown=""` drops) |
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** | Exact for base symbols; **stress digits are dropped** and `AH0`↔`AX` (schwa) is not distinguished | **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 (combining marks, length/stress modifiers) are dropped; other out-of-inventory symbols are replaced with `?` by default (`unknown=` param; `unknown=""` drops) |
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
- test = ["pytest", "pytest-timeout", "pykakasi>=2.3,<3", "pypinyin>=0.50,<1"]
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") -> str:
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
- Stress digits are stripped. ``errors`` selects the unknown-token policy:
194
- ``"pass"`` (default) keeps the token, ``"replace"`` substitutes ``?``,
195
- ``"ignore"`` drops it, ``"strict"`` raises :class:`UnknownSymbolError`.
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
- result.append(ipa)
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. ``errors`` selects the unknown-symbol
221
- policy (default ``"replace"``, preserving the historical behavior of
222
- substituting *unknown*, ``"?"``); ``unknown=""`` still drops silently.
223
- Combining marks and modifier letters qualify the preceding phoneme and
224
- are always dropped rather than treated as unknowns.
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
- tokens.append(_IPA_TO_ARPA[m.group(0)])
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)