Skip to content

Multi-language pronunciation

FaceFX ships an Additional Language Framework: drop-in dictionaries declaring a phonetic alphabet and locale, rule-based pronouncers that see word context, and custom tokenizers so non-Latin scripts survive. OpenFaceFX was hard-wired to English; issue #8 formalizes the grapheme-to-phoneme seam into a protocol and adds that machinery — every hook is opt-in, so the default English path is byte-identical to before.

The seam is a protocol

Pronouncer is a typing.Protocol — a tokenizer (tokenize(text) -> words) plus a word/phrase grapheme-to-phoneme map. The existing G2P is the English implementation; a locale can supply its own conformer.

IPA / SAMPA aliases

Every one of the 39 internal ARPAbet phonemes has an IPA and an X-SAMPA alias (phonemes.IPA_ALIASES / SAMPA_ALIASES), exposed for display and used by the loader. The maps are bijections, so internal -> alias -> internal round-trips exactly (to_ipa/from_ipa, to_sampa/from_sampa, from_alphabet).

Dictionaries

read_dictionary parses a .dict file whose header declares the locale and phoneme alphabet (arpabet | ipa | sampa); each entry's phonemes are mapped into the internal inventory, so a loaded IPA or SAMPA dictionary drives the same downstream pipeline:

;;; locale = xx-Toy
;;; alphabet = ipa
ka  k ɑ
ki  k i
g = G2P()
g.load_dictionary("toy.dict")     # adds mapped entries; first spelling wins
naive_segments("ka ki", 2.0, g2p=g)   # a normal track, via the naive path

Pronouncer hook

Set G2P(pronouncer=fn) with a PronouncerHookcallable(word, prev, next) -> phonemes | None. It is consulted between dictionary lookup and the rule fallback (FaceFX's lookup → pronouncer → rules order) and receives the correct previous/next word context (None at the utterance edges); returning None defers to the rules. This is how Czech/Polish are done entirely in code in FaceFX.

Pluggable tokenizer

Set G2P(tokenizer=fn) with a callable(text) -> words. The English default is re.findall(r"[A-Za-z']+", text), which drops non-Latin script; a per-language tokenizer (e.g. str.split, or a kana/Hangul splitter) keeps every token.

Out-of-inventory phonemes

A phoneme with no internal equivalent passes through and falls to sil at the viseme stage (the documented PHONEME_TO_VISEME behaviour) — an unmapped symbol is silent, never a crash. Map it (extend the alias tables or the mapping) to give it a mouth shape.

openfacefx.pronounce

Multi-language pronunciation framework (issue #8).

FaceFX ships an Additional Language Framework: drop-in dictionaries that declare a phonetic alphabet (arpabet / ipa / sampa) and a locale, rule-based pronouncers that receive previous/current/next word context, and per-language tokenizers so non-Latin scripts survive. OpenFaceFX was hard-wired to English. This module formalizes the grapheme-to-phoneme seam into a small protocol and adds the additive machinery around it — every hook is opt-in, so the default English path (:class:openfacefx.g2p.G2P with no dictionary, pronouncer or custom tokenizer) is byte-identical to before.

  • :class:Pronouncer — the protocol: a tokenizer (text -> words) plus a word/phrase grapheme-to-phoneme map. G2P is the English implementation.
  • :func:read_dictionary — load a .dict file declaring locale and alphabet, mapping each entry into the internal ARPAbet inventory via the IPA/SAMPA alias tables in :mod:openfacefx.phonemes.
  • :data:PronouncerHook — the callable(word, prev, next) -> phonemes | None consulted between dictionary lookup and the rule fallback, mirroring FaceFX's lookup → pronouncer → rules order.

A phoneme that maps to nothing in the internal inventory passes through and falls to sil at the viseme stage (the documented PHONEME_TO_VISEME behaviour), so an unmapped symbol is silent, never a crash.

PronouncerHook = Callable[[str, Optional[str], Optional[str]], Optional[List[str]]] module-attribute

Tokenizer = Callable[[str], List[str]] module-attribute

Pronouncer

Bases: Protocol

The grapheme-to-phoneme seam: a tokenizer plus word/phrase resolution. :class:openfacefx.g2p.G2P is the English implementation; a locale can supply its own conformer.

tokenize(text: str) -> List[str]

Source code in src/openfacefx/pronounce.py
def tokenize(self, text: str) -> List[str]: ...

word(w: str) -> List[str]

Source code in src/openfacefx/pronounce.py
def word(self, w: str) -> List[str]: ...

phrase(text: str) -> List[str]

Source code in src/openfacefx/pronounce.py
def phrase(self, text: str) -> List[str]: ...

Dictionary(locale: str = '', alphabet: str = 'arpabet', entries: Dict[str, List[str]] = dict()) dataclass

A parsed pronunciation dictionary: a locale tag, the source phoneme alphabet and entries already mapped into the internal inventory.

locale: str = '' class-attribute instance-attribute

alphabet: str = 'arpabet' class-attribute instance-attribute

entries: Dict[str, List[str]] = field(default_factory=dict) class-attribute instance-attribute

read_dictionary(path: str) -> Dictionary

Parse a pronunciation .dict file into a :class:Dictionary.

Header lines (;;; locale = ja-JP / ;;; alphabet = ipa) declare the locale and phoneme alphabet; each remaining line is word p1 p2 ... with the phonemes written in that alphabet. Every phoneme is mapped into the internal ARPAbet inventory via the alias tables, so a loaded IPA or SAMPA dictionary drives the same downstream pipeline. The first spelling of a repeated word wins (as CMUdict primary pronunciations do).

Source code in src/openfacefx/pronounce.py
def read_dictionary(path: str) -> Dictionary:
    """Parse a pronunciation ``.dict`` file into a :class:`Dictionary`.

    Header lines (``;;; locale = ja-JP`` / ``;;; alphabet = ipa``) declare the
    locale and phoneme alphabet; each remaining line is ``word  p1 p2 ...`` with
    the phonemes written in that alphabet. Every phoneme is mapped into the
    internal ARPAbet inventory via the alias tables, so a loaded IPA or SAMPA
    dictionary drives the same downstream pipeline. The first spelling of a
    repeated word wins (as CMUdict primary pronunciations do)."""
    locale, alphabet = "", "arpabet"
    entries: Dict[str, List[str]] = {}
    with open(path, encoding="utf-8") as fh:
        for line in fh:
            s = line.strip()
            if not s:
                continue
            if s[0] in ";#":
                m = _HEADER.match(s)
                if m:
                    key, val = m.group(1).lower(), m.group(2).strip()
                    if key == "locale":
                        locale = val
                    elif key in ("alphabet", "phoneset", "phonemes"):
                        alphabet = val.lower()
                continue
            parts = s.split()
            word = parts[0].lower()
            if word not in entries:
                entries[word] = [from_alphabet(p, alphabet) for p in parts[1:]]
    if alphabet not in ALPHABETS:
        raise ValueError(f"dictionary {path}: unknown alphabet {alphabet!r} "
                         f"(declare one of {ALPHABETS})")
    return Dictionary(locale, alphabet, entries)