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.
Files changed (59) hide show
  1. openfacefx/__init__.py +156 -0
  2. openfacefx/__main__.py +3 -0
  3. openfacefx/aligners.py +167 -0
  4. openfacefx/alignment.py +131 -0
  5. openfacefx/anchors.py +484 -0
  6. openfacefx/batch.py +418 -0
  7. openfacefx/batch_manifest.py +167 -0
  8. openfacefx/bethesda.py +122 -0
  9. openfacefx/budget.py +115 -0
  10. openfacefx/cli.py +1791 -0
  11. openfacefx/coart_jali.py +225 -0
  12. openfacefx/coarticulation.py +463 -0
  13. openfacefx/curves.py +99 -0
  14. openfacefx/data/jali_rules.json +31 -0
  15. openfacefx/edits.py +428 -0
  16. openfacefx/emotion.py +436 -0
  17. openfacefx/energy.py +252 -0
  18. openfacefx/events.py +300 -0
  19. openfacefx/export_captions.py +315 -0
  20. openfacefx/export_cues.py +287 -0
  21. openfacefx/export_gltf.py +208 -0
  22. openfacefx/export_godot.py +130 -0
  23. openfacefx/export_lip.py +376 -0
  24. openfacefx/export_live2d.py +167 -0
  25. openfacefx/export_unity.py +282 -0
  26. openfacefx/export_unreal_notifies.py +85 -0
  27. openfacefx/facefxwrapper.py +134 -0
  28. openfacefx/g2p.py +156 -0
  29. openfacefx/gestures.py +171 -0
  30. openfacefx/gestures_layers.py +407 -0
  31. openfacefx/importers.py +373 -0
  32. openfacefx/importers_csv.py +182 -0
  33. openfacefx/inspect.py +302 -0
  34. openfacefx/io_export.py +112 -0
  35. openfacefx/ipa.py +204 -0
  36. openfacefx/layers.py +141 -0
  37. openfacefx/lod.py +176 -0
  38. openfacefx/mapping.py +165 -0
  39. openfacefx/phonemes.py +106 -0
  40. openfacefx/pipeline.py +183 -0
  41. openfacefx/postprocess.py +107 -0
  42. openfacefx/pronounce.py +101 -0
  43. openfacefx/prosody.py +525 -0
  44. openfacefx/qa.py +144 -0
  45. openfacefx/retarget.py +338 -0
  46. openfacefx/ssml.py +209 -0
  47. openfacefx/streaming.py +238 -0
  48. openfacefx/texttags.py +501 -0
  49. openfacefx/timing.py +412 -0
  50. openfacefx/trackdiff.py +170 -0
  51. openfacefx/transforms.py +371 -0
  52. openfacefx/visemes.py +77 -0
  53. openfacefx/vo_audit.py +210 -0
  54. openfacefx-0.18.0.dist-info/METADATA +1283 -0
  55. openfacefx-0.18.0.dist-info/RECORD +59 -0
  56. openfacefx-0.18.0.dist-info/WHEEL +5 -0
  57. openfacefx-0.18.0.dist-info/entry_points.txt +3 -0
  58. openfacefx-0.18.0.dist-info/licenses/LICENSE +21 -0
  59. 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
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+ import sys
3
+ sys.exit(main())
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
@@ -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