v0.2.1 - Nimble-Näbbdjur
"Näbbdjur" is Swedish for platypus — a creature that looks improbable on paper but turns out to be remarkably capable in practice. Fitting for a release that makes the model smarter without touching its architecture.
This release focuses on making BrowSync fully aware of all its input sources. The ML model previously only knew what the face tracker told it; it now also understands head motion and speech prosody during training. Data collection grows from a single path (VRCFT WebSocket) to three — including a direct OpenXR recorder that captures raw Quest hardware values before VRCFT remaps them, with labeled brow ground truth for Quest Pro users.
Server
Mode hysteresis and crossfade
- Source loss now requires 300ms of absence before the inference mode degrades — eliminates mode thrashing from momentary tracker dropouts
- Mode transitions blend over 150ms using pre-smoother AU targets, removing the hard jump artifacts that occurred when switching between
ml,rules_only, andmic_head
Head motion tracking: OpenVR backend
- Replaced the OpenXR head motion backend with OpenVR (
VRApplication_Backgroundinit mode) - SteamVR's OpenXR runtime requires a D3D11/Vulkan graphics context even for headless pose queries; OpenVR bypasses this entirely, making the tracker reliable for background use
- Rotation matrix → quaternion conversion uses Shepperd's numerically stable method; existing Euler extraction and calibration pipeline unchanged
GRU residual median filter
- A 5-frame rolling median is applied to the GRU residual output before it is added to the rule base, eliminating single-frame spikes without introducing lag
Per-session prosody normalization
- Each recording session now maintains its own pitch and energy baseline using Welford's online mean/variance algorithm
- Normalization blends progressively from global stats → session-relative over the first 300 frames (~3.3s), so cold-start frames are not distorted by the speaker's absolute pitch range
- Microphone recalibration (
rkey /recalibrate_headmessage) resets session stats
Configurable smoother parameters
- Spring-damper attack/decay rates and stiffness are now configurable per brow AU via
config.json - Server loads
config.jsonat startup; missing or malformed entries fall back to compiled defaults - No restart required to apply changes between sessions (reload on next server launch)
New control messages
get_calibration— returns calibration phase (settling/locked/degraded) and per-AU output variance over the last 3s; useful for diagnosing whether sources are stableget_status— extended to include current calibration phase alongside mode and source flags
Session event logging
- Structured JSONL event log written to
logs/for each server session - Records source connect/disconnect events, mode transitions, and session start/end
- Crash-safe: each event is flushed immediately; an
atexithandler closes the log on abnormal exit
VRCFT Plugin
Input forwarding
- The module now sends current VRCFT eye and face expression data to the Python server as
frameWebSocket messages — the same data path used during live inference - This enables the Python server to record labeled sessions without a separate capture tool, and ensures the inference server always has the latest tracker state
- Fixed VRCFT enum name mismatches discovered via reflection:
EyeClosedLeft/Right(→ derived from1 - Openness),LipCornerPull(→MouthCornerPull),LipCornerDepressor(→MouthFrown),CheekRaiser(→CheekSquint),LipStretch(→MouthStretch),MouthOpen(→JawOpen)
Mode awareness
- The module reads the
modefield from every server response - In
noise_onlymode (no active tracking sources), brow shapes are zeroed rather than applying the anti-freeze noise to the avatar
Connection reliability
- Frame send queue changed to a capacity-1
Channel<string>withDropOldestpolicy — the module always sends the freshest data and never blocks behind a stale backlog - All WebSocket send operations serialized through a
SemaphoreSlimto prevent concurrent-send exceptions
Training
Faster training loop
- Rule estimates for each sequence's target frame are now pre-computed and cached at dataset construction time
- Previously, the training loop re-ran
RuleBasedEstimator.estimate()per sample, per batch, per epoch — approximately 3 million calls for a 60-session dataset at batch size 64 - With caching, rule estimates are computed once at startup (~50K total) and reused across all epochs; remaining per-batch Python overhead is minimal
- Batch size increased from 64 to 256;
num_workersset to 0 (eliminates Windows process-spawn overhead for small in-memory datasets) - PyTorch thread count explicitly set to all available CPU cores at startup
torch.compileenabled on platforms with MSVC (cl.exe) available; skipped gracefully on Windows without it (Inductor backend requires MSVC to JIT-compile C++ kernels)
Synthetic Data
Full 52-feature coverage
- The synthetic session generator now produces temporally coherent data for all 52 input features, including the 11 head motion and 6 prosody features that were previously zero
- Head motion driven by a per-axis spring-damper (K=16, B=8, critically damped at ω₀≈4 rad/s) with expression-state targets; outputs all of HeadPitch/Roll/Yaw, HeadY/Z, angular/linear velocity and acceleration
- Prosody driven by a speech dynamics model with syllabic energy bursts (~5–6/s), smooth VAD onset/offset, and intonation contours; produces first and second derivatives for PitchDelta2 and EnergyDelta2
- Sign convention and normalization constants match the live OpenVR head tracker exactly, so synthetic and real sessions are directly interchangeable
- New expression states:
whisper,excited,pensive,recoil_surprise - Bug fix: head motion features were being clipped to [0, 1] in
state_to_vector(); they live in [−1, 1] and HeadPitch=−0.20 (concentration posture) was silently zeroed
Data Collection
Three recording paths are now available:
data/record_session.py — VRCFT-based live recorder
- Runs as a drop-in replacement for the inference server on port 7720
- Receives eye/face frames from the C# module, merges microphone prosody and head motion, assembles full 52-feature frames, and writes unlabeled
.jsonlsessions - Sends dummy zero brow responses so the C# module stays connected throughout
- Live status line: elapsed time, frame count, frame rate, active sources
data/record_quest_raw.py — direct OpenXR recorder
- Reads the 70 raw
XR_FB_face_tracking2expression weights from the Quest's face tracking hardware via Meta's OpenXR runtime, bypassing VRCFT and its UnifiedExpressions remapping entirely - Requires Meta Quest Link to be running and set as the active OpenXR runtime; face tracking enabled in Quest privacy settings
- Quest Pro: brow AUs (
INNER_BROW_RAISER,OUTER_BROW_RAISER,BROW_LOWERER) are present in the hardware output and saved as labeled targets (has_labels=True), giving full-weight ground truth for training - Quest 3: face data is captured unlabeled; rule pseudo-labels applied at training time
- Also captures microphone prosody and SteamVR head motion in the same frame, so sessions are fully feature-complete
- Session strategy: attempts
XR_MND_headlessfirst (no graphics context); falls back to a minimal D3D11 device created via ctypes if the runtime requires a graphics binding
data/convert_raw.py — offline converter
- Converts already-captured recordings to BrowSync
.jsonltraining sessions - Accepts WebSocket frame log format (newline-delimited JSON with
inputsdict) or feature CSV with schema.py column names - Injects delta features (eye openness delta, jaw delta) computed across frames
- Batch directory conversion with configurable val split
data/synthetic/openface_converter.py — OpenFace 2.0 support (new in this milestone)
- Converts OpenFace
FeatureExtractionCSV output to labeled BrowSync sessions - Maps AU01/AU02/AU04 (brow AUs) to target vector with ground truth labels
- Includes head pose from
pose_Rx/Ry/Rzcolumns and eye/face features from AU intensities and gaze landmarks
Dependencies
openvr>=1.23.701replacespyopenxrfor head motion (OpenVR background mode, no graphics context)huggingface_hub>=0.20.0added for HF Hub training data donationpyopenxr>=1.0.3added as optional dependency forrecord_quest_raw.py
Migration Notes
config.jsonis optional; the server runs with compiled defaults if it is absent. To customize smoother parameters, createconfig.jsonin the project root — seeCLAUDE.mdfor the schema.- Sessions generated with the previous synthetic generator (head features all zero) can coexist with new sessions but will not teach the model head motion correlations. Re-generate synthetic data with
python data/synthetic/generate_synthetic.py --sessions 60before retraining. - The VRCFT module DLL version is 0.2.1; rebuild with
dotnet build -c Releaseand replace the DLL in%APPDATA%\VRCFaceTracking\CustomLibs\.