Transcript text tags¶
Direct animation from the script (issue #7). Inline tags in the transcript are
extracted before grapheme-to-phoneme conversion, the clean spoken text is
lip-synced as usual, and each tag is mapped onto the timeline the aligner
produced: curve tags become channels, event tags land on the following word,
[emphasis] locally raises articulation, and <T> / [pause] chunk or pad the
timeline with silence. The syntax is modelled on the FaceFX
text-tagging
documentation and, for [emphasis]/[pause], on SSML. Enable it with the
naive --tags flag or generate_naive(..., parse_tags=True); a tagless
transcript is byte-identical to the plain path.
openfacefx.texttags
¶
Transcript text tags (issue #7): direct animation from the script.
A writer steers the generated animation with inline tags in the transcript, the
way FaceFX's text tagging stage does: tags are extracted before G2P, the
clean words are phonemised and lip-synced as usual, and each tag is mapped onto
the timeline using the word timings the aligner produced. The syntax is modelled
on the FaceFX docs (https://facefx.github.io/documentation/doc/text-tagging) and,
for [emphasis]/[pause], on SSML <emphasis>/<break>. Four families:
- Curve —
[Name type=quad|lt|ct|tt v1=.. v2=.. v3=.. v4=.. easein=.. easeout=.. timeshift=.. duration=..]words[/Name]adds a channelNamekeyframed over the span (leading/centered/trailing triplet or quadruplet, 0.2 s ease defaults) — see :func:build_curve_channel. - Event —
[event:NAME k=v ...]/[gesture:NAME ...](or the FaceFX curly{"group|anim" start=.. payload=".." ...}) inject an :class:openfacefx.events.Eventat the following word's start (end of the last word if trailing); non-timing params are kept in the payload. - Emphasis —
[emphasis]word[/emphasis](optionalstrength=) locally raises articulation, reusing the issue-#18 dominance-amplitude pass through :attr:CoartParams.emphasis_windows. - Chunk / pause —
<T>markers chunk the naive utterance into phrases pinned to audio times,silfilling the gaps;[pause:SECONDS]inserts silence at a boundary and[phrase]drops amarker/phraseevent.
Deterministic and stdlib-only (re/shlex); numpy is never imported. A
transcript with no tags parses to itself with an empty tag list, so the naive
pipeline stays byte-identical when tags are absent.
WORD_RE = re.compile("[A-Za-z']+")
module-attribute
¶
DEFAULT_EASE = 0.2
module-attribute
¶
DEFAULT_STRENGTH = 0.5
module-attribute
¶
Tag(kind: str, word_index: int, end_word_index: int = -1, name: str = '', params: Dict[str, str] = dict(), value: Optional[float] = None)
dataclass
¶
One parsed tag anchored to the clean-text word stream.
word_index is the word the tag attaches to: for wrapping tags
(curve/emphasis) the first spanned word, with end_word_index the
exclusive last; for inline tags the following word (== len(words) when
trailing). value carries the pause seconds / chunk marker time;
params the raw key=value map from the tag body.
kind: str
instance-attribute
¶
word_index: int
instance-attribute
¶
end_word_index: int = -1
class-attribute
instance-attribute
¶
name: str = ''
class-attribute
instance-attribute
¶
params: Dict[str, str] = field(default_factory=dict)
class-attribute
instance-attribute
¶
value: Optional[float] = None
class-attribute
instance-attribute
¶
has_tags(text: str) -> bool
¶
True if text contains an unambiguous tag — the auto-detect the naive
command uses to enable tag parsing without an explicit --tags flag.
parse_tagged_transcript(text: str) -> Tuple[str, List[Tag]]
¶
Split a tagged transcript into (clean_text, tags).
clean_text is the spoken words with every tag removed (whitespace left by
a removal is collapsed), ready for G2P; tags is the deterministic list of
:class:Tag records, each anchored to a word index in clean_text's own
[A-Za-z']+ tokenization. A transcript with no tags is returned unchanged
with an empty list, so the caller's downstream path is byte-identical.
Malformed tags pass through gracefully (an unclosed open or stray close yields
no tag; the spanned words are still spoken); the one hard error is chunk
ordering, validated in :func:chunked_segments.
Source code in src/openfacefx/texttags.py
naive_word_segments(clean_text: str, duration: float, g2p: G2P) -> Tuple[List[PhonemeSegment], List[Tuple[float, float]]]
¶
Naive-path segments plus a (start, end) time span per word.
Builds the exact [sil] + phones + [sil] sequence :func:naive_segments
uses and distributes it over duration, so with no chunk/pause tags the
segments are byte-identical to the plain path — the word spans are recovered
from the phone index ranges.
Source code in src/openfacefx/texttags.py
chunked_segments(clean_text: str, duration: float, chunks: List[Tag], g2p: G2P) -> Tuple[List[PhonemeSegment], List[Tuple[float, float]]]
¶
Align the naive utterance into <T>-delimited chunks.
Each marker pins a text position to an audio time; the words between two
markers spread over that [t_i, t_{i+1}] span and the gaps (plus the
pre-roll and tail) fill with sil. Marker times must be non-negative,
within duration and non-decreasing (equal only between two phrases), else
ValueError — a non-monotonic/overlapping timeline cannot be rendered.
Source code in src/openfacefx/texttags.py
resolve_tagged_segments(clean_text: str, duration: float, tags: List[Tag], g2p: G2P) -> Tuple[List[PhonemeSegment], List[Tuple[float, float]], List[Tuple[float, float, float]]]
¶
Phoneme segments, per-word time spans, and emphasis windows for a tagged
transcript. Chunk markers pick the chunked aligner; otherwise the plain naive
alignment runs and any [pause] tags are spliced in afterwards.
Source code in src/openfacefx/texttags.py
emphasis_windows(tags: List[Tag], spans: List[Tuple[float, float]]) -> List[Tuple[float, float, float]]
¶
(t0, t1, gain) windows for :attr:CoartParams.emphasis_windows from the
[emphasis] tags, gain 1 + strength over the tagged word span.
Source code in src/openfacefx/texttags.py
emphasis_params(params, windows: List[Tuple[float, float, float]])
¶
Return params carrying windows on :attr:emphasis_windows (a fresh
:class:CoartParams when params is None), or params untouched when
there is nothing to emphasize.
Source code in src/openfacefx/texttags.py
build_curve_channel(name: str, params: Dict[str, str], w_start: float, w_end: float) -> Channel
¶
A FaceFX curve-tag channel over the word span [w_start, w_end].
type selects the shape: lt/ct/tt are three-point triplets that
peak at the word start / centre / end, quad (default) the four-point
apex-and-valley curve. v1..v4 are the keyframe values (v1=0 v2=1,
v3=1 v4=0 for quad / v3=0 triplets), easein/easeout (0.2 s)
push the first/last key before/after the span, duration sets the quad
hold, timeshift slides the curve. Times clamp at 0 and de-duplicate;
values are unclamped, so a curve may drive a channel past 1.
Source code in src/openfacefx/texttags.py
curve_channels(tags: List[Tag], spans: List[Tuple[float, float]], duration: float) -> List[Channel]
¶
One :class:Channel per curve tag, keyed over its word span.
Source code in src/openfacefx/texttags.py
tag_events(tags: List[Tag], spans: List[Tuple[float, float]], duration: float) -> List[Event]
¶
Time-sorted events from the event and phrase tags, each anchored at
the following word's start (the end of the last word when trailing).