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/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)