euskaphone 0.1.0a2__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.
euskaphone/__init__.py ADDED
@@ -0,0 +1,122 @@
1
+ """euskaphone — dialect-aware Basque phonemization on the orthography2ipa lattice.
2
+
3
+ The phonemizer drives the shared orthography2ipa candidate lattice: a dialect is
4
+ an orthography2ipa Basque lect spec, and the lattice — the spec's grapheme
5
+ table, ``allophones`` and cross-word sandhi — produces the dialect's phonology
6
+ directly. euskaphone contributes the stages orthography2ipa leaves to the
7
+ caller: vigesimal (base-20) number verbalization, Spanish/French code-switch
8
+ handling with nativization onto the Basque inventory, and a proper-name/loan
9
+ lexicon hook. Basque orthography is near-phonemic, so — by design — there is no
10
+ homograph subsystem. See :mod:`euskaphone.lattice_core`.
11
+ """
12
+ from typing import Optional
13
+
14
+ from euskaphone.version import __version__
15
+ from euskaphone.lattice_core import phonemize as _phonemize, register_lexicon
16
+ from euskaphone.lexicons import (
17
+ apply_builtins, register_hitz_overlay, register_toponyms,
18
+ )
19
+ from euskaphone.pitch_accent import (
20
+ AccentClass, PitchAccentLexicon, annotate_sentence as _annotate_pitch,
21
+ default_lexicon,
22
+ )
23
+ from euskaphone.registry import (
24
+ DEFAULT_DIALECT, dialect_aliases, is_supported as _is_supported,
25
+ list_dialects, resolve_lect,
26
+ )
27
+
28
+ #: The lects whose lexical pitch accent euskaphone can annotate. The shipped
29
+ #: cited lexicon documents the Northern Bizkaian sub-area of Biscayan.
30
+ _PITCH_ACCENT_LECTS = ("eu-x-bizkaiera",)
31
+
32
+
33
+ class EuskaPhonemizer:
34
+ """Dialect-aware Basque phonemization on the orthography2ipa lattice.
35
+
36
+ The eight Basque lects orthography2ipa ships are reachable by their BCP-47
37
+ codes ("eu", "eu-x-bizkaiera", "eu-x-zuberera", …) or by a human-readable
38
+ alias ("batua", "biscayan", "souletin"); the full list comes from
39
+ :func:`euskaphone.list_dialects`.
40
+
41
+ Built-in lexicons (:mod:`euskaphone.lexicons`) are wired at construction:
42
+ the Euskaltzaindia toponym seed is registered by default (``toponyms=True``,
43
+ opt-out) unless a caller already registered a lexicon for ``eu``; the HiTZ
44
+ proper-noun overlay is opt-in (``lexicon="hitz"``) and carries a benchmark
45
+ circularity caveat.
46
+ """
47
+
48
+ def __init__(self, toponyms: bool = True,
49
+ lexicon: Optional[str] = None) -> None:
50
+ apply_builtins(toponyms=toponyms, lexicon=lexicon)
51
+
52
+ def phonemize_sentence(self, sentence: str, dialect: str = DEFAULT_DIALECT,
53
+ contact: str = "auto",
54
+ pitch_accent: bool = False) -> str:
55
+ """Phonemize ``sentence`` for the ``dialect`` lect.
56
+
57
+ Parameters:
58
+ sentence: Input text to phonemize.
59
+ dialect: A lect code or human-readable alias (see
60
+ :func:`euskaphone.list_dialects` /
61
+ :func:`euskaphone.dialect_aliases`). Defaults to Standard Batua.
62
+ contact: Embedded-language policy — ``"auto"`` (detect and classify
63
+ each contact word per-word among es/fr/en, unclassified words
64
+ falling to the dialect's side), ``"es"``, ``"fr"``, ``"en"`` or
65
+ ``"none"`` (no code-switching).
66
+ pitch_accent: When ``True``, annotate lexical Northern Bizkaian pitch
67
+ accent (:mod:`euskaphone.pitch_accent`) — a mark on the accented
68
+ syllable of each lexically accented word, nothing on unaccented
69
+ or unknown words. Opt-in and only defined for Biscayan
70
+ (``eu-x-bizkaiera`` / ``biscayan``); a ``ValueError`` is raised
71
+ for any other lect. Annotation is word-level, so cross-word
72
+ sandhi is not applied in this mode.
73
+
74
+ Returns:
75
+ Space-separated IPA for each word.
76
+ """
77
+ if pitch_accent:
78
+ lect = resolve_lect(dialect)
79
+ if lect not in _PITCH_ACCENT_LECTS:
80
+ raise ValueError(
81
+ f"pitch_accent is only defined for Biscayan "
82
+ f"({', '.join(_PITCH_ACCENT_LECTS)}); got {dialect!r} "
83
+ f"({lect}). Coverage is scoped to the Northern Bizkaian "
84
+ f"sub-area Hualde documents.")
85
+ return _annotate_pitch(
86
+ sentence,
87
+ lambda tok: _phonemize(tok, dialect, contact))
88
+ return _phonemize(sentence, dialect, contact)
89
+
90
+ @staticmethod
91
+ def is_supported(dialect: str) -> bool:
92
+ """Whether ``dialect`` names a known Basque lect."""
93
+ return _is_supported(dialect)
94
+
95
+
96
+ __all__ = [
97
+ "EuskaPhonemizer",
98
+ "register_lexicon",
99
+ "register_toponyms",
100
+ "register_hitz_overlay",
101
+ "list_dialects",
102
+ "dialect_aliases",
103
+ "resolve_lect",
104
+ "AccentClass",
105
+ "PitchAccentLexicon",
106
+ "default_lexicon",
107
+ "__version__",
108
+ ]
109
+
110
+
111
+ if __name__ == "__main__":
112
+ ph = EuskaPhonemizer()
113
+ sentences = [
114
+ "Gaur 1936ko gerra ikasi dugu.",
115
+ "Hogeita hamaika katu zuri ikusi ditut.",
116
+ "Bilbon 20:00etan hasiko da kontzertua.",
117
+ ]
118
+ for s in sentences:
119
+ print(s)
120
+ for code in ["eu", "eu-x-bizkaiera", "eu-x-zuberera"]:
121
+ print(f"{code} → {ph.phonemize_sentence(s, code)}")
122
+ print("######")
@@ -0,0 +1,87 @@
1
+ """Abbreviation (laburdura) expansion — the pre-lattice normalizer stage.
2
+
3
+ Written Basque is full of period-marked abbreviations that a grapheme-to-phoneme
4
+ lattice would mis-read letter by letter (``etab.`` is not "e-t-a-b", it is
5
+ ``eta abar``). This stage expands a curated, **cited** list of abbreviations to
6
+ their full spoken form *before* the lattice — and before the Roman-numeral stage
7
+ — so the trailing periods that mark an abbreviation are consumed here and never
8
+ confused with the Roman-numeral ordinal period (``XX.``, see
9
+ :mod:`euskaphone.romans`).
10
+
11
+ The list itself is data, not code: it lives in ``data/abbreviations.tsv`` and is
12
+ extended by adding a cited row, never by editing this module.
13
+
14
+ Sources
15
+ -------
16
+ * **Euskaltzaindia**, **Araua 196**, EBO (I) *"Laburtzapenak: laburdurak eta
17
+ siglak"* — the Academy's norm fixing the standard abbreviation forms and the
18
+ rule that a laburdura is written with a period and read as the full word.
19
+ * **EIMA / Basque Government**, J. R. Zubimendi, *Ortotipografia* (Eusko
20
+ Jaurlaritza, 2004), "Laburdurak" — spells the same list plus the ``K.a.`` /
21
+ ``K.o.`` era abbreviations.
22
+ (``papers/iberian/eima_ortotipografia_zubimendi.pdf``)
23
+ * **Euskalterm**, *Laburtzapenen Hiztegia* (Gasteiz, 2010) — the exhaustive
24
+ reference dictionary of Basque abbreviations.
25
+ (``papers/iberian/euskalterm_laburtzapenen_hiztegia_2010.pdf``)
26
+ """
27
+ from __future__ import annotations
28
+
29
+ import os
30
+ from typing import Dict
31
+
32
+ _DATA = os.path.join(os.path.dirname(__file__), "data", "abbreviations.tsv")
33
+
34
+
35
+ def _load(path: str = _DATA) -> Dict[str, str]:
36
+ """Load the abbreviation → expansion table (case-folded keys)."""
37
+ table: Dict[str, str] = {}
38
+ with open(path, encoding="utf-8") as fh:
39
+ for line in fh:
40
+ line = line.rstrip("\n")
41
+ if not line or line.startswith("#"):
42
+ continue
43
+ parts = line.split("\t")
44
+ if len(parts) < 2:
45
+ continue
46
+ abbr, expansion = parts[0], parts[1]
47
+ if abbr and expansion:
48
+ table[abbr.lower()] = expansion
49
+ return table
50
+
51
+
52
+ #: abbreviation (as written, with its period) → full spoken expansion.
53
+ ABBREVIATIONS: Dict[str, str] = _load()
54
+
55
+ #: The bare (period-stripped) keys, for matching a token whose trailing period
56
+ #: the tokenizer may have split off (``etab`` as well as ``etab.``).
57
+ _BARE = {k.rstrip("."): v for k, v in ABBREVIATIONS.items()}
58
+
59
+
60
+ def expand_token(token: str) -> str:
61
+ """Expand one *token* if it is a known abbreviation, else return it as-is.
62
+
63
+ Both the written form with its period (``etab.``) and the bare form
64
+ (``etab``) resolve; multi-dot abbreviations (``K.a.``, ``h.d.``) are matched
65
+ whole. Matching is case-insensitive but the lookup is exact on the letters.
66
+ """
67
+ key = token.lower()
68
+ if key in ABBREVIATIONS:
69
+ return ABBREVIATIONS[key]
70
+ bare = key.rstrip(".")
71
+ if bare in _BARE:
72
+ return _BARE[bare]
73
+ return token
74
+
75
+
76
+ def expand_abbreviations(text: str) -> str:
77
+ """Expand every known abbreviation token in *text*.
78
+
79
+ Runs word by word so surrounding punctuation and spacing are preserved for
80
+ tokens that are not abbreviations. Multi-word expansions are inserted inline
81
+ (``etab.`` → ``eta abar``) and flow on to the pronunciation lattice as
82
+ ordinary Basque words.
83
+ """
84
+ out = []
85
+ for word in text.split():
86
+ out.append(expand_token(word))
87
+ return " ".join(out)
euskaphone/accent.py ADDED
@@ -0,0 +1,181 @@
1
+ """Accent forcing: re-transcribe or *respell* Batua text toward a target lect.
2
+
3
+ Two modes, one entry point :func:`force_accent`:
4
+
5
+ * ``mode="ipa"`` — the direct view. The text is transcribed straight through the
6
+ target lect's lattice, so the return is the target dialect's own IPA (the
7
+ "IPA delta" against Batua).
8
+
9
+ * ``mode="respell"`` — the verification-gated respeller, ported from tugaphone's
10
+ accent-forcing architecture. It rewrites the *Batua orthography* using Basque
11
+ spelling conventions that push a **Batua** reader toward the target
12
+ pronunciation (``z``→``s`` for the peninsular seseo merger, ``il``→``ill`` for
13
+ palatalisation, ``h``-insertion for continental aspiration, …). Every edit is
14
+ **verification-gated**: an edit is kept only if re-transcribing the respelled
15
+ text *through the base (Batua) lattice* moves the result measurably closer to
16
+ the target lect's IPA. An edit the base lattice cannot cash out — Batua has a
17
+ silent ``h`` and no ``ü``, so continental aspiration and Souletin front
18
+ rounding are simply not recoverable through it — is rejected, and the honest
19
+ consequence is a near-zero respell gain for exactly those features.
20
+
21
+ The gate is what keeps respell honest: it never invents an orthography whose
22
+ Batua reading does not actually approximate the target. :func:`respell_report`
23
+ exposes the per-edit accounting so a benchmark can publish the real ceiling.
24
+ """
25
+ from __future__ import annotations
26
+
27
+ import re
28
+ from typing import Callable, List, NamedTuple, Optional
29
+
30
+ from euskaphone.lattice_core import engine
31
+ from euskaphone.registry import resolve_lect
32
+
33
+ #: The base reading lect: respelled orthography is scored by *this* lattice.
34
+ BASE_LECT = "eu"
35
+
36
+
37
+ def _transcribe(text: str, lect: str) -> str:
38
+ """Pure-lattice transcription (no code-switch, no number stage)."""
39
+ return engine(lect).transcribe(text)
40
+
41
+
42
+ def _levenshtein(a: str, b: str) -> int:
43
+ d = list(range(len(b) + 1))
44
+ for i in range(1, len(a) + 1):
45
+ prev, d[0] = d[0], i
46
+ for j in range(1, len(b) + 1):
47
+ cur = d[j]
48
+ d[j] = min(d[j] + 1, d[j - 1] + 1, prev + (a[i - 1] != b[j - 1]))
49
+ prev = cur
50
+ return d[len(b)]
51
+
52
+
53
+ def _per(ref: str, hyp: str) -> float:
54
+ r, h = ref.replace(" ", ""), hyp.replace(" ", "")
55
+ return _levenshtein(r, h) / max(1, len(r))
56
+
57
+
58
+ class RespellRule(NamedTuple):
59
+ """One candidate orthographic edit, tried against the verification gate."""
60
+
61
+ name: str
62
+ #: What phonological delta the edit chases (documentation only).
63
+ rationale: str
64
+ #: Rewrites Batua orthography toward the target spelling convention.
65
+ apply: Callable[[str], str]
66
+
67
+
68
+ def _sub(pattern: str, repl: str) -> Callable[[str], str]:
69
+ rx = re.compile(pattern)
70
+ return lambda s: rx.sub(repl, s)
71
+
72
+
73
+ #: The respell rule set. Each rule is a *proposal*; the verification gate decides
74
+ #: per text and per target whether it survives. Rules whose delta the Batua
75
+ #: lattice cannot represent (``h``-insertion, front rounding) are proposed too —
76
+ #: they are simply gated out, which is how the honest ceiling is measured.
77
+ RESPELL_RULES: List[RespellRule] = [
78
+ RespellRule(
79
+ "seseo_z_to_s",
80
+ "peninsular seseo: laminal /s̻/ (⟨z⟩) merges into apical /s̺/ (⟨s⟩)",
81
+ _sub(r"z", "s"),
82
+ ),
83
+ RespellRule(
84
+ "palatal_il",
85
+ "expressive/contextual palatalisation ⟨il⟩ → /ʎ/ (spelled ⟨ill⟩)",
86
+ _sub(r"il", "ill"),
87
+ ),
88
+ RespellRule(
89
+ "palatal_in",
90
+ "expressive/contextual palatalisation ⟨in⟩ → /ɲ/ (spelled ⟨iñ⟩)",
91
+ _sub(r"in", "iñ"),
92
+ ),
93
+ RespellRule(
94
+ "aspiration_h",
95
+ "continental aspiration: prothetic ⟨h⟩ on vowel-initial words",
96
+ _sub(r"(?<![^\W\d_])(?=[aeiouAEIOU])", "h"),
97
+ ),
98
+ RespellRule(
99
+ "front_rounding_u_ue",
100
+ "Souletin front rounding: ⟨u⟩ → ⟨ü⟩ [y]",
101
+ _sub(r"u", "ü"),
102
+ ),
103
+ ]
104
+
105
+
106
+ class RespellResult(NamedTuple):
107
+ """The outcome of a respell pass over one text against one target."""
108
+
109
+ source: str
110
+ respelled: str
111
+ target_ipa: str
112
+ base_ipa: str
113
+ respelled_ipa: str
114
+ #: PER of the base reading before respelling; the starting distance.
115
+ per_before: float
116
+ #: PER after keeping every surviving edit; ``per_before - per_after`` is gain.
117
+ per_after: float
118
+ #: Names of the rules the verification gate accepted, in order.
119
+ accepted: List[str]
120
+
121
+ @property
122
+ def gain(self) -> float:
123
+ """PER reduction the respelling bought (>= 0)."""
124
+ return self.per_before - self.per_after
125
+
126
+
127
+ def respell_report(text: str, dialect: str,
128
+ rules: Optional[List[RespellRule]] = None,
129
+ eps: float = 1e-9) -> RespellResult:
130
+ """Greedy, verification-gated respell of ``text`` toward ``dialect``.
131
+
132
+ For each rule in turn, the candidate respelling is scored by transcribing it
133
+ through the **base** (Batua) lattice and measuring PER against the target
134
+ lect's own transcription of the original text. The edit is kept only if it
135
+ strictly lowers that PER; otherwise it is discarded. The result carries the
136
+ full accounting for benchmarking.
137
+ """
138
+ rules = RESPELL_RULES if rules is None else rules
139
+ target = resolve_lect(dialect)
140
+ target_ipa = _transcribe(text, target)
141
+ base_ipa = _transcribe(text, BASE_LECT)
142
+
143
+ current = text
144
+ current_per = _per(target_ipa, base_ipa)
145
+ per_before = current_per
146
+ accepted: List[str] = []
147
+
148
+ for rule in rules:
149
+ candidate = rule.apply(current)
150
+ if candidate == current:
151
+ continue
152
+ cand_ipa = _transcribe(candidate, BASE_LECT)
153
+ cand_per = _per(target_ipa, cand_ipa)
154
+ if cand_per < current_per - eps:
155
+ current, current_per = candidate, cand_per
156
+ accepted.append(rule.name)
157
+
158
+ return RespellResult(
159
+ source=text,
160
+ respelled=current,
161
+ target_ipa=target_ipa,
162
+ base_ipa=base_ipa,
163
+ respelled_ipa=_transcribe(current, BASE_LECT),
164
+ per_before=per_before,
165
+ per_after=current_per,
166
+ accepted=accepted,
167
+ )
168
+
169
+
170
+ def force_accent(text: str, dialect: str, mode: str = "ipa") -> str:
171
+ """Force ``text`` toward ``dialect``.
172
+
173
+ ``mode="ipa"`` returns the target lect's IPA directly. ``mode="respell"``
174
+ returns Batua orthography rewritten (verification-gated) so a Batua reader
175
+ approximates the target pronunciation.
176
+ """
177
+ if mode == "ipa":
178
+ return _transcribe(text, resolve_lect(dialect))
179
+ if mode == "respell":
180
+ return respell_report(text, dialect).respelled
181
+ raise ValueError(f"unknown mode {mode!r}; expected 'ipa' or 'respell'")