cutan.compile.coarticulate¶
Co-articulation for a swap mouth: the passes between a provider’s raw viseme track and the compiler’s channel emission (an#97, epic #9 Wave 6).
A lip-sync provider hands the compiler a list of (time, code) cues — one
mouth shape per phoneme or per character, at whatever density it produced.
Shown as-is, that track flickers: consonant clusters swap the mouth faster
than a frame or two can show, tongue-only shapes swap the lips for one frame, and
every shape lands exactly on its sound instead of a beat ahead of it. These
passes turn the raw track into what an animator would key, in this order:
Symbolic —
merge_duplicates()drops a cue whose shape is already showing;suppress_weak()drops a low-dominance cue that would show for less than one frame (JALI, Edwards et al. 2016 §4.2: “Tongue-only visemes (l n t d g k N) have no influence on the lips” — for a swap mouth, “take the neighbour’s shape” is “do not swap”).Anticipation —
lead()moves every cue earlier by a fixed lead (JALI: “speech onset begins 120 ms before the apex”; the animator’s “two frames ahead”; Rhubarb’s ownmaxExtensionDurationof 60 ms), clamped at 0. 2b. Close after speech —close_after_speech()puts the mouth at rest where the last WORD ends, when the line knows its words (an#213). A provider that aligns from words keyed a rest between words but, until an#213, not after the last one, so the last shape held through the trailing silence of the clip; cached tracks keep that defect, so the compiler closes the mouth rather than asking for a re-alignment. After the lead (on the led times, so a word shorter than the lead still opens the mouth before it closes) and before the decay (which gives the last shape its time).Decay —
decay()gives a shape its time to close: a rest cue that arrives sooner thandecay_safter the shape before it is pushed out todecay_s(JALI: “another 120 ms to decay to zero”), never past the next speaking cue.Minimum hold —
condense(), LAST. It holds and votes; it never drops a cue unvoted (a carried loser can lose its next window too and never show — outvoted twice, not skipped). Windows of at leastmin_hold_sopen at each cue that clears the previous window; within a window the shape with the largestspan × dominance(Rhubarb’s “select shape with highest total duration within the candidate range”, weighted by Cohen–Massaro’s per-segment dominance) shows, placed at the window start. The old compiler loop ``continue``d past every cue inside the window, so a consonant cluster collapsed to whichever shape arrived first — the defect epic #9 names.
Order matters: (1) before (4) so a one-frame /t/ never wins a window; (2) and
(3) before (4) so the hold is measured on shifted times. Every pass is a pure
function over list[Cue], runs in the compiler (never in the audio
pipeline, so a knob change is a recompile and never a paid re-alignment), and
none of these constants may ever enter a cache key.
Dominance: the ORDER of DOMINANCE is sourced — A (bilabial closure;
JALI rule 1 “must close the lips”) > F, G (lip-heavy) > E, D > C > X > B, H
(tongue) — and its values are art direction. A provider that knows the
character behind a cue may scale it through Cue.intensity (Rhubarb’s B
codes both “most consonants” and the vowel EE, so the letter alone cannot say).
Module Attributes
Per-shape dominance for Rhubarb's letters. |
|
A shape not in the table (another convention's code) is neither strong nor weak. |
|
Below this dominance a cue is "weak" for |
|
Anticipation lead — two frames at 24 fps (art direction; JALI's 120 ms is the ceiling). |
|
Time a shape is given to close before rest (JALI's "120 ms to decay"). |
|
The minimum hold, unchanged from the pre-an#97 compiler until measured. |
Functions
|
Rest once speech is over: at |
|
All the passes, in the order the module docstring gives. |
|
Enforce a minimum hold by voting, never by dropping. |
|
Give a shape |
|
Anticipation: every cue moves |
|
Drop a cue whose shape is the one already showing. |
|
Drop a weak (low-dominance) cue that would show for less than |
Classes
|
One mouth-shape cue: when it starts, which shape, how loudly it wants the lips. |
- class cutan.compile.coarticulate.Cue(time, code, intensity=1.0)[source]¶
Bases:
objectOne mouth-shape cue: when it starts, which shape, how loudly it wants the lips.
- cutan.compile.coarticulate.DEFAULT_DECAY_S: float = 0.12¶
Time a shape is given to close before rest (JALI’s “120 ms to decay”).
- cutan.compile.coarticulate.DEFAULT_DOMINANCE: float = 0.5¶
A shape not in the table (another convention’s code) is neither strong nor weak.
- cutan.compile.coarticulate.DEFAULT_LEAD_S: float = 0.08333333333333333¶
Anticipation lead — two frames at 24 fps (art direction; JALI’s 120 ms is the ceiling).
- cutan.compile.coarticulate.DEFAULT_MIN_HOLD_S: float = 0.14¶
The minimum hold, unchanged from the pre-an#97 compiler until measured.
- cutan.compile.coarticulate.DOMINANCE: dict[str, float] = {'A': 1.0, 'B': 0.3, 'C': 0.6, 'D': 0.8, 'E': 0.8, 'F': 0.9, 'G': 0.9, 'H': 0.3, 'X': 0.5}¶
Per-shape dominance for Rhubarb’s letters. Order sourced, values ours.
- cutan.compile.coarticulate.WEAK_BELOW: float = 0.5¶
Below this dominance a cue is “weak” for
suppress_weak().
- cutan.compile.coarticulate.close_after_speech(keys, *, speech_end, rest='X')[source]¶
Rest once speech is over: at
speech_end, or just after the last shape when a shape is keyed at or after it — never before a shape.speech_endis where the line’s last word ends (None: the line does not know its words, and nothing changes). A provider spreads a very short word’s shapes over a minimum span, so a shape can start after its word’s end; the rest then follows that shape, anddecay()pushes it out todecay_safter it (an#213 review). Nothing is inserted when the mouth is already at rest there.restis the TRACK’s rest code (a track keyed in another convention closes with its own).The evidence, “Bye.” timed 0.0–0.5 s in a 1.84 s clip:
>>> raw = [(0, "X"), (0, "A"), (0.167, "B"), (0.333, "C"), (1.838, "X")] >>> [(c.time, c.code) for c in close_after_speech(raw, speech_end=0.5)] [(0.0, 'X'), (0.0, 'A'), (0.167, 'B'), (0.333, 'C'), (0.5, 'X'), (1.838, 'X')] >>> close_after_speech(raw, speech_end=None) == _cues(raw) TrueA 25 ms last word whose second shape starts after it ends:
>>> [(c.time, c.code) for c in close_after_speech( ... [(0, "X"), (1.0, "E"), (1.025, "B"), (2.0, "X")], speech_end=1.02)][-3:] [(1.025, 'B'), (1.025000001, 'X'), (2.0, 'X')]
- cutan.compile.coarticulate.coarticulate(keys, *, fps, end=None, min_hold_s=0.14, lead_s=0.08333333333333333, decay_s=0.12, rest='X', speech_end=None)[source]¶
All the passes, in the order the module docstring gives.
speech_end(where the last word ends, when the line knows its words) closes the mouth there instead of atend(an#213):>>> bye = [(0, "X"), (0, "A"), (0.167, "B"), (0.333, "C"), (1.838, "X")] >>> [(round(c.time, 3), c.code) for c in coarticulate(bye, fps=24, end=1.838)][-2:] [(0.28, 'C'), (1.755, 'X')] >>> [(round(c.time, 3), c.code) for c in coarticulate(bye, fps=24, end=1.838, speech_end=0.5)][-2:] [(0.28, 'C'), (0.5, 'X')](The shapes lead by two frames; the closing rest is placed after the lead, at the word’s end, and the decay keeps it at least
decay_safter the last shape.)>>> raw = [(0.0, "X"), (0.30, "B"), (0.34, "A"), (0.38, "D"), (0.80, "X")] >>> [(round(c.time, 3), c.code) for c in coarticulate(raw, fps=24, end=1.0)] [(0.0, 'X'), (0.257, 'D'), (0.717, 'X')]A track that ends on
reststill does after the passes, atendwhen the decay left no room before it (the compiler appends its own terminal rest at the line’s end regardless — this is for every other caller):>>> [(round(c.time, 3), c.code) for c in coarticulate([(0, "X"), (0.3, "D"), (0.69, "C"), (0.71, "X")], fps=24, end=0.71)] [(0.0, 'X'), (0.217, 'D'), (0.607, 'C'), (0.71, 'X')]The knobs are validated up front — a negative lead or a zero hold is a typo, not a style:
>>> coarticulate(raw, fps=24, end=1.0, min_hold_s=0) Traceback (most recent call last): ... ValueError: min_hold_s must be > 0, got 0
- cutan.compile.coarticulate.condense(keys, *, min_hold_s, end=None)[source]¶
Enforce a minimum hold by voting, never by dropping.
Windows of
min_hold_sopen at each cue that clears the previous window. Every cue inside a window — the opener included — votes with the length of its raw span that falls inside the window times its dominance (Rhubarb’s “select shape with highest total duration within the candidate range”, weighted); the winner shows for the window, placed at the window start. A member whose span runs past the window’s end and did not win is not lost: it opens the next window at the window’s end, delayed — that is the hold doing its job. Ties go to the later arrival.The defect the epic names, verbatim semantics of the old compiler loop — ‘A’ (the closure) and ‘D’ (the open vowel) are gone and a 40 ms ‘B’ holds for 500 ms:
>>> raw = [(0.0, "X"), (0.30, "B"), (0.34, "A"), (0.38, "D"), (0.80, "X")] >>> old = [] >>> for t, v in raw: ... if old and (t - old[-1][0]) < 0.14: ... continue ... old.append((t, v)) >>> old [(0.0, 'X'), (0.3, 'B'), (0.8, 'X')]The vote in the window at 0.30: B (0.04 × 0.3) and A (0.04 × 1.0) lose to D (0.06 in-window × 0.8), which shows at the window’s start:
>>> [(c.time, c.code) for c in condense(raw, min_hold_s=0.14)] [(0.0, 'X'), (0.3, 'D'), (0.8, 'X')]A cue exactly at the window edge opens the next window, untouched — also at the third edge, where
0.28 + 0.14is a hair over0.42in binary:>>> [(c.time, c.code) for c in condense([(0.0, "X"), (0.14, "C"), (0.28, "D"), (0.42, "A")], min_hold_s=0.14)] [(0.0, 'X'), (0.14, 'C'), (0.28, 'D'), (0.42, 'A')]Without
endthe last cue shows forever, so it always survives (it opens its own window when it loses one):>>> [(c.time, c.code) for c in condense([(0.0, "X"), (0.1, "A")], min_hold_s=0.14)] [(0.0, 'X'), (0.14, 'A')]Windows chain, so nothing is lost: the opening rest keeps its window (0.10 of rest beats a 20 ms closure and 20 ms of F); F, running past the window, opens the next one at 0.14 and wins it over the 40 ms of C inside it; C in turn opens the window at 0.28:
>>> [(c.time, c.code) for c in condense([(0.0, "X"), (0.10, "A"), (0.12, "F"), (0.20, "C"), (0.9, "X")], min_hold_s=0.14)] [(0.0, 'X'), (0.14, 'F'), (0.28, 'C'), (0.9, 'X')]A winner equal to the shape already showing is merged:
>>> [(c.time, c.code) for c in condense([(0.0, "X"), (0.05, "B"), (0.10, "X")], min_hold_s=0.14)] [(0.0, 'X')]
- cutan.compile.coarticulate.decay(keys, *, decay_s, rest='X', end=None)[source]¶
Give a shape
decay_sto close: a rest arriving sooner than that after the shape before it is pushed out todecay_s, never past the next cue and never pastend(a rest pushed toendis where the line closes).>>> [(round(c.time, 3), c.code) for c in decay([(0.0, "X"), (0.2, "D"), (0.25, "X"), (0.6, "B")], decay_s=0.12)] [(0.0, 'X'), (0.2, 'D'), (0.32, 'X'), (0.6, 'B')] >>> [(round(c.time, 3), c.code) for c in decay([(0.0, "X"), (0.2, "D"), (0.25, "X"), (0.30, "B")], decay_s=0.12)] [(0.0, 'X'), (0.2, 'D'), (0.3, 'B')]
- cutan.compile.coarticulate.lead(keys, *, lead_s)[source]¶
Anticipation: every cue moves
lead_searlier, clamped at 0.Cues that collide at 0 collapse to the last one — the shape that is still the state once the collision is over. An opening burst shorter than the lead (
[(0, X), (0.02, A), (0.05, D), (0.07, X)]) therefore never opens the mouth; the hold would have refused a 70 ms word anyway, and the old condenser did.>>> [(round(c.time, 3), c.code) for c in lead([(0.0, "X"), (0.05, "D"), (0.5, "B")], lead_s=0.08)] [(0.0, 'D'), (0.42, 'B')]
- cutan.compile.coarticulate.merge_duplicates(keys)[source]¶
Drop a cue whose shape is the one already showing.
>>> [(c.time, c.code) for c in merge_duplicates([(0, "X"), (0.1, "B"), (0.2, "B"), (0.3, "C")])] [(0.0, 'X'), (0.1, 'B'), (0.3, 'C')]
- cutan.compile.coarticulate.suppress_weak(keys, *, max_weak_s, end=None)[source]¶
Drop a weak (low-dominance) cue that would show for less than
max_weak_s.>>> raw = [(0.0, "X"), (0.20, "D"), (0.40, "B"), (0.43, "D"), (0.80, "X")] >>> [(c.time, c.code) for c in suppress_weak(raw, max_weak_s=0.04)] [(0.0, 'X'), (0.2, 'D'), (0.8, 'X')]A weak cue that lasts is kept — this is not the minimum-hold pass:
>>> [(c.time, c.code) for c in suppress_weak([(0.0, "X"), (0.2, "B"), (0.5, "D")], max_weak_s=0.04)] [(0.0, 'X'), (0.2, 'B'), (0.5, 'D')]