Source code for pythaitts.pretrained.fastthaig2p.en_g2p

# -*- coding: utf-8 -*-
"""English-to-Thai-IPA Grapheme-to-Phoneme (G2P) for Thai-style English TTS.

Converts English words into English IPA and maps English IPA phonemes into
Wiktionary-convention Thai IPA phonemes (/syl.la.ble˧/) so that Thai TTS models
(such as FastThaiG2P + Kokoro) pronounce English code-switched text naturally
in Thai style (Thanglish).
"""

from __future__ import annotations

import re
import shutil
import subprocess
from functools import lru_cache
from typing import Dict, List, Optional, Tuple


# ---------------------------------------------------------------------------
# 1. Built-in Dictionary of Common & Irregular English Words -> English IPA
# ---------------------------------------------------------------------------
_EN_IPA_DICT: Dict[str, str] = {
    # Pronouns, articles, prepositions, conjunctions, auxiliaries
    "a": "eɪ",
    "an": "æn",
    "the": "ðə",
    "i": "aɪ",
    "me": "miː",
    "my": "maɪ",
    "mine": "maɪn",
    "you": "juː",
    "your": "jɔːɹ",
    "yours": "jɔːɹz",
    "he": "hiː",
    "him": "hɪm",
    "his": "hɪz",
    "she": "ʃiː",
    "her": "hɜːɹ",
    "hers": "hɜːɹz",
    "it": "ɪt",
    "its": "ɪts",
    "we": "wiː",
    "us": "ʌs",
    "our": "aʊ.ɚ",
    "ours": "aʊ.ɚz",
    "they": "ðeɪ",
    "them": "ðɛm",
    "their": "ðɛəɹ",
    "theirs": "ðɛəɹz",
    "this": "ðɪs",
    "that": "ðæt",
    "these": "ðiːz",
    "those": "ðoʊz",
    "who": "huː",
    "whom": "huːm",
    "whose": "huːz",
    "what": "wɒt",
    "which": "wɪʧ",
    "where": "wɛəɹ",
    "when": "wɛn",
    "why": "waɪ",
    "how": "haʊ",
    "am": "æm",
    "is": "ɪz",
    "are": "ɑːɹ",
    "was": "wɒz",
    "were": "wɜːɹ",
    "be": "biː",
    "been": "biːn",
    "being": "ˈbiː.ɪŋ",
    "have": "hæv",
    "has": "hæz",
    "had": "hæd",
    "having": "ˈhæv.ɪŋ",
    "do": "duː",
    "does": "dʌz",
    "did": "dɪd",
    "done": "dʌn",
    "doing": "ˈduː.ɪŋ",
    "will": "wɪl",
    "would": "wʊd",
    "shall": "ʃæl",
    "should": "ʃʊd",
    "can": "kæn",
    "cannot": "ˈkæn.ɒt",
    "could": "kʊd",
    "may": "meɪ",
    "might": "maɪt",
    "must": "mʌst",
    "in": "ɪn",
    "on": "ɒn",
    "at": "æt",
    "to": "tuː",
    "for": "fɔːɹ",
    "of": "ɒv",
    "off": "ɒf",
    "from": "fɹɒm",
    "with": "wɪθ",
    "without": "wɪˈðaʊt",
    "by": "baɪ",
    "about": "əˈbaʊt",
    "into": "ˈɪn.tuː",
    "onto": "ˈɒn.tuː",
    "over": "ˈoʊ.vɚ",
    "under": "ˈʌn.dɚ",
    "after": "ˈɑːf.tɚ",
    "before": "bɪˈfɔːɹ",
    "between": "bɪˈtwiːn",
    "among": "əˈmʌŋ",
    "through": "θɹuː",
    "during": "ˈdjʊə.ɹɪŋ",
    "until": "ʌnˈtɪl",
    "against": "əˈɡɛnst",
    "around": "əˈɹaʊnd",
    "and": "ænd",
    "or": "ɔːɹ",
    "but": "bʌt",
    "if": "ɪf",
    "because": "bɪˈkɒz",
    "so": "soʊ",
    "than": "ðæn",
    "then": "ðɛn",
    "though": "ðoʊ",
    "although": "ɔːlˈðoʊ",
    "while": "waɪl",
    "not": "nɒt",
    "no": "noʊ",
    "yes": "jɛs",
    "yeah": "jɛə",
    "ok": "oʊˈkeɪ",
    "okay": "oʊˈkeɪ",
    "all": "ɔːl",
    "any": "ˈɛn.i",
    "some": "sʌm",
    "many": "ˈmɛn.i",
    "much": "mʌʧ",
    "more": "mɔːɹ",
    "most": "moʊst",
    "other": "ˈʌð.ɚ",
    "another": "əˈnʌð.ɚ",
    "such": "sʌʧ",
    "only": "ˈoʊn.li",
    "own": "oʊn",
    "same": "seɪm",
    "very": "ˈvɛɹ.i",
    "just": "ʤʌst",
    "now": "naʊ",
    "here": "hɪəɹ",
    "there": "ðɛəɹ",
    "always": "ˈɔːl.weɪz",
    "never": "ˈnɛv.ɚ",
    "often": "ˈɒf.ən",
    "sometimes": "ˈsʌm.taɪmz",
    "again": "əˈɡɛn",
    "already": "ɔːlˈɹɛd.i",
    "still": "stɪl",
    "also": "ˈɔːl.soʊ",
    "too": "tuː",
    "well": "wɛl",
    "even": "ˈiː.vən",
    "back": "bæk",
    "down": "daʊn",
    "up": "ʌp",
    "out": "aʊt",
    "away": "əˈweɪ",
    "today": "təˈdeɪ",
    "tomorrow": "təˈmɒɹ.oʊ",
    "yesterday": "ˈjɛs.tɚ.deɪ",
    "tonight": "təˈnaɪt",

    # Greetings & Everyday Words
    "hello": "həˈloʊ",
    "hi": "haɪ",
    "hey": "heɪ",
    "bye": "baɪ",
    "goodbye": "ɡʊdˈbaɪ",
    "welcome": "ˈwɛl.kʌm",
    "thank": "θæŋk",
    "thanks": "θæŋks",
    "please": "pliːz",
    "sorry": "ˈsɒɹ.i",
    "excuse": "ɪkˈskjuːs",
    "world": "wɜːld",
    "people": "ˈpiː.pəl",
    "person": "ˈpɜː.sən",
    "man": "mæn",
    "men": "mɛn",
    "woman": "ˈwʊm.ən",
    "women": "ˈwɪm.ɪn",
    "child": "ʧaɪld",
    "children": "ˈʧɪl.dɹən",
    "friend": "fɹɛnd",
    "friends": "fɹɛndz",
    "family": "ˈfæm.ə.li",
    "father": "ˈfɑː.ðɚ",
    "mother": "ˈmʌð.ɚ",
    "brother": "ˈbɹʌð.ɚ",
    "sister": "ˈsɪs.tɚ",
    "baby": "ˈbeɪ.bi",
    "boy": "bɔɪ",
    "girl": "ɡɜːl",
    "guy": "ɡaɪ",
    "guys": "ɡaɪz",
    "time": "taɪm",
    "day": "deɪ",
    "week": "wiːk",
    "month": "mʌnθ",
    "year": "jɪəɹ",
    "hour": "ˈaʊ.ɚ",
    "minute": "ˈmɪn.ɪt",
    "second": "ˈsɛk.ənd",
    "morning": "ˈmɔː.nɪŋ",
    "afternoon": "ˌɑːf.tɚˈnuːn",
    "evening": "ˈiːv.nɪŋ",
    "night": "naɪt",
    "weekend": "ˈwiːk.ɛnd",
    "holiday": "ˈhɒl.ɪ.deɪ",
    "birthday": "ˈbɜːθ.deɪ",
    "party": "ˈpɑː.ti",
    "life": "laɪf",
    "live": "laɪv",
    "love": "lʌv",
    "like": "laɪk",
    "good": "ɡʊd",
    "better": "ˈbɛt.ɚ",
    "best": "bɛst",
    "bad": "bæd",
    "worse": "wɜːs",
    "worst": "wɜːst",
    "great": "ɡɹeɪt",
    "nice": "naɪs",
    "cool": "kuːl",
    "fine": "faɪn",
    "happy": "ˈhæp.i",
    "sad": "sæd",
    "easy": "ˈiː.zi",
    "hard": "hɑːd",
    "fast": "fɑːst",
    "slow": "sloʊ",
    "new": "njuː",
    "old": "oʊld",
    "young": "jʌŋ",
    "big": "bɪɡ",
    "small": "smɔːl",
    "large": "lɑːʤ",
    "little": "ˈlɪt.əl",
    "long": "lɒŋ",
    "short": "ʃɔːt",
    "high": "haɪ",
    "low": "loʊ",
    "hot": "hɒt",
    "cold": "koʊld",
    "warm": "wɔːm",
    "full": "fʊl",
    "empty": "ˈɛmp.ti",
    "free": "fɹiː",
    "busy": "ˈbɪz.i",
    "ready": "ˈɹɛd.i",
    "sure": "ʃʊəɹ",
    "real": "ɹiːl",
    "really": "ˈɹiː.ə.li",
    "true": "tɹuː",
    "false": "fɔːls",
    "right": "ɹaɪt",
    "wrong": "ɹɒŋ",
    "left": "lɛft",
    "first": "fɜːst",
    "last": "lɑːst",
    "next": "nɛkst",
    "One": "wʌn",
    "one": "wʌn",
    "two": "tuː",
    "three": "θɹiː",
    "four": "fɔːɹ",
    "five": "faɪv",
    "six": "sɪks",
    "seven": "ˈsɛv.ən",
    "eight": "eɪt",
    "nine": "naɪn",
    "ten": "tɛn",
    "zero": "ˈzɪə.ɹoʊ",
    "hundred": "ˈhʌn.dɹəd",
    "thousand": "ˈθaʊ.zənd",
    "million": "ˈmɪl.jən",
    "billion": "ˈbɪl.jən",
    "half": "hɑːf",
    "once": "wʌns",
    "twice": "twaɪs",

    # Common Verbs (especially irregular / silent-letter ones)
    "go": "ɡoʊ",
    "going": "ˈɡoʊ.ɪŋ",
    "gone": "ɡɒn",
    "went": "wɛnt",
    "come": "kʌm",
    "coming": "ˈkʌm.ɪŋ",
    "came": "keɪm",
    "get": "ɡɛt",
    "getting": "ˈɡɛt.ɪŋ",
    "got": "ɡɒt",
    "make": "meɪk",
    "making": "ˈmeɪ.kɪŋ",
    "made": "meɪd",
    "know": "noʊ",
    "known": "noʊn",
    "knew": "njuː",
    "think": "θɪŋk",
    "thought": "θɔːt",
    "thinking": "ˈθɪŋ.kɪŋ",
    "take": "teɪk",
    "taking": "ˈteɪ.kɪŋ",
    "took": "tʊk",
    "taken": "ˈteɪ.kən",
    "see": "siː",
    "saw": "sɔː",
    "seen": "siːn",
    "look": "lʊk",
    "looking": "ˈlʊk.ɪŋ",
    "want": "wɒnt",
    "give": "ɡɪv",
    "given": "ˈɡɪv.ən",
    "gave": "ɡeɪv",
    "use": "juːz",
    "using": "ˈjuː.zɪŋ",
    "used": "juːzd",
    "find": "faɪnd",
    "found": "faʊnd",
    "tell": "tɛl",
    "told": "toʊld",
    "ask": "ɑːsk",
    "work": "wɜːk",
    "working": "ˈwɜː.kɪŋ",
    "call": "kɔːl",
    "calling": "ˈkɔː.lɪŋ",
    "try": "tɹaɪ",
    "trying": "ˈtɹaɪ.ɪŋ",
    "need": "niːd",
    "feel": "fiːl",
    "feeling": "ˈfiː.lɪŋ",
    "become": "bɪˈkʌm",
    "leave": "liːv",
    "left": "lɛft",
    "put": "pʊt",
    "mean": "miːn",
    "keep": "kiːp",
    "let": "lɛt",
    "begin": "bɪˈɡɪn",
    "seem": "siːm",
    "help": "hɛlp",
    "talk": "tɔːk",
    "talking": "ˈtɔː.kɪŋ",
    "walk": "wɔːk",
    "walking": "ˈwɔː.kɪŋ",
    "turn": "tɜːn",
    "start": "stɑːt",
    "starting": "ˈstɑː.tɪŋ",
    "stop": "stɒp",
    "show": "ʃoʊ",
    "hear": "hɪəɹ",
    "heard": "hɜːd",
    "play": "pleɪ",
    "playing": "ˈpleɪ.ɪŋ",
    "run": "ɹʌn",
    "running": "ˈɹʌn.ɪŋ",
    "move": "muːv",
    "moving": "ˈmuː.vɪŋ",
    "believe": "bɪˈliːv",
    "hold": "hoʊld",
    "bring": "bɹɪŋ",
    "brought": "bɹɔːt",
    "happen": "ˈhæp.ən",
    "write": "ɹaɪt",
    "writing": "ˈɹaɪ.tɪŋ",
    "written": "ˈɹɪt.ən",
    "wrote": "ɹoʊt",
    "provide": "pɹəˈvaɪd",
    "sit": "sɪt",
    "stand": "stænd",
    "lose": "luːz",
    "lost": "lɒst",
    "pay": "peɪ",
    "paid": "peɪd",
    "meet": "miːt",
    "meeting": "ˈmiː.tɪŋ",
    "include": "ɪnˈkluːd",
    "continue": "kənˈtɪn.juː",
    "set": "sɛt",
    "setting": "ˈsɛt.ɪŋ",
    "settings": "ˈsɛt.ɪŋz",
    "learn": "lɜːn",
    "learning": "ˈlɜː.nɪŋ",
    "change": "ʧeɪnʤ",
    "lead": "liːd",
    "leader": "ˈliː.dɚ",
    "understand": "ˌʌn.dɚˈstænd",
    "watch": "wɒʧ",
    "follow": "ˈfɒl.oʊ",
    "following": "ˈfɒl.oʊ.ɪŋ",
    "follower": "ˈfɒl.oʊ.ɚ",
    "create": "kɹiˈeɪt",
    "creator": "kɹiˈeɪ.tɚ",
    "speak": "spiːk",
    "speaking": "ˈspiː.kɪŋ",
    "speaker": "ˈspiː.kɚ",
    "read": "ɹiːd",
    "reading": "ˈɹiː.dɪŋ",
    "allow": "əˈlaʊ",
    "add": "æd",
    "spend": "spɛnd",
    "grow": "ɡɹoʊ",
    "open": "ˈoʊ.pən",
    "close": "kloʊz",
    "win": "wɪn",
    "offer": "ˈɒf.ɚ",
    "remember": "ɹɪˈmɛm.bɚ",
    "consider": "kənˈsɪd.ɚ",
    "appear": "əˈpɪəɹ",
    "buy": "baɪ",
    "bought": "bɔːt",
    "wait": "weɪt",
    "waiting": "ˈweɪ.tɪŋ",
    "serve": "sɜːv",
    "die": "daɪ",
    "send": "sɛnd",
    "expect": "ɪkˈspɛkt",
    "build": "bɪld",
    "built": "bɪlt",
    "building": "ˈbɪl.dɪŋ",
    "stay": "steɪ",
    "fall": "fɔːl",
    "cut": "kʌt",
    "reach": "ɹiːʧ",
    "kill": "kɪl",
    "remain": "ɹɪˈmeɪn",
    "suggest": "səˈʤɛst",
    "raise": "ɹeɪz",
    "pass": "pɑːs",
    "sell": "sɛl",
    "sale": "seɪl",
    "sales": "seɪlz",
    "require": "ɹɪˈkwaɪ.ɚ",
    "report": "ɹɪˈpɔːt",
    "decide": "dɪˈsaɪd",
    "pull": "pʊl",
    "push": "pʊʃ",
    "return": "ɹɪˈtɜːn",
    "explain": "ɪkˈspleɪn",
    "hope": "hoʊp",
    "develop": "dɪˈvɛl.əp",
    "developer": "dɪˈvɛl.ə.pɚ",
    "development": "dɪˈvɛl.əp.mənt",
    "carry": "ˈkæɹ.i",
    "break": "bɹeɪk",
    "receive": "ɹɪˈsiːv",
    "agree": "əˈɡɹiː",
    "support": "səˈpɔːt",
    "hit": "hɪt",
    "produce": "pɹəˈdjuːs",
    "eat": "iːt",
    "cover": "ˈkʌv.ɚ",
    "catch": "kæʧ",
    "draw": "dɹɔː",
    "choose": "ʧuːz",

    # Technology, Computing, Business & Daily Loanwords in Thai
    "computer": "kəmˈpjuː.tɚ",
    "internet": "ˈɪn.tɚ.nɛt",
    "website": "ˈwɛb.saɪt",
    "web": "wɛb",
    "site": "saɪt",
    "online": "ˈɒnˈlaɪn",
    "offline": "ˈɒfˈlaɪn",
    "software": "ˈsɒft.wɛəɹ",
    "hardware": "ˈhɑːd.wɛəɹ",
    "network": "ˈnɛt.wɜːk",
    "system": "ˈsɪs.təm",
    "server": "ˈsɜː.vɚ",
    "service": "ˈsɜː.vɪs",
    "client": "ˈklaɪ.ənt",
    "user": "ˈjuː.zɚ",
    "admin": "ˈæd.mɪn",
    "login": "ˈlɒɡ.ɪn",
    "logout": "ˈlɒɡ.aʊt",
    "password": "ˈpɑːs.wɜːd",
    "account": "əˈkaʊnt",
    "profile": "ˈpɹoʊ.faɪl",
    "data": "ˈdeɪ.tə",
    "database": "ˈdeɪ.tə.beɪs",
    "cloud": "klaʊd",
    "file": "faɪl",
    "files": "faɪlz",
    "folder": "ˈfoʊl.dɚ",
    "upload": "ˈʌp.loʊd",
    "download": "ˈdaʊn.loʊd",
    "update": "ˈʌp.deɪt",
    "upgrade": "ˈʌp.ɡɹeɪd",
    "install": "ɪnˈstɔːl",
    "delete": "dɪˈliːt",
    "remove": "ɹɪˈmuːv",
    "search": "sɜːʧ",
    "filter": "ˈfɪl.tɚ",
    "sort": "sɔːt",
    "click": "klɪk",
    "button": "ˈbʌt.ən",
    "screen": "skɹiːn",
    "display": "dɪˈspleɪ",
    "monitor": "ˈmɒn.ɪ.tɚ",
    "keyboard": "ˈkiː.bɔːd",
    "mouse": "maʊs",
    "printer": "ˈpɹɪn.tɚ",
    "scanner": "ˈskæn.ɚ",
    "camera": "ˈkæm.ɹə",
    "video": "ˈvɪd.i.oʊ",
    "audio": "ˈɔː.di.oʊ",
    "radio": "ˈɹeɪ.di.oʊ",
    "media": "ˈmiː.di.ə",
    "social": "ˈsoʊ.ʃəl",
    "photo": "ˈfoʊ.toʊ",
    "image": "ˈɪm.ɪʤ",
    "picture": "ˈpɪk.ʧɚ",
    "graphic": "ˈɡɹæf.ɪk",
    "graph": "ɡɹɑːf",
    "chart": "ʧɑːt",
    "table": "ˈteɪ.bəl",
    "list": "lɪst",
    "item": "ˈaɪ.təm",
    "detail": "ˈdiː.teɪl",
    "details": "ˈdiː.teɪlz",
    "info": "ˈɪn.foʊ",
    "information": "ˌɪn.fɚˈmeɪ.ʃən",
    "document": "ˈdɒk.jʊ.mənt",
    "format": "ˈfɔː.mæt",
    "text": "tɛkst",
    "font": "fɒnt",
    "style": "staɪl",
    "color": "ˈkʌl.ɚ",
    "colour": "ˈkʌl.ɚ",
    "design": "dɪˈzaɪn",
    "designer": "dɪˈzaɪ.nɚ",
    "layout": "ˈleɪ.aʊt",
    "template": "ˈtɛm.pleɪt",
    "page": "peɪʤ",
    "view": "vjuː",
    "model": "ˈmɒd.əl",
    "train": "tɹeɪn",
    "training": "ˈtɹeɪ.nɪŋ",
    "test": "tɛst",
    "testing": "ˈtɛs.tɪŋ",
    "code": "koʊd",
    "coding": "ˈkoʊ.dɪŋ",
    "script": "skɹɪpt",
    "debug": "diːˈbʌɡ",
    "error": "ˈɛɹ.ɚ",
    "bug": "bʌɡ",
    "issue": "ˈɪʃ.uː",
    "feature": "ˈfiː.ʧɚ",
    "version": "ˈvɜː.ʃən",
    "release": "ɹɪˈliːs",
    "deploy": "dɪˈplɔɪ",
    "package": "ˈpæk.ɪʤ",
    "module": "ˈmɒd.juːl",
    "library": "ˈlaɪ.bɹə.ɹi",
    "framework": "ˈfɹeɪm.wɜːk",
    "platform": "ˈplæt.fɔːm",
    "application": "ˌæp.lɪˈkeɪ.ʃən",
    "program": "ˈpɹoʊ.ɡɹæm",
    "programmer": "ˈpɹoʊ.ɡɹæm.ɚ",
    "project": "ˈpɹoʊ.ʤɛkt",
    "product": "ˈpɹoʊ.dʌkt",
    "production": "pɹoʊˈdʌk.ʃən",
    "process": "ˈpɹoʊ.sɛs",
    "status": "ˈsteɪ.təs",
    "option": "ˈɒp.ʃən",
    "menu": "ˈmɛn.juː",
    "home": "hoʊm",
    "link": "lɪŋk",
    "url": "ˌjuː.ɑːɹˈɛl",
    "browser": "ˈbɹaʊ.zɚ",
    "engine": "ˈɛn.ʤɪn",
    "engineer": "ˌɛn.ʤɪˈnɪəɹ",
    "engineering": "ˌɛn.ʤɪˈnɪə.ɹɪŋ",
    "technology": "tɛkˈnɒl.ə.ʤi",
    "tech": "tɛk",
    "digital": "ˈdɪʤ.ɪ.təl",
    "mobile": "ˈmoʊ.baɪl",
    "phone": "foʊn",
    "smartphone": "ˈsmɑːt.foʊn",
    "device": "dɪˈvaɪs",
    "battery": "ˈbæt.ɚ.i",
    "power": "ˈpaʊ.ɚ",
    "energy": "ˈɛn.ɚ.ʤi",
    "charge": "ʧɑːʤ",
    "charger": "ˈʧɑː.ʤɚ",
    "cable": "ˈkeɪ.bəl",
    "adapter": "əˈdæp.tɚ",
    "sensor": "ˈsɛn.sɚ",
    "signal": "ˈsɪɡ.nəl",
    "wireless": "ˈwaɪə.ləs",
    "bluetooth": "ˈbluː.tuːθ",
    "channel": "ˈʧæn.əl",
    "stream": "stɹiːm",
    "streaming": "ˈstɹiː.mɪŋ",
    "podcast": "ˈpɒd.kɑːst",
    "content": "ˈkɒn.tɛnt",
    "comment": "ˈkɒm.ɛnt",
    "share": "ʃɛəɹ",
    "post": "poʊst",
    "story": "ˈstɔː.ɹi",
    "feed": "fiːd",
    "trend": "tɹɛnd",
    "trending": "ˈtɹɛn.dɪŋ",
    "viral": "ˈvaɪ.ɹəl",
    "brand": "bɹænd",
    "market": "ˈmɑː.kɪt",
    "marketing": "ˈmɑː.kɪ.tɪŋ",
    "business": "ˈbɪz.nəs",
    "company": "ˈkʌm.pə.ni",
    "office": "ˈɒf.ɪs",
    "team": "tiːm",
    "group": "ɡɹuːp",
    "member": "ˈmɛm.bɚ",
    "manager": "ˈmæn.ɪ.ʤɚ",
    "management": "ˈmæn.ɪʤ.mənt",
    "director": "dɪˈɹɛk.tɚ",
    "customer": "ˈkʌs.tə.mɚ",
    "partner": "ˈpɑːt.nɚ",
    "center": "ˈsɛn.tɚ",
    "centre": "ˈsɛn.tɚ",
    "department": "dɪˈpɑːt.mənt",
    "target": "ˈtɑː.ɡɪt",
    "budget": "ˈbʌʤ.ɪt",
    "profit": "ˈpɹɒf.ɪt",
    "cost": "kɒst",
    "price": "pɹaɪs",
    "order": "ˈɔː.dɚ",
    "payment": "ˈpeɪ.mənt",
    "discount": "ˈdɪs.kaʊnt",
    "promotion": "pɹəˈmoʊ.ʃən",
    "coupon": "ˈkuː.pɒn",
    "voucher": "ˈvaʊ.ʧɚ",
    "point": "pɔɪnt",
    "reward": "ɹɪˈwɔːd",
    "bonus": "ˈboʊ.nəs",
    "cash": "kæʃ",
    "credit": "ˈkɹɛd.ɪt",
    "debit": "ˈdɛb.ɪt",
    "card": "kɑːd",
    "bank": "bæŋk",
    "banking": "ˈbæŋ.kɪŋ",
    "transfer": "ˈtɹæns.fɜːɹ",
    "wallet": "ˈwɒl.ɪt",
    "money": "ˈmʌn.i",
    "tax": "tæks",
    "invoice": "ˈɪn.vɔɪs",
    "receipt": "ɹɪˈsiːt",
    "summary": "ˈsʌm.ɚ.i",
    "analysis": "əˈnæl.ə.sɪs",
    "analytics": "ˌæn.əˈlɪt.ɪks",
    "dashboard": "ˈdæʃ.bɔːd",
    "schedule": "ˈskɛʤ.uːl",
    "calendar": "ˈkæl.ɪn.dɚ",
    "event": "ɪˈvɛnt",
    "workshop": "ˈwɜːk.ʃɒp",
    "seminar": "ˈsɛm.ɪ.nɑːɹ",
    "course": "kɔːs",
    "class": "klɑːs",
    "lesson": "ˈlɛs.ən",
    "student": "ˈstjuː.dənt",
    "teacher": "ˈtiː.ʧɚ",
    "school": "skuːl",
    "college": "ˈkɒl.ɪʤ",
    "university": "ˌjuː.nɪˈvɜː.sɪ.ti",
    "science": "ˈsaɪ.əns",
    "math": "mæθ",
    "medical": "ˈmɛd.ɪ.kəl",
    "health": "hɛlθ",
    "hospital": "ˈhɒs.pɪ.təl",
    "doctor": "ˈdɒk.tɚ",
    "nurse": "nɜːs",
    "clinic": "ˈklɪn.ɪk",
    "vaccine": "ˈvæk.siːn",
    "virus": "ˈvaɪ.ɹəs",
    "vitamin": "ˈvaɪ.tə.mɪn",
    "protein": "ˈpɹoʊ.tiːn",
    "hotel": "hoʊˈtɛl",
    "booking": "ˈbʊk.ɪŋ",
    "ticket": "ˈtɪk.ɪt",
    "flight": "flaɪt",
    "travel": "ˈtɹæv.əl",
    "tour": "tʊəɹ",
    "trip": "tɹɪp",
    "guide": "ɡaɪd",
    "map": "mæp",
    "route": "ɹuːt",
    "location": "loʊˈkeɪ.ʃən",
    "address": "ˈæd.ɹɛs",
    "contact": "ˈkɒn.tækt",
    "message": "ˈmɛs.ɪʤ",
    "chat": "ʧæt",
    "voice": "vɔɪs",
    "music": "ˈmjuː.zɪk",
    "song": "sɒŋ",
    "movie": "ˈmuː.vi",
    "film": "fɪlm",
    "game": "ɡeɪm",
    "gaming": "ˈɡeɪ.mɪŋ",
    "gamer": "ˈɡeɪ.mɚ",
    "sport": "spɔːt",
    "football": "ˈfʊt.bɔːl",
    "golf": "ɡɒlf",
    "tennis": "ˈtɛn.ɪs",
    "coffee": "ˈkɒf.i",
    "tea": "tiː",
    "water": "ˈwɔː.tɚ",
    "milk": "mɪlk",
    "juice": "ʤuːs",
    "beer": "bɪəɹ",
    "wine": "waɪn",
    "food": "fuːd",
    "menu": "ˈmɛn.juː",
    "restaurant": "ˈɹɛs.tɹɒnt",
    "cafe": "ˈkæf.eɪ",
    "shop": "ʃɒp",
    "shopping": "ˈʃɒp.ɪŋ",
    "store": "stɔːɹ",
    "mall": "mɔːl",
    "supermarket": "ˈsuː.pɚ.mɑː.kɪt",
    "car": "kɑːɹ",
    "bus": "bʌs",
    "taxi": "ˈtæk.si",
    "station": "ˈsteɪ.ʃən",
    "airport": "ˈɛə.pɔːt",
    "room": "ɹuːm",
    "house": "haʊs",
    "condo": "ˈkɒn.doʊ",
    "city": "ˈsɪt.i",
    "country": "ˈkʌn.tɹi",
    "world": "wɜːld",
    "nature": "ˈneɪ.ʧɚ",
    "future": "ˈfjuː.ʧɚ",
    "culture": "ˈkʌl.ʧɚ",
    "action": "ˈæk.ʃən",
    "section": "ˈsɛk.ʃən",
    "function": "ˈfʌŋk.ʃən",
    "question": "ˈkwɛs.ʧən",
    "answer": "ˈɑːn.sɚ",
    "problem": "ˈpɹɒb.ləm",
    "solution": "səˈluː.ʃən",
    "idea": "aɪˈdiː.ə",
    "concept": "ˈkɒn.sɛpt",
    "topic": "ˈtɒp.ɪk",
    "example": "ɪɡˈzɑːm.pəl",
    "sample": "ˈsɑːm.pəl",
    "result": "ɹɪˈzʌlt",
    "level": "ˈlɛv.əl",
    "normal": "ˈnɔː.məl",
    "standard": "ˈstæn.dɚd",
    "special": "ˈspɛʃ.əl",
    "general": "ˈʤɛn.ɹəl",
    "public": "ˈpʌb.lɪk",
    "private": "ˈpɹaɪ.vət",
    "local": "ˈloʊ.kəl",
    "global": "ˈɡloʊ.bəl",
    "international": "ˌɪn.tɚˈnæʃ.ən.əl",
    "national": "ˈnæʃ.ən.əl",
    "personal": "ˈpɜː.sən.əl",
    "original": "əˈɹɪʤ.ɪ.nəl",
    "final": "ˈfaɪ.nəl",
    "total": "ˈtoʊ.təl",
    "percent": "pɚˈsɛnt",
    "number": "ˈnʌm.bɚ",
    "value": "ˈvæl.juː",
    "quality": "ˈkwɒl.ɪ.ti",
    "security": "sɪˈkjʊə.ɹɪ.ti",
    "community": "kəˈmjuː.nɪ.ti",
    "activity": "ækˈtɪv.ɪ.ti",
    "university": "ˌjuː.nɪˈvɜː.sɪ.ti",
    "copy": "ˈkɒp.i",

    # Programming & Popular Tech Names
    "python": "ˈpaɪ.θɒn",
    "java": "ˈʤɑː.və",
    "javascript": "ˈʤɑː.və.skɹɪpt",
    "typescript": "ˈtaɪp.skɹɪpt",
    "react": "ɹiˈækt",
    "node": "noʊd",
    "docker": "ˈdɒk.ɚ",
    "linux": "ˈlɪn.ʌks",
    "windows": "ˈwɪn.doʊz",
    "apple": "ˈæp.əl",
    "iphone": "ˈaɪ.foʊn",
    "ipad": "ˈaɪ.pæd",
    "macbook": "ˈmæk.bʊk",
    "android": "ˈæn.dɹɔɪd",
    "samsung": "ˈsæm.sʌŋ",
    "galaxy": "ˈɡæl.ək.si",
    "google": "ˈɡuː.ɡəl",
    "microsoft": "ˈmaɪ.kɹoʊ.sɒft",
    "amazon": "ˈæm.ə.zɒn",
    "facebook": "ˈfeɪs.bʊk",
    "instagram": "ˈɪn.stə.ɡɹæm",
    "youtube": "ˈjuː.tjuːb",
    "tiktok": "ˈtɪk.tɒk",
    "twitter": "ˈtwɪt.ɚ",
    "netflix": "ˈnɛt.flɪks",
    "spotify": "ˈspɒt.ɪ.faɪ",
    "realtime": "ˈɹiːl.taɪm",
    "token": "ˈtoʊ.kən",
    "tokens": "ˈtoʊ.kənz",
    "prompt": "pɹɒmpt",
    "agent": "ˈeɪ.ʤənt",
    "agents": "ˈeɪ.ʤənts",
    "workflow": "ˈwɜːk.floʊ",
    "pipeline": "ˈpaɪp.laɪn",
    "audio": "ˈɔː.di.oʊ",
    "speech": "spiːʧ",
    "synthesis": "ˈsɪn.θə.sɪs",
    "phoneme": "ˈfoʊ.niːm",
    "phonemes": "ˈfoʊ.niːmz",
}


