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.
Files changed (92) hide show
  1. phonebox/__init__.py +41 -0
  2. phonebox/bundler.py +77 -0
  3. phonebox/cart/__init__.py +11 -0
  4. phonebox/cart/g2p_predict.py +315 -0
  5. phonebox/cli/__init__.py +8 -0
  6. phonebox/cli/commands/__init__.py +3 -0
  7. phonebox/cli/commands/_common.py +61 -0
  8. phonebox/cli/commands/align.py +73 -0
  9. phonebox/cli/commands/bundle.py +71 -0
  10. phonebox/cli/commands/check.py +218 -0
  11. phonebox/cli/commands/compare.py +157 -0
  12. phonebox/cli/commands/dict.py +120 -0
  13. phonebox/cli/commands/model.py +353 -0
  14. phonebox/cli/commands/normalize.py +79 -0
  15. phonebox/cli/commands/pronounce.py +190 -0
  16. phonebox/cli/commands/recipe.py +302 -0
  17. phonebox/cli/commands/suggest_joins.py +291 -0
  18. phonebox/cli/commands/train.py +182 -0
  19. phonebox/cli/commands/train_multigram.py +124 -0
  20. phonebox/cli/commands/vectorize.py +61 -0
  21. phonebox/cli/main.py +159 -0
  22. phonebox/config/__init__.py +1 -0
  23. phonebox/config/locales/de_DE/config.json +25 -0
  24. phonebox/config/locales/de_DE/defaults.json +13 -0
  25. phonebox/config/locales/de_DE/g2p.xlit +13 -0
  26. phonebox/config/locales/default/config.json +8 -0
  27. phonebox/config/locales/default/defaults.json +8 -0
  28. phonebox/config/locales/default/g2p.xlit +6 -0
  29. phonebox/config/locales/en_IN/config.json +32 -0
  30. phonebox/config/locales/en_IN/defaults.json +12 -0
  31. phonebox/config/locales/en_US/config.json +64 -0
  32. phonebox/config/locales/en_US/defaults.json +12 -0
  33. phonebox/config/locales/en_US/g2p.xlit +6 -0
  34. phonebox/config/locales/es_MX/config.json +12 -0
  35. phonebox/config/locales/es_MX/defaults.json +10 -0
  36. phonebox/config/locales/es_MX/g2p.xlit +30 -0
  37. phonebox/config/locales/fr_FR/config.json +22 -0
  38. phonebox/config/locales/fr_FR/defaults.json +12 -0
  39. phonebox/config/locales/fr_FR/g2p.xlit +14 -0
  40. phonebox/config/locales/it_IT/config.json +22 -0
  41. phonebox/config/locales/it_IT/defaults.json +8 -0
  42. phonebox/config/locales/it_IT/g2p.xlit +23 -0
  43. phonebox/config/locales/norm.xlit +36 -0
  44. phonebox/config/locales/pt_BR/config.json +14 -0
  45. phonebox/config/locales/pt_BR/defaults.json +12 -0
  46. phonebox/config/locales/pt_BR/g2p.xlit +12 -0
  47. phonebox/config_builder.py +63 -0
  48. phonebox/config_loader.py +206 -0
  49. phonebox/configs/__init__.py +54 -0
  50. phonebox/configs/accurate.yaml +25 -0
  51. phonebox/configs/defaults.yaml +82 -0
  52. phonebox/configs/fast.yaml +25 -0
  53. phonebox/configs/pocketsphinx.yaml +28 -0
  54. phonebox/configs/xsampa.yaml +27 -0
  55. phonebox/constants.py +230 -0
  56. phonebox/converter.py +366 -0
  57. phonebox/core/__init__.py +11 -0
  58. phonebox/core/decision_tree.py +8 -0
  59. phonebox/core/em_align.py +514 -0
  60. phonebox/core/g2p_model.py +960 -0
  61. phonebox/core/joint_decode.py +111 -0
  62. phonebox/core/multigram_align.py +703 -0
  63. phonebox/core/multigram_g2p.py +286 -0
  64. phonebox/core/multigram_lm.py +187 -0
  65. phonebox/core/nbest.py +69 -0
  66. phonebox/core/vectorizer.py +585 -0
  67. phonebox/dictionary.py +428 -0
  68. phonebox/eval/__init__.py +29 -0
  69. phonebox/eval/g2p_compare.py +672 -0
  70. phonebox/eval/g2p_compare_all.py +342 -0
  71. phonebox/experiments/__init__.py +11 -0
  72. phonebox/experiments/analysis.py +82 -0
  73. phonebox/experiments/equiv.py +37 -0
  74. phonebox/experiments/metrics.py +14 -0
  75. phonebox/experiments/normalize.py +122 -0
  76. phonebox/experiments/split.py +26 -0
  77. phonebox/locales.py +84 -0
  78. phonebox/normalize.py +62 -0
  79. phonebox/py.typed +0 -0
  80. phonebox/runner.py +245 -0
  81. phonebox/tools/__init__.py +5 -0
  82. phonebox/tools/count_diphones.py +53 -0
  83. phonebox/utils/__init__.py +9 -0
  84. phonebox/utils/icu_utils.py +60 -0
  85. phonebox/utils/io.py +24 -0
  86. phonebox/utils/logging_config.py +21 -0
  87. phonebox-0.1.0.dist-info/METADATA +249 -0
  88. phonebox-0.1.0.dist-info/RECORD +92 -0
  89. phonebox-0.1.0.dist-info/WHEEL +5 -0
  90. phonebox-0.1.0.dist-info/entry_points.txt +2 -0
  91. phonebox-0.1.0.dist-info/licenses/LICENSE +21 -0
  92. 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())
@@ -0,0 +1,8 @@
1
+ """
2
+ Command-line interface for phonebox.
3
+
4
+ This module provides the main CLI entry point and subcommand structure
5
+ for the phonebox grapheme-to-phoneme conversion tool.
6
+ """
7
+
8
+ from __future__ import annotations
@@ -0,0 +1,3 @@
1
+ """CLI command groups."""
2
+
3
+ from __future__ import annotations
@@ -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)