openfacefx 0.18.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- openfacefx/__init__.py +156 -0
- openfacefx/__main__.py +3 -0
- openfacefx/aligners.py +167 -0
- openfacefx/alignment.py +131 -0
- openfacefx/anchors.py +484 -0
- openfacefx/batch.py +418 -0
- openfacefx/batch_manifest.py +167 -0
- openfacefx/bethesda.py +122 -0
- openfacefx/budget.py +115 -0
- openfacefx/cli.py +1791 -0
- openfacefx/coart_jali.py +225 -0
- openfacefx/coarticulation.py +463 -0
- openfacefx/curves.py +99 -0
- openfacefx/data/jali_rules.json +31 -0
- openfacefx/edits.py +428 -0
- openfacefx/emotion.py +436 -0
- openfacefx/energy.py +252 -0
- openfacefx/events.py +300 -0
- openfacefx/export_captions.py +315 -0
- openfacefx/export_cues.py +287 -0
- openfacefx/export_gltf.py +208 -0
- openfacefx/export_godot.py +130 -0
- openfacefx/export_lip.py +376 -0
- openfacefx/export_live2d.py +167 -0
- openfacefx/export_unity.py +282 -0
- openfacefx/export_unreal_notifies.py +85 -0
- openfacefx/facefxwrapper.py +134 -0
- openfacefx/g2p.py +156 -0
- openfacefx/gestures.py +171 -0
- openfacefx/gestures_layers.py +407 -0
- openfacefx/importers.py +373 -0
- openfacefx/importers_csv.py +182 -0
- openfacefx/inspect.py +302 -0
- openfacefx/io_export.py +112 -0
- openfacefx/ipa.py +204 -0
- openfacefx/layers.py +141 -0
- openfacefx/lod.py +176 -0
- openfacefx/mapping.py +165 -0
- openfacefx/phonemes.py +106 -0
- openfacefx/pipeline.py +183 -0
- openfacefx/postprocess.py +107 -0
- openfacefx/pronounce.py +101 -0
- openfacefx/prosody.py +525 -0
- openfacefx/qa.py +144 -0
- openfacefx/retarget.py +338 -0
- openfacefx/ssml.py +209 -0
- openfacefx/streaming.py +238 -0
- openfacefx/texttags.py +501 -0
- openfacefx/timing.py +412 -0
- openfacefx/trackdiff.py +170 -0
- openfacefx/transforms.py +371 -0
- openfacefx/visemes.py +77 -0
- openfacefx/vo_audit.py +210 -0
- openfacefx-0.18.0.dist-info/METADATA +1283 -0
- openfacefx-0.18.0.dist-info/RECORD +59 -0
- openfacefx-0.18.0.dist-info/WHEEL +5 -0
- openfacefx-0.18.0.dist-info/entry_points.txt +3 -0
- openfacefx-0.18.0.dist-info/licenses/LICENSE +21 -0
- openfacefx-0.18.0.dist-info/top_level.txt +1 -0
openfacefx/__init__.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""OpenFaceFX -- an open-source facial/lip-sync animation pipeline.
|
|
2
|
+
|
|
3
|
+
audio + text -> time-stamped phonemes -> visemes -> coarticulated
|
|
4
|
+
animation curves -> export for your rig.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .g2p import G2P
|
|
8
|
+
from .pronounce import (Pronouncer, Dictionary, read_dictionary, PronouncerHook,
|
|
9
|
+
Tokenizer)
|
|
10
|
+
from .phonemes import (IPA_ALIASES, SAMPA_ALIASES, ALPHABETS, to_ipa, from_ipa,
|
|
11
|
+
to_sampa, from_sampa, from_alphabet)
|
|
12
|
+
from .alignment import PhonemeSegment, NaiveAligner, load_mfa_textgrid
|
|
13
|
+
from .coarticulation import (build_viseme_curves, CoartParams, STYLE_PRESETS,
|
|
14
|
+
style_params)
|
|
15
|
+
from .coart_jali import RULE_IDS as JALI_RULE_IDS, load_rules as load_jali_rules
|
|
16
|
+
from .curves import FaceTrack, Channel, Keyframe, reduce_to_track
|
|
17
|
+
from .postprocess import smooth_matrix, time_shift
|
|
18
|
+
from .pipeline import (generate_from_alignment, generate_naive,
|
|
19
|
+
naive_segments, wav_duration, derive_events)
|
|
20
|
+
from .streaming import StreamingGenerator, frames_to_track
|
|
21
|
+
from .energy import energy_envelope, generate_from_energy
|
|
22
|
+
from .prosody import (ProsodyParams, ProsodyTrack, pitch_track,
|
|
23
|
+
prosody_features, prosody_events, detect_events)
|
|
24
|
+
from .gestures import (GestureParams, generate_gestures, gestures_from_wav,
|
|
25
|
+
add_gestures_to_track, GESTURE_CHANNELS)
|
|
26
|
+
from .events import (Event, Alternative, VariantGroup, Variants, EVENT_TYPES,
|
|
27
|
+
resolve, choose, add_event, attach_events, read_events,
|
|
28
|
+
validate_events)
|
|
29
|
+
from .io_export import to_dict, write_json, write_csv, from_dict, read_json
|
|
30
|
+
from .qa import summarize, normalize_transcript, cue_flags
|
|
31
|
+
from .texttags import (parse_tagged_transcript, Tag, has_tags,
|
|
32
|
+
resolve_tagged_segments, curve_channels, tag_events,
|
|
33
|
+
build_curve_channel)
|
|
34
|
+
from .ssml import (parse_ssml, looks_like_ssml, EMPHASIS_STRENGTH,
|
|
35
|
+
BREAK_STRENGTH)
|
|
36
|
+
from .edits import (EditsDoc, diff_edits, apply_edits, load_edits, save_edits,
|
|
37
|
+
sample)
|
|
38
|
+
from .emotion import (EmotionEnvelope, bake_emotion, load_envelope,
|
|
39
|
+
save_envelope, va_to_pose, VA_TABLE, VA_EMOTION_CHANNELS)
|
|
40
|
+
from .importers import (import_cues, detect_format, build_cue_track,
|
|
41
|
+
RHUBARB_TO_VISEME, PRESTON_BLAIR_TO_VISEME)
|
|
42
|
+
from .importers_csv import read_csv
|
|
43
|
+
from .inspect import (inspect_track, validate_asset, validate_file,
|
|
44
|
+
detect_kind)
|
|
45
|
+
from .transforms import (retime, retime_to_duration, mirror, trim, concat,
|
|
46
|
+
MIRROR_PAIRS, MIRROR_NEGATE)
|
|
47
|
+
from .lod import (generate_lods, make_lod, lod_metadata, switching_table,
|
|
48
|
+
LOD_DEFAULT_RDP, LOD_DEFAULT_FPS)
|
|
49
|
+
from .budget import (channel_energy, rank_channels, keep_channels,
|
|
50
|
+
keep_top_weight, budget_channels, budget_metadata)
|
|
51
|
+
from .layers import (Layer, build_layers, flatten_layers, layers_to_dict,
|
|
52
|
+
layers_from_dict)
|
|
53
|
+
from .batch_manifest import read_manifest, manifest_jobs, COLUMN_ALIASES
|
|
54
|
+
from .vo_audit import audit_delivery, audit_report_text
|
|
55
|
+
from .trackdiff import diff_tracks, render_diff
|
|
56
|
+
from .export_unity import write_unity_anim, NAMING_PRESETS
|
|
57
|
+
from .export_unreal_notifies import write_unreal_notifies, notifies_to_dict
|
|
58
|
+
from .export_live2d import write_live2d_motion, lipsync_param_ids
|
|
59
|
+
from .export_godot import write_godot_anim
|
|
60
|
+
from .export_gltf import write_gltf, build_gltf
|
|
61
|
+
from .export_lip import (write_lip, lip_bytes, skyrim_mapping,
|
|
62
|
+
SKYRIM_SLOT_MAP)
|
|
63
|
+
from .export_cues import (
|
|
64
|
+
dominant_cues, write_rhubarb_tsv, write_rhubarb_xml, write_rhubarb_json,
|
|
65
|
+
write_moho_dat, write_pgo,
|
|
66
|
+
)
|
|
67
|
+
from .export_captions import (
|
|
68
|
+
write_captions, write_srt, write_vtt, srt_text, vtt_text, build_cues,
|
|
69
|
+
word_timings, format_timestamp, CaptionCue, parse_vtt,
|
|
70
|
+
)
|
|
71
|
+
from .mapping import Mapping, Target
|
|
72
|
+
from .ipa import IPA_MAPPING, is_ipa_vowel, ipa_unknown_symbols
|
|
73
|
+
from .retarget import (retarget, apply_adjust, rename_only, PRESETS,
|
|
74
|
+
PRESET_FALLBACKS)
|
|
75
|
+
from .visemes import VISEMES, phoneme_to_viseme
|
|
76
|
+
from .timing import (
|
|
77
|
+
TimingEvent, resolve_ends, to_segments, parse_pho, parse_piper_alignments,
|
|
78
|
+
parse_cartesia, parse_azure_visemes, parse_polly_marks,
|
|
79
|
+
viseme_events_to_segments, build_vendor_mapping,
|
|
80
|
+
AZURE_VISEME_TO_TARGET, POLLY_VISEME_TO_TARGET,
|
|
81
|
+
)
|
|
82
|
+
from .anchors import (
|
|
83
|
+
Anchor, anchored_segments, anchors_transcript, parse_srt,
|
|
84
|
+
parse_word_anchors, from_azure_word_boundaries, from_elevenlabs_alignment,
|
|
85
|
+
from_kokoro_tokens, google_ssml_with_marks, from_google_timepoints,
|
|
86
|
+
)
|
|
87
|
+
from .aligners import (from_whisper_json, from_whisperx, from_gentle,
|
|
88
|
+
from_gentle_phones)
|
|
89
|
+
|
|
90
|
+
__version__ = "0.18.0"
|
|
91
|
+
|
|
92
|
+
__all__ = [
|
|
93
|
+
"G2P", "PhonemeSegment", "NaiveAligner", "load_mfa_textgrid",
|
|
94
|
+
"Pronouncer", "Dictionary", "read_dictionary", "PronouncerHook", "Tokenizer",
|
|
95
|
+
"IPA_ALIASES", "SAMPA_ALIASES", "ALPHABETS", "to_ipa", "from_ipa",
|
|
96
|
+
"to_sampa", "from_sampa", "from_alphabet",
|
|
97
|
+
"build_viseme_curves", "CoartParams", "STYLE_PRESETS", "style_params",
|
|
98
|
+
"JALI_RULE_IDS", "load_jali_rules",
|
|
99
|
+
"FaceTrack", "Channel", "Keyframe",
|
|
100
|
+
"reduce_to_track", "smooth_matrix", "time_shift",
|
|
101
|
+
"generate_from_alignment", "generate_naive",
|
|
102
|
+
"naive_segments", "wav_duration", "derive_events",
|
|
103
|
+
"StreamingGenerator", "frames_to_track",
|
|
104
|
+
"energy_envelope", "generate_from_energy",
|
|
105
|
+
"ProsodyParams", "ProsodyTrack", "pitch_track", "prosody_features",
|
|
106
|
+
"prosody_events", "detect_events",
|
|
107
|
+
"GestureParams", "generate_gestures", "gestures_from_wav",
|
|
108
|
+
"add_gestures_to_track", "GESTURE_CHANNELS",
|
|
109
|
+
"Event", "Alternative", "VariantGroup", "Variants", "EVENT_TYPES",
|
|
110
|
+
"resolve", "choose", "add_event", "attach_events", "read_events",
|
|
111
|
+
"validate_events",
|
|
112
|
+
"to_dict", "write_json", "write_csv", "from_dict", "read_json",
|
|
113
|
+
"summarize", "normalize_transcript", "cue_flags",
|
|
114
|
+
"parse_tagged_transcript", "Tag", "has_tags", "resolve_tagged_segments",
|
|
115
|
+
"curve_channels", "tag_events", "build_curve_channel",
|
|
116
|
+
"parse_ssml", "looks_like_ssml", "EMPHASIS_STRENGTH", "BREAK_STRENGTH",
|
|
117
|
+
"EditsDoc", "diff_edits", "apply_edits", "load_edits", "save_edits", "sample",
|
|
118
|
+
"EmotionEnvelope", "bake_emotion", "load_envelope", "save_envelope",
|
|
119
|
+
"va_to_pose", "VA_TABLE", "VA_EMOTION_CHANNELS",
|
|
120
|
+
"import_cues", "detect_format", "build_cue_track",
|
|
121
|
+
"RHUBARB_TO_VISEME", "PRESTON_BLAIR_TO_VISEME", "read_csv",
|
|
122
|
+
"inspect_track", "validate_asset", "validate_file", "detect_kind",
|
|
123
|
+
"retime", "retime_to_duration", "mirror", "trim", "concat",
|
|
124
|
+
"MIRROR_PAIRS", "MIRROR_NEGATE",
|
|
125
|
+
"generate_lods", "make_lod", "lod_metadata", "switching_table",
|
|
126
|
+
"LOD_DEFAULT_RDP", "LOD_DEFAULT_FPS",
|
|
127
|
+
"channel_energy", "rank_channels", "keep_channels", "keep_top_weight",
|
|
128
|
+
"budget_channels", "budget_metadata",
|
|
129
|
+
"Layer", "build_layers", "flatten_layers", "layers_to_dict",
|
|
130
|
+
"layers_from_dict",
|
|
131
|
+
"read_manifest", "manifest_jobs", "COLUMN_ALIASES",
|
|
132
|
+
"audit_delivery", "audit_report_text",
|
|
133
|
+
"diff_tracks", "render_diff",
|
|
134
|
+
"write_unity_anim", "NAMING_PRESETS",
|
|
135
|
+
"write_unreal_notifies", "notifies_to_dict",
|
|
136
|
+
"write_live2d_motion", "lipsync_param_ids", "write_godot_anim",
|
|
137
|
+
"write_gltf", "build_gltf",
|
|
138
|
+
"write_lip", "lip_bytes", "skyrim_mapping", "SKYRIM_SLOT_MAP",
|
|
139
|
+
"dominant_cues", "write_rhubarb_tsv", "write_rhubarb_xml",
|
|
140
|
+
"write_rhubarb_json", "write_moho_dat", "write_pgo",
|
|
141
|
+
"write_captions", "write_srt", "write_vtt", "srt_text", "vtt_text",
|
|
142
|
+
"build_cues", "word_timings", "format_timestamp", "CaptionCue", "parse_vtt",
|
|
143
|
+
"Mapping", "Target",
|
|
144
|
+
"IPA_MAPPING", "is_ipa_vowel", "ipa_unknown_symbols",
|
|
145
|
+
"retarget", "apply_adjust", "rename_only", "PRESETS", "PRESET_FALLBACKS",
|
|
146
|
+
"VISEMES", "phoneme_to_viseme",
|
|
147
|
+
"TimingEvent", "resolve_ends", "to_segments", "parse_pho",
|
|
148
|
+
"parse_piper_alignments", "parse_cartesia", "parse_azure_visemes",
|
|
149
|
+
"parse_polly_marks", "viseme_events_to_segments", "build_vendor_mapping",
|
|
150
|
+
"AZURE_VISEME_TO_TARGET", "POLLY_VISEME_TO_TARGET",
|
|
151
|
+
"Anchor", "anchored_segments", "anchors_transcript", "parse_srt",
|
|
152
|
+
"parse_word_anchors", "from_azure_word_boundaries",
|
|
153
|
+
"from_elevenlabs_alignment", "from_kokoro_tokens", "google_ssml_with_marks",
|
|
154
|
+
"from_google_timepoints",
|
|
155
|
+
"from_whisper_json", "from_whisperx", "from_gentle", "from_gentle_phones",
|
|
156
|
+
]
|
openfacefx/__main__.py
ADDED
openfacefx/aligners.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""First-class adapters for the free, open-source aligners / ASR (issue #54).
|
|
2
|
+
|
|
3
|
+
OpenFaceFX ships timing adapters for every commercial TTS source (Azure,
|
|
4
|
+
ElevenLabs, Kokoro, Google, Piper, Cartesia, Polly) but used to punt the
|
|
5
|
+
open-source tools — Whisper, WhisperX, Gentle — to the user with a "write a
|
|
6
|
+
~15-line adapter" note in three places. These are those adapters: siblings of
|
|
7
|
+
:func:`openfacefx.anchors.from_azure_word_boundaries`, stdlib ``json`` only,
|
|
8
|
+
returning the normalized :class:`~openfacefx.anchors.Anchor` list (or, for
|
|
9
|
+
Gentle's phone timings, :class:`~openfacefx.alignment.PhonemeSegment` s directly).
|
|
10
|
+
|
|
11
|
+
* :func:`from_whisper_json` — OpenAI Whisper ``verbose_json`` word timestamps
|
|
12
|
+
(``segments[].words[]`` or a top-level ``words[]``). **Tolerant** of the key
|
|
13
|
+
variance across openai-whisper / faster-whisper / whisper.cpp (``word`` vs
|
|
14
|
+
``text``, ``probability`` vs ``score``).
|
|
15
|
+
* :func:`from_whisperx` — WhisperX ``segments[].words[]`` (``word``/``start``/
|
|
16
|
+
``end``/``score``).
|
|
17
|
+
* :func:`from_gentle` — Gentle ``words[]`` (word-level anchors); and
|
|
18
|
+
:func:`from_gentle_phones` — Gentle's per-word ``phones[]`` (relative
|
|
19
|
+
``duration`` s, ARPAbet with ``_B``/``_I``/``_E``/``_S`` position suffixes)
|
|
20
|
+
accumulated from the word start into phone-level segments, a path that skips
|
|
21
|
+
the naive spacer entirely.
|
|
22
|
+
|
|
23
|
+
**Deterministic missing-timestamp rule:** aligners leave some words unaligned
|
|
24
|
+
(Whisper omits timestamps, Gentle marks ``case != "success"``). Such a word is
|
|
25
|
+
**dropped** — its neighbours' anchors still pin the timeline and the aligner
|
|
26
|
+
spreads the gap. A phone/word symbol outside the ARPAbet inventory passes through
|
|
27
|
+
and falls to ``sil`` at the viseme stage (documented), never a crash.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import re
|
|
33
|
+
from typing import List
|
|
34
|
+
|
|
35
|
+
from .alignment import PhonemeSegment
|
|
36
|
+
from .anchors import Anchor, _first, _json, _num, _obj_list
|
|
37
|
+
from .phonemes import SILENCE
|
|
38
|
+
|
|
39
|
+
_HAS_WORD_CHAR = re.compile(r"\w", re.UNICODE) # skip pure-punctuation tokens
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _word_anchors(words, who: str) -> List[Anchor]:
|
|
43
|
+
"""Shared word-list -> ``Anchor`` core for Whisper / WhisperX. A word with a
|
|
44
|
+
missing/null ``start`` (the aligner left it unaligned) or that is
|
|
45
|
+
punctuation-only is dropped; ``end`` may be ``None`` (inferred from the next
|
|
46
|
+
anchor). Tolerant of ``word``/``text`` keys."""
|
|
47
|
+
out: List[Anchor] = []
|
|
48
|
+
for i, w in enumerate(words):
|
|
49
|
+
if not isinstance(w, dict):
|
|
50
|
+
raise ValueError(f"{who}: word {i} is not an object")
|
|
51
|
+
text = _first(w, ("word", "text"))
|
|
52
|
+
if not isinstance(text, str):
|
|
53
|
+
raise ValueError(f"{who}: word {i} needs a 'word'/'text' string")
|
|
54
|
+
text = text.strip()
|
|
55
|
+
start = _first(w, ("start", "start_time"))
|
|
56
|
+
if start is None or not _HAS_WORD_CHAR.search(text):
|
|
57
|
+
continue # unaligned / punctuation -> drop
|
|
58
|
+
end = _first(w, ("end", "end_time"))
|
|
59
|
+
out.append(Anchor(text, _num(start, f"{who} word {i} 'start'"),
|
|
60
|
+
None if end is None else _num(end, f"{who} word {i} 'end'")))
|
|
61
|
+
if not out:
|
|
62
|
+
raise ValueError(f"{who}: no aligned words found")
|
|
63
|
+
return out
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _segment_words(segments, who: str) -> list:
|
|
67
|
+
return [w for seg in segments if isinstance(seg, dict)
|
|
68
|
+
for w in (seg.get("words") or [])]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def from_whisper_json(json_text: str) -> List[Anchor]:
|
|
72
|
+
"""OpenAI Whisper ``verbose_json`` (``word_timestamps=True``) -> word anchors.
|
|
73
|
+
|
|
74
|
+
Accepts the ``{"segments": [{"words": [...]}]}`` shape and the flat
|
|
75
|
+
``{"words": [...]}`` / bare-array shapes different wrappers emit, and the
|
|
76
|
+
``word``/``text`` + ``probability``/``score`` key variance across
|
|
77
|
+
openai-whisper, faster-whisper and whisper.cpp. Words Whisper left without a
|
|
78
|
+
timestamp are dropped."""
|
|
79
|
+
d = _json(json_text, "whisper")
|
|
80
|
+
if isinstance(d, dict) and isinstance(d.get("segments"), list):
|
|
81
|
+
words = _segment_words(d["segments"], "whisper")
|
|
82
|
+
elif isinstance(d, dict) and isinstance(d.get("words"), list):
|
|
83
|
+
words = d["words"]
|
|
84
|
+
elif isinstance(d, list):
|
|
85
|
+
words = d
|
|
86
|
+
else:
|
|
87
|
+
raise ValueError("whisper: expected 'segments[].words[]', a 'words[]' "
|
|
88
|
+
"array, or a bare word array")
|
|
89
|
+
return _word_anchors(words, "whisper")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def from_whisperx(json_text: str) -> List[Anchor]:
|
|
93
|
+
"""WhisperX alignment (``segments[].words[]`` with ``word``/``start``/``end``/
|
|
94
|
+
``score``) -> word anchors. Words WhisperX could not align (no ``start``) are
|
|
95
|
+
dropped, the same deterministic rule as :func:`from_whisper_json`."""
|
|
96
|
+
d = _json(json_text, "whisperx")
|
|
97
|
+
if not (isinstance(d, dict) and isinstance(d.get("segments"), list)):
|
|
98
|
+
raise ValueError("whisperx: expected a 'segments' array")
|
|
99
|
+
return _word_anchors(_segment_words(d["segments"], "whisperx"), "whisperx")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def from_gentle(json_text: str) -> List[Anchor]:
|
|
103
|
+
"""Gentle forced-aligner JSON (``words[]``) -> word anchors. Only
|
|
104
|
+
``case == "success"`` words are kept; ``not-found-in-audio`` /
|
|
105
|
+
``not-found-in-transcript`` words fall out as gaps."""
|
|
106
|
+
items = _obj_list(_json(json_text, "gentle"), ("words",), "gentle")
|
|
107
|
+
out: List[Anchor] = []
|
|
108
|
+
for i, w in enumerate(items):
|
|
109
|
+
if not isinstance(w, dict):
|
|
110
|
+
raise ValueError(f"gentle: word {i} is not an object")
|
|
111
|
+
if w.get("case") != "success":
|
|
112
|
+
continue
|
|
113
|
+
text = _first(w, ("alignedWord", "word"))
|
|
114
|
+
start = _first(w, ("start",))
|
|
115
|
+
if not isinstance(text, str) or start is None:
|
|
116
|
+
continue
|
|
117
|
+
end = _first(w, ("end",))
|
|
118
|
+
out.append(Anchor(text, _num(start, f"gentle word {i} 'start'"),
|
|
119
|
+
None if end is None else _num(end, f"gentle word {i} 'end'")))
|
|
120
|
+
if not out:
|
|
121
|
+
raise ValueError("gentle: no successfully-aligned words found")
|
|
122
|
+
return out
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
_GENTLE_SUFFIX = re.compile(r"_[BIES]$")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _gentle_phone(symbol: str) -> str:
|
|
129
|
+
"""Gentle phone token -> internal ARPAbet: strip the ``_B``/``_I``/``_E``/
|
|
130
|
+
``_S`` position suffix and upper-case (``hh_B`` -> ``HH``, ``ow1_E`` ->
|
|
131
|
+
``OW1``); Gentle's silence / out-of-vocab tokens map to ``sil``."""
|
|
132
|
+
ph = _GENTLE_SUFFIX.sub("", symbol).upper()
|
|
133
|
+
return SILENCE if ph in ("SIL", "OOV", "SP", "") else ph
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def from_gentle_phones(json_text: str) -> List[PhonemeSegment]:
|
|
137
|
+
"""Gentle ``words[].phones[]`` -> phone-level :class:`PhonemeSegment` s.
|
|
138
|
+
|
|
139
|
+
Each successful word's phones carry a relative ``duration``; accumulating
|
|
140
|
+
them from the word's ``start`` gives absolute phone times (so the last phone
|
|
141
|
+
ends at the word span within float tolerance). Gaps between successful words
|
|
142
|
+
become silence. This is the accurate phone path — it skips the naive spacer
|
|
143
|
+
and feeds ``generate_from_alignment`` directly."""
|
|
144
|
+
items = _obj_list(_json(json_text, "gentle-phones"), ("words",), "gentle-phones")
|
|
145
|
+
segs: List[PhonemeSegment] = []
|
|
146
|
+
cursor = 0.0
|
|
147
|
+
for i, w in enumerate(items):
|
|
148
|
+
if not isinstance(w, dict) or w.get("case") != "success":
|
|
149
|
+
continue
|
|
150
|
+
start = _first(w, ("start",))
|
|
151
|
+
phones = w.get("phones")
|
|
152
|
+
if start is None or not isinstance(phones, list) or not phones:
|
|
153
|
+
continue
|
|
154
|
+
t = _num(start, f"gentle-phones word {i} 'start'")
|
|
155
|
+
if t > cursor + 1e-9: # silence gap before this word
|
|
156
|
+
segs.append(PhonemeSegment(SILENCE, cursor, t))
|
|
157
|
+
for j, p in enumerate(phones):
|
|
158
|
+
if not isinstance(p, dict):
|
|
159
|
+
raise ValueError(f"gentle-phones: word {i} phone {j} is not an object")
|
|
160
|
+
dur = _num(_first(p, ("duration",)), f"gentle-phones {i}.{j} 'duration'")
|
|
161
|
+
ph = _gentle_phone(_first(p, ("phone", "phoneme")) or "")
|
|
162
|
+
segs.append(PhonemeSegment(ph, t, t + dur))
|
|
163
|
+
t += dur
|
|
164
|
+
cursor = t
|
|
165
|
+
if not segs:
|
|
166
|
+
raise ValueError("gentle-phones: no aligned phones found")
|
|
167
|
+
return segs
|
openfacefx/alignment.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Forced alignment: producing time-stamped phonemes.
|
|
2
|
+
|
|
3
|
+
This is the model-heavy stage of the pipeline. Rather than reinvent a speech
|
|
4
|
+
recogniser, OpenFaceFX defines a small ``PhonemeSegment`` contract and provides:
|
|
5
|
+
|
|
6
|
+
* ``NaiveAligner`` -- no acoustic model. Given the phonemes for an utterance
|
|
7
|
+
and the utterance duration (or word timings), it distributes phonemes over
|
|
8
|
+
time using per-phoneme duration priors. Good enough to see the pipeline end
|
|
9
|
+
to end; not accurate lip-sync.
|
|
10
|
+
|
|
11
|
+
* ``load_mfa_textgrid`` -- parse the output of the Montreal Forced Aligner
|
|
12
|
+
(the recommended production aligner). MFA gives real acoustic alignment.
|
|
13
|
+
|
|
14
|
+
Built-in adapters for the open-source aligners live in
|
|
15
|
+
:mod:`openfacefx.aligners` (Whisper / WhisperX word timings, Gentle word **and**
|
|
16
|
+
phone timings, issue #54); any other source just needs to produce a list of
|
|
17
|
+
``PhonemeSegment`` and the rest of the pipeline is unchanged.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import re
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from typing import List, Optional
|
|
25
|
+
|
|
26
|
+
from .phonemes import strip_stress, is_vowel, SILENCE
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class PhonemeSegment:
|
|
31
|
+
phoneme: str # ARPAbet, may carry stress digit
|
|
32
|
+
start: float # seconds
|
|
33
|
+
end: float # seconds
|
|
34
|
+
confidence: Optional[float] = None # aligner score in [0,1], if provided
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def dur(self) -> float:
|
|
38
|
+
return self.end - self.start
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def dump_segments(segments: List["PhonemeSegment"]) -> List[dict]:
|
|
42
|
+
"""Serialise phoneme segments to the plain-JSON shape the HTML previewer
|
|
43
|
+
consumes (``tools/build_preview.py --segments``): a list of
|
|
44
|
+
``{"phoneme", "start", "end"[, "confidence"]}``. ``confidence`` is emitted
|
|
45
|
+
only when the aligner supplied one."""
|
|
46
|
+
out = []
|
|
47
|
+
for s in segments:
|
|
48
|
+
d = {"phoneme": s.phoneme, "start": round(s.start, 4),
|
|
49
|
+
"end": round(s.end, 4)}
|
|
50
|
+
if s.confidence is not None:
|
|
51
|
+
d["confidence"] = round(s.confidence, 4)
|
|
52
|
+
out.append(d)
|
|
53
|
+
return out
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# Rough relative-duration priors (unitless). Vowels last longer than stops.
|
|
57
|
+
_DUR_PRIOR = {
|
|
58
|
+
"AA": 1.6, "AE": 1.5, "AH": 1.0, "AO": 1.6, "AW": 1.8, "AY": 1.8,
|
|
59
|
+
"EH": 1.3, "ER": 1.5, "EY": 1.6, "IH": 1.1, "IY": 1.4, "OW": 1.6,
|
|
60
|
+
"OY": 1.8, "UH": 1.2, "UW": 1.5,
|
|
61
|
+
"CH": 1.1, "JH": 1.0, "SH": 1.1, "ZH": 1.0, "S": 1.1, "Z": 1.0,
|
|
62
|
+
"F": 1.0, "V": 0.9, "TH": 1.0, "DH": 0.8, "HH": 0.7,
|
|
63
|
+
"P": 0.6, "B": 0.6, "T": 0.6, "D": 0.6, "K": 0.7, "G": 0.7,
|
|
64
|
+
"M": 0.9, "N": 0.9, "NG": 0.9, "L": 0.9, "R": 1.0, "W": 0.8, "Y": 0.8,
|
|
65
|
+
SILENCE: 1.0,
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _prior(ph: str) -> float:
|
|
70
|
+
return _DUR_PRIOR.get(strip_stress(ph).upper(), 1.0)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class NaiveAligner:
|
|
74
|
+
"""Distribute phonemes across a time span using duration priors."""
|
|
75
|
+
|
|
76
|
+
def align(
|
|
77
|
+
self,
|
|
78
|
+
phonemes: List[str],
|
|
79
|
+
total_duration: float,
|
|
80
|
+
start: float = 0.0,
|
|
81
|
+
) -> List[PhonemeSegment]:
|
|
82
|
+
if not phonemes:
|
|
83
|
+
return []
|
|
84
|
+
weights = [_prior(p) for p in phonemes]
|
|
85
|
+
wsum = sum(weights) or 1.0
|
|
86
|
+
segs: List[PhonemeSegment] = []
|
|
87
|
+
t = start
|
|
88
|
+
for p, w in zip(phonemes, weights):
|
|
89
|
+
d = total_duration * (w / wsum)
|
|
90
|
+
segs.append(PhonemeSegment(p, t, t + d))
|
|
91
|
+
t += d
|
|
92
|
+
return segs
|
|
93
|
+
|
|
94
|
+
def align_words(
|
|
95
|
+
self,
|
|
96
|
+
words: List[tuple], # (phoneme_list, word_start, word_end)
|
|
97
|
+
) -> List[PhonemeSegment]:
|
|
98
|
+
"""When you already have word-level timings (common from ASR), align
|
|
99
|
+
phonemes within each word span. Much better than utterance-level."""
|
|
100
|
+
segs: List[PhonemeSegment] = []
|
|
101
|
+
for phones, ws, we in words:
|
|
102
|
+
segs.extend(self.align(phones, we - ws, start=ws))
|
|
103
|
+
return segs
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def load_mfa_textgrid(path: str, tier: str = "phones") -> List[PhonemeSegment]:
|
|
107
|
+
"""Parse a Praat TextGrid produced by the Montreal Forced Aligner.
|
|
108
|
+
|
|
109
|
+
Only the interval tier named ``tier`` is read. Empty / silence intervals
|
|
110
|
+
become the ``sil`` phoneme so the mouth relaxes between words.
|
|
111
|
+
"""
|
|
112
|
+
text = open(path, "r", encoding="utf-8").read()
|
|
113
|
+
# Find the requested interval tier block.
|
|
114
|
+
tier_pat = re.compile(
|
|
115
|
+
r'name = "%s".*?intervals: size = \d+(.*?)(?:item \[\d+\]:|$)' % re.escape(tier),
|
|
116
|
+
re.DOTALL,
|
|
117
|
+
)
|
|
118
|
+
m = tier_pat.search(text)
|
|
119
|
+
if not m:
|
|
120
|
+
raise ValueError(f"tier {tier!r} not found in TextGrid")
|
|
121
|
+
block = m.group(1)
|
|
122
|
+
seg_pat = re.compile(
|
|
123
|
+
r"xmin = ([\d.]+)\s*xmax = ([\d.]+)\s*text = \"([^\"]*)\"",
|
|
124
|
+
re.DOTALL,
|
|
125
|
+
)
|
|
126
|
+
segs: List[PhonemeSegment] = []
|
|
127
|
+
for xmin, xmax, label in seg_pat.findall(block):
|
|
128
|
+
label = label.strip()
|
|
129
|
+
ph = label if label else SILENCE
|
|
130
|
+
segs.append(PhonemeSegment(ph, float(xmin), float(xmax)))
|
|
131
|
+
return segs
|