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