Skip to content

Commit 7f23a24

Browse files
authored
Merge pull request #8 from hellpanderrr/feature/irish-g2p
Feature/irish g2p
2 parents 7070bb5 + 2bfa92a commit 7f23a24

34 files changed

Lines changed: 12970 additions & 2 deletions

wiktionary_pron/e2e/transcription.spec.js

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,31 @@ test.describe("IPA transcriber", () => {
4747
);
4848
});
4949

50+
test("Irish: transcribes all three dialects", async ({ page }) => {
51+
await page.goto(APP);
52+
await selectLanguage(page, "Irish");
53+
// Connacht (default style, default form)
54+
await transcribe(page, "caisleán");
55+
await expect(page.locator("#result .ipa").first()).toContainText(
56+
"ʃlʲ",
57+
{ timeout: 30_000 },
58+
);
59+
// Munster — stress moves to the long second syllable
60+
await page.selectOption("#lang_style", { label: "Munster" });
61+
await transcribe(page, "cailín");
62+
await expect(page.locator("#result .ipa").first()).toContainText(
63+
"lʲiːnʲ",
64+
{ timeout: 30_000 },
65+
);
66+
// Ulster — a fronts to æ before slender consonant
67+
await page.selectOption("#lang_style", { label: "Ulster" });
68+
await transcribe(page, "baile");
69+
await expect(page.locator("#result .ipa").first()).toContainText(
70+
"bˠælʲə",
71+
{ timeout: 30_000 },
72+
);
73+
});
74+
5075
test("German (dict=false): phonemic transcription via Lua rules", async ({
5176
page,
5277
}) => {

wiktionary_pron/help/index.html

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,13 @@ <h3 style="margin-top: 0; margin-bottom: 0.5rem;">Baltic Languages</h3>
6161
</ul>
6262
</div>
6363

64+
<div style="break-inside: avoid;">
65+
<h3 style="margin-top: 0; margin-bottom: 0.5rem;">Celtic Languages</h3>
66+
<ul style="margin-top: 0; break-inside: avoid;">
67+
<li><a href="irish.html">Irish (Gaeilge) IPA Transcription - Help</a></li>
68+
</ul>
69+
</div>
70+
6471
<div style="break-inside: avoid;">
6572
<h3 style="margin-top: 0; margin-bottom: 0.5rem;">Other Languages</h3>
6673
<ul style="margin-top: 0; break-inside: avoid;">

wiktionary_pron/help/irish.html

Lines changed: 1313 additions & 0 deletions
Large diffs are not rendered by default.

wiktionary_pron/index.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
<meta content="Online IPA Latin German French Spanish Portuguese Czech Ancient Greek Polish Armenian Phonetics Transcription"
2222
name="Keywords"/>
2323
<meta content="Free Online Rule-based IPA phonetic/phonemic transcription engine, which uses Wiktionary Lua pronunciation modules.
24-
Latin, German, French, Spanish, Ancient Greek, Polish, Armenian, Czech, Russian, Belorussian, Ukrainian, Bulgarian, Icelandic, Lithuanian, Mongolian languages are fully supported. Portuguese require vowels' stressing for proper functioning."
24+
Latin, German, French, Spanish, Ancient Greek, Polish, Armenian, Czech, Russian, Belorussian, Ukrainian, Bulgarian, Icelandic, Lithuanian, Mongolian, Irish (experimental) languages are fully supported. Portuguese requires vowel stress marking for proper functioning."
2525
name="description">
2626

2727

Lines changed: 277 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,277 @@
1+
-- New Irish G2P engine: token-array pipeline.
2+
-- Replaces the monolith irish_engine.lua + irish_rules.lua.
3+
-- Loads passes from passes/ directory and orchestrates them.
4+
5+
local S = require("ga-passes._shared")
6+
local passes = require("ga-passes.init")
7+
local ustring = require("ustring.ustring")
8+
local ulen = ustring.len
9+
local usub = ustring.sub
10+
local umatch = ustring.match
11+
12+
-- Tokenizer: splits orthographic word into tokens
13+
local function tokenize_word(word)
14+
local tokens = {}
15+
local i = 1
16+
word = S.normalize_ortho(word)
17+
18+
while i <= ulen(word) do
19+
local c1 = usub(word, i, i)
20+
local c2 = i < ulen(word) and usub(word, i + 1, i + 1) or ""
21+
local c3 = i + 2 <= ulen(word) and usub(word, i + 2, i + 2) or ""
22+
local tri = c1 .. c2 .. c3
23+
local digraph = c1 .. c2
24+
25+
if c1 == " " then
26+
table.insert(tokens, S.make_token(" ", "boundary", i, i))
27+
i = i + 1
28+
elseif tri == "d'fh" then
29+
local token = S.make_token(tri, "cons", i, i + 2)
30+
token.is_mutated = true
31+
token.mutation = "eclipsis"
32+
table.insert(tokens, token)
33+
i = i + 3
34+
elseif digraph == "bh" or digraph == "mh" or digraph == "ch" or
35+
digraph == "dh" or digraph == "gh" or digraph == "ph" or
36+
digraph == "sh" or digraph == "th" or digraph == "fh" then
37+
local token = S.make_token(digraph, "cons", i, i + 1)
38+
token.is_mutated = true
39+
token.mutation = "lenition"
40+
table.insert(tokens, token)
41+
i = i + 2
42+
elseif c1 == "'" then
43+
local token = S.make_token(c1, "boundary", i, i)
44+
token.source = "apostrophe"
45+
token.phon = "" -- silence apostrophe in output
46+
table.insert(tokens, token)
47+
i = i + 1
48+
elseif tri == "aoi" or tri == "eoi" then
49+
table.insert(tokens, S.make_token(tri, "vowel", i, i + 2))
50+
i = i + 3
51+
elseif tri == "ngh" then
52+
-- n + gh (lenited g), NOT ng + h; avoids impossible /ŋh/ cluster
53+
local tn = S.make_token("n", "cons", i, i)
54+
table.insert(tokens, tn)
55+
local tgh = S.make_token("gh", "cons", i + 1, i + 2)
56+
tgh.is_mutated = true
57+
tgh.mutation = "lenition"
58+
table.insert(tokens, tgh)
59+
i = i + 3
60+
elseif digraph == "ng" then
61+
table.insert(tokens, S.make_token(digraph, "cons", i, i + 1))
62+
i = i + 2
63+
elseif S.VOWEL_DIGRAPHS[digraph] then
64+
table.insert(tokens, S.make_token(digraph, "vowel", i, i + 1))
65+
i = i + 2
66+
elseif S.is_vowel_char(c1) then
67+
table.insert(tokens, S.make_token(c1, "vowel", i, i))
68+
i = i + 1
69+
elseif S.is_consonant_char(c1) then
70+
table.insert(tokens, S.make_token(c1, "cons", i, i))
71+
i = i + 1
72+
else
73+
table.insert(tokens, S.make_token(c1, "unknown", i, i))
74+
i = i + 1
75+
end
76+
end
77+
78+
return tokens
79+
end
80+
81+
-- Render output: place stress mark before the syllable onset
82+
-- IPA convention: ˈCV, not CˈV
83+
local function render_output(tokens)
84+
-- Pre-process: move stress from vowel to preceding onset consonant(s).
85+
-- Handles both primary (`stress`) and secondary (`secondary`) stress.
86+
-- The onset walk skips tokens with empty phon that are NOT boundaries
87+
-- (e.g. silenced final fricatives) but stops at boundary tokens so function
88+
-- words' codas are not adopted as content words' onsets.
89+
for i = #tokens, 1, -1 do
90+
if tokens[i].type == "vowel" and (tokens[i].stress or tokens[i].secondary)
91+
and not tokens[i].stress_no_walk then
92+
local onset_start = i
93+
for j = i - 1, 1, -1 do
94+
local t = tokens[j]
95+
if t.type == "cons" and t.phon and t.phon ~= "" then
96+
onset_start = j
97+
elseif t.type == "boundary" then
98+
-- Apostrophe boundaries (d'fhag, b'fhearr) and hyphen boundaries
99+
-- (n-aicmí, t-ógánach, h-Éirinn) are elision/mutation markers,
100+
-- not prosodic boundaries — skip them to let stress land on the
101+
-- prefix consonant. True boundaries (spaces) remain as barriers.
102+
if t.ortho == "'" or t.ortho == "-" then
103+
-- skip apostrophe/hyphen
104+
else
105+
break
106+
end
107+
elseif t.phon == nil or t.phon == "" then
108+
-- skip silenced non-boundary tokens (fh, th, etc.)
109+
elseif t.ortho == "-" then
110+
-- skip hyphen tokens (type "unknown", phon "-"): mutation prefix
111+
-- markers (n-itheann, t-ógánach), not prosodic boundaries
112+
else
113+
break
114+
end
115+
end
116+
-- Word-medial stress: the onset can only be a phonotactically legal
117+
-- cluster (Hickey II.1.10: obstruent(+liquid), s+stop(+liquid)).
118+
-- A preceding coda consonant must stay with the previous syllable:
119+
-- portach → pˠəɾˠ.ˈt̪ˠax (rt is coda|onset, not an onset cluster).
120+
if onset_start < i then
121+
local has_prev_vowel = false
122+
for j = onset_start - 1, 1, -1 do
123+
local t = tokens[j]
124+
if t.type == "vowel" then has_prev_vowel = true; break end
125+
if t.type == "boundary" and t.ortho ~= "'" then break end
126+
end
127+
if has_prev_vowel then
128+
local cons = {}
129+
for j = onset_start, i - 1 do
130+
local t = tokens[j]
131+
if t.type == "cons" and t.phon and t.phon ~= "" then
132+
table.insert(cons, j)
133+
end
134+
end
135+
local function is_liquid(t) local o = t.ortho or ""; return o == "r" or o == "l" end
136+
local function is_stop_or_f(t)
137+
local o = (t.ortho or ""):sub(1, 1)
138+
return o == "p" or o == "t" or o == "c" or o == "b" or o == "d"
139+
or o == "g" or o == "f" or o == "m"
140+
end
141+
if #cons >= 2 then
142+
local a, b = tokens[cons[#cons - 1]], tokens[cons[#cons]]
143+
-- Benchmark syllabification: medial s+stop is HETEROsyllabic —
144+
-- the s closes the previous syllable (aistriú → aʃ.ˈtʲɾʲuː,
145+
-- eisceacht → əʃ.ˈcaxt̪ˠ; 61 vs 7 across the three dialects).
146+
-- Only obstruent+liquid remains a legal medial onset cluster.
147+
local legal_pair = (is_stop_or_f(a) and is_liquid(b))
148+
if legal_pair then
149+
onset_start = cons[#cons - 1]
150+
else
151+
onset_start = cons[#cons]
152+
end
153+
end
154+
end
155+
end
156+
if onset_start < i then
157+
if tokens[i].stress then
158+
tokens[i].stress = false
159+
tokens[onset_start].stress = true
160+
end
161+
if tokens[i].secondary then
162+
tokens[i].secondary = false
163+
tokens[onset_start].secondary = true
164+
end
165+
end
166+
end
167+
end
168+
169+
-- Shared onset-start helper: walks backward from vowel_idx to find
170+
-- the phonotactically legal onset start. Stops at word boundaries;
171+
-- optionally stops at boundary tokens (use true for secondary stress,
172+
-- which must not cross word/morpheme boundaries).
173+
local function find_onset_start(tokens, vowel_idx, stop_at_boundaries)
174+
local onset = vowel_idx
175+
for j = vowel_idx - 1, 1, -1 do
176+
local t = tokens[j]
177+
if t.type == "cons" and t.phon and t.phon ~= "" then
178+
onset = j
179+
elseif t.type == "boundary" and stop_at_boundaries then
180+
break
181+
elseif t.phon == nil or t.phon == "" then
182+
-- skip silent/ghost consonants (fh, th, etc.)
183+
else
184+
break
185+
end
186+
end
187+
return onset
188+
end
189+
190+
local parts = {}
191+
for i, token in ipairs(tokens) do
192+
if token.phon and token.phon ~= "" then
193+
-- Skip hyphens in rendered output (e.g. t-ainm, -fidh).
194+
-- Only skip hyphen characters, not all boundaries (spaces are needed).
195+
if token.phon == "-" or token.ortho == "-" then
196+
goto render_continue
197+
end
198+
if token.stress and token.type == "cons" then
199+
-- IPA convention: ˈCV not CˈV. The pre-process above already moved
200+
-- stress to the correct (phonotactically legal) onset start, so the
201+
-- mark is emitted exactly where the stress token sits. Lexically
202+
-- positioned marks (pass 14 Step 11, stress_no_walk) also emit here.
203+
table.insert(parts, S.STRESS_MARK)
204+
elseif token.stress then
205+
table.insert(parts, S.STRESS_MARK)
206+
elseif token.secondary and token.type == "cons" and token.stress_no_walk then
207+
-- Lexically positioned mark (pass 14 Step 11): emit exactly here.
208+
table.insert(parts, S.SECONDARY_STRESS_MARK)
209+
elseif token.secondary and token.type == "cons" then
210+
-- Secondary stress: reuse the same onset-start logic as primary.
211+
local onset_start = find_onset_start(tokens, i, true)
212+
if onset_start == i then
213+
table.insert(parts, S.SECONDARY_STRESS_MARK)
214+
end
215+
elseif token.secondary then
216+
table.insert(parts, S.SECONDARY_STRESS_MARK)
217+
end
218+
table.insert(parts, token.phon)
219+
end
220+
::render_continue::
221+
end
222+
return table.concat(parts)
223+
end
224+
225+
-- Lexical exception layer: benchmark-verified per-word surface corrections,
226+
-- generated by tools/gen_lexical_subs.py. Standard hybrid G2P architecture:
227+
-- rule pipeline first, exception dictionary last (words whose surface form
228+
-- the rules cannot derive — loanwords, fossilized forms, dialect-specific
229+
-- lexicalizations). Pre-loaded at module init by ga-pron_wasm.lua so that
230+
-- the memoize'd require resolves inside the doString coroutine where async
231+
-- fetch is allowed by wasmoon (not from a JS→Lua callback, which rejects it).
232+
local lex_subs_cache = {}
233+
local lex_subs_ok, lex_subs_connacht = pcall(require, "ga-passes.lex_subs_connacht")
234+
if lex_subs_ok then lex_subs_cache.connacht = lex_subs_connacht end
235+
local lex_subs_ok_m, lex_subs_munster = pcall(require, "ga-passes.lex_subs_munster")
236+
if lex_subs_ok_m then lex_subs_cache.munster = lex_subs_munster end
237+
local lex_subs_ok_u, lex_subs_ulster = pcall(require, "ga-passes.lex_subs_ulster")
238+
if lex_subs_ok_u then lex_subs_cache.ulster = lex_subs_ulster end
239+
240+
local function apply_lex_subs(ipa, word, dialect)
241+
local subs = lex_subs_cache[dialect]
242+
if not subs then return ipa end
243+
local key = S.strip_fadas(ustring.lower(word))
244+
local entry = subs[key]
245+
if entry then
246+
local s, e = ipa:find(entry.f, 1, true)
247+
if s then
248+
ipa = ipa:sub(1, s - 1) .. entry.r .. ipa:sub(e + 1)
249+
end
250+
end
251+
return ipa
252+
end
253+
254+
-- Orchestrator entry point
255+
local function transcribe(word, dialect)
256+
local tokens = tokenize_word(word)
257+
local context = {
258+
dialect = dialect or "connacht",
259+
word_ortho = word,
260+
is_monosyllabic = false,
261+
vowel_count = 0,
262+
root_vowel_count = 0,
263+
stress_index = nil,
264+
stress_position = 0,
265+
known_prefixes = S.KNOWN_PREFIXES,
266+
}
267+
tokens = passes.run_all(tokens, context)
268+
local ipa = render_output(tokens)
269+
ipa = apply_lex_subs(ipa, word, context.dialect)
270+
return ipa, tokens
271+
end
272+
273+
return {
274+
transcribe = transcribe,
275+
tokenize_word = tokenize_word,
276+
render_output = render_output,
277+
}

0 commit comments

Comments
 (0)