Skip to content

Add ArmIKLayer, AudioVisemeDriver, and VMC Protocol support for device-driven avatars - #434

Open
arkavo-com wants to merge 10 commits into
mainfrom
issue/433-arm-ik-viseme-vmc
Open

Add ArmIKLayer, AudioVisemeDriver, and VMC Protocol support for device-driven avatars#434
arkavo-com wants to merge 10 commits into
mainfrom
issue/433-arm-ik-viseme-vmc

Conversation

@arkavo-com

Copy link
Copy Markdown
Contributor

Summary

Library work for the Mac "no-device avatar" path tracked in arkavo-org/Creator#40 (VMagicMirror parity). Three additive subsystems, no renderer or shader changes.

Closes #433. Refs arkavo-org/Creator#31, arkavo-org/Creator#33, arkavo-org/Creator#36, arkavo-org/Spatial#89.

ArmIKLayer (Animation/Layers/ArmIKLayer.swift)

  • AnimationLayer at priority 7 (above ArmCounterbalanceLayer) that solves shoulder and elbow rotations toward world-space HandTargets with TwoBoneIKSolver, plus per-finger FingerCurl.
  • Never reads the live arm pose: the chain is rebuilt from rest transforms captured in initialize(with:) under the parent's current world matrix, so deltas are base-pose relative and a constant target gives a constant pose across frames. This matters because the compositor composes base * delta without resetting bones between frames.
  • Releasing a target or curl writes one explicit identity delta so the arm returns to rest instead of freezing.
  • Finger curl is a model-space axis rotation converted into each phalanx's rest frame (inverse(W) * delta * W), so it holds on rigs whose finger bones are not hand-aligned.

AudioVisemeAnalyzer / AudioVisemeDriver (Audio/)

  • PCM [Float] in, VRM viseme weights (aa ih ou ee oh) out. Formant heuristic: energy-weighted spectral centroids in two bands scored against canonical F1/F2 pairs in log-frequency space, with noise gate and loudness curve. Direct DFT on the bins of interest, no Accelerate or AVFoundation.
  • Driver keeps a rolling window fed from the audio thread (push), smooths per viseme through FilterManager, and writes the controller like ARKitFaceDriver (apply(to:) / update(samples:controller:)).

VMC Protocol (VMC/)

  • OSCPacket / OSCMessage / OSCBundle: OSC 1.0 decode and encode, nested bundles, all standard type tags.
  • VMCDriver: /VMC/Ext/Bone/Pos, /Root/Pos, /Blend/Val + /Blend/Apply (commit semantics), /OK, /T. Rotations are composed rest-relative (rest * inverse(restWorld) * delta * restWorld). Unity to model space is a reflection of X for every loaded model because the loader already rotates VRM 0.x to +Z-facing; flipZ is kept for raw 0.x data. Maps Unity HumanBodyBones names including the thumb chain shift (Unity Proximal/Intermediate/Distal → VRM Metacarpal/Proximal/Distal) and VRM 0.x blend aliases (Joy, A, Blink_L, ...).
  • VMCEncoder: inverse mapping for sending; a frame built from a model round-trips through VMCDriver to the same pose.
  • VMCReceiver: thin UDP NWListener, port 0 supported for tests.

Docs

  • New DocC article DeviceDrivenAvatars linked from the catalog; README bullets.

Test plan

  • ArmIKLayerTests (15): hand-on-target for both arms within 5 mm, stability over consecutive frames and after target changes, unreachable clamping, weight blending, elbow hint plane, finger curl by position for left/right/thumb/fist, release restores rest, priority above counterbalance.
  • AudioVisemeTests (12): each prototype vowel classifies to its viseme from synthetic two-tone input, silence and gate, formant tracking, volume saturation, driver write/decay/gain/small-buffer accumulation.
  • OSCPacketTests (10): hand-built byte layouts for a ,sf message and a #bundle, round trips for all types, error paths.
  • VMCDriverTests (16): name mappings both directions, coordinate flips, Unity yaw 90° on the left upper arm lands the hand 0.5 m forward, rest-relative correctness on a twisted rest bone, blend commit-on-Apply, staleness, encoder round trip, UDP loopback on an ephemeral port.
  • Full suite swift test --parallel --num-workers 14 -j 16 --disable-sandbox
  • No .metal changes, so no metallib rebuild or AvatarSample_U.png regeneration.

