-- SPDX-FileCopyrightText: © 2026 Vladimir Zorin -- SPDX-License-Identifier: LicenseRef-OWL-1.0-or-later -- Licensed under OWL v1.0+. See LICENSE. --[==[ English grapheme-to-phoneme for the formant TTS (sound.speech). Two layers, exceptions first: 1. A small dictionary of common irregular words, carrying real stress marks (ARPAbet with 0/1/2 stress digits on the vowels). 2. Context-sensitive letter-to-sound rules in the NRL formalism (Elovitz et al. 1976): each rule is "A[B]C=PH PH ..." — the literal letters B, in left context A and right context C, produce the phones D. First matching rule wins and the cursor advances past B. The rule set here is authored in that formalism (not a verbatim NRL transcription), sized for the charmingly-imperfect retro bar: ~90% of ordinary text comes out right, and the dictionary catches the worst of the rest. Context pattern symbols: # one or more vowels (a e i o u y) : zero or more consonants ^ exactly one consonant . a voiced consonant (bdvgjlmnrwz) + a front vowel (e i y) & a sibilant (s c g z x j ch sh) @ t s r d l z n j th ch sh % a suffix (e er es ed ing ely) (space) word boundary other letters match literally Rules give no stress; a heuristic marks the first vowel of a rule-derived word as primary. Words the rules mangle badly belong in the dictionary — or can be phonemized by hand via the events API (sound.speech.synth). ]==] -- ── exceptions dictionary ──────────────────────────────────────────── -- ARPAbet with stress digits. Function words carry stress 0/1 as they are -- typically spoken in context. local DICT_SRC = { a = "AH0", the = "DH AH0", of = "AH0 V", to = "T UW0", and_ = "AE1 N D", i = "AY1", you = "Y UW1", he = "HH IY1", she = "SH IY1", we = "W IY1", me = "M IY1", be = "B IY1", been = "B IH1 N", was = "W AH0 Z", were = "W ER0", are = "AA0 R", is_ = "IH1 Z", his = "HH IH0 Z", as_ = "AE0 Z", has = "HH AE1 Z", have = "HH AE1 V", had = "HH AE1 D", do_ = "D UW1", does = "D AH1 Z", done = "D AH1 N", gone = "G AO1 N", who = "HH UW1", whom = "HH UW1 M", whose = "HH UW1 Z", what = "W AH1 T", where = "W EH1 R", there = "DH EH1 R", their = "DH EH1 R", they = "DH EY1", them = "DH EH1 M", then_ = "DH EH1 N", than = "DH AE1 N", this = "DH IH1 S", that = "DH AE1 T", these = "DH IY1 Z", those = "DH OW1 Z", my = "M AY1", your = "Y AO1 R", our = "AW1 R", one = "W AH1 N", once = "W AH1 N S", two = "T UW1", four = "F AO1 R", eight = "EY1 T", said = "S EH1 D", says = "S EH1 Z", yes = "Y EH1 S", again = "AH0 G EH1 N", against = "AH0 G EH1 N S T", any = "EH1 N IY0", many = "M EH1 N IY0", some = "S AH1 M", come = "K AH1 M", give = "G IH1 V", live = "L IH1 V", love = "L AH1 V", move = "M UW1 V", lose = "L UW1 Z", none = "N AH1 N", won = "W AH1 N", son = "S AH1 N", front = "F R AH1 N T", month = "M AH1 N TH", money = "M AH1 N IY0", among = "AH0 M AH1 NG", above = "AH0 B AH1 V", woman = "W UH1 M AH0 N", women = "W IH1 M IH0 N", people = "P IY1 P AH0 L", water = "W AO1 T ER0", other = "AH1 DH ER0", another = "AH0 N AH1 DH ER0", mother = "M AH1 DH ER0", father = "F AA1 DH ER0", brother = "B R AH1 DH ER0", nothing = "N AH1 TH IH0 NG", something = "S AH1 M TH IH0 NG", only = "OW1 N L IY0", over = "OW1 V ER0", very = "V EH1 R IY0", every = "EH1 V R IY0", here = "HH IH1 R", word = "W ER1 D", work = "W ER1 K", world = "W ER1 L D", would = "W UH1 D", could = "K UH1 D", should = "SH UH1 D", through = "TH R UW1", though = "DH OW1", thought = "TH AO1 T", enough = "IH0 N AH1 F", rough = "R AH1 F", tough = "T AH1 F", laugh = "L AE1 F", eye = "AY1", buy = "B AY1", busy = "B IH1 Z IY0", business = "B IH1 Z N AH0 S", friend = "F R EH1 N D", great = "G R EY1 T", break_ = "B R EY1 K", heart = "HH AA1 R T", heard = "HH ER1 D", earth = "ER1 TH", early = "ER1 L IY0", learn = "L ER1 N", because = "B IH0 K AO1 Z", before = "B IH0 F AO1 R", between = "B IH0 T W IY1 N", both = "B OW1 TH", answer = "AE1 N S ER0", island = "AY1 L AH0 N D", sure = "SH UH1 R", sugar = "SH UH1 G ER0", machine = "M AH0 SH IY1 N", ocean = "OW1 SH AH0 N", pretty = "P R IH1 T IY0", often = "AO1 F AH0 N", hour = "AW1 R", honest = "AA1 N AH0 S T", how = "HH AW1", now = "N AW1", down = "D AW1 N", town = "T AW1 N", brown = "B R AW1 N", crown = "K R AW1 N", cow = "K AW1", head = "HH EH1 D", dead = "D EH1 D", death = "D EH1 TH", bread = "B R EH1 D", breath = "B R EH1 TH", ready = "R EH1 D IY0", already = "AO0 L R EH1 D IY0", instead = "IH0 N S T EH1 D", heavy = "HH EH1 V IY0", weather = "W EH1 DH ER0", measure = "M EH1 ZH ER0", pleasure = "P L EH1 ZH ER0", usual = "Y UW1 ZH UW0 AH0 L", get = "G EH1 T", girl = "G ER1 L", gift = "G IH1 F T", begin = "B IH0 G IH1 N", together = "T AH0 G EH1 DH ER0", today = "T AH0 D EY1", tonight = "T AH0 N AY1 T", want = "W AA1 N T", watch = "W AA1 CH", wash = "W AA1 SH", warm = "W AO1 R M", war = "W AO1 R", wear = "W EH1 R", bear = "B EH1 R", hi = "HH AY1", goes = "G OW1 Z", shoes = "SH UW1 Z", welcome = "W EH1 L K AH0 M", beacon = "B IY1 K AH0 N", denied = "D IH0 N AY1 D", engine = "EH1 N JH IH0 N", engines = "EH1 N JH IH0 N Z", minute = "M IH1 N IH0 T", minutes = "M IH1 N IH0 T S", second = "S EH1 K AH0 N D", seconds = "S EH1 K AH0 N D Z", signal = "S IH1 G N AH0 L", damage = "D AE1 M IH0 JH", message = "M EH1 S IH0 JH", pressure = "P R EH1 SH ER0", failure = "F EY1 L Y ER0", sequence = "S IY1 K W AH0 N S", guidance = "G AY1 D AH0 N S", power = "P AW1 ER0", tower = "T AW1 ER0", cruise = "K R UW1 Z", -- contractions ["don't"] = "D OW1 N T", ["can't"] = "K AE1 N T", ["won't"] = "W OW1 N T", ["it's"] = "IH1 T S", ["i'm"] = "AY1 M", ["i'll"] = "AY1 L", ["i've"] = "AY1 V", ["you're"] = "Y UH1 R", ["you'll"] = "Y UW1 L", ["you've"] = "Y UW1 V", ["we're"] = "W IH1 R", ["we'll"] = "W IY1 L", ["we've"] = "W IY1 V", ["they're"] = "DH EH1 R", ["they've"] = "DH EY1 V", ["isn't"] = "IH1 Z AH0 N T", ["aren't"] = "AA1 R AH0 N T", ["wasn't"] = "W AH1 Z AH0 N T", ["didn't"] = "D IH1 D AH0 N T", ["doesn't"] = "D AH1 Z AH0 N T", ["couldn't"] = "K UH1 D AH0 N T", ["wouldn't"] = "W UH1 D AH0 N T", ["shouldn't"] = "SH UH1 D AH0 N T", ["that's"] = "DH AE1 T S", ["what's"] = "W AH1 T S", ["let's"] = "L EH1 T S", ["there's"] = "DH EH1 R Z", ["he'll"] = "HH IY1 L", ["she'll"] = "SH IY1 L", } -- keys that collide with Lua keywords carry a trailing underscore local DICT = {} for k, v in pairs(DICT_SRC) do DICT[k:gsub("_$", "")] = v end -- ── letter-to-sound rules ──────────────────────────────────────────── -- Grouped by the first letter of [B]; first match wins, cursor advances #B. local RULES_SRC = { A = { "[A] =AH", "[ALL]=AO L", "[ALK]=AO K", "[AL] =AH L", "[AIR]=EH R", "[AI]=EY", "[AY]=EY", "[AU]=AO", "[AW]=AO", "[ARE] =EH R", "W[AR]=AO R", "[AR] =AA R", "[AR]=AA R", "[A]TION=EY", "[A]^E =EY", "[A]^%=EY", "[A]=AE", }, B = { "[BB]=B", "[B]=B", }, C = { "[CIA]=SH", "[CI]O=SH", "[CI]EN=SH", "[CH]=CH", "[CK]=K", "[C]+=S", "[C]=K", }, D = { "[DD]=D", "[D]=D", }, E = { "#:T[ED] =IH D", "#:D[ED] =IH D", "#:.[ED] =D", "#[ED] =D", "#:[ED] =T", "&[ES] =IH Z", "#:[E]S =", "[EIGH]=EY", "[EE]=IY", "[EAR] =IH R", "[EA]=IY", "@[EW]=UW", "[EW]=Y UW", "[ER] =ER", "[ER]=ER", "[E]^E =IY", "[EI]=IY", "[EY] =IY", "[E] =", "[E]=EH", }, F = { "[FF]=F", "[F]=F", }, G = { " [GH]=G", "[GH]=", "[GG]=G", "[G]+=JH", "[G]=G", }, H = { "[H]#=HH", "[H]=", }, I = { "[IGH]=AY", "[IGN] =AY N", "[IND] =AY N D", "[ING] =IH NG", "[IE] =AY", "[IE]=IY", "[IR]=ER", "[ION] =Y AH N", "[I]^E =AY", "[I]^%=AY", "[I] =IY", "[I]=IH", }, J = { "[J]=JH", }, K = { " [KN]=N", "[K]=K", }, L = { "[LL]=L", "[L]=L", }, M = { "[MM]=M", "[M]=M", }, N = { "[NK]=NG K", "[NG]=NG", "[NN]=N", "[N]=N", }, O = { " C[OM]^=AH M", " C[ON]^=AH N", "[OO]K=UH", "[OO]=UW", "[OLD] =OW L D", "[OUS]=AH S", "[OUR]=AO R", "[OU]=AW", "[OW] =OW", "[OW]=OW", "[OR] =AO R", "[OR]=AO R", "[OA]=OW", "[OI]=OY", "[OY]=OY", "[O]^E =OW", "[O]^%=OW", "[O] =OW", "[O]=AA", }, P = { "[PH]=F", "[PP]=P", "[P]=P", }, Q = { "[QU]=K W", "[Q]=K", }, R = { " [RE]^#=R IY", "[RR]=R", "[R]=R", }, S = { "[SCH]=S K", "[SH]=SH", "[SSION]=SH AH N", "[SION]=ZH AH N", "[SS]=S", "#:.[S] =Z", "#:#[S] =Z", "[S]=S", }, T = { "[TION]=SH AH N", "[TIA]=SH", "#[TH]#=DH", "[TH]=TH", "[TT]=T", "[T]=T", }, U = { "[UR]=ER", "@[U]^E =UW", "[U]^E =Y UW", "@[UE]=UW", "[UE]=Y UW", "[UI]=UW", "@[U]^#=UW", "[U]^#=Y UW", "[U]=AH", }, V = { "[V]=V", }, W = { " [WR]=R", "[WH]=W", "[W]=W", }, X = { " [X]=Z", "[X]=K S", }, Y = { " [Y]=Y", " H[Y]=AY", "#:[Y] =IY", "[Y] =AY", "[Y]^E =AY", "[Y]=IH", }, Z = { "[ZZ]=Z", "[Z]=Z", }, } -- parse "A[B]C=D1 D2" into { a, b, c, d = {phones} } local RULES = {} for letter, list in pairs(RULES_SRC) do local parsed = {} for i, src in ipairs(list) do local a, b, c, d = src:match("^(.-)%[(.+)%](.-)=(.*)$") local phones = {} for ph in d:gmatch("%S+") do phones[#phones + 1] = ph end -- contexts lowercased too: literal letters in A/C must match the -- lowercase word (symbols are unaffected) parsed[i] = { a = a:lower(), b = b:lower(), c = c:lower(), d = phones } end RULES[letter] = parsed end -- ── context matching ───────────────────────────────────────────────── local VOWEL = { a = true, e = true, i = true, o = true, u = true, y = true } local VOICED = { b = true, d = true, v = true, g = true, j = true, l = true, m = true, n = true, r = true, w = true, z = true } local FRONT = { e = true, i = true, y = true } local SIBILANT = { s = true, c = true, g = true, z = true, x = true, j = true } local AT_ONE = { t = true, s = true, r = true, d = true, l = true, z = true, n = true, j = true } local SUFFIXES = { "ing", "ely", "er", "es", "ed", "e" } local is_cons = function(ch) -- '^' deliberately excludes x: it expands to /ks/ and never hosts a -- magic-e or suffix pattern ("boxes" must not read as long o) return ch:match("^[a-z]$") and not VOWEL[ch] and ch ~= "x" end -- match right context `pat` in `w` starting at index i; returns true/false local match_right match_right = function(w, i, pat) for pi = 1, #pat do local pc = pat:sub(pi, pi) local ch = w:sub(i, i) if pc == " " then if ch ~= " " and ch ~= "" then return false end i = i + 1 elseif pc == "#" then if not VOWEL[ch] then return false end repeat i = i + 1 ch = w:sub(i, i) until not VOWEL[ch] elseif pc == ":" then while is_cons(w:sub(i, i)) do i = i + 1 end elseif pc == "^" then if not is_cons(ch) then return false end i = i + 1 elseif pc == "." then if not VOICED[ch] then return false end i = i + 1 elseif pc == "+" then if not FRONT[ch] then return false end i = i + 1 elseif pc == "&" then local two = w:sub(i, i + 1) if two == "ch" or two == "sh" then i = i + 2 elseif SIBILANT[ch] then i = i + 1 else return false end elseif pc == "@" then local two = w:sub(i, i + 1) if two == "th" or two == "ch" or two == "sh" then i = i + 2 elseif AT_ONE[ch] then i = i + 1 else return false end elseif pc == "%" then -- a suffix must actually end the word ("planet" is not "plan"+"et") local matched for _, suf in ipairs(SUFFIXES) do if w:sub(i, i + #suf - 1) == suf then local nxt = w:sub(i + #suf, i + #suf) if nxt == " " or nxt == "" then matched = suf break end end end if not matched then return false end i = i + #matched else if ch ~= pc then return false end i = i + 1 end end return true end -- match left context `pat` in `w` ending at index i (pattern read right-to-left) local match_left match_left = function(w, i, pat) for pi = #pat, 1, -1 do local pc = pat:sub(pi, pi) local ch = i >= 1 and w:sub(i, i) or "" if pc == " " then if ch ~= " " and ch ~= "" then return false end i = i - 1 elseif pc == "#" then if not VOWEL[ch] then return false end repeat i = i - 1 ch = i >= 1 and w:sub(i, i) or "" until not VOWEL[ch] elseif pc == ":" then while i >= 1 and is_cons(w:sub(i, i)) do i = i - 1 end elseif pc == "^" then if not is_cons(ch) then return false end i = i - 1 elseif pc == "." then if not VOICED[ch] then return false end i = i - 1 elseif pc == "+" then if not FRONT[ch] then return false end i = i - 1 elseif pc == "&" then local two = i >= 2 and w:sub(i - 1, i) or "" if two == "ch" or two == "sh" then i = i - 2 elseif SIBILANT[ch] then i = i - 1 else return false end elseif pc == "@" then local two = i >= 2 and w:sub(i - 1, i) or "" if two == "th" or two == "ch" or two == "sh" then i = i - 2 elseif AT_ONE[ch] then i = i - 1 else return false end else if ch ~= pc then return false end i = i - 1 end end return true end -- ── translation ────────────────────────────────────────────────────── local phonemes = require("sound.speech.phonemes") local parse_dict_entry = function(s) local out = {} for tok in s:gmatch("%S+") do local ph, digit = tok:match("^(%a+)(%d?)$") out[#out + 1] = { ph = ph, stress = digit ~= "" and tonumber(digit) or 0 } end return out end local apply_rules = function(word) local w = " " .. word .. " " local p = 2 local out = {} local last = #w - 1 while p <= last do local ch = w:sub(p, p) local rules = RULES[ch:upper()] local advanced = false if rules then for _, r in ipairs(rules) do if w:sub(p, p + #r.b - 1) == r.b then if match_right(w, p + #r.b, r.c) and match_left(w, p - 1, r.a) then for _, ph in ipairs(r.d) do out[#out + 1] = { ph = ph, stress = 0 } end p = p + #r.b advanced = true break end end end end if not advanced then p = p + 1 -- unmatched character (apostrophe etc.): skip silently end end -- stress heuristic: primary stress on the first vowel for _, e in ipairs(out) do if phonemes.is_vocalic(e.ph) then e.stress = 1 break end end return out end ---! Transcribe one lowercase word into phoneme events ---@ word(`w`{.str}) -> `events`{.tbl} --[===[ Returns a list of `{ ph = , stress = 0|1|2 }`. The exceptions dictionary is consulted first (with, then without, apostrophes); otherwise the letter-to-sound rules run with the first-vowel stress heuristic. ]===] local word = function(w) local entry = DICT[w] or DICT[w:gsub("'", "")] if entry then return parse_dict_entry(entry) end return apply_rules(w:gsub("'", "")) end return { word = word, dict = DICT, rules = RULES, }