Changelog¶
All notable changes to OpenFaceFX are documented here. The format follows
Keep a Changelog, and the project uses
Semantic Versioning: while on 0.x, minor versions may
contain breaking changes; the JSON track format is versioned independently via
its version field.
Unreleased¶
Added¶
- Prosody events from a numpy pitch tracker
(#4): a new
openfacefx.prosodymodule (numpy + stdlibwaveonly) follows the pitch of the voice, not just its loudness, and derives typed prosodic events from it.pitch_track()is a short-time autocorrelation F0 tracker in the standard non-ML shape — windowed autocorrelation debiased by the window's own autocorrelation (Boersma/Praat), a two-part voicing gate (energy floor and clarity ≥ 0.45), an octave-cost period pick that suppresses the down-octave error, parabolic-interpolation peak refinement, and a reflect-padded median / octave-repair post-filter that rejects boundary spikes.prosody_features()bundles F0, voicing, clarity, the reusedenergy._frame_rmsloudness follower and a syllable-rate proxy into aProsodyTrack;prosody_events()turns those intoemphasis(coincident pitch and loudness prominence),phrase_boundary(a silent pause, or the utterance end, taggedclause/sentence) andquestion_rise(a rising terminal F0 — the yes/no-question cue) records. The events are ordinaryEvents, so--prosodyonnaive/mfa/energy(each reading the audio from--wav;mfagains an optional--wav) attaches them onto the track and they ride the same JSON / Unity.anim/ Unreal-notify path and compose with--eventsand--gestures. Deterministic — no RNG, and byte-identical events across runs, platforms and Python 3.9/3.13 (the FFT pipeline reproduces bit-for-bit, verified on numpy 2.0/2.5). Honest limitations: this is DSP heuristics, not an ML prosody model — autocorrelation F0 makes octave errors and mislabels voicing on whispered/breathy/creaky voice and low SNR, prominence/question detection are rule-based cue layers (not ToBI), and it will misbehave on music/noise/overlapping speakers; the animation only needs relative pitch movement, so this is acceptable. 16-bit PCM WAV in (convert first withffmpeg -c:a pcm_s16le), same asenergy.py. Fully backward-compatible: without--prosody, output is byte-identical to previous releases.
Backlog: issues — P2/P3
features (#9 edit-preservation, #10 smoothing, #18/#19/#22/#23), adoption
infrastructure (#24 PyPI, #28–#31), and in-game confirmation of the .lip
writer + FaceFXWrapper shim (#12, #33).
0.8.0 — 2026-07-11¶
The rip-and-replace release: a clean-room drop-in for the FaceFXWrapper the whole AI-NPC modding ecosystem depends on, plus an engine event/take layer.
Added¶
FaceFXWrapper.exe-compatible drop-in shim (#33): a CLI-compatible stand-in for Nukem9'sFaceFXWrapper.exe— the tool xVASynth'slip_fuzplugin and the Mantella / Pantella AI-NPC pipelines shell out to for Skyrim.lipgeneration. A newopenfacefx.facefxwrappermodule reproduces the binary's exact contract (verified fromFFXW32/FFXW32.cpp): dispatch on argument count, the input WAV at positional index 3 in both the 7-arg (resample) and 6-arg (pre-resampled) forms, the output.lipat index 5 / 4, dialogue text last, andType∈Skyrim/Fallout4(case-insensitive). It generates a real (experimental, #12) Skyrim.lipthrough the pipeline instead of driving Creation Kit code, and matches the behaviours consumers actually depend on — success is a byte-valid.lipat the output path (exit code and stdout are ignored by consumers; we still return 0/1 and print the wrapper'sUnknown generator type/LIP generation failed/ usage messages), the resampled-WAV path is never written (consumersos.removeit only if present), andFallout4fails honestly with no file so the caller uses its placeholder. Exposed as a nativefacefxwrapperconsole script and aspython -m openfacefx facefxwrapper …(intercepted before argparse so raw positional args — flag-like tokens, paths with spaces — pass through verbatim); aFonixData.cdfstub requirement, the per-consumer drop-in recipe, and the PyInstallerFaceFXWrapper.exebuild (runs under the consumers' Wine prefix) are documented indocs/facefxwrapper.md. Honest limitations: naive duration-based timing (not Fonix acoustic alignment), the.lippayload is experimental / unverified in-game (#12), and Fallout 4 is unsupported. The.fuz/xWMA repacking path stays out of scope (needs an external xWMA encoder).- Event & take layer (#6):
named, timed, typed events with a freeform JSON payload — the game-engine
notify layer, mirroring FaceFX events / Unreal
AnimNotify/ UnityAnimationEvent. A new numpy-freeopenfacefx.eventsmodule addsEvent, weightedVariants/VariantGroup/Alternative"takes", andresolve();FaceTrackgains optionalevents/variantsfields (both default empty). Takes are deterministic: an alternative is chosen by hashing a line id with SHA-256 (FIPS 180-4, no RNG, no wall-clock), so the sameline_idresolves to the same take on every machine and Python version, and each group hashes independently.--eventsauto-authors anemphasis/phraselayer from the speech (reusing the--gesturesaccent detection, but independent of the gesture channels);--events-file+--line-idattach and bake authored takes. Events serialize into the track JSON as optional top-levelevents/variantskeys (emitted only when present, soversionstays1) and fill the Unity.animm_Eventsarray (each event anAnimationEventUnity SendMessage-invokes on the Animator, name+payload packed into the singlestringParameter, ranged events expanding to a_Begin/_Endpair,DontRequireReceiverso a missing handler never errors). A newexport_unreal_notifieswrites anAnimNotifysidecar JSON an editor-Python snippet stamps onto aUAnimSequence. Fully backward-compatible: a track with no events is byte-identical to previous releases, in JSON and.anim.
0.7.0 — 2026-07-11¶
Life beyond the mouth: procedural non-verbal gestures, a previewer that plays the audio, and a full documentation site.
Added¶
- HTML preview: audio playback, waveform & phoneme lane
(#11):
tools/build_preview.pygains--wav(embeds the voice line as a base64data:URI; the transport plays in sync with the playhead and draws a client-side min/max waveform via the Web Audio API) and--segments(a clickable phoneme/word lane above the transport — click a segment to seek, or to hear just that slice when audio is embedded; optional per-segmentconfidencetints blocks red→green so low-confidence alignments stand out for QA).--segmentsaccepts a segments JSON ([{"phoneme", "start", "end"}, ...], optionally wrapped with awordslane) or a Praat.TextGrid; thenaive/mfacommands dump that JSON with a new--emit-segments PATHflag. The page stays a single self-contained file with no network requests (openable fromfile://), and output is byte-identical to previous releases when neither flag is given. - Documentation site (#27):
a MkDocs Material + mkdocstrings site published to
openfacefx.github.io/OpenFaceFX/docs/,
alongside the existing landing page and live demo. Full-text search, a
light/dark toggle on the amber-on-dark brand, the compatibility / retargeting /
TTS-timing guides surfaced straight from
docs/(no content forks), an API reference generated from docstrings for every public module, and this changelog. Built and deployed by the Pages workflow; the pinned build tools live in a newdocsextra (pip install -e ".[docs]") and never touch the numpy-only runtime. - Procedural non-verbal gestures
(#5): a new
openfacefx.gesturesmodule layers eye blinks, eyebrow raises, head nods and idle sway, and gaze saccades onto a finished lip-sync track. Timing is coupled to the speech the way FaceFX/JALI/SmartBody do it — Poisson blinks snap to pauses and stressed syllables (biphasic fast-close/slow-open lid), eyebrow flashes and head nods fire onenergy.pypeaks / primary-stress vowels, and a quasi-periodic sum-of-sines keeps the head from freezing.GestureParamsis the artistic-dial dataclass (blink rate, amplitudes, degree bounds …);generate_gestures()/gestures_from_wav()/add_gestures_to_track()are the API. Everything is deterministic (seeded fromGestureParams.seed, each component on its own sub-stream so toggling one never shifts another; identical keyframes on Python 3.9/3.12) and opt-in:generate_from_alignment,generate_naiveandgenerate_from_energygain agestures=argument, andnaive/mfa/energygain--gestures(+--gesture-seed,--blink-rate,--no-brows); with none given, output is byte-identical to prior releases. Blink/brow channels are[0,1]weights; head/eye are signed pose channels in degrees (or[-1,1]). They pass throughretargetuntouched and are ignored by the mouth-only cue/.lipexporters (they are never mistaken for a viseme).
0.6.1 — 2026-07-11¶
Fixed¶
import openfacefxwas broken in 0.6.0 (#12): 0.6.0 shipped a half-applied rename —export_lip.pydefinedSKYRIM_SLOT_MAPwhile__init__.py,lip_bytes()andlip_calibrate()still referenced the oldSKYRIM_SLOT_ORDER/_ALWAYS_ON_SLOTnames, so importing the package raisedImportErrorand the.lipwriter never ran. The rename is now complete: the package imports, the writer produces byte-valid output, and the full suite is green again.
Changed¶
lip-calibratenow probes every grid slot (#12), not just the 16 slots the provisionalSKYRIM_SLOT_MAPguesses are speech targets: it writesslot_00.lip..slot_32.lip(one per Skyrim payload slot) plus aREADME.txtmanifest of the procedure and current hypothesis. Probing all slots is what actually lets an in-game tester discover the slot→morph mapping — the real morph may live on a slot the guess doesn't use. Each file sweeps a single slot 0→1→0 with a dup-safe resting anchor; all decode byte-exact. See the calibration procedure indocs/COMPATIBILITY.md.
0.6.0 — 2026-07-11¶
The white whale: a clean-room Bethesda .lip writer. The format that every
existing tool delegates to Bethesda's own embedded Creation Kit code has been
reverse-engineered from four public samples and is now writable — verified
byte-identical against the real vanilla asset, flagged experimental until
someone confirms it in-game (calibration kit included).
Added¶
- Experimental Bethesda
.lipwriter for Skyrim (#12): a clean-room writer for the FaceFX facial-animation payload inside a Skyrim.lipfile —openfacefx.export_lip.write_lip(segments, duration_s, path), and-o out.lipon thenaive/mfacommands (with--lip-game skyrim, the default). The byte format was reverse-engineered from four real samples (three mod-author placeholders plus one vanilla Creation-Kit asset); the research codec (tools/lip_codec_research.py, now with anencode_curvesinverse) re-serializes all four byte-identically, and every track the writer emits round-trips through an independent decoder exactly (tests/test_export_lip.py, Oracle B/C). It drives the existing coarticulation solver through an ARPAbet→Skyrim-16Mappingand samples the weight envelopes on Skyrim's 30 fps frame grid. Flagged EXPERIMENTAL: not yet verified in-game. Two facts stay unverifiable without the engine and are documented, prominent assumptions — the slot→morph assignment (the payload numbers curve slots, it does not name them) and the headeru22field (copied from the vanilla asset). Fallout 4 is unsupported (its 43-target vocabulary is undocumented):game='fallout4'raisesNotImplementedErrorrather than emit a bogus file. In-game testers wanted — please report on #12. - More retarget presets and optional-shape fallbacks (#22): two new
--retargetpresets —vrm0(VRM 0.x / VRoid Studio uppercaseA I U E OBlendShapePresets, the 0.x-named sibling ofvrm) andreadyplayerme(the Oculus 15 as Ready Player Me'sviseme_*morph targets) — plus documentation that MetaHuman, Meta Avatars/Quest, NVIDIA Audio2Face and Reallusion CC3 are already covered by the existingarkit/native-Oculus/cc4presets rather than duplicated.retarget()gainsavailable=(the shapes a rig actually has) andfallbacks=: a mapped target the rig lacks reroutes through a per-presetPRESET_FALLBACKStable — chained, cycle-guarded, weights multiplying — instead of dropping silently (e.g. a tongue-less Audio2Face rig sendstongueOut → jawOpen × 0.2). Rhubarb's documented basic-set collapse (G→A H→C X→A) now lives once inPRESET_FALLBACKS, and the cue exporters'--rhubarb-shapesderives its view from it (behaviour unchanged). Provenance and the fallback tables:docs/retargeting.md.
0.5.0 — 2026-07-10¶
The no-transcript-no-problem release: audio-energy fallback, artistic dials, a Rhubarb-style README with a CI-rendered quickstart GIF and a viseme gallery, Live2D and Godot exporters, and out-of-the-box IPA support for TTS timing.
Added¶
- Built-in IPA phoneme preset for
from-timing(#32):pho,piperandcartesianow auto-select a bundled IPA→Oculus-15 mapping when no--mappingis given, so Piper/Cartesia (IPA) and espeak-ng MBROLA.pho(SAMPA) produce rich mouth shapes out of the box instead of degrading to silence — an explicit--mappingstill wins. The preset (openfacefx.ipa. IPA_MAPPING) is data: it keys the targets by the inventory those engines emit, grounded in the espeak-ng phoneme guide, the Montreal Forced Aligner US-English phone set (which Cartesia's sonic models use), and the Wikipedia English IPA key; the symbol→viseme groupings are an articulatory synthesis, likevisemes.PHONEME_TO_VISEME. A small_normalize_ipafolds the diacritics real dumps carry onto the base symbol on lookup (no row duplication): stressˈ ˌ, lengthː ˑ, the affricate tie bar (t͡ʃ=tʃ), MFA'sʰ ʲ ʷ, and any other combining mark (t̪→t,n̩→n). Both diphthong spellings (aɪ…and MFA'saj…), r-colouredɜ ɝ ɚand non-colliding SAMPA fallbacks (@ { 3) are covered; unknown symbols warn once per distinct symbol and relax to silence — never a crash. IPA vowels also feed the coarticulation dominance model (is_ipa_vowel), so vendor vowels get the broad vowel bump. The ARPABET default path is byte-for-byte unchanged (ipa.py; docs/timing.md). - Live2D Cubism
motion3.jsonexporter (#20):-o mouth.motion3.jsonbakes lip-sync as Cubism parameter curves (Version 3, linear segments). Two targeting modes. Default (zero config) collapses the whole viseme track to a singleParamMouthOpenYcurve — the summed weight of every non-silence viseme, clamped to0..1(an openness/loudness proxy that equals1 - silon normalised output), which is the one mouth-open parameter almost every Cubism model exposes; the target Id is overridable (--live2d-param). Per-parameter mode (--live2d-params map.json, aviseme -> ParamIdobject) emits one curve per distinct Id for rigs with per-vowel parameters — noteParamA/I/U/E/Oare a VTuber convention, not a standard, so they must be supplied, not assumed.--live2d-model3 model.jsonauto-reads the mouth parameter from a model'sGroups: LipSyncentry. Both modes are aretargetunder the hood (summed, clamped contributions on the union of key times). TheMetacounts (CurveCount/TotalSegmentCount/TotalPointCount) are derived from the emittedCurvesby the same stride a Cubism loader walks, never guessed — aMetathat disagrees with the segment data is the format's #1 gotcha (loaders read past the array). Pure-stdlib JSON, LF newlines (export_live2d.py). - Godot 4
AnimationPlayerresource exporter (#21):-o lipsync.treswrites a[gd_resource type="Animation" format=3]resource with one value track per active viseme, keyed with the existing RDP-reduced keyframes and linear interpolation (interp = 1). Tracks drive blend shapes by node path (NodePath("Head:blend_shapes/viseme_aa")); Godot weights are0..1so channel values pass straight through (no ×100 as for Unity). Byte-formatting follows Godot's own text saver (verified against engine source): packedtimes/transitionsarrays print trimmed (0,1,0.1) while the genericvaluesarray forces decimals (0.0,1.0) to stay float-typed, the keys dict is orderedtimes, transitions, values, update, and default-valued resource properties (loop_mode 0,step 1/30, emptyresource_name, andlengthwhen 1.0) are omitted — so output matches what the editor re-saves. Shape naming reuses the Unity exporter's presets (--godot-naming oculus|vrchat) or a customviseme -> shapemap (--godot-names map.json); the node name is configurable (--godot-node, defaultHead). By default it also writes a constant-0 track for every viseme the line never fires, clearing weight a previous animation left on that shape. Value tracks (not the importer-onlyblend_shapetrack type) keep the resource hand-writable; text serialisation, LF newlines (export_godot.py). Runtime nodes/signals stay engine-side and out of scope. The optional audio-playback and 2D sprite-frame tracks from #21 are deferred (not in the byte-verified format spec this pass targeted). - README hero onboarding: quickstart GIF, viseme gallery, literal output
(#26). The README now opens with the live-demo link, an animated quickstart
GIF, and
pip install openfacefxabove the fold; the long-form pipeline description moves below. The GIF is recorded as code, not hand-captured:docs/quickstart.tapedrives VHS, and the Pages workflow (pages.yml) re-renders it on every push viacharmbracelet/vhs-actionand publishes it to the GitHub Pages site — so the GIF can never drift from the real CLI and no bot ever commits a binary to the repo (the README points at the Pages URL;docs/quickstart.gifis git-ignored). A newtools/render_viseme_gallery.pyrenders one small SVG per viseme (docs/visemes/*.svg, <800 bytes each, presentation-attributes-only so GitHub inlines them) by porting the schematic-mouthdrawFacegeometry from the previewer and evaluating each viseme at full weight — no art dependency; a table documents all 15 Oculus/Meta visemes with their phonemes. The hero also shows a literalopenfacefx.trackexcerpt that matches the demo command's real output byte-for-byte.test_viseme_gallery.pyguards the committed SVGs against drift from the generator (like the GIF from its tape). Docs/tooling only — no library behavior changed. - Articulation-intensity dials (#18, partial — the JALI-style gain layer):
--intensity(master) and repeatable--gain class=value(e.g.--gain tongue=0.6 --gain jaw=1.2) onnaive/mfa/from-timingscale how strongly each articulator class opens, so one curve set spans mumble to hyper- articulated without retiming —CoartParams.intensityandCoartParams.gainsfor library callers. The scale is applied after normalization: every channel's opening is multiplied byintensity * gains[its class](the class read from the mapping target, exactly as the coarticulation model reads a segment's) andsilreabsorbs the freed weight, so a frame still sums to ~1 and the mouth genuinely opens/closes rather than the curve just being rescaled (open' = Σ scale·open, thensil = max(1 − open', 0)with the non-silchannels capped to fill the frame — proof of the sum-to-1 invariant in_apply_intensity). Enforced lip closures run afterwards and still win, so a whispered bilabial (--intensity 0.5) still seals to the 0.9 floor. Defaults are a byte-identical no-op: all1.0makes the per-channel scale a vector of ones and the step returns before touching the matrix (verified on the two reference commands and by aparams=NonevsCoartParams()equality test). Bad dials fail fast at the CLI boundary (unknown class, non-number, negative). Theenergycommand keeps its own--intensity: it never builds coarticulated curves (no articulator-class channels — it synthesises an aa/E/O/sil partition from an RMS envelope), so theCoartParamsmaster does not apply and its envelope-gain semantics are left unchanged. Not yet from #18: shipped style presets, the lexical-stress amplitude pass, and time- varying (keyframed) dials. - Audio-energy fallback lip-sync (#17):
openfacefx energy --wav voice.wav -o track.jsondrives a mouth from loudness alone — the first path that needs no transcript and no aligner (energy.py, numpy + stdlibwaveonly). This is the common non-ML mechanism behind SALSA/Moho/Live2D, and it is an amplitude fallback, not viseme detection: it cannot tell a /m/ from an /aa/ and will open the mouth on a cough — docstrings,--helpand the README all say so.energy_envelopecomputes per-frame RMS at the track fps, gates the noise floor and normalizes against a high percentile (not the max, so one plosive does not flatten the take), then runs an asymmetric envelope follower (fast attack, ~7x slower release — mouths snap open and relax shut).generate_from_energyturns that envelope into an ordinary Oculus-visemeFaceTrack: jaw-open (aa) is the primary channel, with a small, honestly aestheticspreadbled into two secondary shapes (louder leans roundedO, quieter leans midE) so it does not read as one channel flapping, andsiltakes the rest; each frame partitions unit weight (sil + aa + E + O == 1). Output is deterministic (no jitter/RNG) and flows through the existing keyframe reduction, so--retarget,.animand every cue exporter compose unchanged. Input is 16-bit PCM WAV, mono or stereo (stereo is downmixed by averaging); other sample widths raise a clearValueErrorwith convert-first guidance (ffmpeg -c:a pcm_s16le). CLI knobs:--fpsand--intensity(gain on the opening); library callers also getwindow,gate,smoothingandspread.
0.4.0 — 2026-07-10¶
The adapters-and-interchange release: skip the aligner with TTS timing, pin it with subtitles, export to the 2D animation ecosystem, try it in the browser. Also ships PyPI release automation (pending the one-time publisher registration, #24) and the live demo site.
Added¶
- Rhubarb-dialect cue exporters (#16): flatten a track into a stepped cue
list (dominant viseme per interval) and serialise the formats the indie 2D
ecosystem reads, making OpenFaceFX a drop-in Rhubarb replacement for those
hosts (
export_cues.py, pure stdlib, LF endings). Writers: Rhubarb-f tsv(start<TAB>shapelines + a terminalXrow bounding the last cue),-f xml(rhubarbResulttree, soundFile/duration metadata,mouthCuestart/end elements) and-f json(hand-formattedmetadata+mouthCuesarray); Moho/OpenToonz.dat(MohoSwitch1, 1-based truncated frames with same-frame dedup and a terminal rest row,--cue-fps24..100, Preston-Blair drawing names by default — required by OpenToonz's "Apply Lip Sync Data" — or Rhubarb A–H/X letters via--no-dat-preston-blair); and Papagayo-NG.pgo(single voice/phrase/word phoneme tree, TAB-indented). Shape vocabulary is handled automatically: an Oculus-15 track is retargeted through the built-inrhubarb/preston_blairpresets, a track already in the target shapes passes through, anything else errors clearly. Extended shapes the art lacks collapse to a basic shape via Rhubarb's documented fallback (--rhubarb-shapes ABCDEF; G→A, H→C, X→A). CLI:-odispatches on the.tsv/.xml/.dat/.pgoextension, or--cue-format tsv|xml|json-cues|dat|pgoselects explicitly (json-cuesis needed because.jsonstays the native track format); every format is reachable fromnaive,mfaandfrom-timing.soundFile/sound path default to the literal"openfacefx", never your local absolute path. - Word/segment-anchored alignment (#15): the naive aligner now accepts
anchors —
Anchor(text, start, end=None)spans — and distributes each word's phonemes within its anchored span instead of across the whole utterance, a large accuracy win with zero ML (anchors.py,anchored_segments). Anchor words are matched to the transcript sequentially (case/punctuation-insensitive); uncovered words fill the gaps between anchors; wordless gaps over ~0.15 s relax tosil; with no anchors the output is byte-identical tonaive_segments. Parsers/converters, each pure stdlib with a fixture test:parse_srt(SubRip cues, multi-line, tag-stripped),parse_word_anchors(generic[{text,start,end?}]), and converters from AzureWordBoundaryevents (100-ns ticks), ElevenLabs character alignments (grouped at whitespace,normalized_alignmentpreferred), Kokoro tokens (None-start_ts/end_tstolerant) and Google Cloud TTS timepoints, plus agoogle_ssml_with_markshelper (pure text transform, one<mark/>per word). CLI:openfacefx naive --anchors FILE --anchors-format srt|words|azure|elevenlabs|kokoro|google(SRT supplies its own transcript when--textis omitted). Vendor field names verified against Azure/ElevenLabs/Google docs; snake_case aliases and object wrappers accepted as intiming.py. - TTS timing ingest (#14):
openfacefx from-timingskips the aligner and builds tracks straight from a TTS engine's own timing, through one normalizedTimingEvent(unit, symbol, start, end)schema (timing.py). Parsers for MBROLA.pho(espeak-ng), Piper sample-count alignments, Cartesiaphoneme_timestamps, Azure viseme events (100-ns ticks) and Polly viseme.marks, each converting its native time units (ms / sample counts / ticks / seconds) to seconds and rejecting malformed input with a clear error. Phoneme-unit sources feed the existing weighted mapping and coarticulation unchanged; viseme-unit sources (Azure, Polly) remap onto the Oculus-15 targets via built-in presets (AZURE_VISEME_TO_TARGET,POLLY_VISEME_TO_TARGET), with unknown symbols/IDs downgraded to a QA warning instead of a crash. Missing end times are inferred from the next event's start (resolve_ends, configurable--final-duration).Mappinggainsallow_custom_symbolsso vendor symbols (numeric IDs, case-significant letters, IPA) bypass ARPABET normalization. Capture scripts for Azure and the espeak-ng C API indocs/timing.md; GPL engines (espeak-ng, piper1-gpl) run as external processes only, never vendored. - Live demo site (#25): https://openfacefx.github.io/OpenFaceFX/ — the
previewer autoplays a track regenerated from the current pipeline on every
push (
--autoplayflag ontools/build_preview.py, Pages deploy workflow). - PyPI release automation (#24): tag-triggered
release.yml— version and changelog gates, sdist + universal wheel, the test suite run against the built wheel, GitHub release with artifacts and notes, OIDC trusted publishing (skipped until the pending publisher is registered). PEP 639 metadata and[project.urls]. - End-to-end test suite (
tests/test_e2e.py) and a real-worldexamples/dialogue/voice tree covering both alignment paths throughbatch; PNG logo.
Fixed¶
preston_blairretarget preset: the consonant catch-all is nowetc— the exact layer name Moho/OpenToonz match on — instead ofconsonants, which silently never switched the mouth (found by byte-exact format verification against Rhubarb's DAT exporter and the Papagayo phoneme tables).
0.3.1 — 2026-07-10¶
Fixed¶
- Windows:
openfacefx batchcrashed with "path is on mount 'C:', start on mount 'D:'" when the output tree was on a different drive than the working directory (summary paths are now relative to the output tree). Caught by the CI matrix.
0.3.0 — 2026-07-10¶
The three P1 items from the FaceFX feature-gap backlog.
Added¶
- Component-based coarticulation (#1): per-articulator-class
(basic/jaw/lips/tongue) lead-in/out timing via
CoartParams, short-silence absorption (0.27 s default), guaranteed ≥0.9 lip closures on bilabials/labiodentals, diphthong splitting, onset pre-roll policy. - Data-driven weighted mapping (#2):
Mapping/Targetmodel + JSON format (openfacefx.mappingv1), per-target articulator class and min/max clamps, strict validation, CLI--mapping; shipsexamples/mappings/oculus15.jsonandminimal9.json. Default path is byte-identical to 0.2.0. - Batch processing (#3):
openfacefx batchover directory trees with--recurse/--modified-only/--jobs, manifest-based incremental re-runs, and a worst-first QA summary (failures, aligner confidence, OOV words). G2P.oov_words(), optionalPhonemeSegment.confidence, andFaceTrack.target_set(exports now report the actual target vocabulary).
Changed¶
- Default lip-sync output improves: consonant channels are tighter and lip closures complete (PP peak 0.23 → 0.90 on the example track). Tracks are not byte-identical to 0.2.0 (that guarantee applied to the 0.2.0 mapping change only).
0.2.0 — 2026-07-10¶
Engine-integration release: the wrapper-compatibility work from the FaceFX ecosystem survey.
Added¶
- Unity
.animexporter (write_unity_anim, CLI-o clip.anim): AnimationClip text YAML drivingblendShape.*curves on a SkinnedMeshRenderer;oculus(viseme_*, Ready Player Me / Meta rigs) andvrchat(vrc.v_*) naming presets, custom maps vianames=;--anim-naming/--anim-pathCLI options. (#13) - Viseme retargeting (
retarget,rename_only,PRESETS; CLI--retarget): weighted many-to-many remapping onto ARKit-52 (verified met4citizen/TalkingHead weights), Rhubarb, Preston-Blair, VRM and CC4 conventions — provenance indocs/retargeting.md. - Bethesda tooling (
openfacefx.bethesda): verified.fuzcontainer reader/writer,.lip12-byte header parser,lip_infodiagnostics, Skyrim's 16 speech-target names. A full.lipwriter remains blocked: the payload has no public byte-level spec (#12). pipeline.naive_segments()exposing the phoneme-timing layer.
Changed¶
- README compatibility matrix and roadmap updated to reflect shipped
exporters and the
.lippayload finding (docs/COMPATIBILITY.md has the full analysis).
0.1.0 — 2026-07-10¶
Initial public release.
Added¶
- Full lip-sync pipeline: audio + transcript → time-stamped phonemes →
Oculus/Meta 15-viseme targets → Cohen–Massaro dominance coarticulation →
Ramer–Douglas–Peucker keyframe reduction →
FaceTrack. - Two alignment paths: Montreal Forced Aligner TextGrid parser
(
load_mfa_textgrid) and a dependency-free naive aligner with per-phoneme duration priors. - G2P with seed CMU dictionary and rule fallback;
load_cmudict()for the full dictionary. - CLI:
openfacefx naive(text + WAV/duration) andopenfacefx mfa(TextGrid), JSON and CSV output. - Self-contained HTML previewer (
tools/build_preview.py): schematic articulator, per-channel curve plots, scrubbing playhead. - Versioned JSON interchange format (
openfacefx.track, version 1). - Test suite plus CI across Linux/Windows/macOS on Python 3.9/3.12/3.13.
- FaceFX-ecosystem compatibility survey (
docs/COMPATIBILITY.md).
Fixed¶
- Windows:
tools/build_preview.pynow reads the template and track JSON as UTF-8 instead of the locale default (cp1252), which failed withUnicodeDecodeError.