phonebox 0.1.0__py3-none-any.whl
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.
- phonebox/__init__.py +41 -0
- phonebox/bundler.py +77 -0
- phonebox/cart/__init__.py +11 -0
- phonebox/cart/g2p_predict.py +315 -0
- phonebox/cli/__init__.py +8 -0
- phonebox/cli/commands/__init__.py +3 -0
- phonebox/cli/commands/_common.py +61 -0
- phonebox/cli/commands/align.py +73 -0
- phonebox/cli/commands/bundle.py +71 -0
- phonebox/cli/commands/check.py +218 -0
- phonebox/cli/commands/compare.py +157 -0
- phonebox/cli/commands/dict.py +120 -0
- phonebox/cli/commands/model.py +353 -0
- phonebox/cli/commands/normalize.py +79 -0
- phonebox/cli/commands/pronounce.py +190 -0
- phonebox/cli/commands/recipe.py +302 -0
- phonebox/cli/commands/suggest_joins.py +291 -0
- phonebox/cli/commands/train.py +182 -0
- phonebox/cli/commands/train_multigram.py +124 -0
- phonebox/cli/commands/vectorize.py +61 -0
- phonebox/cli/main.py +159 -0
- phonebox/config/__init__.py +1 -0
- phonebox/config/locales/de_DE/config.json +25 -0
- phonebox/config/locales/de_DE/defaults.json +13 -0
- phonebox/config/locales/de_DE/g2p.xlit +13 -0
- phonebox/config/locales/default/config.json +8 -0
- phonebox/config/locales/default/defaults.json +8 -0
- phonebox/config/locales/default/g2p.xlit +6 -0
- phonebox/config/locales/en_IN/config.json +32 -0
- phonebox/config/locales/en_IN/defaults.json +12 -0
- phonebox/config/locales/en_US/config.json +64 -0
- phonebox/config/locales/en_US/defaults.json +12 -0
- phonebox/config/locales/en_US/g2p.xlit +6 -0
- phonebox/config/locales/es_MX/config.json +12 -0
- phonebox/config/locales/es_MX/defaults.json +10 -0
- phonebox/config/locales/es_MX/g2p.xlit +30 -0
- phonebox/config/locales/fr_FR/config.json +22 -0
- phonebox/config/locales/fr_FR/defaults.json +12 -0
- phonebox/config/locales/fr_FR/g2p.xlit +14 -0
- phonebox/config/locales/it_IT/config.json +22 -0
- phonebox/config/locales/it_IT/defaults.json +8 -0
- phonebox/config/locales/it_IT/g2p.xlit +23 -0
- phonebox/config/locales/norm.xlit +36 -0
- phonebox/config/locales/pt_BR/config.json +14 -0
- phonebox/config/locales/pt_BR/defaults.json +12 -0
- phonebox/config/locales/pt_BR/g2p.xlit +12 -0
- phonebox/config_builder.py +63 -0
- phonebox/config_loader.py +206 -0
- phonebox/configs/__init__.py +54 -0
- phonebox/configs/accurate.yaml +25 -0
- phonebox/configs/defaults.yaml +82 -0
- phonebox/configs/fast.yaml +25 -0
- phonebox/configs/pocketsphinx.yaml +28 -0
- phonebox/configs/xsampa.yaml +27 -0
- phonebox/constants.py +230 -0
- phonebox/converter.py +366 -0
- phonebox/core/__init__.py +11 -0
- phonebox/core/decision_tree.py +8 -0
- phonebox/core/em_align.py +514 -0
- phonebox/core/g2p_model.py +960 -0
- phonebox/core/joint_decode.py +111 -0
- phonebox/core/multigram_align.py +703 -0
- phonebox/core/multigram_g2p.py +286 -0
- phonebox/core/multigram_lm.py +187 -0
- phonebox/core/nbest.py +69 -0
- phonebox/core/vectorizer.py +585 -0
- phonebox/dictionary.py +428 -0
- phonebox/eval/__init__.py +29 -0
- phonebox/eval/g2p_compare.py +672 -0
- phonebox/eval/g2p_compare_all.py +342 -0
- phonebox/experiments/__init__.py +11 -0
- phonebox/experiments/analysis.py +82 -0
- phonebox/experiments/equiv.py +37 -0
- phonebox/experiments/metrics.py +14 -0
- phonebox/experiments/normalize.py +122 -0
- phonebox/experiments/split.py +26 -0
- phonebox/locales.py +84 -0
- phonebox/normalize.py +62 -0
- phonebox/py.typed +0 -0
- phonebox/runner.py +245 -0
- phonebox/tools/__init__.py +5 -0
- phonebox/tools/count_diphones.py +53 -0
- phonebox/utils/__init__.py +9 -0
- phonebox/utils/icu_utils.py +60 -0
- phonebox/utils/io.py +24 -0
- phonebox/utils/logging_config.py +21 -0
- phonebox-0.1.0.dist-info/METADATA +249 -0
- phonebox-0.1.0.dist-info/RECORD +92 -0
- phonebox-0.1.0.dist-info/WHEEL +5 -0
- phonebox-0.1.0.dist-info/entry_points.txt +2 -0
- phonebox-0.1.0.dist-info/licenses/LICENSE +21 -0
- phonebox-0.1.0.dist-info/top_level.txt +1 -0
phonebox/__init__.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Phonebox: grapheme-to-phoneme conversion package.
|
|
3
|
+
|
|
4
|
+
A fast, lightweight grapheme-to-phoneme conversion system using
|
|
5
|
+
decision trees, EM alignment, and optional joint multigram (n:m) models.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
__version__ = "0.1.0"
|
|
11
|
+
__package_name__ = "phonebox"
|
|
12
|
+
|
|
13
|
+
# High-level API (recommended for most users)
|
|
14
|
+
from .converter import G2P
|
|
15
|
+
|
|
16
|
+
# Core classes (for advanced use)
|
|
17
|
+
from .core.decision_tree import DecisionTree
|
|
18
|
+
from .core.em_align import EMAlign
|
|
19
|
+
from .core.multigram_g2p import MultigramG2P
|
|
20
|
+
from .core.vectorizer import Vectorizer
|
|
21
|
+
from .dictionary import Dictionary
|
|
22
|
+
from .locales import (
|
|
23
|
+
canonical_locale,
|
|
24
|
+
load_locale_defaults,
|
|
25
|
+
supplement_defaults,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
# High-level API
|
|
30
|
+
"G2P",
|
|
31
|
+
"Dictionary",
|
|
32
|
+
# Core API
|
|
33
|
+
"DecisionTree",
|
|
34
|
+
"EMAlign",
|
|
35
|
+
"MultigramG2P",
|
|
36
|
+
"Vectorizer",
|
|
37
|
+
# Locale-aware defaults
|
|
38
|
+
"canonical_locale",
|
|
39
|
+
"load_locale_defaults",
|
|
40
|
+
"supplement_defaults",
|
|
41
|
+
]
|
phonebox/bundler.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""
|
|
2
|
+
G2P model bundler - creates standalone Python executables with embedded models.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import tempfile
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from cartlet import bundle as cartlet_bundle
|
|
11
|
+
|
|
12
|
+
from .constants import FILE_ENCODING
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _ensure_cart_format(model_path: str) -> tuple[str, bool]:
|
|
16
|
+
"""
|
|
17
|
+
Ensure model is in .cart format for bundling.
|
|
18
|
+
|
|
19
|
+
Returns:
|
|
20
|
+
(cart_path, needs_cleanup) - path to .cart file and whether to delete it
|
|
21
|
+
"""
|
|
22
|
+
if model_path.endswith(".cart"):
|
|
23
|
+
return model_path, False
|
|
24
|
+
|
|
25
|
+
# Convert .g2p.gz or other formats to .cart
|
|
26
|
+
from .core.g2p_model import G2PDecisionTree
|
|
27
|
+
|
|
28
|
+
dt = G2PDecisionTree()
|
|
29
|
+
dt.load_model(model_path)
|
|
30
|
+
|
|
31
|
+
with tempfile.NamedTemporaryFile(suffix=".cart", delete=False) as tmp:
|
|
32
|
+
cart_path = tmp.name
|
|
33
|
+
|
|
34
|
+
dt._cart.export(cart_path, store_distributions=dt._cart.store_distributions)
|
|
35
|
+
return cart_path, True
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def bundle_g2p(model_path: str, output_path: str) -> None:
|
|
39
|
+
"""
|
|
40
|
+
Bundle G2P model into a standalone Python executable.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
model_path: Path to model file (.g2p.gz, .cart, etc.)
|
|
44
|
+
output_path: Output `.py` file path
|
|
45
|
+
"""
|
|
46
|
+
cart_path, cleanup = _ensure_cart_format(model_path)
|
|
47
|
+
|
|
48
|
+
try:
|
|
49
|
+
with tempfile.NamedTemporaryFile(suffix=".py", delete=False, mode="w") as tmp:
|
|
50
|
+
tmp_path = tmp.name
|
|
51
|
+
|
|
52
|
+
try:
|
|
53
|
+
# library_only excludes cartlet's CLI; we provide our own G2P main.
|
|
54
|
+
cartlet_bundle(cart_path, tmp_path, library_only=True)
|
|
55
|
+
|
|
56
|
+
with open(tmp_path, encoding=FILE_ENCODING) as f:
|
|
57
|
+
cart_code = f.read()
|
|
58
|
+
finally:
|
|
59
|
+
Path(tmp_path).unlink(missing_ok=True)
|
|
60
|
+
|
|
61
|
+
g2p_template_path = Path(__file__).parent / "cart" / "g2p_predict.py"
|
|
62
|
+
with open(g2p_template_path, encoding=FILE_ENCODING) as f:
|
|
63
|
+
g2p_code = f.read()
|
|
64
|
+
|
|
65
|
+
# Strip the file prelude; keep everything from class G2PPredictor onward.
|
|
66
|
+
class_marker = "class G2PPredictor"
|
|
67
|
+
if class_marker in g2p_code:
|
|
68
|
+
idx = g2p_code.index(class_marker)
|
|
69
|
+
g2p_code = g2p_code[idx:]
|
|
70
|
+
|
|
71
|
+
output = cart_code + "\n\n\n" + g2p_code
|
|
72
|
+
|
|
73
|
+
with open(output_path, "w", encoding=FILE_ENCODING) as f:
|
|
74
|
+
f.write(output)
|
|
75
|
+
finally:
|
|
76
|
+
if cleanup:
|
|
77
|
+
Path(cart_path).unlink(missing_ok=True)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""
|
|
2
|
+
G2P predictor template for bundling.
|
|
3
|
+
|
|
4
|
+
The g2p_predict.py template is appended to cartlet's bundled Python
|
|
5
|
+
predictor to create a standalone .py executable with the model embedded.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
phonebox bundle model.g2p.gz -o g2p.py
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
"""
|
|
3
|
+
G2P Predictor - extends cartlet's Predictor with phoneme-specific logic.
|
|
4
|
+
|
|
5
|
+
This template is bundled with a model using:
|
|
6
|
+
phonebox bundle model.g2p.gz -o g2p.py
|
|
7
|
+
|
|
8
|
+
The bundler:
|
|
9
|
+
1. Uses cartlet's bundler with library_only=True
|
|
10
|
+
2. Appends this G2P wrapper code (vectorization, uncooking, CLI)
|
|
11
|
+
|
|
12
|
+
Usage (bundled):
|
|
13
|
+
python g2p.py "Hello, world!" # Tokenizes and pronounces
|
|
14
|
+
from g2p import G2PPredictor; g2p = G2PPredictor.from_embedded()
|
|
15
|
+
|
|
16
|
+
Zero dependencies beyond Python stdlib.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
# NOTE: This file is appended to cartlet's predict.py during bundling.
|
|
22
|
+
# The Predictor class, load_embedded(), etc. are defined above.
|
|
23
|
+
#
|
|
24
|
+
# The bundler slices source from the G2PPredictor class header to EOF, so
|
|
25
|
+
# any module-level helpers MUST appear after the class definition (see the
|
|
26
|
+
# helper section below) to be included in the bundled output.
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# =============================================================================
|
|
30
|
+
# G2P Predictor - extends Predictor with phoneme handling
|
|
31
|
+
# =============================================================================
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class G2PPredictor(Predictor): # type: ignore[name-defined] # noqa: F821
|
|
35
|
+
"""
|
|
36
|
+
G2P predictor with word-to-phoneme conversion.
|
|
37
|
+
|
|
38
|
+
Extends cartlet's Predictor with:
|
|
39
|
+
- Word vectorization (letter context windows, including digraph joining)
|
|
40
|
+
- Phoneme uncooking (split joined phones, remove epsilon)
|
|
41
|
+
- OOV handling
|
|
42
|
+
|
|
43
|
+
Example:
|
|
44
|
+
g2p = G2PPredictor.from_embedded()
|
|
45
|
+
phones = g2p.pronounce("hello") # ['HH', 'AH', 'L', 'OW']
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
# Defensive defaults: applied only when the .cart file ships with no
|
|
49
|
+
# metadata trailer at all. Phonebox always writes one; this fallback
|
|
50
|
+
# keeps the bundle predictable rather than blowing up.
|
|
51
|
+
DEFAULT_WIDTH = 7
|
|
52
|
+
DEFAULT_AETHER = "\u2022" # •
|
|
53
|
+
DEFAULT_EPSILON = "\u2205" # ∅
|
|
54
|
+
DEFAULT_JOIN_CHAR = "\u208a" # ₊
|
|
55
|
+
DEFAULT_CASED = False
|
|
56
|
+
|
|
57
|
+
def __init__(self, path_or_bytes=None):
|
|
58
|
+
super().__init__(path_or_bytes)
|
|
59
|
+
self._apply_meta()
|
|
60
|
+
|
|
61
|
+
def _apply_meta(self):
|
|
62
|
+
"""Apply g2p config from model metadata (or defaults when empty)."""
|
|
63
|
+
# Predictor.metadata reads self._model["metadata"] safely.
|
|
64
|
+
meta = self.metadata
|
|
65
|
+
|
|
66
|
+
self.width = meta.get("width", self.DEFAULT_WIDTH)
|
|
67
|
+
self.aether = meta.get("aether", self.DEFAULT_AETHER)
|
|
68
|
+
self.epsilon = meta.get("epsilon", self.DEFAULT_EPSILON)
|
|
69
|
+
self.join_char = meta.get("join_char", self.DEFAULT_JOIN_CHAR)
|
|
70
|
+
self.cased = meta.get("cased", self.DEFAULT_CASED)
|
|
71
|
+
self.exceptions = meta.get("exceptions", {})
|
|
72
|
+
|
|
73
|
+
self.center_position = (self.width - 1) // 2
|
|
74
|
+
self.padding = [self.aether] * self.center_position
|
|
75
|
+
|
|
76
|
+
letter_joins = meta.get("join", {}).get("letters", [])
|
|
77
|
+
self.lett_join_re = _make_join_re(letter_joins)
|
|
78
|
+
|
|
79
|
+
@classmethod
|
|
80
|
+
def from_embedded(cls):
|
|
81
|
+
"""Create G2PPredictor from embedded model data."""
|
|
82
|
+
model_data = load_embedded() # type: ignore[name-defined] # noqa: F821 - defined by cartlet
|
|
83
|
+
if model_data is None:
|
|
84
|
+
raise ValueError("No embedded model found")
|
|
85
|
+
obj = cls()
|
|
86
|
+
obj._model = model_data
|
|
87
|
+
obj._apply_meta()
|
|
88
|
+
return obj
|
|
89
|
+
|
|
90
|
+
def vectorize_word(self, word):
|
|
91
|
+
"""Convert word to context vectors (applies digraph joining)."""
|
|
92
|
+
if not self.cased:
|
|
93
|
+
word = word.lower()
|
|
94
|
+
|
|
95
|
+
letters = list(word)
|
|
96
|
+
if self.lett_join_re is not None:
|
|
97
|
+
letters = _join_seq(self.lett_join_re, letters, self.join_char)
|
|
98
|
+
|
|
99
|
+
padded = self.padding + letters + self.padding
|
|
100
|
+
return [padded[i : i + self.width] for i in range(len(letters))]
|
|
101
|
+
|
|
102
|
+
def uncook(self, phonemes):
|
|
103
|
+
"""Split joined phones and remove epsilon."""
|
|
104
|
+
result = []
|
|
105
|
+
for phoneme in phonemes:
|
|
106
|
+
if phoneme == self.epsilon:
|
|
107
|
+
continue
|
|
108
|
+
result.extend(phoneme.split(self.join_char))
|
|
109
|
+
return result
|
|
110
|
+
|
|
111
|
+
def pronounce(self, word):
|
|
112
|
+
"""Pronounce a word, returning list of phonemes."""
|
|
113
|
+
if self._model is None:
|
|
114
|
+
raise ValueError("No model loaded")
|
|
115
|
+
|
|
116
|
+
# Check exceptions
|
|
117
|
+
lookup_word = word if self.cased else word.lower()
|
|
118
|
+
if lookup_word in self.exceptions:
|
|
119
|
+
return self.exceptions[lookup_word]
|
|
120
|
+
|
|
121
|
+
# Vectorize and predict, dropping any position whose center letter was
|
|
122
|
+
# never seen in training (otherwise the tree falls through default
|
|
123
|
+
# branches and emits a random phoneme). Matches phonebox.runner.G2PRunner.
|
|
124
|
+
vectors = self.vectorize_word(word)
|
|
125
|
+
raw_phones = []
|
|
126
|
+
for vec in vectors:
|
|
127
|
+
center = vec[self.center_position]
|
|
128
|
+
if self.is_oov(self.center_position, center):
|
|
129
|
+
raw_phones.append(self.epsilon)
|
|
130
|
+
else:
|
|
131
|
+
raw_phones.append(self.predict(vec))
|
|
132
|
+
|
|
133
|
+
return self.uncook(raw_phones)
|
|
134
|
+
|
|
135
|
+
def pronounce_batch(self, words):
|
|
136
|
+
"""Pronounce multiple words."""
|
|
137
|
+
return [(word, self.pronounce(word)) for word in words]
|
|
138
|
+
|
|
139
|
+
def tokenize(self, text):
|
|
140
|
+
"""
|
|
141
|
+
Tokenize text into words for pronunciation.
|
|
142
|
+
|
|
143
|
+
Normalizes and extracts pronounceable tokens:
|
|
144
|
+
- NFC unicode normalization
|
|
145
|
+
- Strips punctuation/symbols from token edges (Unicode categories P, S, C, M, Z)
|
|
146
|
+
- Keeps internal punctuation (apostrophes, hyphens)
|
|
147
|
+
"""
|
|
148
|
+
import unicodedata
|
|
149
|
+
|
|
150
|
+
# Unicode categories to strip from token edges
|
|
151
|
+
exclude_cats = {"P", "S", "C", "M", "Z"}
|
|
152
|
+
|
|
153
|
+
# Normalize unicode
|
|
154
|
+
text = unicodedata.normalize("NFC", text)
|
|
155
|
+
|
|
156
|
+
# Split and normalize each token
|
|
157
|
+
result = []
|
|
158
|
+
for token in text.split():
|
|
159
|
+
# Strip leading excluded characters
|
|
160
|
+
while token and unicodedata.category(token[0])[0] in exclude_cats:
|
|
161
|
+
token = token[1:]
|
|
162
|
+
# Strip trailing excluded characters
|
|
163
|
+
while token and unicodedata.category(token[-1])[0] in exclude_cats:
|
|
164
|
+
token = token[:-1]
|
|
165
|
+
if token:
|
|
166
|
+
result.append(token)
|
|
167
|
+
|
|
168
|
+
return result
|
|
169
|
+
|
|
170
|
+
def pronounce_text(self, text, raw=False):
|
|
171
|
+
"""
|
|
172
|
+
Pronounce text, returning list of (word, phones) tuples.
|
|
173
|
+
|
|
174
|
+
Args:
|
|
175
|
+
text: Input text
|
|
176
|
+
raw: If True, skip normalization (just split on whitespace)
|
|
177
|
+
"""
|
|
178
|
+
words = text.split() if raw else self.tokenize(text)
|
|
179
|
+
return [(word, self.pronounce(word)) for word in words]
|
|
180
|
+
|
|
181
|
+
def __call__(self, word):
|
|
182
|
+
"""Allow direct calling: g2p('hello')"""
|
|
183
|
+
return self.pronounce(word)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# =============================================================================
|
|
187
|
+
# Letter-joining helpers (stdlib-only)
|
|
188
|
+
#
|
|
189
|
+
# Kept module-level so they stay easy to inline into the bundled standalone
|
|
190
|
+
# script. The .cart metadata trailer is parsed by cartlet's loader and
|
|
191
|
+
# exposed as Predictor.metadata; the helpers below only handle the digraph
|
|
192
|
+
# joining that's specific to G2P (not part of cartlet's domain).
|
|
193
|
+
# =============================================================================
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _make_join_re(join_list):
|
|
197
|
+
"""Compile a regex matching any space-separated joining sequence, or None."""
|
|
198
|
+
import re
|
|
199
|
+
|
|
200
|
+
if not join_list:
|
|
201
|
+
return None
|
|
202
|
+
items = sorted(join_list, key=len, reverse=True)
|
|
203
|
+
items = [re.escape(x) for x in items]
|
|
204
|
+
return re.compile(r" (" + r"|".join(items) + r") ")
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _join_seq(regex, seq, join_char):
|
|
208
|
+
"""Collapse adjacent tokens in seq matched by regex using join_char."""
|
|
209
|
+
if not regex:
|
|
210
|
+
return list(seq)
|
|
211
|
+
as_str = " " + " ".join(seq) + " "
|
|
212
|
+
m = regex.search(as_str)
|
|
213
|
+
while m:
|
|
214
|
+
s = m.group(0)
|
|
215
|
+
joined = s[1:-1].replace(" ", join_char)
|
|
216
|
+
as_str = as_str.replace(s, " " + joined + " ")
|
|
217
|
+
m = regex.search(as_str)
|
|
218
|
+
return as_str[1:-1].split()
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
# =============================================================================
|
|
222
|
+
# G2P CLI (replaces cartlet's main when bundled as G2P)
|
|
223
|
+
# =============================================================================
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def g2p_main():
|
|
227
|
+
"""G2P command-line interface."""
|
|
228
|
+
import argparse
|
|
229
|
+
import sys
|
|
230
|
+
|
|
231
|
+
embedded_model = load_embedded() # type: ignore[name-defined] # noqa: F821 - defined by cartlet
|
|
232
|
+
has_embedded = embedded_model is not None
|
|
233
|
+
|
|
234
|
+
parser = argparse.ArgumentParser(
|
|
235
|
+
description="G2P: Convert text to phonemes.",
|
|
236
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
237
|
+
epilog="""
|
|
238
|
+
Examples:
|
|
239
|
+
%(prog)s "Hello, world!" # Pronounce text (tokenizes automatically)
|
|
240
|
+
%(prog)s -f input.txt # Read text from file, tokenize each line
|
|
241
|
+
echo "Hello world" | %(prog)s # Read from stdin
|
|
242
|
+
""",
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
if has_embedded:
|
|
246
|
+
parser.add_argument(
|
|
247
|
+
"-m", "--model", metavar="FILE", help="Model file (overrides embedded)"
|
|
248
|
+
)
|
|
249
|
+
else:
|
|
250
|
+
parser.add_argument("-m", "--model", metavar="FILE", help="Model file (.cart)")
|
|
251
|
+
|
|
252
|
+
parser.add_argument(
|
|
253
|
+
"-f",
|
|
254
|
+
"--file",
|
|
255
|
+
metavar="FILE",
|
|
256
|
+
help="Read text from file (- for stdin), tokenize each line",
|
|
257
|
+
)
|
|
258
|
+
parser.add_argument(
|
|
259
|
+
"-r",
|
|
260
|
+
"--raw",
|
|
261
|
+
action="store_true",
|
|
262
|
+
help="Disable normalization (no punctuation stripping)",
|
|
263
|
+
)
|
|
264
|
+
parser.add_argument(
|
|
265
|
+
"text", nargs="*", help="Text to pronounce (tokenized into words)"
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
args = parser.parse_args()
|
|
269
|
+
|
|
270
|
+
# Load model
|
|
271
|
+
if args.model:
|
|
272
|
+
g2p = G2PPredictor(args.model)
|
|
273
|
+
elif has_embedded:
|
|
274
|
+
g2p = G2PPredictor.from_embedded()
|
|
275
|
+
else:
|
|
276
|
+
parser.error("No model specified. Use -m MODEL or use a bundled version.")
|
|
277
|
+
|
|
278
|
+
# Process text and output pronunciations
|
|
279
|
+
def process_text(text):
|
|
280
|
+
for word, phones in g2p.pronounce_text(text, raw=args.raw):
|
|
281
|
+
print(f"{word}\t{' '.join(phones)}")
|
|
282
|
+
|
|
283
|
+
# Get input
|
|
284
|
+
if args.file:
|
|
285
|
+
f = sys.stdin if args.file == "-" else open(args.file, encoding="utf-8") # noqa: SIM115
|
|
286
|
+
try:
|
|
287
|
+
for line in f:
|
|
288
|
+
process_text(line)
|
|
289
|
+
finally:
|
|
290
|
+
if args.file != "-":
|
|
291
|
+
f.close()
|
|
292
|
+
elif args.text:
|
|
293
|
+
# Join all positional args as one text string
|
|
294
|
+
process_text(" ".join(args.text))
|
|
295
|
+
elif not sys.stdin.isatty():
|
|
296
|
+
for line in sys.stdin:
|
|
297
|
+
process_text(line)
|
|
298
|
+
else:
|
|
299
|
+
# Interactive mode
|
|
300
|
+
print("G2P Predictor. Enter text (Ctrl+D to exit):", file=sys.stderr)
|
|
301
|
+
try:
|
|
302
|
+
while True:
|
|
303
|
+
line = input("> ")
|
|
304
|
+
process_text(line)
|
|
305
|
+
except (EOFError, KeyboardInterrupt):
|
|
306
|
+
print(file=sys.stderr)
|
|
307
|
+
return 0
|
|
308
|
+
|
|
309
|
+
return 0
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
if __name__ == "__main__":
|
|
313
|
+
import sys
|
|
314
|
+
|
|
315
|
+
sys.exit(g2p_main())
|
phonebox/cli/__init__.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Shared argparse helpers for CLI subcommands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from ...constants import DEFAULT_LOCALE, DEFAULT_PHONESET
|
|
10
|
+
|
|
11
|
+
# Conventional exit code for a bad-input / usage error (missing file, etc.).
|
|
12
|
+
EXIT_BAD_INPUT = 2
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def require_file(path: str | Path, label: str = "file") -> int | None:
|
|
16
|
+
"""Return EXIT_BAD_INPUT (after printing a message) if *path* is missing.
|
|
17
|
+
|
|
18
|
+
Lets command handlers guard their inputs uniformly::
|
|
19
|
+
|
|
20
|
+
if (rc := require_file(args.dict, "dictionary")) is not None:
|
|
21
|
+
return rc
|
|
22
|
+
|
|
23
|
+
Returns None when the file exists, so callers proceed normally.
|
|
24
|
+
"""
|
|
25
|
+
if not Path(path).is_file():
|
|
26
|
+
print(f"Error: {label} not found: {path}", file=sys.stderr)
|
|
27
|
+
return EXIT_BAD_INPUT
|
|
28
|
+
return None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def add_vectorizer_args(
|
|
32
|
+
parser: argparse.ArgumentParser,
|
|
33
|
+
*,
|
|
34
|
+
cased: bool = False,
|
|
35
|
+
target_first: bool = False,
|
|
36
|
+
) -> None:
|
|
37
|
+
"""Add the locale/phoneset/stress options shared by vectorizer-driven commands.
|
|
38
|
+
|
|
39
|
+
``cased`` and ``target_first`` add the corresponding optional flags only for
|
|
40
|
+
commands that consume them, keeping each command's surface identical to its
|
|
41
|
+
hand-written original while sharing one definition and one set of defaults.
|
|
42
|
+
"""
|
|
43
|
+
parser.add_argument(
|
|
44
|
+
"--locale", default=DEFAULT_LOCALE, help="Language locale (e.g. en_US)"
|
|
45
|
+
)
|
|
46
|
+
parser.add_argument(
|
|
47
|
+
"--phoneset", default=DEFAULT_PHONESET, help="Phoneset name (e.g. cmu, ipa)"
|
|
48
|
+
)
|
|
49
|
+
parser.add_argument(
|
|
50
|
+
"--remove-stress", action="store_true", help="Remove stress markers"
|
|
51
|
+
)
|
|
52
|
+
if cased:
|
|
53
|
+
parser.add_argument(
|
|
54
|
+
"--cased", action="store_true", help="Case-sensitive letters"
|
|
55
|
+
)
|
|
56
|
+
if target_first:
|
|
57
|
+
parser.add_argument(
|
|
58
|
+
"--target-first",
|
|
59
|
+
action="store_true",
|
|
60
|
+
help="Place target column first (default: last)",
|
|
61
|
+
)
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
"""Alignment operations: align letters to phonemes."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from ._common import add_vectorizer_args, require_file
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def setup_align_commands(subparsers):
|
|
12
|
+
"""Setup align command."""
|
|
13
|
+
align_parser = subparsers.add_parser(
|
|
14
|
+
"align",
|
|
15
|
+
help="Align letters to phonemes",
|
|
16
|
+
description="Align letters to phonemes using EM algorithm",
|
|
17
|
+
)
|
|
18
|
+
align_parser.add_argument("dict", help="Dictionary file")
|
|
19
|
+
align_parser.add_argument("-o", "--output", help="Output alignment file")
|
|
20
|
+
add_vectorizer_args(align_parser)
|
|
21
|
+
align_parser.add_argument("--max-iterations", type=int, help="Max EM iterations")
|
|
22
|
+
align_parser.add_argument(
|
|
23
|
+
"--max-combinations", type=int, help="Max combinations per alignment"
|
|
24
|
+
)
|
|
25
|
+
align_parser.set_defaults(func=handle_align)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def handle_align(args):
|
|
29
|
+
"""Handle 'phonebox align' command."""
|
|
30
|
+
from ...constants import DICT_ENCODING
|
|
31
|
+
from ...core.em_align import EMAlign
|
|
32
|
+
from ...core.vectorizer import Vectorizer
|
|
33
|
+
from ...utils.io import is_dict_comment
|
|
34
|
+
|
|
35
|
+
if (rc := require_file(args.dict, "dictionary")) is not None:
|
|
36
|
+
return rc
|
|
37
|
+
|
|
38
|
+
print(f"Aligning dictionary: {args.dict}", file=sys.stderr)
|
|
39
|
+
|
|
40
|
+
# Create vectorizer with same settings that will be used for vectorization
|
|
41
|
+
vectorizer = Vectorizer(
|
|
42
|
+
locale=args.locale,
|
|
43
|
+
phoneset_name=args.phoneset,
|
|
44
|
+
remove_stress=args.remove_stress,
|
|
45
|
+
verbose=True,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
# Create EM aligner
|
|
49
|
+
em = EMAlign(
|
|
50
|
+
vectorizer,
|
|
51
|
+
max_iterations=args.max_iterations,
|
|
52
|
+
max_combinations=args.max_combinations,
|
|
53
|
+
verbose=True,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
# Load dictionary
|
|
57
|
+
with open(args.dict, encoding=DICT_ENCODING) as f:
|
|
58
|
+
for line in f:
|
|
59
|
+
if not is_dict_comment(line):
|
|
60
|
+
em.add_line(line)
|
|
61
|
+
|
|
62
|
+
# Run alignment
|
|
63
|
+
em.align()
|
|
64
|
+
|
|
65
|
+
# Write output
|
|
66
|
+
if args.output:
|
|
67
|
+
with open(args.output, "w", encoding=DICT_ENCODING) as f:
|
|
68
|
+
em.write(f)
|
|
69
|
+
print(f"Done: Alignments saved to {args.output}", file=sys.stderr)
|
|
70
|
+
else:
|
|
71
|
+
em.write(sys.stdout)
|
|
72
|
+
|
|
73
|
+
return 0
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Bundle command: create standalone G2P predictor."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
import tempfile
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def setup_bundle_command(subparsers):
|
|
11
|
+
"""Setup bundle command."""
|
|
12
|
+
parser = subparsers.add_parser(
|
|
13
|
+
"bundle",
|
|
14
|
+
help="Create standalone Python G2P predictor with embedded model",
|
|
15
|
+
description="""Bundle G2P model into a standalone Python executable.
|
|
16
|
+
|
|
17
|
+
Creates a self-contained .py file with the model embedded.
|
|
18
|
+
|
|
19
|
+
Examples:
|
|
20
|
+
phonebox bundle model.g2p.gz -o g2p.py
|
|
21
|
+
|
|
22
|
+
Usage of bundled file:
|
|
23
|
+
python g2p.py "Hello, world!"
|
|
24
|
+
|
|
25
|
+
Library usage:
|
|
26
|
+
from g2p import G2PPredictor
|
|
27
|
+
g2p = G2PPredictor.from_embedded()
|
|
28
|
+
phones = g2p.pronounce_text('Hello, world!')""",
|
|
29
|
+
)
|
|
30
|
+
parser.add_argument("model", help="Model file (.g2p.gz, .cart, etc.)")
|
|
31
|
+
parser.add_argument("-o", "--output", required=True, help="Output .py file")
|
|
32
|
+
parser.set_defaults(func=handle_bundle)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def handle_bundle(args):
|
|
36
|
+
"""Handle 'phonebox bundle' command."""
|
|
37
|
+
from ...bundler import bundle_g2p
|
|
38
|
+
from ...core.g2p_model import G2PDecisionTree
|
|
39
|
+
|
|
40
|
+
model_path = args.model
|
|
41
|
+
output_path = args.output
|
|
42
|
+
|
|
43
|
+
# Convert to .cart format if needed (cartlet's bundler requires .cart)
|
|
44
|
+
needs_conversion = not model_path.endswith(".cart")
|
|
45
|
+
|
|
46
|
+
if needs_conversion:
|
|
47
|
+
print(f"Converting {model_path} to .cart for bundling...", file=sys.stderr)
|
|
48
|
+
|
|
49
|
+
dt = G2PDecisionTree(model=model_path)
|
|
50
|
+
|
|
51
|
+
with tempfile.NamedTemporaryFile(suffix=".cart", delete=False) as tmp:
|
|
52
|
+
cart_path = tmp.name
|
|
53
|
+
|
|
54
|
+
dt._cart.export(cart_path, store_distributions=dt._cart.store_distributions)
|
|
55
|
+
model_path = cart_path
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
print(f"Bundling -> {output_path}", file=sys.stderr)
|
|
59
|
+
bundle_g2p(model_path, output_path)
|
|
60
|
+
print(f"Done: Created {output_path}", file=sys.stderr)
|
|
61
|
+
|
|
62
|
+
module_name = os.path.splitext(os.path.basename(output_path))[0]
|
|
63
|
+
print("\nUsage:", file=sys.stderr)
|
|
64
|
+
print(f' CLI: python {output_path} "Hello, world!"', file=sys.stderr)
|
|
65
|
+
print(f" Library: from {module_name} import G2PPredictor", file=sys.stderr)
|
|
66
|
+
print(" g2p = G2PPredictor.from_embedded()", file=sys.stderr)
|
|
67
|
+
print(" g2p.pronounce_text('Hello, world!')", file=sys.stderr)
|
|
68
|
+
return 0
|
|
69
|
+
finally:
|
|
70
|
+
if needs_conversion:
|
|
71
|
+
os.unlink(cart_path)
|