Visemes, coarticulation & curves¶
The viseme model (the Oculus/Meta 15-viseme set and the phoneme→viseme map), the
Cohen–Massaro dominance-blending coarticulation solver, the FaceTrack data
model and keyframe reduction, data-driven weighted mapping, and IPA support.
openfacefx.visemes
¶
Viseme inventory and phoneme -> viseme mapping.
A viseme is the visual mouth shape corresponding to one or more phonemes. Many phonemes are visually indistinguishable (e.g. /p/, /b/, /m/ are all a lip closure), so the mapping is many-to-one.
We ship the 15-target set popularised by the Oculus/Meta LipSync SDK because it is a widely adopted, well-documented, IP-free convention that most character rigs already provide blendshapes for. Each viseme name below is a blendshape your rig is expected to expose.
VISEMES = ['sil', 'PP', 'FF', 'TH', 'DD', 'kk', 'CH', 'SS', 'nn', 'RR', 'aa', 'E', 'I', 'O', 'U']
module-attribute
¶
VISEME_INDEX = {name: i for i, name in (enumerate(VISEMES))}
module-attribute
¶
PHONEME_TO_VISEME = {'P': 'PP', 'B': 'PP', 'M': 'PP', 'F': 'FF', 'V': 'FF', 'TH': 'TH', 'DH': 'TH', 'T': 'DD', 'D': 'DD', 'L': 'DD', 'N': 'nn', 'NG': 'nn', 'K': 'kk', 'G': 'kk', 'HH': 'kk', 'CH': 'CH', 'JH': 'CH', 'SH': 'CH', 'ZH': 'CH', 'S': 'SS', 'Z': 'SS', 'R': 'RR', 'ER': 'RR', 'W': 'U', 'Y': 'I', 'AA': 'aa', 'AE': 'aa', 'AH': 'aa', 'AY': 'aa', 'EH': 'E', 'EY': 'E', 'IH': 'E', 'IY': 'I', 'AO': 'O', 'OW': 'O', 'OY': 'O', 'AW': 'O', 'UW': 'U', 'UH': 'U', SILENCE: 'sil'}
module-attribute
¶
phoneme_to_viseme(phoneme: str) -> str
¶
Map a (possibly stressed) ARPAbet phoneme to a viseme name.
openfacefx.coarticulation
¶
Coarticulation via dominance functions (Cohen & Massaro, 1993).
Real speech is not a sequence of discrete mouth poses -- each phoneme's shape is pulled toward its neighbours. A common, well-cited way to model this is to give every phoneme segment a dominance function: a bump in time, peaked at the segment centre, that decays outward. The activation of a viseme channel at any instant is the dominance-weighted average of the targets of all nearby segments.
F_v(t) = sum_i D_i(t) * target(i, v) / sum_i D_i(t)
where D_i(t) = alpha_i * exp( -theta_i * |t - c_i| ) (a Laplacian bump), c_i is the segment centre, and target(i, v) is 1 if segment i maps to viseme v.
The result is smooth, overlapping viseme curves rather than hard switches.
CoartParams(lead: Dict[str, Tuple[float, float]] = (lambda: {'basic': (0.4, 0.45), 'jaw': (0.4, 0.45), 'lips': (0.3, 0.3), 'tongue': (0.15, 0.15)})(), short_silence: float = 0.27, closure_floor: float = 0.9, split_diphthongs: bool = True, preroll: float = 0.0, allow_negative_time: bool = False, intensity: float = 1.0, gains: Dict[str, float] = (lambda: {'basic': 1.0, 'jaw': 1.0, 'lips': 1.0, 'tongue': 1.0})())
dataclass
¶
Component-model tunables (FaceFX-style ca_* knobs).
lead gives per-articulator-class (lead_in, lead_out) extents in
seconds — how far a segment's influence reaches before/after its centre.
The "basic"/"jaw" defaults reproduce the classic symmetric model; lips
and especially tongue targets are tighter, so a quick stop does not smear
across neighbouring vowels.
intensity (master) and gains (per-articulator-class) are JALI-style
artistic dials: after the curves are normalized, every channel's opening is
scaled by intensity * gains[class] and the freed weight flows into
sil (see _apply_intensity). All 1.0 is a byte-identical no-op;
<1 mumbles / softens a class, >1 hyper-articulates, 0 mutes it.
Enforced lip closures still win afterwards, so a whispered bilabial seals.
lead: Dict[str, Tuple[float, float]] = field(default_factory=(lambda: {'basic': (0.4, 0.45), 'jaw': (0.4, 0.45), 'lips': (0.3, 0.3), 'tongue': (0.15, 0.15)}))
class-attribute
instance-attribute
¶
short_silence: float = 0.27
class-attribute
instance-attribute
¶
closure_floor: float = 0.9
class-attribute
instance-attribute
¶
split_diphthongs: bool = True
class-attribute
instance-attribute
¶
preroll: float = 0.0
class-attribute
instance-attribute
¶
allow_negative_time: bool = False
class-attribute
instance-attribute
¶
intensity: float = 1.0
class-attribute
instance-attribute
¶
gains: Dict[str, float] = field(default_factory=(lambda: {'basic': 1.0, 'jaw': 1.0, 'lips': 1.0, 'tongue': 1.0}))
class-attribute
instance-attribute
¶
build_viseme_curves(segments: List[PhonemeSegment], fps: float = 60.0, mapping: Optional[Mapping] = None, params: Optional[CoartParams] = None) -> tuple
¶
Return (times, matrix) where matrix[frame, target] in [0,1].
times is a 1-D array of sample times. Without mapping, columns
follow visemes.VISEMES; with a Mapping they follow
mapping.target_names and any phoneme may drive several targets with
fractional weights. params tunes the component coarticulation model
(per-articulator lead in/out, silence absorption, closure enforcement,
diphthong splitting, onset pre-roll).
Source code in src/openfacefx/coarticulation.py
openfacefx.curves
¶
Animation curves: keyframe reduction and track containers.
The dominance model produces one dense sample per frame. Rigs and engines
prefer sparse keyframes, so we thin each channel with the Ramer-Douglas-Peucker
algorithm: drop samples that lie within epsilon of the straight line between
their neighbours. This is lossy but perceptually safe and shrinks output a lot.
Keyframe(time: float, value: float)
dataclass
¶
Channel(name: str, keys: List[Keyframe] = list())
dataclass
¶
FaceTrack(fps: float, channels: List[Channel], target_set: List[str] = None, events: 'List[Event]' = list(), variants: 'Optional[Variants]' = None)
dataclass
¶
fps: float
instance-attribute
¶
channels: List[Channel]
instance-attribute
¶
target_set: List[str] = None
class-attribute
instance-attribute
¶
events: 'List[Event]' = field(default_factory=list)
class-attribute
instance-attribute
¶
variants: 'Optional[Variants]' = None
class-attribute
instance-attribute
¶
duration: float
property
¶
reduce_to_track(times: np.ndarray, matrix: np.ndarray, fps: float, epsilon: float = 0.015, targets=None) -> FaceTrack
¶
targets: optional list of mapping.Target — supplies channel
names and per-target min/max clamps. Defaults to the Oculus viseme set
with no clamping (identical to previous releases).
Source code in src/openfacefx/curves.py
openfacefx.mapping
¶
Data-driven phoneme -> target mapping (FaceFX-style "mapping spreadsheet").
The built-in behavior maps each phoneme to exactly one Oculus-15 viseme at
weight 1.0 (visemes.PHONEME_TO_VISEME). A Mapping generalizes that:
any phoneme may drive any set of named targets with fractional weights, each
target may declare an articulator class (used by the coarticulation model)
and min/max clamps applied before keyframe reduction.
JSON file format (validated on load)::
{
"format": "openfacefx.mapping",
"version": 1,
"targets": [
{"name": "PP", "class": "lips", "min": 0.0, "max": 1.0},
...
],
"phonemes": { "P": {"PP": 1.0}, "AY": {"aa": 0.7, "E": 0.3}, ... }
}
Mapping.default() reproduces the built-in table exactly — running without
--mapping is bit-for-bit identical to previous releases.
ARTICULATOR_CLASSES = ('basic', 'jaw', 'lips', 'tongue')
module-attribute
¶
Target(name: str, articulator: str = 'basic', lo: float = 0.0, hi: float = 1.0)
dataclass
¶
Mapping(targets: List[Target], rows: Dict[str, Dict[str, float]] = dict(), allow_custom_symbols: bool = False, normalize: Optional[Callable[[str], str]] = None)
dataclass
¶
targets: List[Target]
instance-attribute
¶
rows: Dict[str, Dict[str, float]] = field(default_factory=dict)
class-attribute
instance-attribute
¶
allow_custom_symbols: bool = False
class-attribute
instance-attribute
¶
normalize: Optional[Callable[[str], str]] = None
class-attribute
instance-attribute
¶
target_names: List[str]
property
¶
row(phoneme: str) -> Dict[int, float]
¶
Target-index -> weight for a (possibly stressed) phoneme.
Unknown phonemes fall back to the silence row, like the built-in map.
With allow_custom_symbols the key is matched verbatim (vendor
symbols carry no stress digit and are case-significant), unless a
normalize hook is set, which is applied to the key first.
Source code in src/openfacefx/mapping.py
default() -> 'Mapping'
classmethod
¶
from_json(path: str) -> 'Mapping'
classmethod
¶
Source code in src/openfacefx/mapping.py
to_json(path: str) -> None
¶
Source code in src/openfacefx/mapping.py
openfacefx.ipa
¶
Built-in IPA -> Oculus-15 mapping preset (issue #32).
Piper and Cartesia timestamp their phonemes in IPA, and espeak-ng's MBROLA
.pho dumps a SAMPA variant -- neither matches the ARPABET the default
mapping expects, so from-timing used to relax those sources to silence
unless the user hand-wrote a custom_symbols mapping. This module ships that
mapping as data: IPA_MAPPING keys the Oculus-15 targets by the IPA inventory
those engines actually emit, matched through _normalize_ipa so the
diacritics real dumps carry collapse onto the base symbol instead of
duplicating a table row per variant.
Normalization rules (_normalize_ipa), applied to the lookup key:
* primary/secondary stress marks ˈ ˌ are dropped;
* length marks ː ˑ are dropped (so ɑː matches ɑ);
* the MFA-style secondary-articulation modifier letters ʰ ʲ ʷ are dropped
(so pʰ tʲ kʷ match p t k);
* every combining mark is dropped, which folds the affricate tie bar
(t͡ʃ -> tʃ, matching the plain digraph too), the dental t̪ -> t,
the syllabic n̩ -> n and nasalization ẽ -> e;
* ASCII ' (stress) and : (X-SAMPA length) are dropped.
It is idempotent and a no-op on ARPABET (which carries none of these), so the
default pipeline is untouched.
Symbol inventory is grounded in verifiable sources
- espeak-ng's phoneme guide -- affricates written as tie bars (t͡ʃ d͡ʒ), stress
ˈ ˌ, lengthː ˑ(espeak-ng/espeak-ng docs/phonemes.md). - the Montreal Forced Aligner US-English phone set, which Cartesia's sonic
models use verbatim -- the
aj aw ej ow ɔjdiphthong spellings and thepʰ pʲ pʷ tʰ tʲ tʷ kʰ kʷsecondary articulations (docs.cartesia.ai, "Specify Custom Pronunciations"). - the English IPA key most G2P/TTS front-ends follow -- the
aɪ aʊ eɪ oʊ ɔɪdiphthongs andɜ ɝ ɚr-coloured vowels (Wikipedia Help:IPA/English).
The IPA-symbol -> viseme assignment itself is our articulatory synthesis -- the
same many-to-one judgement calls visemes.PHONEME_TO_VISEME documents for
ARPABET, not a figure lifted from any single source.
IPA_VOWELS = frozenset(_VOWELS) | frozenset(_SAMPA_VOWELS)
module-attribute
¶
IPA_MAPPING = Mapping([(Target(v, _DEFAULT_CLASSES.get(v, 'basic'))) for v in VISEMES], _IPA_ROWS, allow_custom_symbols=True, normalize=_normalize_ipa)
module-attribute
¶
is_ipa_vowel(symbol: str) -> bool
¶
True if a raw IPA/SAMPA token is a vowel (monophthong, diphthong or
r-coloured), consulted by the coarticulation dominance model so vendor
vowels get the broad vowel bump. Normalizes first (ˈaɪ, ɑː ->
aɪ, ɑ). Returns False for every ARPABET symbol, so the ARPABET path
is byte-for-byte unchanged.
Source code in src/openfacefx/ipa.py
ipa_unknown_symbols(symbols: Iterable[str]) -> List[str]
¶
QA warnings for phoneme symbols the preset can't place -- they route to silence -- one line per distinct symbol (sorted), mirroring the vendor viseme path. A lone suprasegmental (a bare stress/length mark that normalizes to empty) is a structural token, not an unknown, so it never warns.