# ---------------------------------------------------------------------------
# 2. Rule-Based English Grapheme-to-Phoneme (OOV Fallback)
# ---------------------------------------------------------------------------
_VOWEL_LETTERS = set("aeiouy")


def _is_vowel_letter(ch: str) -> bool:
    return ch in _VOWEL_LETTERS


def _rule_based_en_to_ipa(word: str) -> str:
    """Convert an out-of-vocabulary English word to approximate English IPA.

    Uses morphological prefix/suffix decomposition, silent-letter rules,
    magic-e detection, digraph/trigraph rules, and syllable-aware vowel rules.
    """
    w = re.sub(r"[^a-z]", "", word.lower())
    if not w:
        return ""

    if w in _EN_IPA_DICT:
        return _EN_IPA_DICT[w]

    # Check regular plural / 3rd person -s / -es / -ies or past -ed / -ing on known stems
    if len(w) > 3:
        if w.endswith("ies") and w[:-3] + "y" in _EN_IPA_DICT:
            base_ipa = _EN_IPA_DICT[w[:-3] + "y"]
            if base_ipa.endswith("i"):
                return base_ipa + "z"
        if w.endswith("es") and w[:-2] in _EN_IPA_DICT:
            base_ipa = _EN_IPA_DICT[w[:-2]]
            return base_ipa + (".ɪz" if base_ipa[-1] in "szʃʒʧʤ" else "z")
        if w.endswith("s") and not w.endswith("ss") and w[:-1] in _EN_IPA_DICT:
            base_ipa = _EN_IPA_DICT[w[:-1]]
            return base_ipa + ("s" if base_ipa[-1] in "ptkfθ" else "z")
        if w.endswith("ing"):
            if w[:-3] in _EN_IPA_DICT:
                return _EN_IPA_DICT[w[:-3]] + ".ɪŋ"
            if w[:-3] + "e" in _EN_IPA_DICT:
                return _EN_IPA_DICT[w[:-3] + "e"] + ".ɪŋ"
            if len(w) > 5 and w[-4] == w[-5] and w[:-4] in _EN_IPA_DICT:
                return _EN_IPA_DICT[w[:-4]] + ".ɪŋ"
        if w.endswith("ed"):
            if w[:-2] in _EN_IPA_DICT:
                base_ipa = _EN_IPA_DICT[w[:-2]]
                return base_ipa + (".ɪd" if base_ipa[-1] in "td" else "d")
            if w[:-1] in _EN_IPA_DICT:
                base_ipa = _EN_IPA_DICT[w[:-1]]
                return base_ipa + (".ɪd" if base_ipa[-1] in "td" else "d")
        if w.endswith("er"):
            if w[:-2] in _EN_IPA_DICT:
                return _EN_IPA_DICT[w[:-2]] + ".ɚ"
            if w[:-1] in _EN_IPA_DICT:
                return _EN_IPA_DICT[w[:-1]] + ".ɚ"
        if w.endswith("ly") and w[:-2] in _EN_IPA_DICT:
            return _EN_IPA_DICT[w[:-2]] + ".li"

    # Silent initial letters
    if w.startswith(("kn", "gn", "pn")) and len(w) > 2:
        w = w[1:]
    elif w.startswith("wr") and len(w) > 2:
        w = w[1:]
    elif w.startswith("ps") and len(w) > 2 and w[2] in _VOWEL_LETTERS:
        w = w[1:]
    elif w.startswith("wh"):
        if w.startswith(("who", "whom", "whose", "whole")):
            w = "h" + w[2:]
        else:
            w = "w" + w[2:]

    # Silent final -mb / -mn
    if len(w) > 2 and w.endswith(("mb", "mn")):
        w = w[:-1]

    # Strip common suffixes and save their IPA tails
    suffix_ipas: List[str] = []
    _SUFFIX_RULES = [
        ("isation", "aɪ.ˈzeɪ.ʃən"),
        ("ization", "aɪ.ˈzeɪ.ʃən"),
        ("ication", "ɪ.ˈkeɪ.ʃən"),
        ("ational", "ˈeɪ.ʃən.əl"),
        ("fulness", "fəl.nəs"),
        ("lessness", "ləs.nəs"),
        ("ousness", "əs.nəs"),
        ("iveness", "ɪv.nəs"),
        ("ability", "ə.ˈbɪl.ɪ.ti"),
        ("ibility", "ɪ.ˈbɪl.ɪ.ti"),
        ("ology", "ˈɒl.ə.ʤi"),
        ("ography", "ˈɒɡ.ɹə.fi"),
        ("tion", "ʃən"),
        ("sion", "ʃən"),
        ("cian", "ʃən"),
        ("ture", "ʧɚ"),
        ("sure", "ʒɚ"),
        ("cious", "ʃəs"),
        ("tious", "ʃəs"),
        ("ious", "i.əs"),
        ("eous", "i.əs"),
        ("ment", "mənt"),
        ("ness", "nəs"),
        ("less", "ləs"),
        ("able", "ə.bəl"),
        ("ible", "ɪ.bəl"),
        ("ally", "ə.li"),
        ("ical", "ɪ.kəl"),
        ("ful", "fʊl"),
        ("ous", "əs"),
        ("ive", "ɪv"),
        ("ing", "ɪŋ"),
        ("est", "ɪst"),
        ("ist", "ɪst"),
        ("ism", "ɪ.zəm"),
        ("ize", "aɪz"),
        ("ise", "aɪz"),
        ("ify", "ɪ.faɪ"),
        ("ity", "ɪ.ti"),
    ]
    for suf, suf_ipa in _SUFFIX_RULES:
        if len(w) >= len(suf) + 2 and w.endswith(suf):
            stem = w[: -len(suf)]
            if any(c in _VOWEL_LETTERS for c in stem):
                w = stem
                suffix_ipas.insert(0, suf_ipa)
                break

    # If the remaining stem is now in _EN_IPA_DICT, use it directly
    if w in _EN_IPA_DICT:
        stem_ipa = _EN_IPA_DICT[w]
        if suffix_ipas:
            return stem_ipa + "." + ".".join(suffix_ipas)
        return stem_ipa

    # Detect magic-e at the end of the stem: ...V + C + 'e'
    magic_e = False
    if (
        len(w) >= 3
        and w.endswith("e")
        and not w.endswith(("ee", "ie", "oe", "ue", "ye", "le", "re", "ce", "ge", "se", "ve", "ze"))
        and w[-2] not in _VOWEL_LETTERS
        and w[-3] in _VOWEL_LETTERS
    ):
        magic_e = True
        w = w[:-1]
    elif (
        len(w) >= 4
        and w.endswith(("ce", "ge", "se", "ve", "ze"))
        and w[-3] in _VOWEL_LETTERS
        and (len(w) < 4 or w[-4] not in _VOWEL_LETTERS)
    ):
        magic_e = True

    # Scan left-to-right with multi-character phoneme rules
    out: List[str] = []
    i = 0
    n = len(w)

    while i < n:
        rem = w[i:]

        # 4-letter patterns
        if rem.startswith("eigh"):
            out.append("eɪ")
            i += 4
            continue
        if rem.startswith("ough"):
            out.append("oʊ")
            i += 4
            continue
        if rem.startswith("augh"):
            out.append("ɔː")
            i += 4
            continue

        # 3-letter patterns
        if rem.startswith("igh"):
            out.append("aɪ")
            i += 3
            continue
        if rem.startswith("tch"):
            out.append("ʧ")
            i += 3
            continue
        if rem.startswith("dge"):
            out.append("ʤ")
            i += 3
            continue
        if rem.startswith(("ear", "eer")):
            out.append("ɪə")
            i += 3
            continue
        if rem.startswith(("air", "are")):
            out.append("ɛə")
            i += 3
            continue
        if rem.startswith("ore"):
            out.append("ɔː")
            i += 3
            continue
        if rem.startswith("our"):
            out.append("aʊ.ɚ")
            i += 3
            continue
        if rem.startswith("ire"):
            out.append("aɪ.ɚ")
            i += 3
            continue
        if rem.startswith("ure"):
            out.append("ʊə")
            i += 3
            continue

        # 2-letter consonant digraphs
        if rem.startswith("sh"):
            out.append("ʃ")
            i += 2
            continue
        if rem.startswith("ch"):
            out.append("ʧ")
            i += 2
            continue
        if rem.startswith("ph"):
            out.append("f")
            i += 2
            continue
        if rem.startswith("th"):
            out.append("θ")
            i += 2
            continue
        if rem.startswith("ck"):
            out.append("k")
            i += 2
            continue
        if rem.startswith("qu"):
            out.append("kw")
            i += 2
            continue
        if rem.startswith("ng"):
            out.append("ŋ")
            i += 2
            continue
        if rem.startswith("nk"):
            out.append("ŋk")
            i += 2
            continue
        if rem.startswith("gh"):
            if i == 0:
                out.append("ɡ")
            i += 2
            continue

        # 2-letter vowel digraphs & r-colored vowels
        if rem.startswith(("ee", "ea")):
            out.append("iː")
            i += 2
            continue
        if rem.startswith("oo"):
            if rem.startswith(("ook", "ood", "oot")):
                out.append("ʊ")
            else:
                out.append("uː")
            i += 2
            continue
        if rem.startswith(("ai", "ay", "ei", "ey")):
            out.append("eɪ")
            i += 2
            continue
        if rem.startswith(("oi", "oy")):
            out.append("ɔɪ")
            i += 2
            continue
        if rem.startswith("ou"):
            out.append("aʊ")
            i += 2
            continue
        if rem.startswith("ow"):
            # Final -ow in multi-syllable words is usually oʊ (window, follow, yellow)
            if i + 2 == n and i > 1:
                out.append("oʊ")
            else:
                out.append("aʊ")
            i += 2
            continue
        if rem.startswith(("au", "aw")):
            out.append("ɔː")
            i += 2
            continue
        if rem.startswith(("oa", "oe")):
            out.append("oʊ")
            i += 2
            continue
        if rem.startswith(("ew", "ue", "ui")):
            out.append("uː")
            i += 2
            continue
        if rem.startswith("ie"):
            if i + 2 == n:
                out.append("aɪ" if n <= 4 else "iː")
            else:
                out.append("iː")
            i += 2
            continue

        # Vowel + r (when not followed by another vowel)
        if len(rem) >= 2 and rem[1] == "r" and (len(rem) == 2 or rem[2] not in _VOWEL_LETTERS):
            if rem[0] == "a":
                out.append("ɑː")
                i += 2
                continue
            if rem[0] == "o":
                out.append("ɔː")
                i += 2
                continue
            if rem[0] in ("e", "i", "u"):
                out.append("ɚ" if i > 0 and i + 2 == n else "ɜː")
                i += 2
                continue

        # Final -le after a consonant (apple, table, google)
        if i + 2 == n and rem == "le" and i > 0 and w[i - 1] not in _VOWEL_LETTERS:
            out.append("əl")
            i += 2
            continue

        # Double consonants (bb, dd, ff, gg, ll, mm, nn, pp, rr, ss, tt, zz)
        if len(rem) >= 2 and rem[0] == rem[1] and rem[0] not in _VOWEL_LETTERS:
            if rem.startswith("cc") and len(rem) >= 3 and rem[2] in "eiy":
                out.append("ks")
                i += 2
                continue
            # Emit single consonant and advance by 2
            ch = rem[0]
            rem = ch + rem[2:]
            i += 1
        else:
            ch = rem[0]

        # Single-letter rules
        is_last_vowel = magic_e and (i == n - 2 or (i == n - 3 and w.endswith(("ce", "ge", "se", "ve", "ze"))))

        if ch == "a":
            if is_last_vowel:
                out.append("eɪ")
            elif i + 1 == n:
                out.append("ə")
            elif rem.startswith(("all", "alk", "alt", "ald")):
                out.append("ɔː")
                if rem.startswith("alk"):
                    out.append("k")
                    i += 3
                    continue
            else:
                out.append("æ")
        elif ch == "e":
            if i + 1 == n and n > 2:
                # Silent final -e
                pass
            elif is_last_vowel or (i + 1 == n and n <= 2):
                out.append("iː")
            else:
                out.append("ɛ")
        elif ch == "i":
            if is_last_vowel or rem.startswith(("ind", "ild", "ign")):
                out.append("aɪ")
            else:
                out.append("ɪ")
        elif ch == "o":
            if is_last_vowel or i + 1 == n or rem.startswith(("old", "olt", "ost", "oll")):
                out.append("oʊ")
            else:
                out.append("ɒ")
        elif ch == "u":
            if is_last_vowel:
                out.append("juː")
            elif rem.startswith(("ull", "ush", "ut")) and w in ("put", "push", "pull", "full", "bull"):
                out.append("ʊ")
            else:
                out.append("ʌ")
        elif ch == "y":
            if i == 0:
                out.append("j")
            elif i + 1 == n:
                out.append("aɪ" if n <= 3 else "i")
            elif is_last_vowel:
                out.append("aɪ")
            else:
                out.append("ɪ")
        elif ch == "c":
            if len(rem) >= 2 and rem[1] in "eiy":
                out.append("s")
            else:
                out.append("k")
        elif ch == "g":
            if len(rem) >= 2 and rem[1] in "eiy":
                out.append("ʤ")
            else:
                out.append("ɡ")
        elif ch == "x":
            if i == 0:
                out.append("z")
            else:
                out.append("ks")
        elif ch == "s":
            if i > 0 and i + 1 < n and w[i - 1] in _VOWEL_LETTERS and w[i + 1] in _VOWEL_LETTERS:
                out.append("z")
            else:
                out.append("s")
        elif ch == "r":
            out.append("ɹ")
        elif ch == "j":
            out.append("ʤ")
        elif ch in "bdfhklmnptvwz":
            out.append(ch)

        i += 1

    stem_ipa = "".join(out)
    if suffix_ipas:
        if stem_ipa:
            return "ˈ" + stem_ipa + "." + ".".join(suffix_ipas)
        return ".".join(suffix_ipas)
    return "ˈ" + stem_ipa if stem_ipa else ""


