scriptconv 0.0.1__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.
- scriptconv/__init__.py +95 -0
- scriptconv/__main__.py +109 -0
- scriptconv/notation.py +950 -0
- scriptconv/py.typed +0 -0
- scriptconv/scripts.py +818 -0
- scriptconv/translit.py +154 -0
- scriptconv/version.py +10 -0
- scriptconv-0.0.1.dist-info/METADATA +210 -0
- scriptconv-0.0.1.dist-info/RECORD +12 -0
- scriptconv-0.0.1.dist-info/WHEEL +5 -0
- scriptconv-0.0.1.dist-info/licenses/LICENSE +202 -0
- scriptconv-0.0.1.dist-info/top_level.txt +1 -0
scriptconv/notation.py
ADDED
|
@@ -0,0 +1,950 @@
|
|
|
1
|
+
"""Phoneme-notation transcoding — IPA ↔ ARPABET, IPA ↔ X-SAMPA,
|
|
2
|
+
IPA ↔ Lexique, IPA ↔ Kirshenbaum, Buckwalter ↔ Arabic script.
|
|
3
|
+
|
|
4
|
+
All tables are pure Python data; zero runtime dependencies.
|
|
5
|
+
|
|
6
|
+
ARPABET table derived from chorusai/arpa2ipa
|
|
7
|
+
(https://github.com/chorusai/arpa2ipa), licensed Apache-2.0.
|
|
8
|
+
Buckwalter ↔ Arabic table follows Tim Buckwalter's transliteration scheme.
|
|
9
|
+
Lexique phoneme-code table from:
|
|
10
|
+
New, B. & Pallier, C. — Manuel de Lexique 3, v3.11, Tableau 2 (p. 12).
|
|
11
|
+
Lexique383, https://github.com/chrplr/openlexicon, CC BY-SA 4.0.
|
|
12
|
+
Key: N=ɲ (palatal nasal, e.g. agneau), G=ŋ (velar nasal, e.g. camping),
|
|
13
|
+
°=schwa élidable, 3=schwa non-élidable, x=/x/ (Spanish loanword jota).
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import re
|
|
18
|
+
import unicodedata
|
|
19
|
+
from collections.abc import Generator, Iterable
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from enum import Enum
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"Notation",
|
|
25
|
+
"NotationInfo",
|
|
26
|
+
"NOTATION_INFO",
|
|
27
|
+
"convert",
|
|
28
|
+
"can_convert",
|
|
29
|
+
"convert_batch",
|
|
30
|
+
"arpa_to_ipa",
|
|
31
|
+
"ipa_to_arpa",
|
|
32
|
+
"xsampa_to_ipa",
|
|
33
|
+
"ipa_to_xsampa",
|
|
34
|
+
"buckwalter_to_arabic",
|
|
35
|
+
"arabic_to_buckwalter",
|
|
36
|
+
"lexique_to_ipa",
|
|
37
|
+
"ipa_to_lexique",
|
|
38
|
+
"kirshenbaum_to_ipa",
|
|
39
|
+
"ipa_to_kirshenbaum",
|
|
40
|
+
"looks_like_ipa",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Notation(str, Enum):
|
|
45
|
+
"""Supported phoneme notation systems."""
|
|
46
|
+
|
|
47
|
+
IPA = "ipa"
|
|
48
|
+
ARPA = "arpa"
|
|
49
|
+
XSAMPA = "x-sampa"
|
|
50
|
+
BUCKWALTER = "buckwalter"
|
|
51
|
+
ARABIC = "arabic" # Arabic script (target/source for BW)
|
|
52
|
+
LEXIQUE = "lexique" # Lexique one-char-per-phoneme French notation
|
|
53
|
+
KIRSHENBAUM = "kirshenbaum" # ASCII-IPA (espeak-ng native notation)
|
|
54
|
+
|
|
55
|
+
def __repr__(self) -> str:
|
|
56
|
+
return f"Notation.{self.name}"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# ---------------------------------------------------------------------------
|
|
60
|
+
# ARPABET ↔ IPA
|
|
61
|
+
#
|
|
62
|
+
# Table derived from chorusai/arpa2ipa
|
|
63
|
+
# (https://github.com/chorusai/arpa2ipa), licensed Apache-2.0.
|
|
64
|
+
#
|
|
65
|
+
# Stress digits (0/1/2) are stripped on ARPA→IPA and not re-added on
|
|
66
|
+
# IPA→ARPA (lossy in that direction; they have no IPA equivalent here).
|
|
67
|
+
# ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
# Base ARPA→IPA (without stress digit variants; those are added below)
|
|
70
|
+
_ARPA_BASE: dict[str, str] = {
|
|
71
|
+
# Monophthongs
|
|
72
|
+
"AO": "ɔ",
|
|
73
|
+
"AA": "ɑ",
|
|
74
|
+
"IY": "i",
|
|
75
|
+
"UW": "u",
|
|
76
|
+
"EH": "e",
|
|
77
|
+
"IH": "ɪ",
|
|
78
|
+
"UH": "ʊ",
|
|
79
|
+
"AH": "ʌ", # AH0 → ə handled separately
|
|
80
|
+
"AE": "æ",
|
|
81
|
+
"AX": "ə",
|
|
82
|
+
# Diphthongs
|
|
83
|
+
"EY": "eɪ",
|
|
84
|
+
"AY": "aɪ",
|
|
85
|
+
"OW": "oʊ",
|
|
86
|
+
"AW": "aʊ",
|
|
87
|
+
"OY": "ɔɪ",
|
|
88
|
+
# R-colored vowels
|
|
89
|
+
"ER": "ɜr",
|
|
90
|
+
"AXR": "ər",
|
|
91
|
+
# Stops
|
|
92
|
+
"P": "p",
|
|
93
|
+
"B": "b",
|
|
94
|
+
"T": "t",
|
|
95
|
+
"D": "d",
|
|
96
|
+
"K": "k",
|
|
97
|
+
"G": "ɡ", # script g U+0261 — canonical IPA, matches X-SAMPA/Lexique tables
|
|
98
|
+
# Affricates
|
|
99
|
+
"CH": "tʃ",
|
|
100
|
+
"JH": "dʒ",
|
|
101
|
+
# Fricatives
|
|
102
|
+
"F": "f",
|
|
103
|
+
"V": "v",
|
|
104
|
+
"TH": "θ",
|
|
105
|
+
"DH": "ð",
|
|
106
|
+
"S": "s",
|
|
107
|
+
"Z": "z",
|
|
108
|
+
"SH": "ʃ",
|
|
109
|
+
"ZH": "ʒ",
|
|
110
|
+
"HH": "h",
|
|
111
|
+
# Nasals
|
|
112
|
+
"M": "m",
|
|
113
|
+
"EM": "m̩",
|
|
114
|
+
"N": "n",
|
|
115
|
+
"EN": "n̩",
|
|
116
|
+
"NG": "ŋ",
|
|
117
|
+
"ENG": "ŋ̍",
|
|
118
|
+
# Liquids
|
|
119
|
+
"L": "l",
|
|
120
|
+
"EL": "ɫ̩",
|
|
121
|
+
"R": "r",
|
|
122
|
+
"DX": "ɾ",
|
|
123
|
+
"NX": "ɾ̃",
|
|
124
|
+
# Semivowels
|
|
125
|
+
"W": "w",
|
|
126
|
+
"Y": "j",
|
|
127
|
+
"Q": "ʔ",
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
# Build full lookup with stress variants (AH0 → ə special-cased)
|
|
131
|
+
_ARPA_TO_IPA: dict[str, str] = {}
|
|
132
|
+
for _arpa, _ipa in _ARPA_BASE.items():
|
|
133
|
+
_ARPA_TO_IPA[_arpa] = _ipa
|
|
134
|
+
for _stress in ("0", "1", "2"):
|
|
135
|
+
_key = _arpa + _stress
|
|
136
|
+
if _key == "AH0":
|
|
137
|
+
_ARPA_TO_IPA["AH0"] = "ə"
|
|
138
|
+
else:
|
|
139
|
+
_ARPA_TO_IPA[_key] = _ipa
|
|
140
|
+
|
|
141
|
+
# Reverse: IPA → ARPA (base form, no stress digit).
|
|
142
|
+
# Multi-char IPA values are deduplicated; keep first.
|
|
143
|
+
_IPA_TO_ARPA: dict[str, str] = {}
|
|
144
|
+
for _arpa, _ipa in _ARPA_BASE.items():
|
|
145
|
+
if _ipa not in _IPA_TO_ARPA:
|
|
146
|
+
_IPA_TO_ARPA[_ipa] = _arpa
|
|
147
|
+
# Accept ASCII "g" (U+0067) as well as canonical script "ɡ" (U+0261) on input.
|
|
148
|
+
_IPA_TO_ARPA.setdefault("g", "G")
|
|
149
|
+
|
|
150
|
+
# Precompiled regex for IPA → ARPA (longest-first)
|
|
151
|
+
_IPA_ARPA_KEYS_SORTED = sorted(_IPA_TO_ARPA.keys(), key=len, reverse=True)
|
|
152
|
+
_IPA_ARPA_RE = re.compile("|".join(re.escape(s) for s in _IPA_ARPA_KEYS_SORTED))
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def arpa_to_ipa(arpa_sequence: str) -> str:
|
|
156
|
+
"""Convert a space-separated ARPABET sequence to an IPA string.
|
|
157
|
+
|
|
158
|
+
Stress digits are stripped. Unknown tokens are passed through.
|
|
159
|
+
|
|
160
|
+
Examples
|
|
161
|
+
--------
|
|
162
|
+
>>> arpa_to_ipa("HH AH0 L OW1")
|
|
163
|
+
'həloʊ'
|
|
164
|
+
"""
|
|
165
|
+
tokens = arpa_sequence.strip().split()
|
|
166
|
+
result = []
|
|
167
|
+
for tok in tokens:
|
|
168
|
+
ipa = _ARPA_TO_IPA.get(tok)
|
|
169
|
+
if ipa is None:
|
|
170
|
+
# strip trailing digit and retry
|
|
171
|
+
stripped = tok.rstrip("012")
|
|
172
|
+
ipa = _ARPA_TO_IPA.get(stripped, tok)
|
|
173
|
+
result.append(ipa)
|
|
174
|
+
return "".join(result)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def ipa_to_arpa(ipa_string: str, unknown: str = "?") -> str:
|
|
178
|
+
"""Convert an IPA string to a space-separated ARPABET sequence.
|
|
179
|
+
|
|
180
|
+
Matches longest IPA symbol first. Characters outside the ARPABET table
|
|
181
|
+
are replaced by *unknown* (default ``"?"``); pass ``unknown=""`` to drop
|
|
182
|
+
them silently.
|
|
183
|
+
|
|
184
|
+
Examples
|
|
185
|
+
--------
|
|
186
|
+
>>> ipa_to_arpa("həloʊ")
|
|
187
|
+
'HH AX L OW'
|
|
188
|
+
>>> ipa_to_arpa("ɸ")
|
|
189
|
+
'?'
|
|
190
|
+
"""
|
|
191
|
+
tokens = []
|
|
192
|
+
pos = 0
|
|
193
|
+
while pos < len(ipa_string):
|
|
194
|
+
m = _IPA_ARPA_RE.match(ipa_string, pos)
|
|
195
|
+
if m:
|
|
196
|
+
tokens.append(_IPA_TO_ARPA[m.group(0)])
|
|
197
|
+
pos = m.end()
|
|
198
|
+
else:
|
|
199
|
+
ch = ipa_string[pos]
|
|
200
|
+
# Diacritics and suprasegmentals (combining marks, length/stress
|
|
201
|
+
# modifier letters) have no ARPABET equivalent; they qualify the
|
|
202
|
+
# preceding phoneme rather than standing alone, so drop them
|
|
203
|
+
# instead of emitting a spurious *unknown* token.
|
|
204
|
+
if unknown and unicodedata.category(ch) not in ("Mn", "Mc", "Me", "Lm", "Sk"):
|
|
205
|
+
tokens.append(unknown)
|
|
206
|
+
pos += 1
|
|
207
|
+
return " ".join(tokens)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
# ---------------------------------------------------------------------------
|
|
211
|
+
# X-SAMPA ↔ IPA
|
|
212
|
+
#
|
|
213
|
+
# Standard X-SAMPA mapping. Multi-character X-SAMPA symbols are matched
|
|
214
|
+
# longest-first at both conversion directions.
|
|
215
|
+
#
|
|
216
|
+
# References: https://en.wikipedia.org/wiki/X-SAMPA
|
|
217
|
+
# ---------------------------------------------------------------------------
|
|
218
|
+
|
|
219
|
+
_XSAMPA_TO_IPA: dict[str, str] = {
|
|
220
|
+
# Consonants (multi-char first for longest-match ordering)
|
|
221
|
+
"ts`": "ʈ͡ʂ",
|
|
222
|
+
"dz`": "ɖ͡ʐ",
|
|
223
|
+
"tS": "tʃ",
|
|
224
|
+
"dZ": "dʒ",
|
|
225
|
+
"ts": "ts",
|
|
226
|
+
"dz": "dz",
|
|
227
|
+
"p\\": "ɸ",
|
|
228
|
+
"B\\": "ʙ",
|
|
229
|
+
"r\\": "ɹ",
|
|
230
|
+
"r`": "ɽ", # retroflex FLAP (not approximant)
|
|
231
|
+
"R\\": "ʀ",
|
|
232
|
+
"l`": "ɭ",
|
|
233
|
+
"L\\": "ʎ",
|
|
234
|
+
"f\\": "ɸ", # alias for p\
|
|
235
|
+
"v\\": "ʋ",
|
|
236
|
+
"r\\`": "ɻ", # retroflex APPROXIMANT
|
|
237
|
+
"s`": "ʂ",
|
|
238
|
+
"z`": "ʐ",
|
|
239
|
+
"S": "ʃ",
|
|
240
|
+
"Z": "ʒ",
|
|
241
|
+
"C": "ç",
|
|
242
|
+
"j\\": "ʝ",
|
|
243
|
+
"G": "ɣ",
|
|
244
|
+
"X": "χ",
|
|
245
|
+
"R": "ʁ",
|
|
246
|
+
"H\\": "ʜ",
|
|
247
|
+
"?\\": "ʕ",
|
|
248
|
+
"h\\": "ɦ",
|
|
249
|
+
"?": "ʔ",
|
|
250
|
+
"H": "ɥ",
|
|
251
|
+
# Retroflex plosives
|
|
252
|
+
"t`": "ʈ",
|
|
253
|
+
"d`": "ɖ",
|
|
254
|
+
# Palatal plosives
|
|
255
|
+
"c": "c",
|
|
256
|
+
"J\\`": "ɟ",
|
|
257
|
+
# Uvular plosive
|
|
258
|
+
"q": "q",
|
|
259
|
+
# Alveolo-palatal fricatives
|
|
260
|
+
"s\\`": "ɕ",
|
|
261
|
+
"z\\`": "ʑ",
|
|
262
|
+
# Retroflex nasal
|
|
263
|
+
"n\\`": "ɳ",
|
|
264
|
+
# Pharyngeal fricative
|
|
265
|
+
"X\\`": "ħ",
|
|
266
|
+
# Lateral flap
|
|
267
|
+
"l\\`": "ɺ",
|
|
268
|
+
# Nasals
|
|
269
|
+
"F": "ɱ",
|
|
270
|
+
"J": "ɲ",
|
|
271
|
+
"N\\": "ɴ",
|
|
272
|
+
"N": "ŋ",
|
|
273
|
+
# Laterals
|
|
274
|
+
"K": "ɬ",
|
|
275
|
+
"K\\": "ɮ",
|
|
276
|
+
"L": "ʟ",
|
|
277
|
+
# Basic consonants (must come after multi-char)
|
|
278
|
+
"p": "p", "b": "b", "t": "t", "d": "d", "k": "k", "g": "ɡ",
|
|
279
|
+
"m": "m", "n": "n", "f": "f", "v": "v", "s": "s", "z": "z",
|
|
280
|
+
"l": "l", "w": "w", "j": "j", "x": "x", "B": "β", "D": "ð",
|
|
281
|
+
"T": "θ", "V": "ʌ", "G\\": "ɢ",
|
|
282
|
+
# Vowels
|
|
283
|
+
"@\\": "ɘ",
|
|
284
|
+
"@`": "ɚ",
|
|
285
|
+
"@": "ə",
|
|
286
|
+
"{": "æ",
|
|
287
|
+
"}": "ʉ",
|
|
288
|
+
"1": "ɨ",
|
|
289
|
+
"2": "ø",
|
|
290
|
+
"3\\": "ɞ",
|
|
291
|
+
"3": "ɜ",
|
|
292
|
+
"4": "ɾ",
|
|
293
|
+
"5": "ɫ",
|
|
294
|
+
"6": "ɐ",
|
|
295
|
+
"7": "ɤ",
|
|
296
|
+
"8": "ɵ",
|
|
297
|
+
"9": "œ",
|
|
298
|
+
"&": "æ", # alias
|
|
299
|
+
"A": "ɑ",
|
|
300
|
+
"Q": "ɒ",
|
|
301
|
+
"E": "ɛ",
|
|
302
|
+
"I\\": "ᵻ",
|
|
303
|
+
"I": "ɪ",
|
|
304
|
+
"O": "ɔ",
|
|
305
|
+
"M\\": "ɰ",
|
|
306
|
+
"M": "ɯ",
|
|
307
|
+
"U\\": "ᵿ",
|
|
308
|
+
"U": "ʊ",
|
|
309
|
+
"W": "ʍ",
|
|
310
|
+
"Y": "ʏ",
|
|
311
|
+
"a": "a", "e": "e", "i": "i", "o": "o", "u": "u", "y": "y",
|
|
312
|
+
# Suprasegmentals
|
|
313
|
+
'"': "ˈ",
|
|
314
|
+
"%": "ˌ",
|
|
315
|
+
":": "ː",
|
|
316
|
+
"-.": ".", # syllable boundary
|
|
317
|
+
"-\\": "\u203F", # undertie (U+203F)
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
# Reverse: IPA → X-SAMPA (first occurrence wins)
|
|
321
|
+
_IPA_TO_XSAMPA: dict[str, str] = {}
|
|
322
|
+
for _xs, _ip in _XSAMPA_TO_IPA.items():
|
|
323
|
+
if _ip not in _IPA_TO_XSAMPA:
|
|
324
|
+
_IPA_TO_XSAMPA[_ip] = _xs
|
|
325
|
+
|
|
326
|
+
# Longest-first sorted keys for pattern matching
|
|
327
|
+
_XS_KEYS_SORTED = sorted(_XSAMPA_TO_IPA.keys(), key=len, reverse=True)
|
|
328
|
+
_IPA_KEYS_SORTED = sorted(_IPA_TO_XSAMPA.keys(), key=len, reverse=True)
|
|
329
|
+
|
|
330
|
+
# Precompiled regexes
|
|
331
|
+
_XS_RE = re.compile("|".join(re.escape(k) for k in _XS_KEYS_SORTED))
|
|
332
|
+
_IPA_XS_RE = re.compile("|".join(re.escape(k) for k in _IPA_KEYS_SORTED))
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def xsampa_to_ipa(xsampa: str) -> str:
|
|
336
|
+
"""Convert an X-SAMPA string to IPA.
|
|
337
|
+
|
|
338
|
+
Multi-character symbols are matched longest-first. Unknown characters
|
|
339
|
+
are passed through.
|
|
340
|
+
|
|
341
|
+
Examples
|
|
342
|
+
--------
|
|
343
|
+
>>> xsampa_to_ipa("S")
|
|
344
|
+
'ʃ'
|
|
345
|
+
>>> xsampa_to_ipa("@")
|
|
346
|
+
'ə'
|
|
347
|
+
"""
|
|
348
|
+
result = []
|
|
349
|
+
pos = 0
|
|
350
|
+
while pos < len(xsampa):
|
|
351
|
+
m = _XS_RE.match(xsampa, pos)
|
|
352
|
+
if m:
|
|
353
|
+
result.append(_XSAMPA_TO_IPA[m.group(0)])
|
|
354
|
+
pos = m.end()
|
|
355
|
+
else:
|
|
356
|
+
result.append(xsampa[pos])
|
|
357
|
+
pos += 1
|
|
358
|
+
return "".join(result)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def ipa_to_xsampa(ipa: str) -> str:
|
|
362
|
+
"""Convert an IPA string to X-SAMPA.
|
|
363
|
+
|
|
364
|
+
Multi-character IPA symbols are matched longest-first. Unknown
|
|
365
|
+
characters are passed through.
|
|
366
|
+
|
|
367
|
+
Examples
|
|
368
|
+
--------
|
|
369
|
+
>>> ipa_to_xsampa("ʃ")
|
|
370
|
+
'S'
|
|
371
|
+
>>> ipa_to_xsampa("ə")
|
|
372
|
+
'@'
|
|
373
|
+
"""
|
|
374
|
+
result = []
|
|
375
|
+
pos = 0
|
|
376
|
+
while pos < len(ipa):
|
|
377
|
+
m = _IPA_XS_RE.match(ipa, pos)
|
|
378
|
+
if m:
|
|
379
|
+
result.append(_IPA_TO_XSAMPA[m.group(0)])
|
|
380
|
+
pos = m.end()
|
|
381
|
+
else:
|
|
382
|
+
result.append(ipa[pos])
|
|
383
|
+
pos += 1
|
|
384
|
+
return "".join(result)
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
# ---------------------------------------------------------------------------
|
|
388
|
+
# Buckwalter ↔ Arabic script
|
|
389
|
+
#
|
|
390
|
+
# A direct Buckwalter ↔ Arabic-script table (independent of IPA) following
|
|
391
|
+
# Tim Buckwalter's published Arabic transliteration scheme (a 1:1 factual
|
|
392
|
+
# mapping between Arabic letters and ASCII characters).
|
|
393
|
+
# ---------------------------------------------------------------------------
|
|
394
|
+
|
|
395
|
+
# Standard Buckwalter → Arabic Unicode
|
|
396
|
+
_BW_TO_ARABIC: dict[str, str] = {
|
|
397
|
+
"'": "ء", # ء hamza
|
|
398
|
+
"|": "آ", # آ alef madda
|
|
399
|
+
">": "أ", # أ alef hamza above
|
|
400
|
+
"&": "ؤ", # ؤ waw hamza
|
|
401
|
+
"<": "إ", # إ alef hamza below
|
|
402
|
+
"}": "ئ", # ئ ya hamza
|
|
403
|
+
"A": "ا", # ا alef
|
|
404
|
+
"b": "ب", # ب ba
|
|
405
|
+
"p": "ة", # ة ta marbuta
|
|
406
|
+
"t": "ت", # ت ta
|
|
407
|
+
"v": "ث", # ث tha
|
|
408
|
+
"j": "ج", # ج jim
|
|
409
|
+
"H": "ح", # ح ha
|
|
410
|
+
"x": "خ", # خ kha
|
|
411
|
+
"d": "د", # د dal
|
|
412
|
+
"*": "ذ", # ذ dhal
|
|
413
|
+
"r": "ر", # ر ra
|
|
414
|
+
"z": "ز", # ز zayn
|
|
415
|
+
"s": "س", # س sin
|
|
416
|
+
"$": "ش", # ش shin
|
|
417
|
+
"S": "ص", # ص sad
|
|
418
|
+
"D": "ض", # ض dad
|
|
419
|
+
"T": "ط", # ط ta
|
|
420
|
+
"Z": "ظ", # ظ dha
|
|
421
|
+
"E": "ع", # ع ain
|
|
422
|
+
"g": "غ", # غ ghayn
|
|
423
|
+
"f": "ف", # ف fa
|
|
424
|
+
"q": "ق", # ق qaf
|
|
425
|
+
"k": "ك", # ك kaf
|
|
426
|
+
"l": "ل", # ل lam
|
|
427
|
+
"m": "م", # م mim
|
|
428
|
+
"n": "ن", # ن nun
|
|
429
|
+
"h": "ه", # ه ha
|
|
430
|
+
"w": "و", # و waw
|
|
431
|
+
"Y": "ى", # ى alef maqsura
|
|
432
|
+
"y": "ي", # ي ya
|
|
433
|
+
# Short vowels (diacritics)
|
|
434
|
+
"a": "َ", # fatha
|
|
435
|
+
"u": "ُ", # damma
|
|
436
|
+
"i": "ِ", # kasra
|
|
437
|
+
"~": "ّ", # shadda
|
|
438
|
+
"o": "ْ", # sukun
|
|
439
|
+
"F": "ً", # tanwin fath
|
|
440
|
+
"N": "ٌ", # tanwin damm
|
|
441
|
+
"K": "ٍ", # tanwin kasr
|
|
442
|
+
# Special
|
|
443
|
+
"_": "ـ", # tatweel
|
|
444
|
+
"^": "ّ", # shadda alias
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
_ARABIC_TO_BW: dict[str, str] = {v: k for k, v in _BW_TO_ARABIC.items()}
|
|
448
|
+
# Pre-composed lam-alef ligatures (single codepoints) → Buckwalter
|
|
449
|
+
_ARABIC_TO_BW["\uFEFB"] = "lA" # لا lam + alef ligature
|
|
450
|
+
_ARABIC_TO_BW["\uFEF9"] = "l<" # لإ lam + alef hamza below
|
|
451
|
+
_ARABIC_TO_BW["\uFEF7"] = "l>" # لأ lam + alef hamza above
|
|
452
|
+
_ARABIC_TO_BW["\uFEF8"] = "l|" # لآ lam + alef madda
|
|
453
|
+
# The reverse comprehension lets the "^" shadda alias win over canonical "~";
|
|
454
|
+
# restore the standard Buckwalter shadda so round-trips stay faithful.
|
|
455
|
+
_ARABIC_TO_BW["\u0651"] = "~" # shadda -> canonical "~", not the "^" alias
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def buckwalter_to_arabic(bw: str) -> str:
|
|
459
|
+
"""Convert a Buckwalter-encoded string to Arabic Unicode.
|
|
460
|
+
|
|
461
|
+
Unknown characters are passed through unchanged.
|
|
462
|
+
|
|
463
|
+
Examples
|
|
464
|
+
--------
|
|
465
|
+
>>> buckwalter_to_arabic("mrHbA")
|
|
466
|
+
'مرحبا'
|
|
467
|
+
"""
|
|
468
|
+
return "".join(_BW_TO_ARABIC.get(ch, ch) for ch in bw)
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def arabic_to_buckwalter(arabic: str) -> str:
|
|
472
|
+
"""Convert an Arabic Unicode string to Buckwalter transliteration.
|
|
473
|
+
|
|
474
|
+
Unknown characters are passed through unchanged.
|
|
475
|
+
|
|
476
|
+
Examples
|
|
477
|
+
--------
|
|
478
|
+
>>> arabic_to_buckwalter("مرحبا")
|
|
479
|
+
'mrHbA'
|
|
480
|
+
"""
|
|
481
|
+
return "".join(_ARABIC_TO_BW.get(ch, ch) for ch in arabic)
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
# ---------------------------------------------------------------------------
|
|
485
|
+
# Lexique ↔ IPA
|
|
486
|
+
#
|
|
487
|
+
# Lexique uses a one-character-per-phoneme code for French.
|
|
488
|
+
# Source: New, B. & Pallier, C. — Manuel de Lexique 3 v3.11, Tableau 2
|
|
489
|
+
# (https://github.com/chrplr/openlexicon), CC BY-SA 4.0.
|
|
490
|
+
#
|
|
491
|
+
# Critical disambiguation verified against the official table (p. 12):
|
|
492
|
+
# N → ɲ (palatal nasal; examples: agneau, vigne)
|
|
493
|
+
# G → ŋ (velar nasal, English loanwords; example: camping)
|
|
494
|
+
# ° → ə (schwa élidable)
|
|
495
|
+
# 3 → ə (schwa non-élidable; distinct in input but same IPA target)
|
|
496
|
+
# x → x (velar fricative, Spanish loanwords; example: jota)
|
|
497
|
+
# ---------------------------------------------------------------------------
|
|
498
|
+
|
|
499
|
+
_LEXIQUE_TO_IPA: dict[str, str] = {
|
|
500
|
+
# Vowels
|
|
501
|
+
"a": "a", # bat, plat
|
|
502
|
+
"i": "i", # lit, émis
|
|
503
|
+
"y": "y", # lu
|
|
504
|
+
"u": "u", # roue
|
|
505
|
+
"o": "o", # peau, mot (o fermé)
|
|
506
|
+
"O": "ɔ", # éloge, fort (o ouvert)
|
|
507
|
+
"e": "e", # été (e fermé)
|
|
508
|
+
"E": "ɛ", # paire, treize (e ouvert)
|
|
509
|
+
"°": "ə", # schwa élidable (abordera)
|
|
510
|
+
"2": "ø", # deux (eu fermé)
|
|
511
|
+
"9": "œ", # œuf, peur (eu ouvert)
|
|
512
|
+
"5": "ɛ̃", # cinq, linge (voyelle nasale in)
|
|
513
|
+
"1": "œ̃", # un, parfum (voyelle nasale un)
|
|
514
|
+
"@": "ɑ̃", # ange (voyelle nasale an)
|
|
515
|
+
"§": "ɔ̃", # on, savon (voyelle nasale on)
|
|
516
|
+
"3": "ə", # schwa non-élidable (parvenu)
|
|
517
|
+
# Semi-vowels (glides)
|
|
518
|
+
"j": "j", # yeux, paille
|
|
519
|
+
"8": "ɥ", # huit, lui
|
|
520
|
+
"w": "w", # oui, nouer
|
|
521
|
+
# Consonants
|
|
522
|
+
"p": "p", # père, soupe
|
|
523
|
+
"b": "b", # bon, robe
|
|
524
|
+
"t": "t", # terre, vite
|
|
525
|
+
"d": "d", # dans, aide
|
|
526
|
+
"k": "k", # carré, laque
|
|
527
|
+
"g": "ɡ", # gare, bague
|
|
528
|
+
"f": "f", # feu, neuf
|
|
529
|
+
"v": "v", # vous, rêve
|
|
530
|
+
"s": "s", # sale, dessous
|
|
531
|
+
"z": "z", # zéro, maison
|
|
532
|
+
"S": "ʃ", # chat, tâche
|
|
533
|
+
"Z": "ʒ", # gilet, mijoter
|
|
534
|
+
"m": "m", # main, femme
|
|
535
|
+
"n": "n", # nous, tonne
|
|
536
|
+
"N": "ɲ", # agneau, vigne (consonne nasale palatale)
|
|
537
|
+
"l": "l", # lent, sol
|
|
538
|
+
"R": "ʁ", # rue, venir
|
|
539
|
+
"x": "x", # jota (emprunt espagnol)
|
|
540
|
+
"G": "ŋ", # camping (ng, emprunt anglais)
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
# Reverse: IPA → Lexique (first-occurrence wins for ties like ə → °)
|
|
544
|
+
_IPA_TO_LEXIQUE: dict[str, str] = {}
|
|
545
|
+
for _lx, _ip in _LEXIQUE_TO_IPA.items():
|
|
546
|
+
if _ip not in _IPA_TO_LEXIQUE:
|
|
547
|
+
_IPA_TO_LEXIQUE[_ip] = _lx
|
|
548
|
+
|
|
549
|
+
# Longest-first sorted keys
|
|
550
|
+
_LX_KEYS_SORTED = sorted(_LEXIQUE_TO_IPA.keys(), key=len, reverse=True)
|
|
551
|
+
_IPA_FOR_LX_SORTED = sorted(_IPA_TO_LEXIQUE.keys(), key=len, reverse=True)
|
|
552
|
+
|
|
553
|
+
# Precompiled regex
|
|
554
|
+
_IPA_LX_RE = re.compile("|".join(re.escape(s) for s in _IPA_FOR_LX_SORTED))
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def lexique_to_ipa(lexique: str) -> str:
|
|
558
|
+
"""Convert a Lexique phoneme-code string to IPA.
|
|
559
|
+
|
|
560
|
+
Each Lexique phoneme is exactly one character; they are read left-to-right
|
|
561
|
+
with no separator. Unknown characters are passed through unchanged.
|
|
562
|
+
|
|
563
|
+
Source: New & Pallier, Manuel de Lexique 3 v3.11, Tableau 2 (CC BY-SA 4.0).
|
|
564
|
+
|
|
565
|
+
Examples
|
|
566
|
+
--------
|
|
567
|
+
>>> lexique_to_ipa("b§ZuR")
|
|
568
|
+
'bɔ̃ʒuʁ'
|
|
569
|
+
>>> lexique_to_ipa("v5")
|
|
570
|
+
'vɛ̃'
|
|
571
|
+
"""
|
|
572
|
+
result = []
|
|
573
|
+
for ch in lexique:
|
|
574
|
+
result.append(_LEXIQUE_TO_IPA.get(ch, ch))
|
|
575
|
+
return "".join(result)
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def ipa_to_lexique(ipa: str) -> str:
|
|
579
|
+
"""Convert an IPA string to Lexique phoneme codes.
|
|
580
|
+
|
|
581
|
+
Matches longest IPA symbol first. Unknown characters are passed through
|
|
582
|
+
unchanged. The conversion is French-centric; symbols outside the Lexique
|
|
583
|
+
inventory are not representable.
|
|
584
|
+
|
|
585
|
+
Source: New & Pallier, Manuel de Lexique 3 v3.11, Tableau 2 (CC BY-SA 4.0).
|
|
586
|
+
|
|
587
|
+
Examples
|
|
588
|
+
--------
|
|
589
|
+
>>> ipa_to_lexique("bɔ̃ʒuʁ")
|
|
590
|
+
'b§ZuR'
|
|
591
|
+
>>> ipa_to_lexique("dø")
|
|
592
|
+
'd2'
|
|
593
|
+
"""
|
|
594
|
+
result = []
|
|
595
|
+
pos = 0
|
|
596
|
+
while pos < len(ipa):
|
|
597
|
+
m = _IPA_LX_RE.match(ipa, pos)
|
|
598
|
+
if m:
|
|
599
|
+
result.append(_IPA_TO_LEXIQUE[m.group(0)])
|
|
600
|
+
pos = m.end()
|
|
601
|
+
else:
|
|
602
|
+
result.append(ipa[pos])
|
|
603
|
+
pos += 1
|
|
604
|
+
return "".join(result)
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
# ---------------------------------------------------------------------------
|
|
608
|
+
# Kirshenbaum (ASCII-IPA) ↔ IPA
|
|
609
|
+
#
|
|
610
|
+
# Kirshenbaum is the ASCII phonetic alphabet defined by Kirshenbaum 1993
|
|
611
|
+
# (comp.speech), also used natively by espeak-ng. The single-character map
|
|
612
|
+
# below is the factual ASCII → IPA codepoint correspondence of that standard
|
|
613
|
+
# (index i → the IPA codepoint for ASCII 0x20+i), cross-checked against the
|
|
614
|
+
# espeak-ng ``ipa1[96]`` table.
|
|
615
|
+
# ---------------------------------------------------------------------------
|
|
616
|
+
|
|
617
|
+
# ipa1[96]: IPA codepoint for each printable ASCII char, 0x20..0x7F.
|
|
618
|
+
_KIRSHENBAUM_IPA1 = (
|
|
619
|
+
0x20, 0x21, 0x22, 0x2b0, 0x24, 0x25, 0x0e6, 0x2c8, 0x28, 0x29, 0x27e, 0x2b, 0x2cc, 0x2d, 0x2e, 0x2f,
|
|
620
|
+
0x252, 0x31, 0x32, 0x25c, 0x34, 0x35, 0x36, 0x37, 0x275, 0x39, 0x2d0, 0x2b2, 0x3c, 0x3d, 0x3e, 0x294,
|
|
621
|
+
0x259, 0x251, 0x3b2, 0xe7, 0xf0, 0x25b, 0x46, 0x262, 0x127, 0x26a, 0x25f, 0x4b, 0x26b, 0x271, 0x14b, 0x254,
|
|
622
|
+
0x3a6, 0x263, 0x280, 0x283, 0x3b8, 0x28a, 0x28c, 0x153, 0x3c7, 0xf8, 0x292, 0x32a, 0x5c, 0x5d, 0x5e, 0x5f,
|
|
623
|
+
0x60, 0x61, 0x62, 0x63, 0x64, 0x65, 0x66, 0x261, 0x68, 0x69, 0x6a, 0x6b, 0x6c, 0x6d, 0x6e, 0x6f,
|
|
624
|
+
0x70, 0x71, 0x72, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7a, 0x7b, 0x7c, 0x7d, 0x303, 0x7f,
|
|
625
|
+
)
|
|
626
|
+
# Kirshenbaum symbols that map to an IPA character different from the ASCII
|
|
627
|
+
# input carry real phonetic meaning; identity rows (letter → itself) are kept
|
|
628
|
+
# so plain ASCII survives a round-trip.
|
|
629
|
+
_KIRSHENBAUM_TO_IPA: dict[str, str] = {
|
|
630
|
+
chr(0x20 + _i): chr(_cp) for _i, _cp in enumerate(_KIRSHENBAUM_IPA1)
|
|
631
|
+
}
|
|
632
|
+
_IPA_TO_KIRSHENBAUM: dict[str, str] = {}
|
|
633
|
+
for _k, _ip in _KIRSHENBAUM_TO_IPA.items():
|
|
634
|
+
_IPA_TO_KIRSHENBAUM.setdefault(_ip, _k)
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def kirshenbaum_to_ipa(kirshenbaum: str) -> str:
|
|
638
|
+
"""Convert a Kirshenbaum (ASCII-IPA) string to IPA.
|
|
639
|
+
|
|
640
|
+
Each character is mapped independently. Characters outside the table
|
|
641
|
+
pass through unchanged.
|
|
642
|
+
|
|
643
|
+
Examples
|
|
644
|
+
--------
|
|
645
|
+
>>> kirshenbaum_to_ipa("S")
|
|
646
|
+
'ʃ'
|
|
647
|
+
>>> kirshenbaum_to_ipa("N")
|
|
648
|
+
'ŋ'
|
|
649
|
+
"""
|
|
650
|
+
return "".join(_KIRSHENBAUM_TO_IPA.get(ch, ch) for ch in kirshenbaum)
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
def ipa_to_kirshenbaum(ipa: str) -> str:
|
|
654
|
+
"""Convert an IPA string to Kirshenbaum (ASCII-IPA).
|
|
655
|
+
|
|
656
|
+
Characters outside the table pass through unchanged.
|
|
657
|
+
|
|
658
|
+
Examples
|
|
659
|
+
--------
|
|
660
|
+
>>> ipa_to_kirshenbaum("ʃ")
|
|
661
|
+
'S'
|
|
662
|
+
>>> ipa_to_kirshenbaum("ŋ")
|
|
663
|
+
'N'
|
|
664
|
+
"""
|
|
665
|
+
return "".join(_IPA_TO_KIRSHENBAUM.get(ch, ch) for ch in ipa)
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
# ---------------------------------------------------------------------------
|
|
669
|
+
# convert — facade routing through IPA where no direct map exists
|
|
670
|
+
# ---------------------------------------------------------------------------
|
|
671
|
+
|
|
672
|
+
def convert(text: str, src: str | Notation, dst: str | Notation) -> str:
|
|
673
|
+
"""Convert *text* from *src* notation to *dst* notation.
|
|
674
|
+
|
|
675
|
+
Supported pairs (direct):
|
|
676
|
+
- ``arpa`` ↔ ``ipa``
|
|
677
|
+
- ``x-sampa`` ↔ ``ipa``
|
|
678
|
+
- ``lexique`` ↔ ``ipa``
|
|
679
|
+
- ``buckwalter`` ↔ ``arabic``
|
|
680
|
+
|
|
681
|
+
Indirect pairs route through IPA (e.g. ``arpa`` → ``x-sampa`` goes
|
|
682
|
+
``arpa`` → ``ipa`` → ``x-sampa``).
|
|
683
|
+
|
|
684
|
+
Parameters
|
|
685
|
+
----------
|
|
686
|
+
text:
|
|
687
|
+
Input string.
|
|
688
|
+
src:
|
|
689
|
+
Source notation (``Notation`` member or its string value).
|
|
690
|
+
dst:
|
|
691
|
+
Target notation.
|
|
692
|
+
|
|
693
|
+
Returns
|
|
694
|
+
-------
|
|
695
|
+
str
|
|
696
|
+
Converted string.
|
|
697
|
+
|
|
698
|
+
Raises
|
|
699
|
+
------
|
|
700
|
+
ValueError
|
|
701
|
+
When *src* or *dst* is not a recognised ``Notation`` value, or when
|
|
702
|
+
the requested conversion path is not supported.
|
|
703
|
+
"""
|
|
704
|
+
src = Notation(src)
|
|
705
|
+
dst = Notation(dst)
|
|
706
|
+
|
|
707
|
+
if src == dst:
|
|
708
|
+
return text
|
|
709
|
+
|
|
710
|
+
# IPA-pivot notations: <notation> → IPA → <notation>.
|
|
711
|
+
if src in _TO_IPA and dst == Notation.IPA:
|
|
712
|
+
return _TO_IPA[src](text)
|
|
713
|
+
if src == Notation.IPA and dst in _FROM_IPA:
|
|
714
|
+
return _FROM_IPA[dst](text)
|
|
715
|
+
if src in _TO_IPA and dst in _FROM_IPA:
|
|
716
|
+
return _FROM_IPA[dst](_TO_IPA[src](text))
|
|
717
|
+
|
|
718
|
+
# Buckwalter ↔ Arabic script is a direct pair (not IPA-routable).
|
|
719
|
+
if (src, dst) in _DIRECT_PAIRS:
|
|
720
|
+
return _DIRECT_PAIRS[(src, dst)](text)
|
|
721
|
+
|
|
722
|
+
raise ValueError(f"Unsupported conversion: {src!r} → {dst!r}")
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
# ---------------------------------------------------------------------------
|
|
726
|
+
# Converter registry — the single source of truth for supported paths.
|
|
727
|
+
#
|
|
728
|
+
# _TO_IPA / _FROM_IPA hold the IPA-pivot notations; anything registered in
|
|
729
|
+
# both can convert to anything else in the other (routed through IPA).
|
|
730
|
+
# _DIRECT_PAIRS holds non-IPA pairs (Buckwalter ↔ Arabic script).
|
|
731
|
+
# ---------------------------------------------------------------------------
|
|
732
|
+
|
|
733
|
+
_TO_IPA: dict[Notation, "callable"] = {
|
|
734
|
+
Notation.ARPA: arpa_to_ipa,
|
|
735
|
+
Notation.XSAMPA: xsampa_to_ipa,
|
|
736
|
+
Notation.LEXIQUE: lexique_to_ipa,
|
|
737
|
+
Notation.KIRSHENBAUM: kirshenbaum_to_ipa,
|
|
738
|
+
}
|
|
739
|
+
_FROM_IPA: dict[Notation, "callable"] = {
|
|
740
|
+
Notation.ARPA: ipa_to_arpa,
|
|
741
|
+
Notation.XSAMPA: ipa_to_xsampa,
|
|
742
|
+
Notation.LEXIQUE: ipa_to_lexique,
|
|
743
|
+
Notation.KIRSHENBAUM: ipa_to_kirshenbaum,
|
|
744
|
+
}
|
|
745
|
+
_DIRECT_PAIRS: dict[tuple[Notation, Notation], "callable"] = {
|
|
746
|
+
(Notation.BUCKWALTER, Notation.ARABIC): buckwalter_to_arabic,
|
|
747
|
+
(Notation.ARABIC, Notation.BUCKWALTER): arabic_to_buckwalter,
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
# ---------------------------------------------------------------------------
|
|
752
|
+
# can_convert — predicate for supported conversion paths
|
|
753
|
+
# ---------------------------------------------------------------------------
|
|
754
|
+
|
|
755
|
+
def _build_supported_pairs() -> set[tuple[Notation, Notation]]:
|
|
756
|
+
pairs: set[tuple[Notation, Notation]] = set(_DIRECT_PAIRS)
|
|
757
|
+
for _s in _TO_IPA:
|
|
758
|
+
pairs.add((_s, Notation.IPA))
|
|
759
|
+
for _d in _FROM_IPA:
|
|
760
|
+
pairs.add((Notation.IPA, _d))
|
|
761
|
+
for _s in _TO_IPA:
|
|
762
|
+
for _d in _FROM_IPA:
|
|
763
|
+
if _s != _d:
|
|
764
|
+
pairs.add((_s, _d))
|
|
765
|
+
return pairs
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
_SUPPORTED_PAIRS: set[tuple[Notation, Notation]] = _build_supported_pairs()
|
|
769
|
+
|
|
770
|
+
|
|
771
|
+
def can_convert(src: str | Notation, dst: str | Notation) -> bool:
|
|
772
|
+
"""Return ``True`` if a conversion from *src* to *dst* is supported.
|
|
773
|
+
|
|
774
|
+
Parameters
|
|
775
|
+
----------
|
|
776
|
+
src:
|
|
777
|
+
Source notation.
|
|
778
|
+
dst:
|
|
779
|
+
Target notation.
|
|
780
|
+
|
|
781
|
+
Examples
|
|
782
|
+
--------
|
|
783
|
+
>>> can_convert("arpa", "ipa")
|
|
784
|
+
True
|
|
785
|
+
>>> can_convert("arpa", "x-sampa")
|
|
786
|
+
True
|
|
787
|
+
>>> can_convert("buckwalter", "ipa")
|
|
788
|
+
False
|
|
789
|
+
"""
|
|
790
|
+
src = Notation(src)
|
|
791
|
+
dst = Notation(dst)
|
|
792
|
+
return (src, dst) in _SUPPORTED_PAIRS
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
# ---------------------------------------------------------------------------
|
|
796
|
+
# NotationInfo — queryable fidelity metadata (fidelity is data, not prose)
|
|
797
|
+
# ---------------------------------------------------------------------------
|
|
798
|
+
|
|
799
|
+
@dataclass(frozen=True)
|
|
800
|
+
class NotationInfo:
|
|
801
|
+
"""Fidelity metadata for a phoneme notation, relative to the IPA hub.
|
|
802
|
+
|
|
803
|
+
Attributes
|
|
804
|
+
----------
|
|
805
|
+
notation:
|
|
806
|
+
The :class:`Notation` this describes.
|
|
807
|
+
lossless_to_ipa:
|
|
808
|
+
``True`` if ``<notation> → IPA → <notation>`` reproduces every input
|
|
809
|
+
symbol exactly (round-trip safe from the notation's side).
|
|
810
|
+
lossless_from_ipa:
|
|
811
|
+
``True`` if ``IPA → <notation> → IPA`` reproduces every IPA symbol —
|
|
812
|
+
i.e. the notation's inventory covers IPA. ``False`` for
|
|
813
|
+
restricted-inventory notations such as ARPABET.
|
|
814
|
+
token_separated:
|
|
815
|
+
``True`` when symbols are whitespace-separated tokens (ARPABET) rather
|
|
816
|
+
than a contiguous string (IPA, X-SAMPA, Kirshenbaum, Lexique).
|
|
817
|
+
reference:
|
|
818
|
+
Citation for the mapping table.
|
|
819
|
+
"""
|
|
820
|
+
|
|
821
|
+
notation: Notation
|
|
822
|
+
lossless_to_ipa: bool
|
|
823
|
+
lossless_from_ipa: bool
|
|
824
|
+
token_separated: bool
|
|
825
|
+
reference: str
|
|
826
|
+
|
|
827
|
+
|
|
828
|
+
NOTATION_INFO: dict[Notation, NotationInfo] = {
|
|
829
|
+
Notation.ARPA: NotationInfo(
|
|
830
|
+
Notation.ARPA, lossless_to_ipa=False, lossless_from_ipa=False,
|
|
831
|
+
token_separated=True,
|
|
832
|
+
reference="https://github.com/chorusai/arpa2ipa",
|
|
833
|
+
),
|
|
834
|
+
Notation.XSAMPA: NotationInfo(
|
|
835
|
+
Notation.XSAMPA, lossless_to_ipa=False, lossless_from_ipa=True,
|
|
836
|
+
token_separated=False,
|
|
837
|
+
reference="https://en.wikipedia.org/wiki/X-SAMPA",
|
|
838
|
+
),
|
|
839
|
+
Notation.LEXIQUE: NotationInfo(
|
|
840
|
+
Notation.LEXIQUE, lossless_to_ipa=False, lossless_from_ipa=True,
|
|
841
|
+
token_separated=False,
|
|
842
|
+
reference="New & Pallier, Manuel de Lexique 3 v3.11, Tableau 2 (CC BY-SA 4.0)",
|
|
843
|
+
),
|
|
844
|
+
Notation.KIRSHENBAUM: NotationInfo(
|
|
845
|
+
Notation.KIRSHENBAUM, lossless_to_ipa=True, lossless_from_ipa=False,
|
|
846
|
+
token_separated=False,
|
|
847
|
+
reference="Kirshenbaum 1993 ASCII-IPA standard (comp.speech)",
|
|
848
|
+
),
|
|
849
|
+
Notation.BUCKWALTER: NotationInfo(
|
|
850
|
+
Notation.BUCKWALTER, lossless_to_ipa=True, lossless_from_ipa=False,
|
|
851
|
+
token_separated=False,
|
|
852
|
+
reference="Tim Buckwalter Arabic transliteration scheme",
|
|
853
|
+
),
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
|
|
857
|
+
# ---------------------------------------------------------------------------
|
|
858
|
+
# convert_batch — line-by-line generator
|
|
859
|
+
# ---------------------------------------------------------------------------
|
|
860
|
+
|
|
861
|
+
|
|
862
|
+
def convert_batch(
|
|
863
|
+
lines: Iterable[str],
|
|
864
|
+
src: str | Notation,
|
|
865
|
+
dst: str | Notation,
|
|
866
|
+
) -> Generator[str, None, None]:
|
|
867
|
+
"""Convert each line from *src* to *dst* notation, yielding results.
|
|
868
|
+
|
|
869
|
+
Blank lines are yielded unchanged (no conversion attempted).
|
|
870
|
+
|
|
871
|
+
Parameters
|
|
872
|
+
----------
|
|
873
|
+
lines:
|
|
874
|
+
Iterable of input strings (e.g. file lines).
|
|
875
|
+
src:
|
|
876
|
+
Source notation.
|
|
877
|
+
dst:
|
|
878
|
+
Target notation.
|
|
879
|
+
|
|
880
|
+
Yields
|
|
881
|
+
------
|
|
882
|
+
str
|
|
883
|
+
Converted string for each input line.
|
|
884
|
+
|
|
885
|
+
Examples
|
|
886
|
+
--------
|
|
887
|
+
>>> list(convert_batch(["HH AH0 L OW1", "", "AY1"], "arpa", "ipa"))
|
|
888
|
+
['həloʊ', '', 'aɪ']
|
|
889
|
+
"""
|
|
890
|
+
_src = Notation(src)
|
|
891
|
+
_dst = Notation(dst)
|
|
892
|
+
for line in lines:
|
|
893
|
+
stripped = line.rstrip("\n\r")
|
|
894
|
+
if not stripped:
|
|
895
|
+
yield stripped
|
|
896
|
+
else:
|
|
897
|
+
yield convert(stripped, _src, _dst)
|
|
898
|
+
|
|
899
|
+
|
|
900
|
+
# ---------------------------------------------------------------------------
|
|
901
|
+
# looks_like_ipa — heuristic notation detection
|
|
902
|
+
# ---------------------------------------------------------------------------
|
|
903
|
+
|
|
904
|
+
# Unicode blocks/characters that are distinctive to IPA transcription. Basic
|
|
905
|
+
# Latin letters (p, a, t, …) and the Greek letters IPA borrows (β, θ, χ) are
|
|
906
|
+
# deliberately excluded: they are indistinguishable from ordinary Latin/Greek
|
|
907
|
+
# text, so they carry no signal.
|
|
908
|
+
_IPA_DISTINCTIVE_RANGES = (
|
|
909
|
+
(0x0250, 0x02AF), # IPA Extensions
|
|
910
|
+
(0x02B0, 0x02FF), # Spacing Modifier Letters (ˈ ˌ ː ˑ ʰ ʲ …)
|
|
911
|
+
(0x0363, 0x036F), # Combining diacritics used in IPA
|
|
912
|
+
(0x1D00, 0x1D7F), # Phonetic Extensions
|
|
913
|
+
(0x1D80, 0x1DBF), # Phonetic Extensions Supplement
|
|
914
|
+
)
|
|
915
|
+
# Individual combining marks common in IPA (nasalisation, syllabicity, etc.).
|
|
916
|
+
_IPA_DISTINCTIVE_CHARS = frozenset("̩̪̥̃̊͜͡")
|
|
917
|
+
|
|
918
|
+
|
|
919
|
+
def _is_ipa_distinctive(ch: str) -> bool:
|
|
920
|
+
cp = ord(ch)
|
|
921
|
+
if ch in _IPA_DISTINCTIVE_CHARS:
|
|
922
|
+
return True
|
|
923
|
+
return any(lo <= cp <= hi for lo, hi in _IPA_DISTINCTIVE_RANGES)
|
|
924
|
+
|
|
925
|
+
|
|
926
|
+
def looks_like_ipa(text: str) -> bool:
|
|
927
|
+
"""Heuristically decide whether *text* is an IPA transcription.
|
|
928
|
+
|
|
929
|
+
Returns ``True`` when *text* contains at least one character that is
|
|
930
|
+
distinctive to IPA — from the IPA Extensions, Spacing Modifier Letters,
|
|
931
|
+
or Phonetic Extensions blocks, or an IPA combining diacritic (e.g.
|
|
932
|
+
``ɑ``, ``ʃ``, ``ː``, ``ˈ``, nasalisation).
|
|
933
|
+
|
|
934
|
+
This is a positive-signal heuristic, not a classifier: a transcription
|
|
935
|
+
written only with characters IPA shares with the Latin alphabet
|
|
936
|
+
(``"pat"``, ``"bad"``) has no distinctive marker and returns ``False``.
|
|
937
|
+
It cannot be otherwise — those characters are the same codepoints as
|
|
938
|
+
ordinary text. Use it to *guard* against feeding IPA where orthography
|
|
939
|
+
is expected, not to prove a string is not IPA.
|
|
940
|
+
|
|
941
|
+
Examples
|
|
942
|
+
--------
|
|
943
|
+
>>> looks_like_ipa("pʰɑtʃ")
|
|
944
|
+
True
|
|
945
|
+
>>> looks_like_ipa("ˈhɛloʊ")
|
|
946
|
+
True
|
|
947
|
+
>>> looks_like_ipa("hello")
|
|
948
|
+
False
|
|
949
|
+
"""
|
|
950
|
+
return any(_is_ipa_distinctive(ch) for ch in text)
|