Design notes for reviewers

  • Priority 7 with .blend(weight) intentionally replaces the counterbalance brace's additive arm deltas while a target is set.
  • The viseme classifier is a heuristic, not MFCC plus calibration. Its job is to keep the mouth alive when no tracker is connected; Creator should prefer a tracked mouth when one exists.
  • ADR-0047 in Spatial rejected VMC on the phone; this PR is the Creator-side bridge that ADR anticipated.

🤖 Generated with Claude Code

https://claude.ai/code/session_01FgZDdzrNEtPmbMupyyUTXj

arkavo-com and others added 6 commits September 7, 2026 22:18
Drives each arm toward a world-space hand target with elbow hints and
per-target blend weight, and curls fingers about model-space axes
converted into each phalanx's rest frame. Rebuilds the chain from cached
rest transforms under the live parent every frame so solutions are
base-pose relative and stable across frames; releases write an explicit
identity delta so a cleared target returns the arm to rest.

Part of #433. Refs arkavo-org/Creator#31.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgZDdzrNEtPmbMupyyUTXj
Classifies PCM windows into the five VRM visemes from a two-band formant
centroid estimate scored against canonical F1/F2 pairs, with a noise
gate and loudness curve. The driver keeps a rolling window fed from the
audio thread, smooths through FilterManager, and writes the controller
like ARKitFaceDriver. No AVFoundation in the library.

Part of #433. Refs arkavo-org/Creator#33.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgZDdzrNEtPmbMupyyUTXj
OSC 1.0 message and bundle decode/encode. VMCDriver applies Bone/Pos,
Root/Pos, Blend/Val+Apply, OK and T to a model and expression controller
with the Unity-to-model X flip, rest-relative rotation composition,
VRM 0.x blend aliases, and the Unity thumb-chain shift. VMCEncoder emits
the inverse for sending; VMCReceiver is a thin UDP NWListener.

Part of #433. Refs arkavo-org/Creator#36, arkavo-org/Spatial#89.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgZDdzrNEtPmbMupyyUTXj
Part of #433.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgZDdzrNEtPmbMupyyUTXj
… VRM 0.x asset

Drops the wrist bones from ArmIKLayer.affectedBones since the layer
never writes them, makes the four-finger curl axes configurable, and
adds a guarded fixture test that loads a VRM 0.x model and confirms a
Unity yaw swings the left hand forward under the X-flip convention.

Part of #433.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgZDdzrNEtPmbMupyyUTXj
Comment thread Sources/VRMMetalKit/VMC/VMCReceiver.swift
Comment thread Sources/VRMMetalKit/VMC/VMCReceiver.swift Outdated
Comment thread Sources/VRMMetalKit/VMC/OSCPacket.swift Outdated
Comment thread Sources/VRMMetalKit/VMC/OSCPacket.swift
@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown

Claude Code Review ran on a4aa4ef — result: success.
Review completed. Tool permission denials: 0. Turns: 9. Duration: 48s.

The completed review reported no tool permission denials.
Findings are posted as review comments. Run log.

- OSCArgument.intValue uses Int(exactly:) so NaN, infinity, or out-of-range
  floats off the wire return nil instead of trapping.
- Bundle decoding caps nesting at OSCPacket.maxBundleDepth (8) and
  sub-readers share the datagram bytes instead of copying per level;
  OSCPacket.messages flattens iteratively.
- VMCReceiver recreates its readiness semaphore per start() and ignores
  state callbacks from a superseded listener, so waitUntilReady cannot
  consume a stale signal across stop()/start(); sender flows are capped
  at maxConnections (default 8) with oldest-first eviction.

Part of #433.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgZDdzrNEtPmbMupyyUTXj
arkavo-com added a commit that referenced this pull request Sep 8, 2026
… programs

The reviewer was denied Bash 28 times on PR #434 because pipeline
segments like grep, tail, cut, sort, and xargs were not on the allowlist.
Adds those read-only tools and makes the diagnostics step list the
program names (never arguments) of denied Bash calls so future gaps are
visible from the heartbeat comment.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgZDdzrNEtPmbMupyyUTXj
@gitguardian

gitguardian Bot commented Sep 8, 2026

Copy link
Copy Markdown

️✅ There are no secrets present in this pull request anymore.