# ---------------------------------------------------------------------------
# 3. English-to-IPA Entry Point (Dictionary + Rule-Based + Optional espeak-ng)
# ---------------------------------------------------------------------------
[docs] @lru_cache(maxsize=4096) def en_to_ipa(word: str, use_espeak: bool = False) -> str: """Convert an English word (or phrase) to English IPA. :param str word: English word or text :param bool use_espeak: Whether to try espeak-ng CLI when available (default: False) :return: English IPA string (e.g. '/həˈloʊ/') """ if not word or not word.strip(): return "" tokens = re.findall(r"[A-Za-z]+(?:'[A-Za-z]+)?", word) if not tokens: return "" results: List[str] = [] for tok in tokens: clean = tok.lower().replace("'", "") if not clean: continue if clean in _EN_IPA_DICT: results.append(f"/{_EN_IPA_DICT[clean]}/") continue if use_espeak and shutil.which("espeak-ng"): try: proc = subprocess.run( ["espeak-ng", "-q", "--ipa", "-v", "en-us", clean], capture_output=True, text=True, timeout=2.0, check=False, ) espeak_out = proc.stdout.strip() if espeak_out: results.append(f"/{espeak_out}/") continue except Exception: pass ipa = _rule_based_en_to_ipa(clean) if ipa: results.append(f"/{ipa}/") return " ".join(results)
# --------------------------------------------------------------------------- # 4. English IPA -> Thai IPA Phoneme Converter (Thai-Style Adaptation) # --------------------------------------------------------------------------- # Ordered vowel tokens in normalized English IPA (longest first) _EN_VOWEL_TOKENS = ( "aɪ", "ɑɪ", "ʌɪ", "aʊ", "ɑʊ", "eɪ", "ɛɪ", "oʊ", "əʊ", "ɔɪ", "oɪ", "ɪə", "iə", "ɛə", "eə", "ʊə", "uə", "iː", "uː", "ɑː", "ɔː", "ɜː", "ɚ", "ɝ", "ɪ", "ᵻ", "i", "ʊ", "u", "ɛ", "e", "æ", "ɑ", "ɒ", "ɔ", "ʌ", "ɐ", "ə", "a", "o", ) _EN_VOWEL_SET = set(_EN_VOWEL_TOKENS) _EN_CONSONANT_TOKENS = ( "ʧ", "ʤ", "p", "b", "t", "d", "k", "ɡ", "f", "v", "θ", "ð", "s", "z", "ʃ", "ʒ", "h", "m", "n", "ŋ", "l", "ɹ", "j", "w", "ʔ", ) _EN_CONSONANT_SET = set(_EN_CONSONANT_TOKENS) # Valid 2-consonant English onset clusters (excluding s-clusters, handled separately) _VALID_EN_ONSET_CLUSTERS = { ("p", "ɹ"), ("p", "l"), ("p", "j"), ("b", "ɹ"), ("b", "l"), ("b", "j"), ("t", "ɹ"), ("t", "j"), ("t", "w"), ("d", "ɹ"), ("d", "j"), ("d", "w"), ("k", "ɹ"), ("k", "l"), ("k", "w"), ("k", "j"), ("ɡ", "ɹ"), ("ɡ", "l"), ("ɡ", "w"), ("ɡ", "j"), ("f", "ɹ"), ("f", "l"), ("f", "j"), ("v", "j"), ("m", "j"), ("n", "j"), ("h", "j"), ("θ", "ɹ"), ("ʃ", "ɹ"), } # Short lax English vowels that trigger consonant gemination in stressed open syllables _SHORT_LAX_EN_VOWELS = {"ɪ", "ɛ", "æ", "ɒ", "ʌ", "ʊ"} # Valid Thai onset clusters in FastThaiG2P's ipa.json _VALID_THAI_CLUSTERS = { "kr", "kl", "kw", "kʰr", "kʰl", "kʰw", "pr", "pl", "pʰr", "pʰl", "tr", "tʰr", "br", "bl", "dr", "fr", "fl", } # Thai low-class onset consonants (for loanword tone assignment) _LOW_CLASS_ONSETS = { "kʰ", "kʰr", "kʰl", "kʰw", "tʰ", "tʰr", "pʰ", "pʰr", "pʰl", "t͡ɕʰ", "f", "fr", "fl", "s", "h", "m", "n", "ŋ", "r", "l", "j", "w", } def _normalize_en_ipa_word(ipa_word: str) -> str: """Normalize a single English IPA word string prior to tokenization.""" s = ipa_word.strip().strip("/[]") if not s: return "" # Pre-composed affricates and tie-bars s = s.replace("t͡ʃ", "ʧ").replace("t͜ʃ", "ʧ").replace("tʃ", "ʧ") s = s.replace("d͡ʒ", "ʤ").replace("d͜ʒ", "ʤ").replace("dʒ", "ʤ") s = s.replace("g", "ɡ") s = s.replace("ɾ", "ɹ").replace("ɻ", "ɹ").replace("r", "ɹ") s = s.replace("ɫ", "l") # Syllabic consonants (n̩, m̩, l̩) s = s.replace("n̩", "ən").replace("m̩", "əm").replace("l̩", "əl") # Normalize r-colored diphthongs & vowels when followed by non-vowel (coda r) # Match 3-char diphthongs (ɪəɹ, ɛəɹ, ʊəɹ) before 2-char əɹ! s = re.sub(r"ɪə?ɹ(?![aeiouɑɒɔɛɪʊʌəɚɝæ])", "ɪə", s) s = re.sub(r"[ɛe]ə?ɹ(?![aeiouɑɒɔɛɪʊʌəɚɝæ])", "ɛə", s) s = re.sub(r"ʊə?ɹ(?![aeiouɑɒɔɛɪʊʌəɚɝæ])", "ʊə", s) s = re.sub(r"ɑː?ɹ(?![aeiouɑɒɔɛɪʊʌəɚɝæ])", "ɑː", s) s = re.sub(r"[ɔo]ː?ɹ(?![aeiouɑɒɔɛɪʊʌəɚɝæ])", "ɔː", s) s = re.sub(r"[ɜɝ]ː?ɹ?(?![aeiouɑɒɔɛɪʊʌəɚɝæ])", "ɜː", s) s = re.sub(r"əɹ(?![aeiouɑɒɔɛɪʊʌəɚɝæ])", "ɚ", s) return s def _tokenize_en_ipa(s: str) -> List[str]: """Tokenize normalized English IPA into phoneme units + boundary markers.""" tokens: List[str] = [] i = 0 n = len(s) all_units = ( ("ˈ", "ˌ", ".") + _EN_VOWEL_TOKENS + _EN_CONSONANT_TOKENS ) while i < n: if s[i].isspace(): i += 1 continue matched = False for unit in all_units: if s.startswith(unit, i): tokens.append(unit) i += len(unit) matched = True break if not matched: # Skip unknown diacritics (e.g. ː, ʰ, ̃) i += 1 return tokens def _syllabify_en_ipa( tokens: List[str], ) -> List[Tuple[int, List[str], str, List[str]]]: """Group English IPA phoneme tokens into syllables: (stress, onset, vowel, coda). stress: 1 = primary ('ˈ'), 2 = secondary ('ˌ'), 0 = unstressed. """ items: List[Tuple[str, str]] = [] # ('B', marker) | ('C', tok) | ('V', tok) for t in tokens: if t in ("ˈ", "ˌ", "."): items.append(("B", t)) elif t in _EN_VOWEL_SET: items.append(("V", t)) elif t in _EN_CONSONANT_SET: items.append(("C", t)) v_indices = [idx for idx, (kind, _) in enumerate(items) if kind == "V"] if not v_indices: return [] syllables: List[Tuple[int, List[str], str, List[str]]] = [] num_v = len(v_indices) onsets: List[List[str]] = [[] for _ in range(num_v)] codas: List[List[str]] = [[] for _ in range(num_v)] stresses: List[int] = [0 for _ in range(num_v)] vowels: List[str] = [items[v_idx][1] for v_idx in v_indices] # Leading items before first vowel for kind, val in items[: v_indices[0]]: if kind == "B": if val == "ˈ": stresses[0] = 1 elif val == "ˌ" and stresses[0] == 0: stresses[0] = 2 elif kind == "C": onsets[0].append(val) # Trailing items after last vowel for kind, val in items[v_indices[-1] + 1 :]: if kind == "C": codas[-1].append(val) # Intervocalic stretches between v_indices[k] and v_indices[k + 1] for k in range(num_v - 1): mid = items[v_indices[k] + 1 : v_indices[k + 1]] b_pos = -1 for idx, (kind, val) in enumerate(mid): if kind == "B": b_pos = idx if val == "ˈ": stresses[k + 1] = 1 elif val == "ˌ" and stresses[k + 1] == 0: stresses[k + 1] = 2 if b_pos != -1: # Explicit boundary exists coda_c = [val for kind, val in mid[:b_pos] if kind == "C"] onset_c = [val for kind, val in mid[b_pos + 1 :] if kind == "C"] if not onset_c and coda_c: last_c = coda_c[-1] if vowels[k] in _SHORT_LAX_EN_VOWELS or vowels[k] == "ə": # Geminate: keep in coda of k AND copy to onset of k+1 (e.g. ˈkɒp.i -> kɔp.piː) onset_c = [last_c] else: onset_c = [coda_c.pop()] elif len(onset_c) == 1 and not coda_c and vowels[k] in _SHORT_LAX_EN_VOWELS and stresses[k] == 1: if onset_c[0] in ("p", "b", "t", "d", "k", "ɡ", "f", "m", "n"): coda_c = [onset_c[0]] codas[k].extend(coda_c) onsets[k + 1].extend(onset_c) else: # No explicit boundary: apply Maximal Onset Principle on consonants cons = [val for kind, val in mid if kind == "C"] if not cons: if vowels[k] in ("aʊ", "oʊ", "əʊ", "uː", "ʊ"): onsets[k + 1].append("w") elif vowels[k] in ("aɪ", "eɪ", "ɔɪ"): onsets[k + 1].append("j") elif len(cons) == 1: c = cons[0] if vowels[k] in _SHORT_LAX_EN_VOWELS and (stresses[k] == 1 or k == 0): if c in ("p", "b", "t", "d", "k", "ɡ", "f", "m", "n"): codas[k].append(c) onsets[k + 1].append(c) else: if tuple(cons[-2:]) in _VALID_EN_ONSET_CLUSTERS: codas[k].extend(cons[:-2]) onsets[k + 1].extend(cons[-2:]) elif len(cons) >= 2 and cons[-2] == "s" and cons[-1] in ("p", "t", "k"): codas[k].extend(cons[:-1]) onsets[k + 1].append(cons[-1]) else: codas[k].extend(cons[:-1]) onsets[k + 1].append(cons[-1]) if all(s == 0 for s in stresses) and stresses: stresses[0] = 1 for k in range(num_v): syllables.append((stresses[k], onsets[k], vowels[k], codas[k])) return syllables def _map_en_onset_to_thai( onset: List[str], after_s: bool = False, is_word_initial: bool = True, stress: int = 1, ) -> str: """Map an English syllable onset consonant list to a Thai IPA onset string.""" if not onset: return "ʔ" def _map_single(c: str, in_cluster: bool = False) -> str: if c == "p": if after_s or (not is_word_initial and stress == 0 and not in_cluster): return "p" return "pʰ" if c == "t": if after_s or (not is_word_initial and stress == 0 and not in_cluster): return "t" return "tʰ" if c == "k": return "k" if after_s else "kʰ" if c == "b": return "b" if c == "d": return "d" if c == "ɡ": return "k" if c == "ʧ": return "t͡ɕʰ" if c == "ʤ": return "t͡ɕ" if c == "f": return "f" if c == "v": return "w" if c == "θ": return "tʰ" if c == "ð": return "d" if is_word_initial else "tʰ" if c in ("s", "z"): return "s" if c in ("ʃ", "ʒ"): return "t͡ɕʰ" if c == "h": return "h" if c == "m": return "m" if c == "n": return "n" if c == "ŋ": return "ŋ" if c == "l": return "l" if c == "ɹ": return "r" if c == "j": return "j" if c == "w": return "w" return "ʔ" if len(onset) == 1: return _map_single(onset[0]) # 2-consonant onset cluster if onset[0] == "p" and onset[1] == "ɹ": return "pr" c1 = _map_single(onset[0], in_cluster=True) c2 = _map_single(onset[1], in_cluster=True) cand = c1 + c2 if cand in _VALID_THAI_CLUSTERS: return cand return c1 def _map_en_coda_to_thai(coda: List[str]) -> str: """Map an English coda consonant list to a single Thai IPA coda.""" # Filter out post-vocalic r c_list = [c for c in coda if c != "ɹ"] if not c_list: return "" pair = tuple(c_list[:2]) # Nasal + stop/fricative clusters keep the nasal in Thai loanwords if c_list[0] == "m" and len(c_list) >= 2 and c_list[1] in ("p", "b", "f", "z", "s"): return "m" if c_list[0] == "n" and len(c_list) >= 2 and c_list[1] in ("t", "d", "s", "z", "ʧ", "ʤ", "θ"): return "n" if c_list[0] == "ŋ" and len(c_list) >= 2 and c_list[1] in ("k", "ɡ", "z", "s"): return "ŋ" # Special obstruent clusters if pair in (("k", "s"), ("ɡ", "z"), ("k", "t"), ("s", "k")): return "k̚" if pair in (("s", "t"), ("f", "t") if False else ("s", "t"), ("t", "s"), ("d", "z")): return "t̚" if pair in (("f", "t"), ("p", "t"), ("s", "p"), ("p", "s"), ("b", "z")): return "p̚" if pair in (("l", "d"), ("l", "t"), ("l", "z"), ("l", "s")): return "n" # Default: map the first coda consonant c = c_list[0] if c in ("p", "b", "f", "v"): return "p̚" if c in ("t", "d", "θ", "ð", "s", "z", "ʃ", "ʒ", "ʧ", "ʤ"): return "t̚" if c in ("k", "ɡ"): return "k̚" if c == "m": return "m" if c in ("n", "l"): return "n" if c == "ŋ": return "ŋ" if c == "j": return "j" if c == "w": return "w" return "" def _assign_thai_tone( onset: str, vowel: str, coda: str, stress: int, is_first: bool, is_last: bool, total_syls: int, ) -> str: """Assign a Thai tone (˧, ˨˩, ˥˩, ˦˥, ˩˩˦) for Thai-style English pronunciation.""" is_dead = coda in ("k̚", "t̚", "p̚", "ʔ") or (coda == "" and vowel in ("a", "i", "u")) is_long = "ː" in vowel or "̯" in vowel is_low_onset = onset in _LOW_CLASS_ONSETS if is_dead: if coda == "" and vowel in ("a", "i", "u"): return "˨˩" if not is_long: # Short vowel + stop coda (k̚, t̚, p̚) if is_low_onset or total_syls == 1 or (is_first and stress > 0): return "˦˥" return "˨˩" else: # Long vowel + stop coda if is_low_onset and (vowel in ("ɤː", "ɔː") or total_syls == 1): return "˦˥" return "˨˩" # Live syllable (open long vowel or sonorant coda m, n, ŋ, j, w) if total_syls == 1: return "˧" if not is_last: return "˧" # Final live syllable in a multi-syllable word: # Primary-stressed final syllable takes mid tone (e.g. online, cartoon, hello); # unstressed final syllable takes falling tone (e.g. computer, meeting, copy, google). if stress == 1: return "˧" return "˥˩" def _convert_single_en_ipa_word(en_ipa_word: str) -> str: """Convert a single word's English IPA string into Wiktionary Thai IPA `/syl.syl/`.""" norm = _normalize_en_ipa_word(en_ipa_word) if not norm: return "" tokens = _tokenize_en_ipa(norm) if not tokens: return "" raw_syllables = _syllabify_en_ipa(tokens) if not raw_syllables: return "" # Expand syllables (handling s-cluster epenthesis) expanded: List[Tuple[int, str, str, str]] = [] # (stress, thai_onset, thai_vowel, thai_coda) num_raw = len(raw_syllables) for idx, (stress, onset, vowel, coda) in enumerate(raw_syllables): is_first_raw = idx == 0 is_last_raw = idx == num_raw - 1 # 1. Check s + consonant onset cluster -> epenthetic 'sa˨˩' after_s = False if len(onset) >= 2 and onset[0] == "s": expanded.append((0, "s", "a", "")) onset = onset[1:] after_s = True # 2. Check C + j + uː/ʊ -> C + iw (e.g. computer -> kʰɔm.pʰiw.tɤː, new -> niw, cute -> kʰiw) if len(onset) >= 2 and onset[-1] == "j" and vowel in ("uː", "u", "ʊ"): onset = onset[:-1] thai_onset = _map_en_onset_to_thai( onset, after_s=after_s, is_word_initial=is_first_raw, stress=stress ) expanded.append((stress, thai_onset, "i", "w")) continue thai_onset = _map_en_onset_to_thai( onset, after_s=after_s, is_word_initial=is_first_raw, stress=stress ) # 3. Diphthongs with offglides (aɪ, aʊ, ɔɪ) where the offglide becomes the Thai coda if vowel in ("aɪ", "ɑɪ", "ʌɪ"): thai_v = "aː" if (not coda or coda[0] in ("l", "n", "m", "d", "v", "z")) else "a" expanded.append((stress, thai_onset, thai_v, "j")) continue if vowel in ("aʊ", "ɑʊ"): thai_v = "aː" if (not coda or coda[0] in ("l", "n", "m", "d", "v", "z")) else "a" expanded.append((stress, thai_onset, thai_v, "w")) continue if vowel in ("ɔɪ", "oɪ"): expanded.append((stress, thai_onset, "ɔː", "j")) continue # 4. Map coda thai_coda = _map_en_coda_to_thai(coda) # If coda had 'ɹ' that was stripped, lengthen short vowels had_r_coda = "ɹ" in coda # 5. Map vowel if vowel == "iː": thai_v = "iː" elif vowel == "i": thai_v = "iː" if not thai_coda else "i" elif vowel in ("ɪ", "ᵻ"): if had_r_coda: thai_v = "ia̯" elif not thai_coda and is_last_raw: thai_v = "iː" else: thai_v = "i" elif vowel in ("eɪ", "ɛɪ"): thai_v = "eː" elif vowel in ("ɛ", "e"): if had_r_coda: thai_v = "ɛː" elif not thai_coda: thai_v = "eː" else: thai_v = "e" elif vowel == "æ": if not thai_coda or thai_coda == "n": thai_v = "ɛː" else: thai_v = "ɛ" elif vowel in ("ɑː", "ɑ", "a"): if vowel == "ɑː" or had_r_coda or not thai_coda: thai_v = "aː" else: thai_v = "a" elif vowel == "ɒ": if had_r_coda or not thai_coda: thai_v = "ɔː" else: thai_v = "ɔ" elif vowel in ("ɔː", "ɔ"): thai_v = "ɔː" elif vowel in ("oʊ", "əʊ", "o"): thai_v = "oː" elif vowel == "ʊ": if had_r_coda: thai_v = "ua̯" else: thai_v = "u" elif vowel in ("uː", "u"): thai_v = "uː" elif vowel in ("ʌ", "ɐ"): thai_v = "a" elif vowel in ("ɜː", "ɝ", "ɚ"): thai_v = "ɤː" elif vowel == "ə": if had_r_coda: thai_v = "ɤː" elif not thai_coda: if is_last_raw: thai_v = "aː" elif thai_onset == "h" and idx + 1 < num_raw and raw_syllables[idx + 1][1] == ["l"]: # e.g. hello /həˈloʊ/ -> he˧.loː˧ thai_v = "eː" else: thai_v = "a" elif coda and coda[0] == "l": thai_v = "ɤː" elif thai_coda in ("m", "n") and thai_onset in ("kʰ", "k") and is_first_raw: # com- / con- prefix (computer, control, connect) thai_v = "ɔ" else: thai_v = "a" elif vowel in ("ɪə", "iə"): thai_v = "ia̯" elif vowel in ("ɛə", "eə"): thai_v = "ɛː" elif vowel in ("ʊə", "uə"): thai_v = "ua̯" else: thai_v = "aː" if not thai_coda else "a" # Enforce Thai phonotactics: mid/open-mid short vowels cannot be open without coda if not thai_coda and thai_v in ("e", "ɛ", "o", "ɔ", "ɤ", "ɯ"): thai_v = thai_v + "ː" expanded.append((stress, thai_onset, thai_v, thai_coda)) # 6. Assign tones and join with '.' total_exp = len(expanded) thai_syls: List[str] = [] for s_idx, (stress, t_onset, t_vowel, t_coda) in enumerate(expanded): tone = _assign_thai_tone( onset=t_onset, vowel=t_vowel, coda=t_coda, stress=stress, is_first=(s_idx == 0), is_last=(s_idx == total_exp - 1), total_syls=total_exp, ) thai_syls.append(f"{t_onset}{t_vowel}{t_coda}{tone}") if not thai_syls: return "" return "/" + ".".join(thai_syls) + "/"
[docs] def en_ipa_to_thai_ipa(en_ipa: str) -> str: """Convert an English IPA string into Wiktionary-style Thai IPA phonemes. Adapts English consonants, vowels, consonant clusters, codas, and stress into Thai phonological structure (/syl.la.ble˧/) so Thai TTS models speak English words with natural Thai-style pronunciation. :param str en_ipa: English IPA string (e.g. '/həˈloʊ/' or '/kəmˈpjuːtɚ/') :return: Thai Wiktionary IPA string (e.g. '/heː˧.loː˧/' or '/kʰɔm˧.pʰiw˧.tɤː˥˩/') """ if not en_ipa or not en_ipa.strip(): return "" # Split into word-level IPA chunks (either slash-delimited or space-delimited) slash_chunks = re.findall(r"/[^/]+/", en_ipa) if slash_chunks: words = slash_chunks else: words = en_ipa.strip().split() out = [] for w in words: converted = _convert_single_en_ipa_word(w) if converted: out.append(converted) return " ".join(out)
[docs] @lru_cache(maxsize=4096) def en_g2p(word: str, use_espeak: bool = False) -> str: """Convert English word(s) to Thai-style Wiktionary IPA (/syl.la.ble˧/). Pipeline: English text -> English IPA -> Thai IPA. :param str word: English word or text :param bool use_espeak: Whether to use espeak-ng when available (default: False) :return: Thai Wiktionary IPA string """ en_ipa = en_to_ipa(word, use_espeak=use_espeak) if not en_ipa: return word thai_ipa = en_ipa_to_thai_ipa(en_ipa) return thai_ipa if thai_ipa else word
[docs] class EnglishG2P: """English-to-Thai-IPA G2P converter for speaking English in Thai style."""
[docs] def __init__(self, use_espeak: bool = False): self.use_espeak = use_espeak
[docs] def to_en_ipa(self, text: str) -> str: """Convert English text to English IPA.""" return en_to_ipa(text, use_espeak=self.use_espeak)
[docs] def to_thai_ipa(self, en_ipa: str) -> str: """Convert English IPA phoneme string to Thai Wiktionary IPA.""" return en_ipa_to_thai_ipa(en_ipa)
[docs] def convert(self, text: str) -> str: """Convert English text directly to Thai Wiktionary IPA.""" return en_g2p(text, use_espeak=self.use_espeak)
def __call__(self, text: str) -> str: return self.convert(text)