Timing & anchors¶
Skip the aligner: ingest a TTS engine's own phoneme or viseme timing (espeak / MBROLA, Piper, Cartesia, Azure, Polly), or pin the naive aligner at known word/segment boundaries from subtitle cues and TTS word timestamps.
openfacefx.timing
¶
Normalized TTS timing schema + vendor adapters (issue #14).
TTS engines already know exactly when each phoneme or viseme happens, so they
can replace the aligner entirely. This module defines one intermediate schema --
TimingEvent(unit, symbol, start, end) -- and parsers that turn each vendor's
dump into a list of them. From there:
- phoneme-unit events ->
to_segments-> the existing weighted mapping and coarticulation, unchanged. The symbol is used verbatim as the phoneme label, so the source's alphabet must match the active mapping's: the default pipeline expects ARPABET, while Piper/Cartesia emit IPA and MBROLA emits its voice's SAMPA -- feed those a matching--mapping(which may itself setallow_custom_symbols) for meaningful visemes. - viseme-unit events (Azure, Polly) skip phoneme->target mapping: the vendor's
small fixed symbol set is remapped straight onto the Oculus-15 targets via
AZURE_VISEME_TO_TARGET/POLLY_VISEME_TO_TARGETand fed to coarticulation through aMapping(allow_custom_symbols=True).
Only start times are guaranteed. resolve_ends fills any missing end from the
next event's start (the start-time-only sources, Azure and Polly), with a
configurable duration for the final event.
Time units per vendor, all converted to float seconds:
* MBROLA .pho -- per-phoneme duration in milliseconds, cumulative.
* Piper -- per-phoneme audio sample counts / voice sample_rate.
* Cartesia -- explicit start/end seconds.
* Azure viseme -- audio offset in 100-ns ticks (ticks / 10000 = ms).
* Polly marks -- time in integer milliseconds.
Parsers are pure stdlib text/JSON (numpy is not imported here) and reject malformed input with a clear ValueError. Field names for the SDK-event sources (Azure viseme JSON; the espeak C API phoneme path) are set by the ~10-line capture scripts in docs/timing.md; the parsers also accept the obvious aliases.
AZURE_VISEME_TO_TARGET: Dict[int, str] = {0: 'sil', 1: 'aa', 2: 'aa', 3: 'O', 4: 'E', 5: 'RR', 6: 'I', 7: 'U', 8: 'O', 9: 'O', 10: 'O', 11: 'aa', 12: 'kk', 13: 'RR', 14: 'DD', 15: 'SS', 16: 'CH', 17: 'TH', 18: 'FF', 19: 'DD', 20: 'kk', 21: 'PP'}
module-attribute
¶
POLLY_VISEME_TO_TARGET: Dict[str, str] = {'sil': 'sil', 'p': 'PP', 't': 'DD', 'S': 'CH', 'T': 'TH', 'f': 'FF', 'k': 'kk', 'i': 'I', 'r': 'RR', 's': 'SS', 'u': 'U', '@': 'aa', 'a': 'aa', 'e': 'E', 'E': 'E', 'o': 'O', 'O': 'O', 'l': 'DD'}
module-attribute
¶
TimingEvent(unit: str, symbol: str, start: float, end: Optional[float] = None)
dataclass
¶
resolve_ends(events: List[TimingEvent], final_duration: float = 0.08) -> List[TimingEvent]
¶
Fill every end is None from the next event's start; the last such
event gets start + final_duration. Events that already carry an end (the
duration / explicit-end sources) are left untouched. Order is preserved.
Source code in src/openfacefx/timing.py
to_segments(events: List[TimingEvent]) -> List[PhonemeSegment]
¶
Phoneme-unit events -> PhonemeSegment list (ends must be resolved).
The symbol becomes the phoneme label verbatim.
Source code in src/openfacefx/timing.py
parse_pho(text: str) -> List[TimingEvent]
¶
MBROLA .pho: one PHONEME DURATION_MS [pos% pitch_hz ...] per line.
; starts a comment; blank and comment lines are ignored. Durations are
cumulative from t=0; the trailing pitch-target pairs are ignored (only the
timing matters). Symbols are the MBROLA voice's alphabet (a SAMPA variant),
not ARPABET.
Source code in src/openfacefx/timing.py
parse_piper_alignments(json_text: str, sample_rate: int) -> List[TimingEvent]
¶
Piper per-phoneme alignments: audio sample counts -> seconds via the
voice sample_rate. Accepts either parallel arrays
{"phonemes": [...], "phoneme_id_samples": [...]} (the Python AudioChunk
attribute names; alignments / samples are accepted aliases) or a list
{"alignments": [{"phoneme": "h", "num_samples": 2205}, ...]}. Counts are
cumulative from t=0. Piper's docs name the fields but ship no example JSON, so
both shapes are supported; the sample-count semantics are documented.
Source code in src/openfacefx/timing.py
parse_cartesia(json_text: str) -> List[TimingEvent]
¶
Cartesia phoneme_timestamps: parallel phonemes / start /
end arrays already in seconds. Accepts the full stream message
{"phoneme_timestamps": {...}} or a bare object carrying the three
arrays. Symbols are IPA.
Source code in src/openfacefx/timing.py
parse_azure_visemes(json_text: str) -> List[TimingEvent]
¶
Azure VisemeReceived events: audio offset in 100-ns ticks
(ticks / 10000 = ms) + integer viseme ID (documented range 0-21). Accepts a
bare JSON array of events or {"visemes"|"events": [...]}; each event
needs an offset (audio_offset) and an id (viseme_id). Ends come from
the next event (resolve_ends). Symbol is str(id); IDs outside the
documented table are kept here and flagged as unknown at mapping time.
Source code in src/openfacefx/timing.py
parse_polly_marks(text: str) -> List[TimingEvent]
¶
Amazon Polly speech marks: newline-delimited JSON, one object per line.
Only type == 'viseme' lines are kept; time is integer
milliseconds and value the viseme symbol. A mark's start/end
are byte offsets into the input text, not time, and are ignored. Ends come
from the next viseme mark (resolve_ends).
Source code in src/openfacefx/timing.py
build_vendor_mapping(table: Dict) -> Mapping
¶
A Mapping over the Oculus-15 targets whose rows are the vendor viseme
symbols, each driving its target at weight 1.0. allow_custom_symbols lets
numeric Azure IDs and case-significant Polly letters through verbatim. A
sil fallback row absorbs any symbol routed to silence.
Source code in src/openfacefx/timing.py
viseme_events_to_segments(events: List[TimingEvent], table: Dict) -> Tuple[List[PhonemeSegment], List[str]]
¶
Resolved viseme events -> (segments, warnings). Each segment's phoneme is
the vendor symbol, consumed by build_vendor_mapping(table). Symbols
absent from table are routed to silence and reported as QA warnings
(never a crash), per the acceptance criteria.
Source code in src/openfacefx/timing.py
openfacefx.anchors
¶
Word/segment-anchored alignment (issue #15).
The naive aligner spreads phonemes across a whole utterance by duration priors. That is only as good as the assumption that speech fills the clip uniformly -- it does not. Anything that already knows when words happen (subtitle cue times, TTS word timestamps) can pin the aligner at those boundaries and let it distribute phonemes within each span. Same zero-ML machinery, far better sync.
This module adds:
Anchor(text, start, end=None)-- one or more words pinned to a time span.end=Nonemeans "unknown": the span runs to the next anchor's start (or a duration-proportional share when uncovered words sit in that gap).-
anchored_segments(transcript, duration, anchors, g2p=None)-- the naive aligner, anchored. Anchor words are matched against the transcript sequentially (case/punctuation-insensitive, the regexG2P.phraseuses); their phonemes stay inside the anchor span; transcript words no anchor covers fill the time gaps between anchors; wordless gaps longer than ~0.15 s relax tosil; utterance edges are padded withsilexactly likepipeline.naive_segments(with no anchors the output is byte-identical). -
Parsers/converters, each pure stdlib and rejecting malformed input with a clear ValueError:
parse_srt-- SubRip cues (segment anchors), multi-line,HH:MM:SS,mmm.parse_word_anchors-- generic[{text, start, end?}](this project's own schema).from_azure_word_boundaries-- AzureWordBoundaryevents.from_elevenlabs_alignment-- character arrays grouped into words.from_kokoro_tokens-- per-tokenstart_ts/end_ts(None-tolerant).google_ssml_with_marks/from_google_timepoints-- inject one<mark/>per word, then map returned timepoints back to words.
Field names are verified against vendor docs where a vendor exists (see each
docstring: VERIFIED vs assumed); the snake_case aliases and object wrappers are
the tolerant extras, mirroring timing.py. Times land in float seconds:
Azure offsets/durations are 100-ns ticks (÷1e7); ElevenLabs, Kokoro and Google
are already seconds.
Anchor(text: str, start: float, end: Optional[float] = None)
dataclass
¶
anchored_segments(transcript: str, duration: float, anchors: Optional[List[Anchor]] = None, g2p: Optional[G2P] = None) -> List[PhonemeSegment]
¶
Time-stamped phonemes for transcript over duration seconds, pinned
at anchors. With no anchors this is exactly pipeline.naive_segments.
Source code in src/openfacefx/anchors.py
anchors_transcript(anchors: List[Anchor]) -> str
¶
Concatenate cue text into a transcript -- what --anchors-format srt
uses when --text is omitted.
parse_srt(text: str) -> List[Anchor]
¶
SubRip subtitles -> segment anchors. Cues are blank-line separated; the
HH:MM:SS,mmm --> HH:MM:SS,mmm line sets the span (. also accepted as
the decimal mark) and every following line is the cue text, joined with
spaces. A leading index line is ignored. Formatting tags (<i>, {\an8})
are stripped so they never leak into word matching.
Source code in src/openfacefx/anchors.py
parse_word_anchors(json_text: str) -> List[Anchor]
¶
Generic word/segment anchors: a JSON array (or {"anchors"|"words": [...]})
of {"text": str, "start": seconds, "end": seconds|null}. This is
OpenFaceFX's own normalized schema -- the target every converter below
produces -- so start/end are plain float seconds. end may be
omitted or null (inferred from the next anchor).
Source code in src/openfacefx/anchors.py
from_azure_word_boundaries(json_text: str) -> List[Anchor]
¶
Azure Speech WordBoundary events -> anchors. VERIFIED field names
(learn.microsoft.com/.../how-to-speech-synthesis): Text, AudioOffset
and Duration in 100-ns ticks (÷1e7 = seconds), BoundaryType in
{Word, Punctuation, Sentence}. The Python SDK lower-cases these
(text/audio_offset/duration/boundary_type); both, plus a
{"words"|"events": [...]} wrapper, are accepted. Punctuation boundaries
(and any tokens without letters) are skipped so they do not offset matching.
Source code in src/openfacefx/anchors.py
from_elevenlabs_alignment(json_text: str) -> List[Anchor]
¶
ElevenLabs timestamped TTS -> anchors. VERIFIED field names
(elevenlabs.io/docs/.../convert-with-timestamps): a top-level alignment
and/or normalized_alignment object, each with parallel characters,
character_start_times_seconds and character_end_times_seconds arrays
(already seconds). normalized_alignment is preferred when present. A
bare alignment object (the three arrays at top level) is also accepted.
Characters are grouped into words at whitespace; a word spans its first
character's start to its last character's end.
Source code in src/openfacefx/anchors.py
from_kokoro_tokens(json_text: str) -> List[Anchor]
¶
Kokoro per-token timestamps -> anchors. Field names from the community
write-up (ryanwelch.co.uk/blog/kokoro-word-timestamps; not a vendor doc):
tokens carry text, start_ts and end_ts (float seconds, either
may be None). A JSON array or {"tokens": [...]} is accepted. Tokens
with start_ts is None are dropped (that word falls back to the surrounding
gap distribution); end_ts is None leaves the span open to the next token.
Source code in src/openfacefx/anchors.py
google_ssml_with_marks(transcript: str) -> str
¶
Insert an SSML <mark name="wN"/> before each transcript word so Google
Cloud TTS (synthesized with enableTimePointing=SSML_MARK) returns one
timepoint per word. Pure text transform, no SDK: the original text is kept
intact (punctuation and spacing preserved for natural prosody) with XML
metacharacters escaped; only the marks are added. from_google_timepoints
maps the wN marks back to words using the same tokenisation.
Source code in src/openfacefx/anchors.py
from_google_timepoints(json_text: str, transcript: str) -> List[Anchor]
¶
Google Cloud TTS timepoints -> anchors. VERIFIED field names
(cloud.google.com/.../v1beta1/text/synthesize): a top-level timepoints
array of {"markName": str, "timeSeconds": number} (already seconds). A
bare array is also accepted. Each wN mark (from google_ssml_with_marks)
identifies transcript word N and pins its start; ends are left open (Google
marks give word starts only), so the next word's start closes each span.