If these secrets were true positive and are still valid, we highly recommend you to revoke them.
While these secrets were previously flagged, we no longer have a reference to the
specific commits where they were detected. Once a secret has been leaked into a git
repository, you should consider it compromised, even if it was deleted immediately.
Find here more information about risks.


🦉 GitGuardian detects secrets in your source code to help developers and security teams secure the modern development process. You are seeing this because you or someone else with access to this repository has authorized GitGuardian to scan your pull request.

Comment thread Sources/VRMMetalKit/VMC/VMCReceiver.swift
Sending from two flows at once let the second flow evict the first
before its datagram was read under parallel test load. Wait for each
flow's packet in turn before asserting the cap.

Part of #433.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgZDdzrNEtPmbMupyyUTXj
Moves the onError call past the listener identity guard so a superseded
listener failing during a restart cannot emit a spurious error.

Part of #433.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgZDdzrNEtPmbMupyyUTXj
Comment thread Sources/VRMMetalKit/VMC/VMCReceiver.swift Outdated
Comment thread Sources/VRMMetalKit/VMC/VMCDriver.swift
Comment thread Sources/VRMMetalKit/VMC/VMCDriver.swift Outdated
…etters

- VMCReceiver captures its listener weakly in the state handler and
  cancels in deinit, so dropping a started receiver releases the port.
- VMCDriver caps distinct pending blend-shape names at 256 per Apply
  cycle; updates to existing names are still accepted.
- VMCDriver.frame and counters, and VMCReceiver.boundPort and counters,
  are lock-taking computed getters over private storage. Suites pass
  under ThreadSanitizer.

Part of #433.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgZDdzrNEtPmbMupyyUTXj
@gitar-bot

gitar-bot Bot commented Sep 8, 2026

Copy link
Copy Markdown
Code Review ✅ Approved 3 resolved / 3 findings

Comprehensive device-driven avatar support with ArmIKLayer for IK-solved arm poses, AudioVisemeDriver for mic-driven lip-sync, and VMC Protocol implementation (OSC codec, driver, encoder, receiver). VMCReceiver idle connection leak and stale ready signal consumption were fixed; onError timing issue was dismissed. No open issues remain.

✅ 3 resolved
Quality: VMCReceiver never releases idle UDP connections

📄 Sources/VRMMetalKit/VMC/VMCReceiver.swift:128-139 📄 Sources/VRMMetalKit/VMC/VMCReceiver.swift:164-168
accept appends every incoming NWConnection to connections, but entries are only removed from remove(), which fires solely on .failed/.cancelled connection states. A healthy UDP sender's connection stays .ready indefinitely, so a long-lived receiver that sees senders on changing source ports accumulates connection objects that are never torn down until stop(). For the typical single-sender VMC case this is one connection, so impact is low; consider capping or aging out connections if multiple/rotating senders are expected.

Edge Case: VMCReceiver.start()/waitUntilReady can consume a stale ready signal

📄 Sources/VRMMetalKit/VMC/VMCReceiver.swift:56 📄 Sources/VRMMetalKit/VMC/VMCReceiver.swift:108-113 📄 Sources/VRMMetalKit/VMC/VMCReceiver.swift:115-126
stop() resets readySignalled=false and boundPort=nil but does not drain the ready DispatchSemaphore. If start() signalled ready on a prior session and waitUntilReady was never called (or timed out), the leftover semaphore count causes the next waitUntilReady to return immediately, potentially observing boundPort == nil (false) before the new listener is actually ready, or a stale value. Re-create the semaphore in start() (or drain it in stop()) so readiness signaling is scoped per listener lifecycle.

Edge Case: onError fires before listener identity guard on .failed

📄 Sources/VRMMetalKit/VMC/VMCReceiver.swift:107-114
In the listener .failed handler, self.onError?(...) is called before the guard self.listener === listener check, whereas the same guard correctly protects the boundPort/readySignalled mutations. A stale listener from a previous start/stop cycle (or the old listener during a restart) that transitions to .failed will emit a spurious error to the caller's onError callback even though it is no longer the active listener. Move the onError call after the identity guard so only the current listener reports failures.

Was this helpful? React with 👍 / 👎 | Gitar

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Arm IK layer, audio viseme driver, and VMC Protocol driver for Creator's no-device avatar path

1 participant