ipakit 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.
- ipakit/__init__.py +522 -0
- ipakit/_base.py +65 -0
- ipakit/_convert.py +95 -0
- ipakit/analysis.py +373 -0
- ipakit/cli/__init__.py +156 -0
- ipakit/cli/__main__.py +8 -0
- ipakit/cli/analysis_cmds.py +327 -0
- ipakit/cli/analyze.py +380 -0
- ipakit/cli/base.py +276 -0
- ipakit/cli/convert.py +442 -0
- ipakit/cli/distance.py +368 -0
- ipakit/cli/features.py +155 -0
- ipakit/cli/hierarchy.py +190 -0
- ipakit/cli/info.py +73 -0
- ipakit/cli/query.py +387 -0
- ipakit/constants.py +34 -0
- ipakit/data/confusion.json +1 -0
- ipakit/data/ipa.xml +412 -0
- ipakit/data/phonemaps/cmu.xml +99 -0
- ipakit/data/phonemaps/kirshenbaum.xml +167 -0
- ipakit/data/phonemaps/lookalikes.xml +15 -0
- ipakit/data/phonemaps/timit.xml +96 -0
- ipakit/data/phonemaps/xsampa.xml +208 -0
- ipakit/distance.py +249 -0
- ipakit/distance_model.py +436 -0
- ipakit/features.py +686 -0
- ipakit/hierarchy.py +139 -0
- ipakit/mapper.py +212 -0
- ipakit/models.py +135 -0
- ipakit/phonemaps.py +177 -0
- ipakit/py.typed +0 -0
- ipakit/validation.py +76 -0
- ipakit/xsampa.py +55 -0
- ipakit-0.1.0.dist-info/METADATA +202 -0
- ipakit-0.1.0.dist-info/RECORD +39 -0
- ipakit-0.1.0.dist-info/WHEEL +5 -0
- ipakit-0.1.0.dist-info/entry_points.txt +2 -0
- ipakit-0.1.0.dist-info/licenses/LICENSE +21 -0
- ipakit-0.1.0.dist-info/top_level.txt +1 -0
ipakit/__init__.py
ADDED
|
@@ -0,0 +1,522 @@
|
|
|
1
|
+
"""ipakit - IPA phonetic features library.
|
|
2
|
+
|
|
3
|
+
Simple API:
|
|
4
|
+
import ipakit as ipa
|
|
5
|
+
|
|
6
|
+
ipa.distance("p", "b") # 0.043
|
|
7
|
+
ipa.features("p") # {'manner': 'plosive', ...}
|
|
8
|
+
ipa.to_cmu("ˈhɛloʊ") # ['HH', 'EH1', 'L', 'OW0']
|
|
9
|
+
ipa.to_ipa(["HH", "EH1", "L"]) # 'hˈɛl'
|
|
10
|
+
ipa.tokenize("t͡ʃe͡ɪnd͡ʒ") # ['t͡ʃ', 'e͡ɪ', 'n', 'd͡ʒ']
|
|
11
|
+
ipa.normalize("tʃ eɪ n dʒ") # 't͡ʃe͡ɪnd͡ʒ'
|
|
12
|
+
|
|
13
|
+
Class API:
|
|
14
|
+
from ipakit import IPAFeatures, CMUMapper
|
|
15
|
+
|
|
16
|
+
Converter return types follow the target format: converters to a token-oriented
|
|
17
|
+
phone set (``to_cmu``, ``to_timit``) return ``list[str]``, while converters to a
|
|
18
|
+
transcription string (``ipa_to_xsampa``, ``to_kirshenbaum``) return ``str``.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import functools
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
__version__ = "0.1.0"
|
|
27
|
+
|
|
28
|
+
# Re-export classes
|
|
29
|
+
from .constants import (
|
|
30
|
+
DATA_DIR,
|
|
31
|
+
DEFAULT_CMU_MAP,
|
|
32
|
+
DEFAULT_IPA_FEATS,
|
|
33
|
+
PHONEMAPS_DIR,
|
|
34
|
+
TIE_BAR,
|
|
35
|
+
)
|
|
36
|
+
from .distance import WordDistanceResult
|
|
37
|
+
from .distance_model import DistanceModel
|
|
38
|
+
from .features import IPAFeatures
|
|
39
|
+
from .mapper import CMUMapper
|
|
40
|
+
from .models import Feature, Phone, PhoneMapping, Phoneset
|
|
41
|
+
from .phonemaps import (
|
|
42
|
+
from_kirshenbaum,
|
|
43
|
+
from_timit,
|
|
44
|
+
ipa_to_phonemap,
|
|
45
|
+
phonemap_to_ipa,
|
|
46
|
+
to_kirshenbaum,
|
|
47
|
+
to_timit,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
# X-SAMPA string conversion lives in ipakit.xsampa, the single source of truth
|
|
51
|
+
# for the IPA <-> X-SAMPA table. Re-exported here for the flat module API.
|
|
52
|
+
from .xsampa import ipa_to_xsampa, xsampa_to_ipa
|
|
53
|
+
|
|
54
|
+
# =============================================================================
|
|
55
|
+
# Module-level API (lazy singletons)
|
|
56
|
+
# =============================================================================
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@functools.lru_cache(maxsize=1)
|
|
60
|
+
def _get_ipa() -> IPAFeatures:
|
|
61
|
+
return IPAFeatures()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@functools.lru_cache(maxsize=1)
|
|
65
|
+
def _get_cmu() -> CMUMapper:
|
|
66
|
+
return CMUMapper()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@functools.lru_cache(maxsize=1)
|
|
70
|
+
def _get_default_model() -> DistanceModel:
|
|
71
|
+
return DistanceModel.global_(_get_ipa())
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def load_ipa_features(xml_path: Path = DEFAULT_IPA_FEATS) -> IPAFeatures:
|
|
75
|
+
"""Convenience function to load IPA features."""
|
|
76
|
+
return IPAFeatures(xml_path)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# --- Distance & Features ---
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def distance(phone1: str, phone2: str) -> float:
|
|
83
|
+
"""Compute phonetic distance between two IPA phones (0.0-1.0)."""
|
|
84
|
+
return _get_ipa().distance(phone1, phone2)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def word_distance(
|
|
88
|
+
ipa1: str,
|
|
89
|
+
ipa2: str,
|
|
90
|
+
weighted: bool = True,
|
|
91
|
+
return_alignment: bool = False,
|
|
92
|
+
) -> WordDistanceResult:
|
|
93
|
+
"""Compute phonetic edit distance between two IPA words.
|
|
94
|
+
|
|
95
|
+
Uses Levenshtein-style dynamic programming with phonetic feature costs.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
ipa1: First IPA string
|
|
99
|
+
ipa2: Second IPA string
|
|
100
|
+
weighted: If True, use feature distance for substitution costs.
|
|
101
|
+
return_alignment: If True, include the alignment path in result.
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
WordDistanceResult with distance, similarity, and optional alignment.
|
|
105
|
+
|
|
106
|
+
Examples:
|
|
107
|
+
>>> ipakit.word_distance("kæt", "kæd")
|
|
108
|
+
WordDistanceResult(distance=0.04..., similarity=0.98..., alignment=None)
|
|
109
|
+
>>> ipakit.word_distance("kæt", "dɒɡ")
|
|
110
|
+
WordDistanceResult(distance=..., similarity=..., alignment=None)
|
|
111
|
+
"""
|
|
112
|
+
return _get_ipa().word_distance(
|
|
113
|
+
ipa1, ipa2, weighted=weighted, return_alignment=return_alignment
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def word_similarity(ipa1: str, ipa2: str, weighted: bool = True) -> float:
|
|
118
|
+
"""Compute phonetic similarity between two IPA words.
|
|
119
|
+
|
|
120
|
+
Returns a value from 0.0 (completely different) to 1.0 (identical).
|
|
121
|
+
Similarity = 1 - (edit_distance / max_length), with lower bound of 0.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
ipa1: First IPA string
|
|
125
|
+
ipa2: Second IPA string
|
|
126
|
+
weighted: If True, use feature distance for substitution costs.
|
|
127
|
+
|
|
128
|
+
Examples:
|
|
129
|
+
>>> ipakit.word_similarity("kæt", "kæd")
|
|
130
|
+
0.98...
|
|
131
|
+
>>> ipakit.word_similarity("kæt", "dɒɡ") # weighted subs are cheap (shared features)
|
|
132
|
+
0.9...
|
|
133
|
+
"""
|
|
134
|
+
return _get_ipa().word_similarity(ipa1, ipa2, weighted=weighted)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def normalized_distance(phone1: str, phone2: str) -> float:
|
|
138
|
+
"""CDF-renormalized distance (percentile within the bundled IPA inventory)."""
|
|
139
|
+
return _get_default_model().distance(phone1, phone2)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def confusability(phone1: str, phone2: str) -> float:
|
|
143
|
+
"""Normalized confusability (percentile similarity) in the bundled IPA inventory.
|
|
144
|
+
|
|
145
|
+
The complement of :func:`normalized_distance`; 1.0 for identical phones.
|
|
146
|
+
For an inventory-scoped model, build one with :func:`distance_model`.
|
|
147
|
+
|
|
148
|
+
Examples:
|
|
149
|
+
>>> round(ipakit.confusability("p", "b"), 3)
|
|
150
|
+
0.845
|
|
151
|
+
>>> ipakit.confusability("p", "p")
|
|
152
|
+
1.0
|
|
153
|
+
"""
|
|
154
|
+
return _get_default_model().confusability(phone1, phone2)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def distance_model(
|
|
158
|
+
reference: Phoneset | list[str] | None = None,
|
|
159
|
+
*,
|
|
160
|
+
gamma: float = 1.0,
|
|
161
|
+
sub_mode: str = "simple",
|
|
162
|
+
insert_cost: float = 1.0,
|
|
163
|
+
delete_cost: float = 1.0,
|
|
164
|
+
threshold: float | None = None,
|
|
165
|
+
max_length_ratio: float | None = None,
|
|
166
|
+
) -> DistanceModel:
|
|
167
|
+
"""Build a distribution-aware distance model over a reference inventory.
|
|
168
|
+
|
|
169
|
+
``reference=None`` uses the bundled global IPA inventory (default).
|
|
170
|
+
"""
|
|
171
|
+
ipa = _get_ipa()
|
|
172
|
+
if reference is None:
|
|
173
|
+
return DistanceModel.global_(
|
|
174
|
+
ipa,
|
|
175
|
+
gamma=gamma,
|
|
176
|
+
sub_mode=sub_mode,
|
|
177
|
+
insert_cost=insert_cost,
|
|
178
|
+
delete_cost=delete_cost,
|
|
179
|
+
threshold=threshold,
|
|
180
|
+
max_length_ratio=max_length_ratio,
|
|
181
|
+
)
|
|
182
|
+
ps = reference if isinstance(reference, Phoneset) else Phoneset.from_list(reference)
|
|
183
|
+
return DistanceModel.for_phoneset(
|
|
184
|
+
ipa,
|
|
185
|
+
ps,
|
|
186
|
+
gamma=gamma,
|
|
187
|
+
sub_mode=sub_mode,
|
|
188
|
+
insert_cost=insert_cost,
|
|
189
|
+
delete_cost=delete_cost,
|
|
190
|
+
threshold=threshold,
|
|
191
|
+
max_length_ratio=max_length_ratio,
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def features(phone: str, with_defaults: bool = True) -> dict[str, str]:
|
|
196
|
+
"""Get phonetic features for an IPA phone."""
|
|
197
|
+
return _get_ipa().get_features(phone, with_defaults=with_defaults)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def features_from_cmu(
|
|
201
|
+
cmu_symbols: list[str], with_defaults: bool = True
|
|
202
|
+
) -> list[dict[str, str]]:
|
|
203
|
+
"""Get feature bundles from CMU ARPABET symbols."""
|
|
204
|
+
ipa_str = _get_cmu().cmu_to_ipa(cmu_symbols)
|
|
205
|
+
return _get_ipa().compose(ipa_str, with_defaults=with_defaults)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def features_from_xsampa(
|
|
209
|
+
xsampa: str, with_defaults: bool = True
|
|
210
|
+
) -> list[dict[str, str]]:
|
|
211
|
+
"""Get feature bundles from X-SAMPA string."""
|
|
212
|
+
ipa_str = xsampa_to_ipa(xsampa)
|
|
213
|
+
return _get_ipa().compose(ipa_str, with_defaults=with_defaults)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
# --- CMU ARPABET Conversion ---
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def to_cmu(
|
|
220
|
+
ipa_string: str,
|
|
221
|
+
with_stress: bool = True,
|
|
222
|
+
include_extras: bool = False,
|
|
223
|
+
strict: bool = False,
|
|
224
|
+
) -> list[str]:
|
|
225
|
+
"""Convert IPA string to list of CMU ARPABET symbols.
|
|
226
|
+
|
|
227
|
+
With ``strict=True``, raise ``ValueError`` on unconvertible phones.
|
|
228
|
+
"""
|
|
229
|
+
return _get_cmu().ipa_to_cmu(
|
|
230
|
+
ipa_string,
|
|
231
|
+
with_stress=with_stress,
|
|
232
|
+
include_extras=include_extras,
|
|
233
|
+
strict=strict,
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def from_cmu(
|
|
238
|
+
cmu_symbols: list[str], include_extras: bool = True, strict: bool = False
|
|
239
|
+
) -> str:
|
|
240
|
+
"""Convert list of CMU ARPABET symbols to IPA string.
|
|
241
|
+
|
|
242
|
+
With ``strict=True``, raise ``ValueError`` on unknown CMU symbols.
|
|
243
|
+
|
|
244
|
+
Examples:
|
|
245
|
+
>>> ipakit.from_cmu(["K", "AE1", "T"])
|
|
246
|
+
'kˈæt'
|
|
247
|
+
"""
|
|
248
|
+
return _get_cmu().cmu_to_ipa(
|
|
249
|
+
cmu_symbols, include_extras=include_extras, strict=strict
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def to_ipa(
|
|
254
|
+
cmu_symbols: list[str], include_extras: bool = True, strict: bool = False
|
|
255
|
+
) -> str:
|
|
256
|
+
"""Alias for :func:`from_cmu` (CMU ARPABET -> IPA).
|
|
257
|
+
|
|
258
|
+
``from_cmu`` is the canonical name -- it names the source format, matching
|
|
259
|
+
``from_xsampa``/``from_timit``/``from_kirshenbaum``, which also produce IPA.
|
|
260
|
+
``to_ipa`` is kept for backward compatibility.
|
|
261
|
+
"""
|
|
262
|
+
return from_cmu(cmu_symbols, include_extras=include_extras, strict=strict)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
# --- Tokenization & Normalization ---
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def tokenize(ipa_string: str) -> list[str]:
|
|
269
|
+
"""Parse IPA string into list of segment tokens."""
|
|
270
|
+
return _get_ipa().tokenize_ipa(ipa_string)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def segment(ipa_string: str) -> str:
|
|
274
|
+
"""Parse IPA string and return whitespace-separated segments."""
|
|
275
|
+
return _get_ipa().segment_ipa(ipa_string)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def normalize(segments: str) -> str:
|
|
279
|
+
"""Normalize whitespace-separated IPA segments into decodable IPA string."""
|
|
280
|
+
return _get_ipa().normalize_ipa(segments)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def normalize_lookalikes(text: str) -> str:
|
|
284
|
+
"""Replace lookalike characters with proper IPA equivalents.
|
|
285
|
+
|
|
286
|
+
Converts visually similar keyboard characters to their
|
|
287
|
+
correct IPA Unicode codepoints (e.g., 'g' → 'ɡ', ':' → 'ː').
|
|
288
|
+
"""
|
|
289
|
+
return _get_ipa().normalize_lookalikes(text)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def add_ties(segment: str) -> str:
|
|
293
|
+
"""Add tie bars between base phones in a multi-phone segment."""
|
|
294
|
+
return _get_ipa().add_tie_bars(segment)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def feature_bundles(
|
|
298
|
+
ipa_string: str, with_defaults: bool = True
|
|
299
|
+
) -> list[dict[str, str]]:
|
|
300
|
+
"""Get list of feature dicts for each segment in an IPA string."""
|
|
301
|
+
return _get_ipa().compose(ipa_string, with_defaults=with_defaults)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def phones_matching(
|
|
305
|
+
query: dict[str, str] | list[str] | set[str], with_defaults: bool = True
|
|
306
|
+
) -> list[str]:
|
|
307
|
+
"""Get all phones matching features. Accepts dict or list/set of short names."""
|
|
308
|
+
return _get_ipa().phones_matching(query, with_defaults=with_defaults)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def features_to_shorts(bundle: dict[str, str]) -> list[str]:
|
|
312
|
+
"""Convert a feature dict to list of short names."""
|
|
313
|
+
return _get_ipa().features_to_shorts(bundle)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def shorts_to_features(shorts: list[str] | set[str]) -> dict[str, str]:
|
|
317
|
+
"""Convert list of short names to feature dict."""
|
|
318
|
+
return _get_ipa().shorts_to_features(shorts)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _make_wiki_url(ipa: IPAFeatures, href: str | None) -> str | None:
|
|
322
|
+
"""Construct full Wikipedia URL from article name."""
|
|
323
|
+
if href and ipa.wiki_base and not href.startswith("http"):
|
|
324
|
+
return ipa.wiki_base + href
|
|
325
|
+
return href
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def wiki(phone: str) -> str | None:
|
|
329
|
+
"""Get Wikipedia URL for an IPA phone symbol.
|
|
330
|
+
|
|
331
|
+
Example:
|
|
332
|
+
>>> ipakit.wiki("p")
|
|
333
|
+
'https://en.wikipedia.org/wiki/Voiceless_bilabial_plosive'
|
|
334
|
+
"""
|
|
335
|
+
ipa = _get_ipa()
|
|
336
|
+
href = None
|
|
337
|
+
if phone in ipa.phones:
|
|
338
|
+
href = ipa.phones[phone].features.get("href")
|
|
339
|
+
elif phone in ipa.diacritics:
|
|
340
|
+
href = ipa.diacritics[phone].features.get("href")
|
|
341
|
+
return _make_wiki_url(ipa, href)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def wiki_ref(name: str) -> str | None:
|
|
345
|
+
"""Get Wikipedia URL for a general IPA reference.
|
|
346
|
+
|
|
347
|
+
Example:
|
|
348
|
+
>>> ipakit.wiki_ref("IPA")
|
|
349
|
+
'https://en.wikipedia.org/wiki/International_Phonetic_Alphabet'
|
|
350
|
+
>>> ipakit.wiki_ref("X-SAMPA")
|
|
351
|
+
'https://en.wikipedia.org/wiki/X-SAMPA'
|
|
352
|
+
"""
|
|
353
|
+
ipa = _get_ipa()
|
|
354
|
+
href = ipa.references.get(name)
|
|
355
|
+
return _make_wiki_url(ipa, href)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def wiki_refs() -> dict[str, str]:
|
|
359
|
+
"""Get all general IPA reference URLs.
|
|
360
|
+
|
|
361
|
+
Returns dict mapping reference names to full Wikipedia URLs.
|
|
362
|
+
"""
|
|
363
|
+
ipa = _get_ipa()
|
|
364
|
+
return {
|
|
365
|
+
name: url
|
|
366
|
+
for name, href in ipa.references.items()
|
|
367
|
+
if (url := _make_wiki_url(ipa, href)) is not None
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
# --- Analysis functions ---
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def describe(phone: str, with_defaults: bool = True) -> str:
|
|
375
|
+
"""Generate human-readable IPA description for a phone.
|
|
376
|
+
|
|
377
|
+
Examples:
|
|
378
|
+
>>> ipakit.describe("p")
|
|
379
|
+
'voiceless bilabial plosive'
|
|
380
|
+
>>> ipakit.describe("ɛ")
|
|
381
|
+
'open-mid front unrounded vowel'
|
|
382
|
+
>>> ipakit.describe("t͡ʃ")
|
|
383
|
+
'voiceless postalveolar affricate'
|
|
384
|
+
"""
|
|
385
|
+
return _get_ipa().describe(phone, with_defaults=with_defaults)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def natural_class(
|
|
389
|
+
phones: list[str],
|
|
390
|
+
with_defaults: bool = True,
|
|
391
|
+
exclude_features: set[str] | None = None,
|
|
392
|
+
) -> dict[str, str]:
|
|
393
|
+
"""Find features shared by all phones in a set (natural class).
|
|
394
|
+
|
|
395
|
+
Examples:
|
|
396
|
+
>>> ipakit.natural_class(["p", "t", "k"]) # shared features (incl. defaults)
|
|
397
|
+
{'manner': 'plosive', ...'voiced': '-', ...}
|
|
398
|
+
>>> ipakit.natural_class(["i", "e", "ɛ"])
|
|
399
|
+
{'manner': 'vowel', ...'backness': 'front', ...}
|
|
400
|
+
"""
|
|
401
|
+
return _get_ipa().natural_class(
|
|
402
|
+
phones, with_defaults=with_defaults, exclude_features=exclude_features
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def minimal_pairs(
|
|
407
|
+
phone: str,
|
|
408
|
+
with_defaults: bool = True,
|
|
409
|
+
max_distance: float = 0.3,
|
|
410
|
+
) -> list[tuple[str, str, str | None]]:
|
|
411
|
+
"""Find phones that differ by approximately one feature (minimal pairs).
|
|
412
|
+
|
|
413
|
+
Returns list of (phone, differing_feature, differing_value) tuples.
|
|
414
|
+
|
|
415
|
+
Examples:
|
|
416
|
+
>>> ipakit.minimal_pairs("p")
|
|
417
|
+
[('ɸ', 'manner', 'fricative'), ('f', 'manner', 'fricative'), ...]
|
|
418
|
+
"""
|
|
419
|
+
return _get_ipa().minimal_pairs(
|
|
420
|
+
phone, with_defaults=with_defaults, max_distance=max_distance
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def nearest_phones(
|
|
425
|
+
phone: str,
|
|
426
|
+
n: int = 10,
|
|
427
|
+
with_defaults: bool = True,
|
|
428
|
+
) -> list[tuple[str, float]]:
|
|
429
|
+
"""Find the n nearest phones by phonetic distance.
|
|
430
|
+
|
|
431
|
+
Returns list of (phone, distance) tuples sorted by distance.
|
|
432
|
+
|
|
433
|
+
Examples:
|
|
434
|
+
>>> [(p, round(d, 3)) for p, d in ipakit.nearest_phones("p", n=3)]
|
|
435
|
+
[('ɸ', 0.005), ('f', 0.008), ('p͡f', 0.008)]
|
|
436
|
+
"""
|
|
437
|
+
return _get_ipa().nearest_phones(phone, n=n, with_defaults=with_defaults)
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def validate_ipa(ipa: str, strict: bool = False) -> list[dict[str, str]]:
|
|
441
|
+
"""Validate an IPA string for well-formedness.
|
|
442
|
+
|
|
443
|
+
Returns a list of issue dicts. Empty list means valid.
|
|
444
|
+
|
|
445
|
+
Examples:
|
|
446
|
+
>>> ipakit.validate_ipa("kæt")
|
|
447
|
+
[]
|
|
448
|
+
>>> ipakit.validate_ipa("k4t") # 'x', 'y', 'z' are all valid IPA; '4' is not
|
|
449
|
+
[{'type': 'error', 'code': 'unknown_symbol', 'message': "Unknown symbol '4' (U+0034)", 'position': '1', 'symbol': '4'}]
|
|
450
|
+
"""
|
|
451
|
+
return _get_ipa().validate_ipa(ipa, strict=strict)
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def is_valid_ipa(ipa: str) -> bool:
|
|
455
|
+
"""Check if an IPA string is valid (no errors).
|
|
456
|
+
|
|
457
|
+
Examples:
|
|
458
|
+
>>> ipakit.is_valid_ipa("kæt")
|
|
459
|
+
True
|
|
460
|
+
>>> ipakit.is_valid_ipa("k4t") # 'x', 'y', 'z' are valid IPA; '4' is not
|
|
461
|
+
False
|
|
462
|
+
"""
|
|
463
|
+
return _get_ipa().is_valid_ipa(ipa)
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
__all__ = [
|
|
467
|
+
# Classes
|
|
468
|
+
"CMUMapper",
|
|
469
|
+
"DistanceModel",
|
|
470
|
+
"Feature",
|
|
471
|
+
"IPAFeatures",
|
|
472
|
+
"Phone",
|
|
473
|
+
"PhoneMapping",
|
|
474
|
+
"Phoneset",
|
|
475
|
+
"WordDistanceResult",
|
|
476
|
+
# Constants
|
|
477
|
+
"DATA_DIR",
|
|
478
|
+
"DEFAULT_CMU_MAP",
|
|
479
|
+
"DEFAULT_IPA_FEATS",
|
|
480
|
+
"PHONEMAPS_DIR",
|
|
481
|
+
"TIE_BAR",
|
|
482
|
+
# Functions
|
|
483
|
+
"add_ties",
|
|
484
|
+
"confusability",
|
|
485
|
+
"describe",
|
|
486
|
+
"distance",
|
|
487
|
+
"distance_model",
|
|
488
|
+
"feature_bundles",
|
|
489
|
+
"features",
|
|
490
|
+
"features_from_cmu",
|
|
491
|
+
"features_from_xsampa",
|
|
492
|
+
"features_to_shorts",
|
|
493
|
+
"from_cmu",
|
|
494
|
+
"from_kirshenbaum",
|
|
495
|
+
"from_timit",
|
|
496
|
+
"ipa_to_phonemap",
|
|
497
|
+
"ipa_to_xsampa",
|
|
498
|
+
"is_valid_ipa",
|
|
499
|
+
"load_ipa_features",
|
|
500
|
+
"minimal_pairs",
|
|
501
|
+
"natural_class",
|
|
502
|
+
"nearest_phones",
|
|
503
|
+
"normalize",
|
|
504
|
+
"normalize_lookalikes",
|
|
505
|
+
"normalized_distance",
|
|
506
|
+
"phonemap_to_ipa",
|
|
507
|
+
"phones_matching",
|
|
508
|
+
"segment",
|
|
509
|
+
"shorts_to_features",
|
|
510
|
+
"to_cmu",
|
|
511
|
+
"to_ipa",
|
|
512
|
+
"to_kirshenbaum",
|
|
513
|
+
"to_timit",
|
|
514
|
+
"tokenize",
|
|
515
|
+
"validate_ipa",
|
|
516
|
+
"wiki",
|
|
517
|
+
"wiki_ref",
|
|
518
|
+
"wiki_refs",
|
|
519
|
+
"word_distance",
|
|
520
|
+
"word_similarity",
|
|
521
|
+
"xsampa_to_ipa",
|
|
522
|
+
]
|
ipakit/_base.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Shared base for IPAFeatures mixins.
|
|
2
|
+
|
|
3
|
+
The mixins (Analysis/Distance/Hierarchy/Validation) call attributes and methods
|
|
4
|
+
that live on the concrete ``IPAFeatures`` class or on sibling mixins. Inheriting
|
|
5
|
+
this base lets a type checker resolve those references without each mixin
|
|
6
|
+
annotating ``self`` as ``IPAFeatures`` (which strict mypy rejects, since the
|
|
7
|
+
erased self type must be a supertype of the defining class).
|
|
8
|
+
|
|
9
|
+
At runtime these declarations are inert: ``IPAFeatures`` and the mixins override
|
|
10
|
+
every member below, so the stub bodies are never executed.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from .models import Feature, Phone, Phoneset
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class IPAFeaturesBase:
|
|
19
|
+
"""Declares the cross-mixin surface of ``IPAFeatures`` for type checking."""
|
|
20
|
+
|
|
21
|
+
# Data populated by IPAFeatures._load()
|
|
22
|
+
phones: dict[str, Phone]
|
|
23
|
+
diacritics: dict[str, Phone]
|
|
24
|
+
separators: dict[str, Phone]
|
|
25
|
+
features: dict[str, Feature]
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def feature_order(self) -> list[str]:
|
|
29
|
+
raise NotImplementedError
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def consonant_manners(self) -> frozenset[str]:
|
|
33
|
+
raise NotImplementedError
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def stress_markers(self) -> dict[str, int]:
|
|
37
|
+
raise NotImplementedError
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def stress_to_marker(self) -> dict[int, str]:
|
|
41
|
+
raise NotImplementedError
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def syllable_break(self) -> str:
|
|
45
|
+
raise NotImplementedError
|
|
46
|
+
|
|
47
|
+
def get_features(self, phone: str, with_defaults: bool = True) -> dict[str, str]:
|
|
48
|
+
raise NotImplementedError
|
|
49
|
+
|
|
50
|
+
def expand_ligatures(self, ipa: str) -> str:
|
|
51
|
+
raise NotImplementedError
|
|
52
|
+
|
|
53
|
+
def compose(
|
|
54
|
+
self,
|
|
55
|
+
segment: str,
|
|
56
|
+
with_defaults: bool = True,
|
|
57
|
+
phoneset: Phoneset | None = None,
|
|
58
|
+
) -> list[dict[str, str]]:
|
|
59
|
+
raise NotImplementedError
|
|
60
|
+
|
|
61
|
+
def tokenize_ipa(self, ipa: str, phoneset: Phoneset | None = None) -> list[str]:
|
|
62
|
+
raise NotImplementedError
|
|
63
|
+
|
|
64
|
+
def distance(self, phone1: str, phone2: str) -> float:
|
|
65
|
+
raise NotImplementedError
|
ipakit/_convert.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Shared tokenization/conversion helpers.
|
|
2
|
+
|
|
3
|
+
Several converters (feature parsing, stress normalization, IPA validation, CMU,
|
|
4
|
+
X-SAMPA and phonemap conversion) all walk a string left to right, taking the
|
|
5
|
+
longest substring that is a key in some lookup. That one loop is ``longest_match``.
|
|
6
|
+
|
|
7
|
+
They also share an opt-in ``strict`` error policy: collect the symbols that could
|
|
8
|
+
not be converted and, when strict, raise via ``require_convertible``.
|
|
9
|
+
|
|
10
|
+
Per-site state (diacritic collection, stress handling, validation tracking)
|
|
11
|
+
stays in the caller; only these shared steps live here.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from collections.abc import Collection, Mapping
|
|
17
|
+
|
|
18
|
+
from .constants import TIE_BAR
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def require_convertible(skipped: list[str], what: str) -> None:
|
|
22
|
+
"""Raise ``ValueError`` if any input symbols could not be converted.
|
|
23
|
+
|
|
24
|
+
Used by converters called with ``strict=True``. ``what`` names the
|
|
25
|
+
conversion, e.g. ``"to CMU ARPABET"`` or ``"IPA -> X-SAMPA"``.
|
|
26
|
+
"""
|
|
27
|
+
if skipped:
|
|
28
|
+
unknown = sorted(set(skipped))
|
|
29
|
+
raise ValueError(f"Cannot convert {what}: unknown symbols {unknown}")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def longest_match(
|
|
33
|
+
text: str,
|
|
34
|
+
start: int,
|
|
35
|
+
lookup: Collection[str],
|
|
36
|
+
max_len: int,
|
|
37
|
+
tie_set: Collection[str] | None = None,
|
|
38
|
+
) -> tuple[str | None, int]:
|
|
39
|
+
"""Find the longest ``text[start:]`` prefix (up to ``max_len``) in ``lookup``.
|
|
40
|
+
|
|
41
|
+
Returns ``(matched_substring, length)``, or ``(None, 0)`` if nothing matches.
|
|
42
|
+
The caller maps the substring to a value (``lookup[match]``) when needed.
|
|
43
|
+
|
|
44
|
+
If ``tie_set`` is given, a candidate containing the tie bar also matches when
|
|
45
|
+
every tie-bar-separated part is a non-empty member of ``tie_set`` (handles
|
|
46
|
+
composed phones like ``t͡ʃ`` that are not themselves keys). A lone or dangling
|
|
47
|
+
tie bar -- which produces an empty part -- is therefore not a match, so the
|
|
48
|
+
caller can flag it. ``max_len`` must be wide enough to span such composites,
|
|
49
|
+
so it is a deliberate bound, not the longest key length.
|
|
50
|
+
"""
|
|
51
|
+
for length in range(min(max_len, len(text) - start), 0, -1):
|
|
52
|
+
candidate = text[start : start + length]
|
|
53
|
+
if candidate in lookup:
|
|
54
|
+
return candidate, length
|
|
55
|
+
if tie_set is not None and TIE_BAR in candidate:
|
|
56
|
+
parts = candidate.split(TIE_BAR)
|
|
57
|
+
if all(p in tie_set for p in parts):
|
|
58
|
+
return candidate, length
|
|
59
|
+
return None, 0
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def convert_greedy(
|
|
63
|
+
text: str,
|
|
64
|
+
lookup: Mapping[str, str],
|
|
65
|
+
*,
|
|
66
|
+
max_len: int | None = None,
|
|
67
|
+
strict: bool = False,
|
|
68
|
+
what: str = "",
|
|
69
|
+
) -> list[str]:
|
|
70
|
+
"""Greedy longest-match conversion of ``text`` through a string->string map.
|
|
71
|
+
|
|
72
|
+
Walks left to right, replacing the longest matching key with its value;
|
|
73
|
+
unmatched characters are skipped. With ``strict=True`` the skipped symbols
|
|
74
|
+
raise ``ValueError`` via ``require_convertible`` (``what`` names the
|
|
75
|
+
direction). ``max_len`` defaults to the longest key length.
|
|
76
|
+
"""
|
|
77
|
+
if not lookup:
|
|
78
|
+
return []
|
|
79
|
+
if max_len is None:
|
|
80
|
+
max_len = max(len(k) for k in lookup)
|
|
81
|
+
out: list[str] = []
|
|
82
|
+
skipped: list[str] = []
|
|
83
|
+
i = 0
|
|
84
|
+
n = len(text)
|
|
85
|
+
while i < n:
|
|
86
|
+
key, length = longest_match(text, i, lookup, max_len)
|
|
87
|
+
if key is not None:
|
|
88
|
+
out.append(lookup[key])
|
|
89
|
+
i += length
|
|
90
|
+
else:
|
|
91
|
+
skipped.append(text[i])
|
|
92
|
+
i += 1
|
|
93
|
+
if strict:
|
|
94
|
+
require_convertible(skipped, what)
|
|
95
|
+
return out
|