780 Commits

Author SHA1 Message Date
L'électron rare 8cfa0694a7 feat(web): tempo + master steppers on matrix tab
CI build oscope-of / build-check (push) Has been cancelled
Same -/+ stepper controls duplicated on the MATRICE tab; the wiring is
factorized into bindStepGroup so both tabs' sliders and readouts stay
in sync while driving the same OSC addresses.
2026-07-02 22:43:02 +02:00
L'électron rare 1fe186bdb8 feat(web): master gain slider with -/+ steppers
CI build oscope-of / build-check (push) Has been cancelled
The web surface had no master control at all (SC-only via
/control/doMaster 0..2). Adds the slider next to Tempo with 0.05
steppers and a live readout, same pattern as the tempo control.
2026-07-02 22:39:47 +02:00
L'électron rare d058858b23 feat(web): tempo -/+ stepper buttons
CI build oscope-of / build-check (push) Has been cancelled
One-BPM steps beside the tempo slider (fine adjustment is hard on a
touch slider mid-performance) plus a live numeric readout; both paths
share the same /launch/tempo send.
2026-07-02 22:38:16 +02:00
L'électron rare 144d55a7fa feat(matrix): MD-index pinch slot = breakdown
CI build oscope-of / build-check (push) Has been cancelled
User request live: right-hand index pinch fires breakdown instead of
relanceEvolve — SC default, generator default, web defaults, the 196
presets and the test pins all updated. relanceEvolve stays in the
assignable vocabulary.
2026-07-02 21:44:40 +02:00
L'électron rare 0bc15e7000 feat(matrix): composed live variants
CI build oscope-of / build-check (push) Has been cancelled
Regenerate 112 *_live-[1-4].matrix with the composition engine.
84 intro/live/outro originals untouched. All 6 preset tests pass.
2026-07-02 21:22:12 +02:00
L'électron rare 0e5c4df3d7 feat(matrix): melodic composition engine
7 style families x 8 melodic roles x 6 composition stages. Chord
progressions (i-VI-III-VII etc.) aligned across all voices per colour;
call-response via +4-step offset for melody/stab vs arp/acid. Pure
deterministic transforms: rotate, ornament, climax (+7 oct lift),
sparse, resolve-to-anchors. New MEL[role]["composed_<style>"] entries;
existing hand-authored families untouched.
2026-07-02 21:22:05 +02:00
L'électron rare e71ae814f7 feat(viz): hold last X/Y markers when hand drops
CI build oscope-of / build-check (push) Has been cancelled
SC's mod cache keeps the last received value when a hand vanishes, but
the gauge markers and wrist cross disappeared — the screen no longer
showed what the matrix was hearing. Markers now hold the last known
position (dim conf 0.45) and go full-bright when the hand is live.
2026-07-02 20:43:14 +02:00
L'électron rare ba5db3237e feat(matrix): 112 live variant presets
CI build oscope-of / build-check (push) Has been cancelled
Context: live sets needed variety; the single *_live.matrix per style
gave no choice during a performance.

Approach: 28 styles x 4 variants each produced by
generate_live_variant_sc() with deterministic era kits and family
overrides.

Changes:
- Add *_live-1..4.matrix for every style (112 files)
- v1 house/M1: m1_organ/m1_piano/dx7_epiano on chord/bells/melody
- v2 acid/hoover: tb303 on acid/arp, hoover on lead/reese
- v3 virus/nord: virus_saw on lead/arp, nord_lead on melody/arp
- v4 raw 909/rave: tr909_snare on snare, rave_stab/brass_stab on stab
- Each variant uses a distinct pattern family and busy-block profile

Impact: 196 total .matrix presets; SC engine discovers all via ls scan.
2026-07-02 20:28:04 +02:00
L'électron rare c8318cecc2 feat(matrix): live variant generator
Add generate_live_variant_sc() to produce 4 deterministic live
variants per style (112 files). Each variant differs by:
  - pattern family (VARIANT_FAMILIES per style group)
  - era instrument kit overlay (house/M1, acid/hoover, virus/nord,
    raw 909/rave) merged from VARIANT_ERA_KITS
  - busy-block recolour config (VARIANT_BUSY, 4 distinct profiles)

Also: fix generator defaults to match live state:
  - globalActions: fill/doubleTime/washReverb/muteDrums/relanceEvolve/
    halfTime/next/muteMelo (was fill/drop/breakdown/evolve/...)
  - hand-mod depths 0.70 (was 0.40/0.50 for rHandX/rHandY/lHandX/lHandY)
  - INST_CHOICES synced with matrix.scd ~matInstChoices (10 new vintage
    synths: tr909_snare hoover m1_piano m1_organ dx7_epiano brass_stab
    nord_lead virus_saw rave_stab reese_juno)

Update test_global_actions.py to expect new 8-action mapping.
Add test_live_variants.py: 112 files, legal instruments, distinct
variants, determinism.
2026-07-02 20:27:36 +02:00
L'électron rare 876afc0cf9 feat(matrix): wire vintage synths into voices
Add hoover/nord_lead/virus_saw to arp+lead, brass_stab/rave_stab to
stab, m1_piano/m1_organ/dx7_epiano to chord+sub+bells+melody,
reese_juno to sub+pad+reese, tr909_snare to clap+snare.
Mirror changes in web UI MATRIX_INST_CHOICES.
2026-07-02 20:14:42 +02:00
L'électron rare a6556e809a feat(sound): vintage 90s synthdefs
Add 10 classic machine emulations for the matrix:
tr909_snare, hoover, m1_piano, m1_organ, dx7_epiano,
brass_stab, nord_lead, virus_saw, rave_stab, reese_juno.
All note-terminating; cutoff+res args on res-voice targets.
2026-07-02 20:14:36 +02:00
L'électron rare 51a3412158 feat(viz): bold hand overlay strokes
CI build oscope-of / build-check (push) Has been cancelled
Same concentric-offset technique as the body skeleton, one step
thinner (SKEL_LINE_WIDTH - 1) so small far hands do not blob.
2026-07-02 19:34:06 +02:00
L'électron rare 69c560dfe6 feat(viz): bold body skeleton strokes
CI build oscope-of / build-check (push) Has been cancelled
Metal lines are 1px and the skeleton was barely visible in
performance. Each ARKit bone is now drawn in concentric offset passes
(SKEL_LINE_WIDTH, default 4 approx px; 1 restores the thin look).
2026-07-02 18:57:36 +02:00
L'électron rare 7b039fbb78 feat(viz): fixed default viz mode (voronoi on t)
CI build oscope-of / build-check (push) Has been cancelled
VIZ_DEFAULT_MODE=<name> boots the viz on that mode and disables the
auto-openpos switch entirely, so the mode is truly always active (user
request: the t-key voronoi effect permanent). Concert launcher defaults
to voronoi; "auto" keeps the historical behavior.
2026-07-02 18:54:48 +02:00
L'électron rare d970f4cbe9 docs(viz): portrait orientation validated, doc fixed
CI build oscope-of / build-check (push) Has been cancelled
The module doc still mandated landscape (2026-06-30 ARKit constraint);
portrait was validated live 2026-07-02 and is now the concert default.
Document the rotation split (video ccw, 2D points already upright).
2026-07-02 18:49:23 +02:00
L'électron rare 8d6d42ed3c feat(pose): wrist-anchored xy mods + wrist markers
CI build oscope-of / build-check (push) Has been cancelled
The X/Y voice mods were anchored to the 21-landmark centroid, so
opening or pinching the fingers shifted the mod values (gesture/mod
cross-talk). cx/cy now track the WRIST (kp 0). The renderer draws a
cross marker at each wrist on the video whenever features exist, so
the control point stays visible even for far hands the overlay
filters out (user request live 2026-07-02).
2026-07-02 18:47:57 +02:00
L'électron rare fbc6d4efa3 chore: drop preset pycache from git
The deepen-bindings commit accidentally staged the preset generator's
__pycache__ directory (compiled .pyc artifacts). Untrack the files and
add a local .gitignore so preset regeneration never stages them again.
2026-07-02 18:46:37 +02:00
L'électron rare 1302ffd87a feat(matrix): deepen hand mod bindings to 0.70
CI build oscope-of / build-check (push) Has been cancelled
User request live: more audible depth on the hand X/Y mods. All 1848
lHand/rHand X/Y bindings across the 84 presets raised from 0.40-0.50
to 0.70 (per-voice editable in the web UI as before).
2026-07-02 18:44:10 +02:00
L'électron rare 720d566294 feat(concert): portrait video + pinch on defaults
CI build oscope-of / build-check (push) Has been cancelled
Session-validated setup becomes the launcher default so app-icon
launches match: VIDEO_ROTATE=ccw (iPhone portrait) and PINCH_ENABLE=1
(full gate chain validated live 2026-07-02).
2026-07-02 18:38:35 +02:00
L'électron rare 1d59d68777 feat(viz): gate numeric source keys too
CI build oscope-of / build-check (push) Has been cancelled
0-9 preset bundles join wxcvbn behind VIZ_SOURCE_KEYS (default off) —
the whole source row is now perf-safe.
2026-07-02 18:33:39 +02:00
L'électron rare 8d93163766 feat(viz): gate audio-scene keys off by default
CI build oscope-of / build-check (push) Has been cancelled
qsdfghjkl fired auto-synth scenes on stray keypresses during matrix
performance — same gate as wxcvbn (VIZ_AUDIO_KEYS=1 re-enables). The
m matrix-mode shortcut (scene stop + openpos) stays active.
2026-07-02 16:53:27 +02:00
L'électron rare 6d43120c0a feat(viz): m key = matrix mode, fix test pins
CI build oscope-of / build-check (push) Has been cancelled
m now stops the auto data-synth scene AND forces the openpos viz
(matrix unaffected — it is not a scene). Repairs the two test pins the
frozen pinch defaults broke (ext_min 1) and makes the skeleton path
safe for __new__-built renderers (hand gate getattr).
2026-07-02 16:49:16 +02:00
L'électron rare 1c07211dd8 feat(viz): perf-safe keys, frozen pinch tuning
CI build oscope-of / build-check (push) Has been cancelled
Live session outcomes: (1) pinch thresholds validated for inner
fingers frozen as defaults (ratio_on 0.50, margin 0.05, ext_min 1);
(2) panel frames + X/Y gauges now permanent (no early-return when
nothing is detected); (3) wxcvbn source-bundle shortcuts gated off by
default (VIZ_SOURCE_KEYS=1 re-enables) — a stray key mid-performance
switched the audio scene.
2026-07-02 16:44:28 +02:00
L'électron rare 6fe98c6b00 feat(viz): continuous frame quality intensity
CI build oscope-of / build-check (push) Has been cancelled
Add gesture_quality() pure helper (hand_display.py) that maps a slot's
plausibility, facing, and proximity to a score in [0,1]. Renderer uses
it to modulate panel frame brightness (0.25+0.75*q) and stroke count
(1..3 passes at q≥0, ≥0.5, ≥0.85 / status==3). Status still drives
hue (pid 7/8/9). New gesture_slot_quality field in State written
alongside gesture_slot_status each tick.

Add gauge_segments() for X/Y position gauges around each panel: a
horizontal rail below and a vertical rail on the outer side, each with
a bold two-tick notch at the hand's normalised cx/cy (mirror-aware for
X). Dim rails (conf 0.25) when slot absent. Renderer reads hand_feats
L/R directly from state.

11 new tests (6 gesture_quality + 5 gauge_segments); suite 430 passed.
2026-07-02 16:34:08 +02:00
L'électron rare 32a722e281 feat(viz): gesture status panel indicators
CI build oscope-of / build-check (push) Has been cancelled
Add gesture_slot_status [0..3] to State (absent/detected/armed/engaged).
PinchDetector.engaged_slots() exposes per-slot pinch state. Publisher
writes status after each tick via _update_gesture_slot_status().
Renderer draws panel frame always: dim (pid7 conf=0.3) when absent,
normal (pid7) detected, pid8 armed, pid9 double-stroke engaged.
2026-07-02 15:16:14 +02:00
L'électron rare eb3803680b feat(pose): facing gate for gestures
Add hand_facing() palm-spread metric (dist(kp5,kp17)/hand_size).
Gate GestureSlotStabilizer slot activation on facing >= HAND_FACE_MIN
(default 0.5) with 0.8x hysteresis; side-on palms no longer arm
pinch/strike. Add VizConfig.hand_face_min + HAND_FACE_MIN env var.

Update test fixtures: _near_hand() now spreads kp[5]/kp[17] so
existing tests keep passing through both near+face gates.
2026-07-02 15:16:01 +02:00
L'électron rare bcf7ac180e chore(matrix): migrate presets to new mapping
CI build oscope-of / build-check (push) Has been cancelled
Update globalActions in all 84 .matrix preset files from old
default to: [\fill, \doubleTime, \washReverb, \muteDrums,
\relanceEvolve, \halfTime, \next, \muteMelo].
2026-07-02 14:56:50 +02:00
L'électron rare a569122f14 feat(matrix): drum feel + relance actions
Add \doubleTime and \halfTime as drum-only subdivision feel toggles
(\matDrumFeel \normal/\double/\half, mutually exclusive). Engaging a
feel re-sources only drum voices (matLastColor reset + matApplyBar).
The \relanceEvolve composite action seeks to bar 0 then fires
matRelanceEvolve. \matDrumFeelPush sends /matrix/drumfeel on toggle
and on preset load (resets to \normal). OSCdef mat_drumfeel_get added.
Drum feel stretch applied in matVariationOverlay + matColorStepPattern
via txStretch local var; non-drum voices use matTxStretch unchanged.
Web vocab: relanceEvolve added, evolve relabelled Évolue.
Test file extended with sections R (feel toggle) and S (relanceEvolve).
2026-07-02 14:56:07 +02:00
L'électron rare feb32938fb fix(viz): skeleton points not rotated by default
CI build oscope-of / build-check (push) Has been cancelled
Live: skeleton still misrotated after the conjugation fix. Both app
2D streams (ARKit skeleton2D + Vision hands) arrive already uprighted
in portrait — the interface orientation drives the projection. Points
are no longer rotated by default; VIDEO_ROTATE only turns the video.
IPHONE_SKEL_ROTATE / IPHONE_HANDS_ROTATE remain as scan knobs.
2026-07-02 14:53:28 +02:00
L'électron rare c878056733 fix(viz): separate hands rotation from skeleton
CI build oscope-of / build-check (push) Has been cancelled
Live report: skeleton straight but hands off the wrists. The app runs
Vision with a hardcoded .right orientation (ARBodySession.swift:261),
so hand coords live in an uprighted frame while ARKit skeleton2D lives
in the raw sensor frame. Hands get their own IPHONE_HANDS_ROTATE knob
(default none); VIDEO_ROTATE now only rotates the skeleton2D points.
2026-07-02 14:47:05 +02:00
L'électron rare 3199aa326d feat(matrix): remove fist gestures
SC handlers (edge detector, tick, OSCdefs, pose/hands calls) and web
UI (Poings section, sensibilite poing slider, fistprogress indicators)
removed. Python FIST_ENABLE=0 kill-switch was already in effect.
Old .matrix files with fistThresh key load without error (key silently
ignored). Test harness Q section removed from test_global_actions.scd.
2026-07-02 14:42:43 +02:00
L'électron rare c66c4271fa fix(viz): mirror-conjugate point rotation
CI build oscope-of / build-check (push) Has been cancelled
Live report: video straight but skeleton rotated wrong. Flip and
90-degree rotation do not commute — the video is flipped THEN rotated
while points are rotated THEN display-flipped. Points now use the
mirror-conjugated mode (cw/ccw swapped under mirror), proven by a
composition test.
2026-07-02 14:41:57 +02:00
L'électron rare ef1d557eff feat(viz): rotate iphone 2d points, hide fingers
CI build oscope-of / build-check (push) Has been cancelled
Portrait mode validated live: VIDEO_ROTATE now also rotates the
iPhone 2D landmarks (skeleton2D + Vision hands) at the decode layer so
overlays/panels/gestures stay aligned with the rotated video. ARKit
per-finger joints are masked from the wireframe (unreliable 2D, seen
as false hands); the display shows the filtered Vision hands instead.
2026-07-02 14:36:57 +02:00
L'électron rare 3ffd81afc7 feat(pose): validate hands before analysis
Apply hand_plausible + HandPersistenceGate in _step_stab before
route_hands so gestures and /pose/hands features only see quality-
gated hands. Cache per-tick validated set (_tick_hands/_tick_chir/
_tick_mirror) so all three emitters share one acquisition. Gate
stepped at 30 Hz publisher cadence (audit R6). Non-protocol hands
(raw numpy arrays) pass through via try/except for backward compat.
2026-07-02 14:35:03 +02:00
L'électron rare 65f13b4621 test(pose): gesture input validation gates
Tests (a-f) for plausibility gate, persistence establishment,
chirality alignment, feature path, MediaPipe pass-through.
Update helpers with .x/.y/.c _Kp objects; add HAND_PERSIST_FRAMES=1
to legacy _pub()/_pub_envs() so existing tests need no warmup ticks.
2026-07-02 14:34:53 +02:00
L'électron rare b85a9c93a9 fix(viz): no body mesh when arkit is the source
CI build oscope-of / build-check (push) Has been cancelled
The body-mesh triangles (solid cyan blob reported live) were the one
surface no filter covered: under iphone-usb persons_body is the ARKit
conversion with c=1.0, so garbage/stale joints painted full triangles.
Skip BODY_TRIANGLES whenever the ARKit topology is present (webcam
MediaPipe path unchanged); the body stays the freshness-gated ARKit
wireframe.
2026-07-02 14:26:03 +02:00
L'électron rare 651fd86d37 docs(viz): fix config docstrings + env tables
config.py: PINCH_EXT_MIN docs said "min frames for debounce"
— it is min extended fingers required to fire; fix wording.
HAND_PERSIST_FRAMES said "persist after disappearance" — it
is frames required before a hand is drawn; fix wording.

CLAUDE.md: MEDIAPIPE_DELEGATE default was "cpu" — real
default is "gpu" (config.py); fix the RC0.1+ table row.
Add missing PINCH_ENABLE / VIZ_HUD / CONCERT_MIRROR rows
to the main env-vars table.
2026-07-02 14:24:01 +02:00
L'électron rare d337ed9b29 fix(pose): clear iphone hands on stream drop
iphone_usb_source.py: after stream ends, clear
persons_hands_iphone, persons_hands_chirality, and
persons_hands (when write_hands=True) under state.lock()
to prevent renderer display freeze.

test_iphone_usb_reconnect.py: add
test_hands_cleared_on_stream_drop to verify lists are
empty after simulated USB disconnect.
2026-07-02 14:23:53 +02:00
L'électron rare 0126fc97a8 fix(viz): wire arkit-full + osc port via config
renderer.py: read ARKIT_FULL_SKELETON via VizConfig at
initWithState_ instead of module-level os.environ; store as
self._arkit_full, use getattr fallback for __new__ test bypass.

main.py: pass cfg.iphone_osc_port to IphoneOSCListener
instead of hardcoded 57128; log actual port.

hand_slots.py: import _finite/_coord/_clamp from hand_features
at module level (acyclic: hand_features imports hand_slots only
lazily inside step()); delete the duplicate copies.
2026-07-02 14:23:47 +02:00
L'électron rare 4ea0bb2c2a feat(pose): fist kill-switch, default off
CI build oscope-of / build-check (push) Has been cancelled
User request live 2026-07-02: disable the fist gesture completely
(false held-fist actions during play). fist is forced to 0.0 at the
extractor unless FIST_ENABLE=1; continuous features unchanged. SC
held-fist actions (reload/next) go silent without touching SC.
2026-07-02 14:22:31 +02:00
L'électron rare ba4a1bb103 perf(pose): 30hz gesture loop under iphone-usb
CI build oscope-of / build-check (push) Has been cancelled
iphone-usb path: camera decode + ARKit conversion is cheap (no
MediaPipe inference on Mac). Frame-based gesture calibrations
(debounce/hold/grace/persist) assume 30 Hz. Keep 18 Hz for the
webcam MediaPipe path (M5 CPU protection).
Tests: period formula + MultiWorker.__init__ signature check.
2026-07-02 14:13:52 +02:00
L'électron rare 05d0fc1df1 fix(pose): mirror-coherent gesture fallback
Read state.mirror_2d in _pick_hands and thread it through
_step_stab (route_hands mirror=) and _emit_hands
(HandFeatureExtractor.step mirror=) so the cx fallback slot
assignment matches the renderer panel layout on chirality dropout.

Add mirror kwarg to HandFeatureExtractor.step plumbed to route_hands.
Tests: extractor-level mirror flip + publisher _emit_hands mirror_2d.
2026-07-02 14:13:36 +02:00
L'électron rare caa41fd7f1 test(viz): pin stabilizer defaults, fix seams
CI build oscope-of / build-check (push) Has been cancelled
Detection v3 review follow-ups: pin hand_persist_grace/near_off/
hold_frames defaults; use the module-level route_hands (restores the
monkeypatch seam, drops the dead local alias); document the grace
count-inheritance window in the gate docstring.
2026-07-02 12:00:28 +02:00
L'électron rare e10361c56d docs(pose): fix stale swap note, pin mesh rig
CI build oscope-of / build-check (push) Has been cancelled
hand_slots module docstring still claimed inverted chirality (default
1) after the live A/B validated chirality correct (default 0); align
it. Pin the MESH_RIG ne0 bool convention (chantier 5 review minor).
2026-07-02 11:52:20 +02:00
L'électron rare a7a1563ef8 docs: detection v3 env vars 2026-07-02 11:49:32 +02:00
L'électron rare 5769af3d26 feat(pose): fist near + established gates 2026-07-02 11:48:58 +02:00
L'électron rare f3ca0ac100 feat(pose): gesture slot stabilizer 2026-07-02 11:48:01 +02:00
L'électron rare 4e33a03e1b feat(viz): persistence grace frames 2026-07-02 11:46:01 +02:00
L'électron rare 8b08282aa5 fix(viz): remote log vars + avbody host param
Chantier 5 review: NameError on MULTIHMR_BACKEND=remote (LOG.info
referenced removed locals) and SMPLXTCPSender host param silently
ignored after the VizConfig migration (env-present check restores the
pre-migration contract).
2026-07-02 11:40:55 +02:00
L'électron rare 91b3923431 docs(viz): chantier-5 report + HAND_SWAP_LR doc
CI build oscope-of / build-check (push) Has been cancelled
2026-07-02 11:33:42 +02:00
L'électron rare e2a41c0d8a test(viz): VizConfig and iphone-usb gating tests
61 tests: all VizConfig defaults, four bool conventions,
numeric cast fallback, monkeypatch round-trip, and Part B
gating (_ensure_model never called under --iphone-usb).
Fix hand_swap_lr from_env default: was True, now False.
2026-07-02 11:32:24 +02:00
L'électron rare 65bf3aad08 refactor(viz): remaining consumers read VizConfig
pose_filter _parse_env_* read POSE_FILTER* via VizConfig.
multi_hmr_worker reads MULTIHMR_BACKEND/AUTOCAST/REMOTE via VizConfig.
multihmr_remote reads JPEG/ASYNC/HOST/PORT via VizConfig.
smplx_osc_sender reads AVBODY_HOST/REID/ALPHA via VizConfig.
pose_bridge reads AVBODY_HOST/VDMX_* via VizConfig.
iphone_usb_source reads CONCERT_MIRROR via VizConfig.
lidar_calib reads ICP_LIDAR_EXTRINSIC via VizConfig.
multihmr_coreml reads COREML_COMPUTE_UNITS via VizConfig.
2026-07-02 11:26:57 +02:00
L'électron rare c0cde337c9 refactor(viz): consumers read VizConfig
multi.py reads 5 discrimination thresholds via VizConfig.
main.py reads ICP/Multi-HMR/AV_LIVE_* flags via VizConfig.
2026-07-02 11:24:19 +02:00
L'électron rare e7ce016688 feat(viz): central VizConfig from env
Add data_only_viz/config.py: frozen dataclass VizConfig with typed
fields for all 56 env reads in data_only_viz. from_env() reads
os.environ at call time — no module-level singleton, test-compat.

Migrate action_head_pub._init_finger_piano and renderer.initWithState_
to consume VizConfig. Keep renderer.py ARKIT_FULL module-level (test
uses MetalRenderer.__new__ without calling initWithState_).

HAND_SWAP_LR default changed False (was True): Vision chirality
validated correct live 2026-07-02; swap knob now defaults off.
2026-07-02 11:21:55 +02:00
L'électron rare 5d425b1fbd fix(pose): plumb swap into hand features slots
CI build oscope-of / build-check (push) Has been cancelled
Review C1 (Critical): /pose/hands per-voice L/R mods routed without
the HAND_SWAP_LR swap while pinch/strike used it — the two paths
contradicted each other on an inverted-chirality source. step() now
takes swap, the publisher passes its env-read value, two tests pin
the contract. Also hoists the route_hands imports (M5) and fixes the
stale slot comment (M1).
2026-07-02 11:09:06 +02:00
L'électron rare 32cdfa0d3e fix(pose): swap chirality only, default on
CI build oscope-of / build-check (push) Has been cancelled
User-confirmed live: ARBodyTracker Vision chirality arrives inverted
vs the mirrored view. HAND_SWAP_LR now inverts the chirality mapping
inside route_hands (cx fallback untouched, it is already screen
relative) and defaults to 1. Also: HandPersistenceGate greedy-match
limitation documented + malformed-entry guard (review follow-ups).
2026-07-02 11:00:25 +02:00
L'électron rare e20a4d130a docs: hand router env vars 2026-07-02 10:55:38 +02:00
L'électron rare 366d916e9d refactor(pose): detectors consume routed slots
Convert all four consumers to accept pre-routed [L|None, R|None]:
- finger_strike: remove order_hands_by_cx + _slot_hands; step()
  iterates enumerate(slotted), None slot resets state / synthesizes
  release edges in-place.
- hand_features: step(hands, chirality=None) delegates to route_hands;
  speed buffer indexed by slot, not cx order.
- action_head_pub: _pick_hands returns chirality; _emit_fingers/
  _emit_pinch call route_hands with HAND_SWAP_LR/HAND_NEAR_MIN;
  _emit_hands plumbs chirality to HandFeatureExtractor.
- renderer: panels block replaced with route_hands call; persistence
  gate + hand_plausible mask applied before routing so a ghost hand
  cannot steal a slot; HAND_SWAP_LR read at init.
Test updates: [hand, None] slot lists in test_finger_strike;
_hand() fixture in test_action_head_finger_emit gains realistic size
so near_min=0.10 gate passes.
2026-07-02 10:55:32 +02:00
L'électron rare 9ee7990dcd feat(pose): hand router chirality + near gate
New module hand_slots.route_hands():
- Chirality path: iPhone ARBodyTracker Vision 0/1 -> L/R slots,
  alignment preserved before invalid-hand filtering.
- Fallback: ascending cx sort (mirror-aware).
- swap=True: swaps the two slots at the very end (safety knob,
  HAND_SWAP_LR=1, if a source inverts Vision chirality).
- near_min gate: drops slots below wrist->middle-MCP distance.
18 new tests covering all branches.
2026-07-02 10:55:21 +02:00
L'électron rare 8bc7acbd14 fix(pose): fresh arkit joint arrays per frame
Extract apply_skeleton_joints() as a pure module-level function that
copies prev_arr (or zeros) and writes a new array, never mutating the
old one.  The TAG_SKELETON handler now stores the new array under the
state lock, so readers that captured a reference to the previous array
(multi.py shallow-copy + ArkitFuse.apply outside the lock) always see
a complete, consistent snapshot — eliminating the B4 tearing race.

6 new tests cover: shape, identity (is not), value preservation for
invalid joints, value application for valid joints, wrong-shape reset,
and no-mutation of prev_arr.
2026-07-02 10:41:41 +02:00
L'électron rare 0a4f51ebfb test(viz): pin arkit max-len behavior change
Review follow-up: make the v2 filter's rejection of legacy 0.707
diagonal fixtures explicit instead of silently retuned, and document
the close-up caveat on max_len (ARKIT_BONE_MAX raises it live).
2026-07-02 10:37:02 +02:00
L'électron rare 039abaa4a8 docs: hand filter env vars
CI build oscope-of / build-check (push) Has been cancelled
Add HAND_CONF_MIN, HAND_PERSIST_FRAMES, ARKIT_BONE_MAX to the
Environment variables table in the root CLAUDE.md.
2026-07-02 10:28:23 +02:00
L'électron rare 5bfc63f86d feat(viz): hand persistence gate + conf knobs
HandPersistenceGate (min_frames=3, radius=0.15) suppresses ghost
hands that flash for fewer than min_frames consecutive frames.
Gate is called once per frame in the renderer on persons_hands;
result filters both the video overlay loop and the side panels.

hand_plausible now rejects wrists outside [-0.1, 1.1] (out-of-frame
ghost anchor check).  panel_segments gains a conf_min param.

Env knobs read at renderer init (single parse, no per-frame cost):
  HAND_CONF_MIN (default 0.45, raised from 0.3)
  HAND_PERSIST_FRAMES (default 3)
  ARKIT_BONE_MAX (default 0.5, passed to arkit_segments)

New tests: 12 gate tests + 4 wrist-bounds tests.
2026-07-02 10:27:58 +02:00
L'électron rare ac6768d8a8 fix(viz): arkit bone bounds + length filter
arkit_segments now accepts max_len and bounds params.
Bones whose endpoints fall outside [-bounds, 1+bounds] or whose
length exceeds max_len are skipped (tracking-loss garbage).
Defaults: max_len=0.5, bounds=0.1.  Renderer will pass
max_len=ARKIT_BONE_MAX explicitly (Part 3).

Tests updated to use realistic bone lengths; new tests added
for the out-of-bounds and overlong-bone cases.
2026-07-02 10:27:49 +02:00
L'électron rare 287c4977a9 fix(viz): install key monitors at app launch
NSEvent keyboard monitors were placed at the END of
_start_pose_worker, unreachable: every backend path returns
early (lines 369/384/399/413/440) so monitors were never
installed. ESC, space (concertNext) and keymaps were silently
dead in all real concert paths.

Move the local + global monitor installation into
applicationDidFinishLaunching_, after window focus setup,
non-headless path only. The --multi-hmr headless early-return
(line 95) is unaffected.
2026-07-02 10:15:19 +02:00
L'électron rare b8ae1a668c fix(pose): read real smplx person dataclass
_read_sources used .get("pid")/.get("v3d") (dict API) but
multi_hmr_worker emits SMPLXPerson dataclasses (field: vertices_3d).
AttributeError was swallowed by LOG.exception at 30 Hz.

- Dual-read via isinstance check: dataclass path uses p.pid /
  p.vertices_3d / p.expression; dict path keeps p.get() unchanged
- 3 new tests: real SMPLXPerson emits OSC, expression field accepted,
  legacy dict format still works
2026-07-02 10:14:17 +02:00
L'électron rare 48b04084fa fix(viz): gate arkit overlay on 2d freshness
persons_arkit_2d_t was written by IphoneUSBSource but never
read. A USB drop left the frozen skeleton on screen indefinitely.

- Add arkit_2d_fresh(ts_by_pid, now, max_age=1.0) pure helper
  in hand_display.py (8 TDD tests, all green)
- renderer._update_skeleton: use_arkit now requires at least one
  pid younger than 1 s (replaces bool check)
- Per-pid freshness gate in draw loop skips stale pids
2026-07-02 10:13:05 +02:00
L'électron rare 06b868e377 docs(viz): refactor audit ref (chantiers 1-6)
CI build oscope-of / build-check (push) Has been cancelled
2026-07-02 10:07:00 +02:00
L'électron rare 0a2865e136 feat(viz): left/right hand front-view panels
CI build oscope-of / build-check (push) Has been cancelled
Add panel_rect/panel_segments/panel_frame helpers to hand_display.py
(PANEL_SIDE=0.30, PANEL_MARGIN=0.02, PANEL_INNER=0.10). Segments use
uniform pixel-space scale so the hand aspect ratio is preserved
regardless of view aspect. mirror=True flips X within the panel.

In renderer._update_skeleton add push_panel (no mirror flip) and a
panels block before the segs==0 epilogue: routes persons_hands to
left/right panel via chirality list when aligned, screen-cx fallback
otherwise. Draws frame (pid 7) + wireframe (pid 5 left / 6 right).
Runs on every path including use_arkit. Guards: _mp_bones None, empty
hands, misaligned chirality, height==0.

26 tests pass (10 new for panel helpers).
2026-07-02 09:56:08 +02:00
L'électron rare 3a3e6b113f feat(viz): hud debug panel off by default
The AV-Live data-only text HUD hid the lower-left corner of the
performance display and would collide with the new left-hand panel.
VIZ_HUD=1 re-enables it; the H key toggle guards the absent view.
2026-07-02 09:47:54 +02:00
L'électron rare ef719e76f3 feat(viz): filter hand overlay artifacts
Add hand_display.py with hand_plausible / segment_ok / hand_size pure
helpers; wire into both skeleton hands loops (plausibility gate +
per-segment confidence+spike filter + real min(A.c,B.c) push); remove
filled mesh triangles for hands from _update_mesh (HAND_TRIANGLES block
deleted).
2026-07-02 09:42:51 +02:00
L'électron rare 60640a881e feat(pose): decode hand confidence + chirality
_decode_hands now returns (hands, chirality) tuple instead of a bare
list. Landmark .z keeps the raw wire value; .c = clamp(z, 0, 1).
Chirality byte (1=right, else 0=left) is decoded per hand and stored
in State.persons_hands_chirality under the same lock as
persons_hands_iphone.
2026-07-02 09:37:24 +02:00
L'électron rare 29c66c7151 docs(viz): hand display cleanup spec + plan 2026-07-02 09:33:34 +02:00
L'électron rare 1f19a5db96 feat(pose): pinch gate env vars + docs
CI build oscope-of / build-check (push) Has been cancelled
2026-07-02 09:18:14 +02:00
L'électron rare ea42ba4bb3 feat(pose): pinch engage debounce 2026-07-02 09:14:18 +02:00
L'électron rare 021c53f078 feat(pose): pinch open-hand extension gate 2026-07-02 09:10:06 +02:00
L'électron rare 50bc6f5d71 docs(pose): pinch gate implementation plan 2026-07-02 09:05:26 +02:00
L'électron rare 1510a37059 docs(pose): pinch open-hand gate + debounce spec 2026-07-02 09:00:56 +02:00
clement 095fd51812 Merge branch 'feat/disable-matrix-pinch'
CI build oscope-of / build-check (push) Has been cancelled
2026-06-30 22:51:39 +02:00
clement 1491aef30e feat(matrix): disable finger pinch by default 2026-06-30 22:49:41 +02:00
clement 24ce960992 fix(iphone): mirror skeleton overlay to video 2026-06-30 22:40:31 +02:00
clement 08b7c72e49 feat(iphone): render full arkit body skeleton 2026-06-30 22:14:35 +02:00
clement 86c071d0c9 feat(iphone): arkit skeleton segment builder 2026-06-30 22:09:10 +02:00
clement 04eb182fab feat(iphone): store topology + 2d validity
Add arkit_joint_names, arkit_parents, persons_arkit_2d_valid
to State. Wire TAG_TOPOLOGY=7 dispatch in IphoneUSBSource:
decode_topology stored under lock; TAG_SKELETON2D branch now
also stores per-joint validity bool array (91,) alongside pts.
2026-06-30 22:05:32 +02:00
clement b00b6dfbda fix(iphone): topology decode return type + test 2026-06-30 22:02:35 +02:00
clement fbc8b55044 feat(iphone): python topology codec 2026-06-30 21:58:42 +02:00
clement 67f347e705 feat(iphone): emit skeleton topology on connect 2026-06-30 21:58:35 +02:00
clement 6f941c52cb feat(wire): topology payload + tag 2026-06-30 21:49:37 +02:00
clement b2dab64aae fix(iphone): sendable hand buffer handoff 2026-06-30 21:47:29 +02:00
clement bb90820988 docs(iphone): phase 1 faithful skeleton plan 2026-06-30 21:45:31 +02:00
clement 5adb8abdc8 docs(iphone): SOTA 2026 refonte design spec 2026-06-30 21:33:45 +02:00
L'électron rare 40c21a246c perf(iphone): drop face vision + RealityKit view
CI build oscope-of / build-check (push) Has been cancelled
ARKit-only-body mode no longer consumes face: the Mac dropped face
entirely (face=0, /pose/mouth gone), so VNDetectFaceLandmarksRequest
and the .face USB frames were pure wasted per-frame compute. Drop the
face request and its send.

The on-device ARView (RealityKit) rendered a black background behind
the SwiftUI skeleton overlay -- it never showed the camera -- so it
only spun up the RealityKit AR render pipeline (the engine:rematerial
console spam) to draw black. Remove the ARView; the screen stays
black + skeleton overlay, identical UX, minus the GPU/thermal cost.

Video (HEVC) and hands are kept: the Mac uses the iPhone video as the
visualizer backdrop and Vision hands drive pinch/fist gestures.
2026-06-30 20:31:10 +02:00
L'électron rare 2b7b4a81c6 docs(data-only-viz): arkit body source mode
CI build oscope-of / build-check (push) Has been cancelled
2026-06-30 20:14:17 +02:00
L'électron rare ef99c32cac fix(iphone): explicit SIMD2<Float> for skeleton2D
CI build oscope-of / build-check (push) Has been cancelled
2026-06-30 19:42:22 +02:00
L'électron rare 27d5ce9d98 feat(iphone): send 2D projected skeleton
Add camera: ARCamera param to publishUSB and thread frame.camera
through from the session delegate. After the existing .skeleton
send, project all 91 joints to viewport 2D, normalize to [0,1],
and send as Skeleton2DPayload / FrameTag.skeleton2D (tag 6).
2026-06-30 19:41:02 +02:00
L'électron rare 33de64f599 chore(launcher): drop arkit_fuse, arkit is source 2026-06-30 19:32:04 +02:00
L'électron rare 5a9a6338a0 feat(pose): arkit body, skip mediapipe pose+face
Under --iphone-usb, skip all three MediaPipe detectors
(pose, face, hand) and rebuild the MP-33 body from
state.persons_arkit_{2d,joints} via new arkit_body_2d /
arkit_body_3d builders. Add head anchor (ARKIT_HEAD_IDX=51,
needs live verify) as MP33 slot 0. Webcam path unchanged.
2026-06-30 19:29:20 +02:00
L'électron rare c49de5c500 feat(pose): decode iphone 2D skeleton
Add TAG_SKELETON2D=6, SKEL2D_BYTES=819, decode_skeleton2D() to
iphone_usb_bridge.py; handler in IphoneUSBSource writing
persons_arkit_2d / persons_arkit_2d_t to state; new state fields
in State dataclass. TDD: test_arkit_body.py (round-trip + wrong
length → None). Import check verified.
2026-06-30 19:19:54 +02:00
L'électron rare 660d493ad6 feat(wire): skeleton2D frame payload
Add FrameTag.skeleton2D (raw 6) and Skeleton2DPayload for
2D-projected ARKit joints: 91×SIMD2<Float> + 91 valid bytes,
big-endian wire format mirroring SkeletonPayload style.

Note: raw value is 6 (not 5 as drafted) because face=5 is taken.
2026-06-30 19:15:16 +02:00
L'électron rare bd9f9fca29 docs: arkit-only body position plan 2026-06-30 18:47:10 +02:00
L'électron rare 7037ebcd82 docs: arkit-only body position spec 2026-06-30 18:43:20 +02:00
L'électron rare 02f7ad42b2 feat(pose): all-fingers-curled fist metric 2026-06-30 18:22:38 +02:00
L'électron rare 16cd6a3655 fix(pose): pinch closest-finger-wins + margin
CI build oscope-of / build-check (push) Has been cancelled
2026-06-30 18:06:34 +02:00
L'électron rare 4c0794a3db fix(pose): iphone-only hands, cut mac mediapipe
CI build oscope-of / build-check (push) Has been cancelled
2026-06-30 17:32:55 +02:00
L'électron rare 9111da4a5c feat(control): fist sensitivity + indicators
CI build oscope-of / build-check (push) Has been cancelled
Add matFistThresh state (default 0.15) with localStorage persistence.
Wire a "Sensibilite poing" slider in Reglages, synced via fisthresh.
Add two fist indicator rows in #morceau-fists driven by fistprogress.
2026-06-30 16:54:54 +02:00
L'électron rare 65c06ed4c6 feat(presets): emit default fist threshold
Context: Task 1 wired fistThresh into the SC matrix engine (default
0.15); the generator was not yet emitting this field, so every
.matrix was missing it and the engine fell back to its hardcoded
default.

Approach: TDD — added a failing check to test_global_actions.py,
then edited to_sc so pinchHoldMs ends with a comma and fistThresh
0.15 follows as the last field before the closing paren.
Regenerated 84 presets twice for determinism.

Changes:
- generate_presets.py: pinchHoldMs now ends with comma; fistThresh
  0.15 emitted as the final key in every preset's SC Event literal
- test_global_actions.py: EXPECT_FTH block checks to_sc output and
  all 84 on-disk .matrix files
- *.matrix (84 files): regenerated with fistThresh field

Impact: all four gates green (GLOBALACT-PY, test_sections,
test_families, GLOBALACT SC); fistThresh is now a first-class
preset field readable at load time by ~matLoadFile.
2026-06-30 16:50:02 +02:00
L'électron rare 24078111b4 feat(matrix): fist-held gestures (reload/next)
Held closed-fist (low openness, hysteresis+presence guard) per hand
fires an action after ~matPinchHoldMs: left hand reloads the current
preset, right hand advances to the next. Progress emitted on
/matrix/fistprogress.

- ~matFistEdge edge detector (engage/release/absent + hysteresis)
- ~matFistTick fires once past hold threshold (guarded by ~matFistFired)
- ~matFistReloadCurrent and ~matNextPreset wired to hand 0/1
- /matrix/fisthresh[/get] OSCdefs + ~matFistThreshPush
- fistThresh saved and restored per .matrix file
- 30 Hz poller now calls ~matFistTick alongside ~matPinchTick
- Harness group Q: A-Q all green
2026-06-30 16:42:58 +02:00
L'électron rare 4cfd2f0a98 docs(matrix): fist-held gestures plan 2026-06-30 16:39:02 +02:00
L'électron rare f9f62e2951 docs(matrix): fist-held gestures spec 2026-06-30 16:36:34 +02:00
L'électron rare 38dca5642e feat(control): pinch hold, progress, 2-col menu
CI build oscope-of / build-check (push) Has been cancelled
- matPinchHoldMs state (default 300ms) + setPinchHoldMs + persistence
- .morceau-slots column-first grid (MG left, MD right, 4 rows)
- per-slot .morceau-slot-fill progress bar (/matrix/pinchprogress)
- hold slider in Réglages wired to /matrix/pinchhold (50-2000ms)
2026-06-30 16:08:47 +02:00
L'électron rare 2ced4e728f feat(presets): emit default pinch hold ms
Add top-level 'pinchHoldMs': 300 to every generated .matrix so the SC
engine (Task 2) reads it instead of its hardcoded default.

- generate_presets.py: globalSettings ends with ',' and
  'pinchHoldMs': 300 is the last field before the closing ')'
- test_global_actions.py: EXPECT_PHMS check added (TDD red then green)
- 84 .matrix files regenerated (28 morceaux x intro/live/outro)
2026-06-30 16:01:59 +02:00
L'électron rare 0daa6d6bcd feat(matrix): pinch hold-to-fire + progress 2026-06-30 15:53:59 +02:00
L'électron rare b3ec348378 feat(pose): pinch engage/release edges + launcher
PinchDetector now emits state=1 on engage and state=0 on release
(and on hand-disappear). send_pinch appends the state as the 4th
OSC arg so SC can distinguish hold from lift. Launcher switches
from FINGER_PIANO to PINCH_ENABLE to gate only the pinch detector.
2026-06-30 15:46:13 +02:00
L'électron rare 2902f5c7cd docs(matrix): pinch refinements plan 2026-06-30 15:43:24 +02:00
L'électron rare 4bccbc1580 feat(pose): decouple pinch from air-piano gate 2026-06-30 15:40:33 +02:00
L'électron rare 50386efe8f docs(matrix): pinch refinements spec 2026-06-30 15:39:28 +02:00
L'électron rare d31c6c52d0 feat(launcher): enable pinch detection for matrix
CI build oscope-of / build-check (push) Has been cancelled
2026-06-30 15:10:35 +02:00
L'électron rare 65fe76d5f0 feat(control): 8 pinch slots panel + tempo 2026-06-30 15:07:06 +02:00
L'électron rare 140953f3cb feat(control): 8 pinch slots + trim settings state 2026-06-30 15:02:11 +02:00
L'électron rare 9590615069 feat(presets): 8 pinch actions, trim settings
Expand globalActions from 4 to 8 slots:
[\fill, \drop, \breakdown, \evolve,
 \muteDrums, \muteMelo, \washReverb, \next]

Drop openThresh/closeThresh from globalSettings (now
engine-side constants via Task 1). Regenerate all 84
.matrix files deterministically.

All gates: GLOBALACT-PY PASS, TEST PASS x2, GLOBALACT PASS
2026-06-30 14:56:50 +02:00
L'électron rare cc485003f6 feat(matrix): pinch-triggered global actions
Replace open/close hand detection with 8 pinch slots (hand*4+finger-1).
Global actions array grows from 4 to 8 entries (L/R x index..ring).
Remove ~matHandFlip, ~matHandState, openThresh/closeThresh entirely.
2026-06-30 14:49:49 +02:00
L'électron rare 2994778b02 docs(matrix): pinch-trigger global actions plan 2026-06-30 14:42:26 +02:00
L'électron rare e04fb88e42 docs(matrix): pinch-trigger global actions spec 2026-06-30 14:35:04 +02:00
L'électron rare afc220f83f feat(matrix): matrix bpm tempo route 2026-06-30 14:29:23 +02:00
L'électron rare d4306f6252 docs(matrix): global actions phase 3 plan 2026-06-30 14:27:13 +02:00
L'électron rare 9dbf0bcbc9 feat(control): extended global action choices
CI build oscope-of / build-check (push) Has been cancelled
2026-06-30 14:08:23 +02:00
L'électron rare 0bd8e46dd7 feat(matrix): mute groups, time-warp, wash, next 2026-06-30 14:00:09 +02:00
L'électron rare d483a29d3d feat(control): reglages group in morceau panel
Add the Reglages group to the morceau panel: two threshold sliders
(open/close, the close one being the user-requested "sensibilite"),
a transient-duration slider (1-8 bars), and a breakdown kept-voices
checkbox grid covering all MATRIX_VOICES.

Wired over /matrix/globalsettings with inbound sync and onOpen
get-request. Imports consolidated into a single coherent block;
no duplicate identifiers. DOM container added to index.html;
styles appended to control.css.
2026-06-30 13:45:17 +02:00
L'électron rare 64348aa90d feat(control): global settings state + persistence 2026-06-30 13:40:19 +02:00
L'électron rare 8bead14e23 feat(presets): emit default global settings
Context: Phase 2 Task 1 made the SC engine load a globalSettings Event
from .matrix files (openThresh/closeThresh/transientBars/breakdownKeep)
and default it when absent. Generator was not yet writing the field.

Approach: append globalSettings after globalActions in to_sc() return
string; extend test_global_actions.py with EXPECT_SET check against
both the to_sc sample and all 84 .matrix files on disk.

Changes:
- generate_presets.py: to_sc now ends with globalActions (comma) then
  globalSettings Event with default values matching SC defaults
- test_global_actions.py: add EXPECT_SET literal + two check() calls
- *.matrix (84 files): each preset now carries the globalSettings field

Impact: every preset ships with openThresh/closeThresh/transientBars/
breakdownKeep defaults; SC engine reads them on load; no more implicit
fallback needed at runtime for any preset in the library.
2026-06-30 13:36:00 +02:00
L'électron rare 9f53f27317 feat(matrix): editable global action settings
Replace four hard-coded constants (~matTransientBars,
~matBreakdownKeep, ~matOpenThresh, ~matCloseThresh) with
~matGlobalSettings Event (openThresh, closeThresh,
transientBars, breakdownKeep). All three consumers
(~matHandFlip, ~matSetTransient, ~matFireGlobal) now
read from settings. Add ~matGlobalSettingsPush + OSCdefs
/matrix/globalsettings[/get]. Save/restore in .matrix.
Harness A-K green.
2026-06-30 13:27:51 +02:00
L'électron rare 1d6f65bbb2 docs(matrix): global actions phase 2 plan 2026-06-30 13:20:41 +02:00
L'électron rare cadbced958 fix(matrix): no baseGrid leak in evolve one-shot
CI build oscope-of / build-check (push) Has been cancelled
When the evolve toggle is OFF, ~matRelanceEvolve now snapshots
~matBaseGrid, mutates once, then restores it to nil. When ON it
keeps the persistent base and re-mutates from it. Fixes stale-
snapshot corruption across preset loads. Case I added to harness.
2026-06-30 12:19:14 +02:00
L'électron rare 8ed0365309 feat(control): morceau panel with action slots
Add bottom-of-matrix panel with 4 gesture-slot dropdowns wired
over OSC (/matrix/globalaction). State lives in matrix-state.js;
syncs on connect and on incoming OSC echo.
2026-06-30 12:00:55 +02:00
L'électron rare 69176fb94d feat(control): global action state + persistence 2026-06-30 11:56:06 +02:00
L'électron rare 50e9e79a22 feat(presets): emit default global actions mapping
Context: SC engine (Task 2) loads 'globalActions' from .matrix files
and defaults to [\fill, \drop, \evolve, \breakdown] when absent.
Generated presets did not write this field, causing implicit reliance
on the engine default.

Approach: extend to_sc() return string to include the globalActions
key so every preset explicitly carries the default mapping
(LH-open=fill, LH-closed=drop, RH-open=evolve, RH-closed=breakdown).

Changes:
- generate_presets.py: add 'globalActions' line in to_sc() return
- test_global_actions.py: new Python test verifying to_sc() output
  and all 84 on-disk .matrix files carry the field
- 84 .matrix files: regenerated with globalActions field present

Impact: presets are self-documenting; SC engine reads the explicit
value rather than applying a silent default.
2026-06-30 11:50:51 +02:00
L'électron rare 0cc64194df feat(matrix): hand gesture detection + mapping io
Add ~matHandFlip (hysteresis open/closed per hand),
~matGlobalActionsPush, OSCdefs /matrix/globalaction + /get,
/pose/hands integration, globalActions save/load roundtrip.
2026-06-30 11:41:55 +02:00
L'électron rare b72056ecb3 feat(matrix): global actions + transient engine
Adds fill/drop/breakdown/evolve one-shot transient overrides:
- ~matEffColor: per-voice colour gate for any active transient
- ~matSetTransient/~matTransientTick: set and expire transients
- ~matRelanceEvolve: one-shot evolve mutation
- ~matFireGlobal: dispatch gesture-slot -> action
- ~matGlobalActions/~matBreakdownKeep: configurable defaults
- ~matApplyBar now routes through ~matEffColor (grid untouched)
- ~matPlay routine ticks transient once per bar
- test_global_actions.scd harness: GLOBALACT PASS
2026-06-30 11:29:53 +02:00
L'électron rare c0e13a0fa0 docs(matrix): global actions phase 1 plan 2026-06-30 11:24:41 +02:00
L'électron rare 2f9f43e664 docs(matrix): global morceau actions design spec 2026-06-30 11:16:05 +02:00
L'électron rare 8dde561c34 feat(presets): per-style variation in live loop
CI build oscope-of / build-check (push) Has been cancelled
The live loop tiled the plateau into 4 identical blocks -- full but static.
LIVE_STYLE maps each morceau to a style group; LIVE_VAR holds per-style
profiles; _live_variation recolours active cells in some 16-bar blocks
(rhythmic 2/4, melodic 3 octave) and adds phrase-end fills (colour 6) so the
full loop breathes in a genre-appropriate way -- techno double-time hats +
fills, breaks snare rolls, ambient gentle bell octaves, etc. Active cells
only -> density unchanged, no voice dropped, no intro/breakdown back.

test_sections asserts the live still has no intro/breakdown AND varies (not
4 identical blocks). Regen 84 deterministic, VALIDATE 84/84 + DEFAULTS PASS.
2026-06-30 10:27:05 +02:00
L'électron rare 15e0b79083 docs(presets): live per-style variation plan 2026-06-30 10:25:13 +02:00
L'électron rare d8d6017443 docs(presets): live per-style variation design 2026-06-30 10:22:19 +02:00
L'électron rare db8d7d465f feat(presets): live = continuous full loop
CI build oscope-of / build-check (push) Has been cancelled
Each morceau's _live section reused the standalone arc, which re-introduced
(near-empty intro -> rebuild -> plateau -> breakdown). Now make_live_continuous
tiles the plateau (P, bars 32-47) across all 64 bars: full groove from bar 0,
no intro ramp, no breakdown -- a clean loop the _intro builds to and the
_outro descends from. Applied post-apply_colors for the live section only via
to_sc(post=); intro/outro unchanged.

test_sections asserts the live is full-from-start + has no breakdown dip.
Regen 84 deterministic, VALIDATE 84/84 + DEFAULTS PASS, test_families PASS.
2026-06-30 10:03:16 +02:00
L'électron rare 635343262e docs(presets): live continuous groove plan 2026-06-30 10:01:08 +02:00
L'électron rare d02a83d6a2 docs(presets): live continuous groove design 2026-06-30 09:57:46 +02:00
L'électron rare 5def521587 fix(presets): remove default pose->voice triggers
CI build oscope-of / build-check (push) Has been cancelled
The SP-A default bound danse->gate on the fx and sweep voices, so the
action-head classifying the dancer as "danse" toggled those voices on/off
even with no fx/sweep cells in the grid -- poses triggering FX that the
user never placed. Pose->voice triggers are now opt-in (default_poses
returns []); continuous pose modulation (default_mods) is unchanged.

Updated test_default_mods.py + the validate_presets DEFAULTS assertion
(fx voicePoses now expected empty). Regenerated 84 presets. VALIDATE
84/84 + DEFAULTS PASS; test_families + test_sections PASS.
2026-06-30 09:48:21 +02:00
L'électron rare 6de2627f7e fix(launcher): cpu delegate, gpu crashes
CI build oscope-of / build-check (push) Has been cancelled
The Metal GPU MediaPipe delegate (set as default earlier to fix pose
choppiness) crashes under sustained load: fatal CVPixelBuffer alloc
failure (-6662) in gpu_buffer_storage_cv_pixel_buffer.cc /
Image::ConvertToGpu(), on M1 Max too. The choppiness it addressed was CPU
starvation (baby-brain + model servers), now stopped -- so the CPU
delegate has headroom and is both fluid and stable. Default to cpu,
overridable via MEDIAPIPE_DELEGATE=gpu for short sessions.
2026-06-30 09:41:13 +02:00
L'électron rare fc268405d2 fix(iphone): retry initial USB connect
CI build oscope-of / build-check (push) Has been cancelled
The iPhone USB source gave up permanently if the first connect_device()
failed (app not streaming on :7000 at startup -- phone locked, app not
launched/crashed, USB hiccup): start() returned False and the read thread
(which holds the reconnect loop) never ran. Auto-reconnect only covered a
mid-session drop, never a startup race.

Now start() launches the thread regardless and _run reconnects from the
top of its loop, so the initial connect retries with backoff exactly like
a mid-session drop -- the source connects whenever the iPhone app appears,
no data_only_viz restart needed.

Test: tests/test_iphone_usb_reconnect.py (start() keeps retrying when no
device). The 2 unrelated pre-existing collection errors are unchanged.
2026-06-30 09:34:23 +02:00
L'électron rare ce00b90f43 feat(matrix): autofill name on load
CI build oscope-of / build-check (push) Has been cancelled
Emit /matrix/loaded <name> at each user-facing load point (the /matrix/load
OSCdef, the queued branch of ~matWrapStep firing at the wrap, and the
not-playing fallback in /matrix/loadnext) - not from the shared ~matLoad,
to avoid spamming during the boot autoload. The web fills #matrix-name from
it, so SAUVER updates the loaded matrix in place. For CHARGER SUIVANT the
name tracks the actual swap (at the wrap), never the merely-armed preset.

VALIDATE 84/84, test_matnames PASS, node --check + 29 web tests pass.
2026-06-30 09:25:08 +02:00
L'électron rare f635240df9 docs(matrix): loaded-name feedback design 2026-06-30 09:23:27 +02:00
L'électron rare e0c4405044 feat(control): SUIVANT queued-load button
CI build oscope-of / build-check (push) Has been cancelled
Add a SUIVANT button to the matrix persist bar that arms the selected
preset to load at the next loop wrap (/matrix/loadnext), with an armed
pulse + queued-name label driven by the /matrix/queued feedback.

No jsdom unit test: the project's web tests cover pure logic modules, not
main.js DOM wiring (jsdom is not a dependency); the button is verified by
the live smoke. node --check + the existing 29-test suite pass.
2026-06-30 09:15:53 +02:00
L'électron rare 68fa3c4e3b feat(matrix): queued load at loop wrap 2026-06-30 09:13:23 +02:00
L'électron rare af55f8a145 fix(matrix): persist user saves in load list 2026-06-30 09:12:04 +02:00
L'électron rare f6b350a35c docs(matrix): save-persist + queued-load plan 2026-06-30 09:10:22 +02:00
L'électron rare 0e6604cb80 docs(matrix): save-persist + queued-load design 2026-06-30 09:07:08 +02:00
L'électron rare 3a5cf80aa7 refactor(presets): breaks arcs independent
CI build oscope-of / build-check (push) Has been cancelled
Expand the 7 breaks morceaux from the shared _breaks_intro/_breaks_outro
helper into 14 fully-independent arc bodies (one intro + one outro each),
per the "no shared helper" requirement. Each varies by morceau character
(bass_science reese-forward, jungle_amen amen-snare, footwork kick step2,
neuro_reese stab, tribal_perc tom/perc).

test_sections PASS, VALIDATE 84/84.
2026-06-30 00:24:28 +02:00
L'électron rare 4c7d960b45 feat(presets): wire families + live default
Wire the hand-authored families into the 84 presets and make the launcher
boot the live section.

Changes:
- patterns_for: strip the _intro/_live/_outro suffix before the
  PRESET_FAMILY lookup, so all 84 section presets resolve to their
  morceau's family.
- PRESET_FAMILY: retarget mel families to the new banks where they fit -
  house (detroit/rave/trance/melodic/electro), psy (hypnotic/psytrance/
  minimal), dub (dub_techno/melodic_deep), ambient (ambient_intro); drum
  families now style ride/shaker/clap/perc per dfam.
- launch_concert.sh: MATRIX_PRESET default -> techno_drive_live.

Impact: genre presets now use the authored families. test_families PASS,
test_sections PASS, regen deterministic, VALIDATE PASS 84/84.
2026-06-30 00:20:19 +02:00
L'électron rare 1090f2c223 feat(presets): 56 intro/outro arcs
Hand-write the intro and outro arc for each of the 28 morceaux (56 new
independent arc functions). intro = voices enter across the 64 bars
(sparse->full, capped at the plateau with a light O lead-in into live);
outro = voices exit (full early, dropping out toward the break/outro).

Approach:
- one arc_intro_<m> / arc_outro_<m> per morceau, registered in
  INTRO_ARCS/OUTRO_ARCS; composed from the primitive fill/hit grid API
  over the I/B/R/D/P/K/O section codes.
- the 7 breaks morceaux share a _breaks_intro/_breaks_outro shape
  parameterized per morceau (deviation from "fully independent" - flagged
  for review).
- test_sections.py gate: intro builds, outro breaks down, live densest -
  all 28 PASS.

Impact: 84 presets regenerated, VALIDATE PASS 84/84. Section feel is
shape-correct; musical review by ear pending.
2026-06-30 00:17:51 +02:00
L'électron rare fc73be4851 feat(presets): hand-authored pattern families
Add explicit 6-colour style families to the pattern bank so genre presets
feel musically distinct (merged SP-C2). All colours hand-written following
the role convention (base/busier/broken/double/sparse/fill), velocity-
humanized; existing families and every default stay untouched.

Changes:
- MEL: dub (reese/pad/chord/bells/melody), psy (sub/reese/arp/lead/stab),
  house (pad/chord/stab/bells/lead/melody), ambient (pad/bells/melody/
  sub/reese) - 21 families x 6 motifs.
- DRUMS: percu-voice + style-completion families for ride/shaker/crash/
  rim/fx/sweep/perc (techno/house/breaks/dnb/trance/ambient).

Impact: vocabulary for the genre presets; wired per preset in a later
task. test_families.py PASS (validity + >=4 distinct + coverage).
2026-06-30 00:06:08 +02:00
L'électron rare 38d79f8510 feat(presets): intro/live/outro 84 skeleton
Each morceau is now decomposed into three sections (intro/live/outro)
as three separate .matrix files, replacing the single-grid presets.

Approach:
- MORCEAUX: lazy view over PRESETS; live = the existing arc, intro/outro
  resolve from INTRO_ARCS/OUTRO_ARCS registries (fallback to live) so the
  per-morceau arcs can be populated anywhere at module level.
- main() emits <morceau>_<section>.matrix (28 x 3 = 84).
- test_sections.py: section-shape gate (intro builds, outro breaks down,
  live densest) - currently RED on outros (placeholders) to drive the
  hand-written arc tasks.
- validate_presets.scd: count 28 -> 84, DEFAULTS check on peak_drop_live.

Changes:
- generate_presets.py: MORCEAUX/_sections/_Morceaux, main() loop.
- removed the 28 old single-name .matrix; wrote 84 section files.

Impact: structural base for hand-authored intro/outro arcs (Tasks 3-9)
and the new pattern families (Task 2). VALIDATE PASS 84/84.
2026-06-30 00:00:25 +02:00
L'électron rare 6acc302982 docs(presets): v4 intro/live/outro impl plan 2026-06-29 23:53:51 +02:00
L'électron rare b7fa138172 docs(presets): v4 independent intro/outro arcs 2026-06-29 23:50:56 +02:00
L'électron rare 3d366be50c docs(presets): v4 intro/live/outro design 2026-06-29 23:48:31 +02:00
L'électron rare eb42da0f31 test(presets): pattern family validity harness 2026-06-29 23:41:28 +02:00
L'électron rare b7d537b10d docs(presets): hand-authored families plan 2026-06-29 23:38:16 +02:00
L'électron rare 6bb8e41af1 docs(presets): hand-authored families design 2026-06-29 23:35:08 +02:00
L'électron rare d8f1cd9e71 fix(launcher): gpu mediapipe delegate on non-M5
CI build oscope-of / build-check (push) Has been cancelled
2026-06-29 22:24:14 +02:00
L'électron rare 6f8903e303 fix(presets): alignment-independent contrast
CI build oscope-of / build-check (push) Has been cancelled
Busier/sparse/broken in derive_drum_colors and derive_mel_colors
now operate on the base's own rest/hit lists, not fixed i%4
positions. Stab (all hits at i%4==2) went from 4 distinct colours
(busier==broken==base, sparse EMPTY) to 6. Test strengthened with
distinctness and sparse-not-empty asserts for stab/kick/hats/acid/
shaker/pad. 28 presets regenerated; determinism confirmed (md5 zero
diff on two consecutive runs). VALIDATE PASS 28/28.
2026-06-29 21:45:17 +02:00
L'électron rare c7ba26100e test(presets): assert default colours are derived
Replace weak != property checks with equality assertions against
derive_drum_colors / derive_mel_colors. The old checks passed even
without wiring; equality fails if patterns_for reverts to raw authored
defaults.
2026-06-29 21:29:18 +02:00
L'électron rare d0a66a5fd3 feat(presets): contrasted default colours, regen
Wire derive_drum_colors/derive_mel_colors into patterns_for:
default family colours 2-6 are now derived from colour 1
(busier/broken/double/sparse/fill); authored style families
pass through unchanged. Remove dead _pick/_DRUM_VELS.
Regenerate 28 .matrix files; sclang VALIDATE PASS 28/28.
2026-06-29 21:24:09 +02:00
L'électron rare 39ac5d0ad1 feat(presets): 6-colour contrast derivation fns 2026-06-29 21:17:47 +02:00
L'électron rare f8c65b91ba docs(presets): colour contrast derivation plan 2026-06-29 21:12:32 +02:00
L'électron rare 9d2a414718 docs(presets): colour contrast derivation design 2026-06-29 21:08:36 +02:00
L'électron rare e99fe1f585 fix(matrix): evolve clear + idempotent enable
CI build oscope-of / build-check (push) Has been cancelled
Fix 1: ~matClear now mirrors the zero grid into ~matBaseGrid when
evolving, preventing resurrection on the next ~matEvolveStep cycle.

Fix 2: ~matSetEvolve.(true) guards the base snapshot with a
not-yet-evolving check; re-enabling while already evolving no longer
overwrites the original baseline.

Fix 3: ~matLoadFile now calls ~matEvolvePush after load so
reconnecting web clients see the correct toggle and rate.

RED->GREEN on two new test assertions (test_matrix.scd).
2026-06-29 20:50:03 +02:00
L'électron rare 2532a9a7e5 feat(web): matrix evolve toggle and rate 2026-06-29 20:37:25 +02:00
L'électron rare 47fe6a46af feat(matrix): evolve edit/save/load + OSC routes 2026-06-29 20:31:32 +02:00
L'électron rare b254d7cf1d feat(matrix): generative per-cycle evolve core 2026-06-29 20:25:19 +02:00
L'électron rare 8559cba14a docs(matrix): generative evolve impl plan 2026-06-29 20:22:12 +02:00
L'électron rare 93bbfd8960 docs(matrix): generative evolve design 2026-06-29 20:19:00 +02:00
L'électron rare ee4cd9b92c fix(matrix): neutral rest for position mod sources
CI build oscope-of / build-check (push) Has been cancelled
Position sources (lHandX/Y, rHandX/Y, bodyX/Y, headX/Y, bodySym)
now default to 0.5 (centre) when absent from cache instead of 0.
Magnitude sources (speed, accel, openness, span, danse, depth,
mouthOpen) keep 0 as correct rest. Fixes pitch-flat-at-boot-without-pose
and matching meter skew in matModValuesPush.
2026-06-29 19:46:05 +02:00
L'électron rare 7bd5a4f5fe feat(presets): regenerate with default mappings
Regenerate 28 .matrix presets with role-adapted voiceMods/voicePoses:
soloists (acid/arp/lead/stab/bells/melody) get hand bindings (rHandX/Y,
lHandX/Y); drums get body bindings (bodyVitesse/bodyX); fx/sweep get
danse->gate pose. Update validate_presets.scd to assert new scheme via
peak_drop (acid 4 hand bindings, kick 2 body, fx danse->gate).
2026-06-29 19:14:44 +02:00
L'électron rare cd77189c12 feat(presets): default pose-instrument mod scheme 2026-06-29 19:06:55 +02:00
L'électron rare e9ca68102a docs(presets): default pose mappings plan 2026-06-29 19:04:25 +02:00
L'électron rare ae146ac48e docs(presets): default pose mappings design 2026-06-29 18:54:37 +02:00
L'électron rare 0c3d410e98 feat(web): pitch and res mod targets
CI build oscope-of / build-check (push) Has been cancelled
2026-06-29 18:19:55 +02:00
L'électron rare e53ae8d94d feat(matrix): pitch modulation target 2026-06-29 18:12:21 +02:00
L'électron rare ece67bf1fb feat(matrix): res modulation target 2026-06-29 18:01:51 +02:00
L'électron rare 09fec3aa36 docs(matrix): pitch+res mod targets plan 2026-06-29 17:34:47 +02:00
L'électron rare 84de68cb12 docs(matrix): pitch+res mod targets design 2026-06-29 17:27:14 +02:00
L'électron rare 8daad4040b refactor(matrix): drop dead per-color mod API
CI build oscope-of / build-check (push) Has been cancelled
Remove 4 SC functions (matSetColorMod/Pose, matColorMod/PosePush),
2 web OSC handlers (/matrix/colormod /colorpose), and the test
blocks that called them. Superseded by per-voice system; OSCdefs
were already gone. Legacy field read paths and colorDef fields
preserved for preset migration.
2026-06-29 17:00:24 +02:00
L'électron rare 5063e5929a fix(matrix): finger/pinch OSC arg offset
/pose/finger and /pose/pinch carry a leading pid at msg[1]; hand is
msg[2] and finger is msg[3]. Previous code read pid as hand and hand as
finger, making 18/21 curated events unreachable.

Add OSCdef-level spy assertions in test_matrix.scd that would have
caught this: RED against old indices, GREEN against the fix.
2026-06-29 15:58:46 +02:00
L'électron rare 8db2c2a7a9 feat(presets): emit per-voice mods and poses
Add VOICE_MODS_DEMO/VOICE_POSES_DEMO dicts and emit_voicemods/
emit_voiceposes helpers to generate_presets.py. Every preset now
carries voiceMods:/voicePoses: keys (22-element arrays, default []).
techno_drive carries 3 demo bindings: acid bodyVitesse->cutoff 0.6,
lead rHandY->rev 0.4, kick danse/trigger pose.

Regenerate all 28 presets. Add validate_presets.scd harness:
28/28 load OK, demo bindings parse correctly (VALIDATE PASS).
2026-06-29 15:41:43 +02:00
L'électron rare 8aa41e2aff feat(web): live mod-source meters 2026-06-29 15:28:57 +02:00
L'électron rare d3a2a591ae feat(web): per-voice MOD list + pose dropdown
Replace the old per-colour single-mod row in the MOD/POSE tab with a
per-voice additive list (matVoiceMods) and a curated pose-event
dropdown (matVoicePoses), both independent of cdColor.

Add OSC handlers for /matrix/voicemod and /matrix/voicepose echoes.
Update openFor to request voicemod/get + voicepose/get on expand.
Extend colordefs-parse test stub to export all new matrix-state symbols.
2026-06-29 15:22:23 +02:00
L'électron rare 89ade5524c feat(web): mod source labels + per-voice state 2026-06-29 15:13:29 +02:00
L'électron rare 3ecef87f4b feat(pose): emit /pose/mouth from bridge
Add PoseSoundBridge.send_mouth(pid, mouth_open) mirroring send_kin
pattern; call it in ActionHeadPublisher._tick after send_kin using
the already-in-scope `mouth` scalar; add route to module docstring.
3 unit tests (address, VJ mirror, boundary values) all green.
2026-06-29 15:07:54 +02:00
L'électron rare 244d5dd944 test(matrix): cover voice mods/poses + sources 2026-06-29 15:00:58 +02:00
L'électron rare 21d5209047 feat(matrix): persist + migrate voice mods/poses
- ~matSave payload gains voiceMods/voicePoses keys (one per voice)
- ~matLoadFile restores new keys; collapses legacy per-colour mod/pose
  into voice lists (dedup) when new keys absent
- push ~matVoiceModPush/~matVoicePosePush for all voices after load
- round-trip test in test_matrix.scd (RED->GREEN)
2026-06-29 14:51:26 +02:00
L'électron rare c82b149741 feat(matrix): push live modvalues at 12 Hz 2026-06-29 14:45:09 +02:00
L'électron rare b954032165 feat(matrix): per-voice pose + finger/pinch events
Replace mat_colorpose/mat_colorpose_get OSCdefs with per-voice
mat_voicepose/mat_voicepose_get. Rewrite matPoseFire to iterate
matVoicePoses[name] using current bar color. Add matSetVoicePose,
matVoicePosePush, matEventFinger/Pinch mappers, mat_ev_finger/pinch
OSCdefs. Old color-pose functions left intact.
2026-06-29 14:39:01 +02:00
L'électron rare 0179e32837 feat(matrix): voicemod set/get OSC routes 2026-06-29 14:30:18 +02:00
L'électron rare 0c8ea8b104 feat(matrix): per-voice additive modulation pairs
Introduce ~matVoiceMods (IdentityDictionary voice→binding list) and
~matVoicePoses. Rewrite ~matModPairs to read ~matVoiceMods[name]
instead of spec[\mod], summing contributions per target additively.
Disallowed targets (not in ~matModTargets[name]) are silently dropped.
Test: pan-cancel, rev-depth, cutoff-gate — RED→GREEN confirmed.
2026-06-29 14:21:02 +02:00
L'électron rare 6964831036 feat(matrix): route new iphone mod sources 2026-06-29 14:01:50 +02:00
L'électron rare adeb98e569 docs(matrix): fix plan SC test protocol + paths 2026-06-29 13:58:08 +02:00
L'électron rare 73af4890b1 docs(matrix): per-voice mod/pose impl plan 2026-06-29 13:49:41 +02:00
L'électron rare ab265e4473 docs(matrix): mouthOpen now first-class source 2026-06-29 13:37:50 +02:00
L'électron rare fa7797d259 docs(matrix): per-voice mod/pose design spec 2026-06-29 13:36:27 +02:00
L'électron rare d646f4649d feat: 6 step patterns per colour on all voices
CI build oscope-of / build-check (push) Has been cancelled
2026-06-29 13:18:57 +02:00
L'électron rare 71926dec84 feat(matrix): per-step playhead /matrix/stephead
SC emits /matrix/stephead s (0-15) every 16th note via
~matStepRoutine (started/stopped with ~matPlay/~matStop).
Web highlights .seq-cell or .pr-cell[data-step] with
.playhead class; CSS adds white outline + brightness.
2026-06-29 13:13:40 +02:00
L'électron rare 80aa33a19c fix: push steps to surfaces on matrix load 2026-06-29 13:06:23 +02:00
L'électron rare 946b83d9ea fix(voiceeditor): colordefs stride 6→8, add res/rev
CI build oscope-of / build-check (push) Has been cancelled
SC ~matColorDefPush sends 8 fields per colour (stretch, octave,
amp, inst, cutoff, pan, res, rev). The JS receiver used stride 6,
so colours 2-6 parsed from shifted indices and res/rev were never
read — edits snapped back on every echo.

- Extract pure parseColorDefs(args) (stride 8, all 4 sentinels
  decoded: cutoff=-1, pan=-2, res=-3, rev=-4 → null); export it.
- Rewrite handler to use parseColorDefs; preserve mod/pose/steps.
- Fix stale CSS comment: 16x32 → 22x64.
- Add test/colordefs-parse.test.mjs (8 cases, guard against drift).
- node --test tests/*.mjs: 20/20 pass (was 12).
2026-06-29 12:37:56 +02:00
L'électron rare cde2882975 chore(voiceeditor): lowercase res/reverb labels
Synth/FX tab labels "RES"/"REVERB" -> "res"/"reverb" to match
all sibling labels (inst, stretch, oct, amp, cutoff, pan).

C5 headless tests: node --test 12/12, test_matrix TEST PASS,
presets 28/28, node --check 10/10 all green.
2026-06-29 12:24:50 +02:00
L'électron rare c0a360ff58 feat: synth/fx tab with res and reverb controls 2026-06-29 12:14:55 +02:00
L'électron rare 5aa9b31ec5 fix(voiceeditor): drum grid API + wheel rerender
- renderDrumGrid(vi, colour, body): explicit params, def guard
- wheel handler calls renderPattern so loud class stays in sync
- skip empty steps-mode-hdr for isDrumOnly voices
2026-06-29 12:10:18 +02:00
L'électron rare 1edeaf1e6e feat: adaptive pitch-roll and drum step modes
- stepMode map (per voice, persists for session) init from
  defaultStepMode(); melodic->pitch, drum/semi->drum
- renderStepsTab branches on stepMode[vi]: Pitch toggle shows
  a 15-row x 16-col piano-roll; Drum shows 3-level pads
- renderDrumGrid: click cycles off->soft(vel 0.6)->loud(vel 0.95);
  wheel fine-tunes vel; sends /matrix/step (degree 0 or -99)
- renderPitchRoll: one note per step, click places/moves/clears,
  wheel adjusts vel; sends /matrix/step (degree d or -99)
- Pitch|Drum toggle hidden for pure-drum voices (VOICE_CLASS=drum)
- CSS: .pianoroll/.pr-row/.pr-cell, .seq-cell.soft/.loud,
  .steps-mode-hdr/.steps-mode-btn; --pr-color/--row-color vars
- Guard added: renderStepsTab returns early if color def missing
2026-06-29 12:02:36 +02:00
L'électron rare e81a70de26 feat: tabbed voice editor
Split renderPattern into renderStepsTab, renderSynthFxTab,
renderModPoseTab behind a 3-tab bar. activeTab module state
resets to steps on open; colour selector stays above tabs.
Per-voice editor-row accent via borderLeftColor; mvoice-label
cursor moved to CSS.
2026-06-29 11:53:30 +02:00
L'électron rare 5470ae9ed5 feat: inline accordion voice editor
Replace fullscreen #colordef-modal with a .voice-editor-row that
expands inline under each voice in the matrix grid. One voice open
at a time; ▷ toggle rotates to ▼ when expanded; clicking label or
button calls toggle(vi, containerEl). Editor content (colour 1-6,
var row, steps, mod, pose, AUDITION) is identical, only location
changes. CSS: drop .cd-modal/.cd-panel, add .voice-editor-row /
.voice-expand-btn.
2026-06-29 11:43:04 +02:00
L'électron rare bc661aea5a refactor: extract matrix grid and voice editor
CI build oscope-of / build-check (push) Has been cancelled
2026-06-29 11:30:05 +02:00
L'électron rare aec2014b0a refactor: extract mixer transport scenes seq pads
Move five orthogonal concerns out of main.js into ES modules:
- mixer.js: per-voice fader/mute strips, /launch/vol sends
- transport.js: /sync/beat, /sync/rms, tempo/filter/quant DOM
- scenes.js: sceneMode, /scene/state, slot/prev/next wiring
- sequencer.js: rhythm+melody editors, avlive.seq persistence,
  /seq/* handlers, suppressEmit guard
- launchpad.js: armed/clipState, /armed/ prefix, pads, #clear

Each module exports init(); main.js calls all five in
DOMContentLoaded after loadMatState(). Matrix grid, colordef
modal, timeline, glow system, and generic buttons remain in main.js.
2026-06-29 11:22:10 +02:00
L'électron rare 8615472589 refactor: single colour palette via css vars
Install --m1..--m6 on :root from PALETTE in main.js at bootstrap.
Replace triplicated hex in mcell/cd-row/seq-row with var(--mN).
Remove local COLORS array from renderColorSel; use imported PALETTE.
matrix-state.js stays DOM-free.
2026-06-29 11:10:22 +02:00
L'électron rare b0357af715 refactor: extract matrix state and palette module 2026-06-29 11:01:27 +02:00
L'électron rare 3bab0d5e42 refactor: split osc transport into es module
Move control.js → js/main.js; extract WebSocket↔OSC bridge
into js/osc.js (send/on/onOpen/onClose/onPrefix exports).
Replace 250-line message switch with per-address on() and
onPrefix() registrations. Point index.html at module entry.
2026-06-29 10:49:42 +02:00
L'électron rare 7b35c19017 feat: per-colour res and rev in matrix engine 2026-06-29 10:33:52 +02:00
L'électron rare 3098593dd0 docs: matrix editor implementation plan
CI build oscope-of / build-check (push) Has been cancelled
2026-06-29 10:23:28 +02:00
L'électron rare 04ff9ceb22 docs: matrix editor refactor design spec
CI build oscope-of / build-check (push) Has been cancelled
Inline-accordion voice editor (tabs: steps / synth-fx / mod-pose),
adaptive pitch piano-roll vs drum step sequencer, synth+fx tab with
res/rev engine extension, ES-module split, single colour palette.
2026-06-29 10:18:58 +02:00
L'électron rare 8ebd028039 feat: matrix presets v3 - melodies + colour arcs
CI build oscope-of / build-check (push) Has been cancelled
Deep refactor of the 28 matrix presets for style fidelity:
- fix 6 invalid instrument assignments (silent lp_ fallback)
- per-style melodies via colorDefs steps on 7 mono voices
- exploit all 6 variation colours (build rolls, drop lift,
  half-time + octave-down break) instead of monochrome 1
- restore tribal_perc; validate kits against matInstChoices

Validated: 28/28 load via matLoadFile, all step patterns
stream clean on the per-color engine.
2026-06-29 09:58:39 +02:00
clement 929fd9aaf0 fix: colorpose list decode and migrate guard 2026-06-29 01:21:29 +02:00
clement 25bfdf0cb2 merge: reconcile per-color pattern with main
Context: main drifted in parallel (matrix v2: 22 voices x 64 bars,
28 presets, classic-machine instrument emulations) touching the same
files this branch rewrites (matrix.scd, control.js, test_matrix.scd,
launchpad.scd).

Resolution (semantic):
- matrix.scd auto-merged; engine (per-color ~matModPairs, pose fire,
  persistence + migration) composes with main's 22 voices via
  ~matVoices.size; SC test PASS, P:0 B:0.
- control.js: kept main's dynamic dimensions (MATRIX_VOICES.length,
  MATRIX_BARS=64); the per-color model (matColorDefs[vi][c].{mod,pose,
  steps}) wins, so matMod/matSteps/matPoses are removed (folded in);
  reintroduced 16/32 hardcodes replaced with the dynamic constants
  (16-step sequence arrays kept); matLoopEnd -> MATRIX_BARS-1;
  renderRhyGrid fixed to literal 16 steps. Color-centric modal,
  /matrix/colormod + /matrix/colorpose handlers, row-left live select
  retained.
- test_matrix.scd: unioned main's grid-migration tests with the
  per-color mod/pose/rev suite.

Validation: SC TEST PASS, node --check clean, glow 4/4, P:0 B:0,
zero matMod/matSteps/matPoses refs, zero voice-count-16 hardcodes.
2026-06-29 01:12:39 +02:00
clement f03b7bb3bd fix: guard colormod handlers and drop dead css
Add null guard to /matrix/colormod and /matrix/colorpose WS
handlers to prevent TypeError when legacy browser cache holds
null color slots. Remove dead CSS rules for .cd-tabs, .cd-tab,
.cd-tab.on, .cd-pane[hidden].
2026-06-29 00:56:51 +02:00
L'électron rare 2908dc4c28 feat: 28 matrix presets, 22 voices x 64 bars
CI build oscope-of / build-check (push) Has been cancelled
Rewrite the 16 classic presets and add 12 new (electro/EBM/
synthwave, DnB/jungle/footwork, melodic techno/progressive) as
22-voice x 64-bar arrangements with section arcs (intro/build/
rise/drop/plateau/break/outro) and per-preset machine kits
(909/808/303/juno/supersaw/minimoog/dx7/sp1200...). The generator
now emits the Event (grid+instruments) format. All load + valid.
2026-06-29 00:51:00 +02:00
clement 84054714d3 feat: web color-centric pattern editor 2026-06-29 00:50:38 +02:00
L'électron rare 042da7faa0 feat: matrix v2 - 22 voices x 64 bars
CI build oscope-of / build-check (push) Has been cancelled
Wire 6 new tracks (melody, chord, fx, snare, crash, shaker) into
~matVoices and extend the grid to 64 bars. Per-voice dicts (mod
targets, neutral cut, instrument choices) gain the new voices.
~matLoadFile auto-migrates any legacy grid (tile bars, pad voices)
so the old 16x32 presets still load. Web grid/mixer/timeline +
handlers de-hardcode 16/32/512 to MATRIX_VOICES.length/MATRIX_BARS.
Headless test assertions made dimension-dynamic.
2026-06-29 00:42:28 +02:00
clement 4cb6ce14c0 feat: persist and migrate per-color mod pose 2026-06-29 00:39:37 +02:00
clement dcb35772ae fix: restore rev mod target in matModPairs
The rev (reverb send) modulation target was dropped from ~matModPairs
when factoring capture effects. This branch now emits a 0..1 reverb
send scaled by mod depth, matching the previous engine behavior.
2026-06-29 00:30:21 +02:00
L'électron rare 7b458b8b58 feat: voices for new matrix tracks
CI build oscope-of / build-check (push) Has been cancelled
Add synthdefs (lp_snare, lp_crash, lp_shaker, lp_fx) and base
Pdefs (lp_melody, lp_chord, lp_fx, lp_snare, lp_crash, lp_shaker)
for 6 new matrix tracks. Foundation only -- not yet wired into
matVoices/the grid (the 22-voice x 64-bar core change + preset
migration + web overhaul comes next).
2026-06-29 00:30:04 +02:00
clement df04d8d051 feat: engine reads per-color mod and pose 2026-06-29 00:27:21 +02:00
L'électron rare dc766fe896 feat: more classic machine emulations
CI build oscope-of / build-check (push) Has been cancelled
Web-researched the iconic machines per style and added 7 more:
Minimoog (ladder bass/lead), Roland JP-8000 supersaw (trance/
progressive), Juno-106 (chorus pad), Yamaha DX7 (FM), E-mu
SP-1200 (12-bit drum crunch), Akai sampler (lo-fi grit) and
Roland System-100. Standard UGens only (MoogFF/Latch/DelayC).
Wired per voice into the choices + web mirror; all kit/test
instrument targets kept in the choice lists.
2026-06-29 00:20:34 +02:00
clement 25ce6e0139 docs: keep live row instrument select in plan 2026-06-29 00:15:26 +02:00
clement b18cb7c514 fix: fresh color pose arrays and guards
Replace pose: [] with pose: Array.new to ensure fresh arrays per color.
Guard nil action in ~matSetColorPose with default \trigger.
Add test assertions for dedup, echo, and out-of-bounds safety.
2026-06-29 00:14:23 +02:00
L'électron rare c535367817 feat: classic-machine instrument emulations
CI build oscope-of / build-check (push) Has been cancelled
Add faithful-ish TR-909 (kick/clap/hat/ride), TR-808 (kick/clap/
hat/cowbell), TB-303 (acid), Korg MS-20 and Roland SH-101 as extra
matrix instrument choices. All note-terminating (perc+doneAction
or gated ASR), Pbind-safe, with pan + reverb send; standard UGens
only. Wired into ~matInstChoices and the web mirror per voice.
2026-06-29 00:08:25 +02:00
clement 6cf09c21be feat: per-color mod and pose state 2026-06-29 00:06:28 +02:00
clement a87044f698 docs: per-color pattern implementation plan 2026-06-29 00:03:53 +02:00
clement be02a71bde docs: per-color pattern restructure design 2026-06-29 00:01:24 +02:00
clement 74e43d6915 chore: remove dead css and polish
Remove dead per-row .minst/.mmod CSS rules (moved to modal).
Add self-healing guard in renderPoses for malformed localStorage.
2026-06-28 23:55:54 +02:00
clement 8c7a1aee00 merge: reconcile per-instrument editor with main
Context: main added a richer capture-modulation palette (more mod sources
and targets in matrix.scd, control.js, launchpad) in parallel with the
complete per-instrument editor build. Both touch the mod/matrix surfaces.

Approach: git auto-merged cleanly (disjoint hunks); validated semantically.

Changes:
- matrix.scd unions the new mod-source/target palette with the step-sequence
  engine, pose bindings, and colorDefs steps.
- control.js: the modal Assign pane iterates the enriched MATRIX_MOD_SOURCES,
  so the new sources appear automatically; row consolidation and the step and
  pose tabs are intact.

Impact: the per-instrument editor (instrument, colors, 16-step per-color
sequencer, capture assignment with the richer palette, pose bindings) coexists
with main's mod palette. SC test PASS, P:0 B:0, node --check OK.
2026-06-28 23:51:18 +02:00
clement 2d64e8d6aa fix: clamp step degree and gate drum freq 2026-06-28 23:47:58 +02:00
L'électron rare 68e5c5c8a8 feat: richer matrix capture-modulation palette
CI build oscope-of / build-check (push) Has been cancelled
Add horizontal position sources (bodyX, lHandX, rHandX) and make
pan + a per-voice reverb send (rev) modulatable on every voice,
plus cutoff on perc and reese. All lp_ synthdefs gain pan + rev
(send to the reverb bus, nil-safe); perc/reese gain a cutoff
control that keeps their freq-tracking timbre when unmodulated.
matrix.scd mod sources/targets/neutral-cut + a rev modPairs
branch (in the color-editor overlay); web mod mirror updated.
2026-06-28 23:42:29 +02:00
clement 8e6434cf2a feat: web matrix pose bindings tab 2026-06-28 23:35:28 +02:00
clement 25acb50e59 feat: web matrix step sequencer grid 2026-06-28 23:28:24 +02:00
clement d1c9485904 feat: unified per-instrument editor modal
Consolidate per-row instrument select and mod controls into a
tabbed modal (Instrument, Colors, Sequence, Assign, Poses tabs).
Remove .minst/.mmod/.minst-head/.mmod-head from matrix rows;
.minst-edit stays as modal trigger. WS echo handlers repointed
to modal elements (#cd-inst-sel, .cd-mod-src/tgt/dep), updating
JS state regardless of modal visibility. Steps/poses panes are
empty stubs, requesting /matrix/steps/get and /matrix/poses/get
on open (wired in Tasks 6-7).
2026-06-28 23:20:22 +02:00
clement 74b1ccfa1a fix: guard pose fire and test gate
Adds nil-guard to ~matPoseFire's per-voice binding loop to prevent
raised when ~matPoseBindings[vi] is nil. Adds gate binding test in
test_matrix.scd to verify \gate action is stored correctly.
2026-06-28 23:14:51 +02:00
clement 07268dd8f3 feat: matrix pose bindings and fire 2026-06-28 23:09:27 +02:00
clement 89d201b068 feat: persist matrix step sequences 2026-06-28 22:58:46 +02:00
clement 3d38b1006e fix: live steps read and stepped glow 2026-06-28 22:48:16 +02:00
clement 296dc99435 feat: matrix per-color step sequence engine 2026-06-28 22:41:12 +02:00
clement 39ac9406a8 fix: guard matrix steps push bounds 2026-06-28 22:36:39 +02:00
clement 8f6886f44e docs: fix step push size in plan 2026-06-28 22:32:52 +02:00
clement 9f7f8558d4 feat: matrix per-color step state and push 2026-06-28 22:31:44 +02:00
clement 3eb6426f61 docs: complete per-instrument editor plan 2026-06-28 22:24:58 +02:00
clement 8a3e7fa5cb Merge branch 'feat/matrix-followups' into feat/matrix-instruments 2026-06-28 22:18:11 +02:00
clement 162eab3d2e docs: complete per-instrument editor design 2026-06-28 22:16:09 +02:00
clement 2a03a40270 fix: matrix follow-up polish
- pass base into matVariationOverlay (no double matBaseFor call)
- add nil fallback (? 1.0) on spec[\amp] in mod-amp Pfunc
- push colorDefs for all voices after matLoadFile
- guard Pdef.play to fire once in matAudition routine
- beforeunload stops audition if active (tab close)
- add color-1 identity assertion to test_matrix.scd
2026-06-28 22:00:20 +02:00
clement 0edaf7b2ba Merge commit '05f16aa' into feat/matrix-instruments 2026-06-28 21:53:39 +02:00
clement 05f16aa886 merge: reconcile color editor with main
Context: main advanced in parallel (octave->freqRatio variation rework,
drone-instrument free fix, mixer/loop/seek) while the per-instrument
color-editor feature was built on an isolated branch. Both heavily edited
~matVariation, producing a semantic conflict.

Approach: keep the color-editor's per-voice editable ~matColorDefs as the
variation source, but adopt main's freq-multiply scheme for octave colors
so editable octaves transpose voices that compute \freq via Pfunc.

Changes:
- ~matVariationOverlay drops \octave; derives freqRatio = 2**octave and adds
  a pitched-only \freq multiply, appended with instPair, cdPairs and modPairs
  (mod last so live capture overrides static color cutoff/pan).
- ~matVariation reads ~matColorDefs[vi][color] (per-voice editable) instead
  of main's hardcoded freqRatio table.
- test_matrix.scd unions the color-editor and loop-region test blocks.
- control.js/control.css/index.html union the mixer/timeline and color-editor
  modal additions (auto-merged).

Impact: per-voice editable colors (variation, sound, audition) coexist with
main's freqRatio transpose, per-voice mixer volume, loop/seek, presets, glow,
instrument selection and capture-effect. SC test PASS, P:0 B:0, node --check OK.
2026-06-28 21:51:22 +02:00
clement d11addea4a fix: clear color instrument via string default 2026-06-28 21:45:26 +02:00
clement 532b23735c docs: coerce color inst string in plan 2026-06-28 21:44:02 +02:00
L'électron rare 684ea5d4d3 fix: matrix drops never-freeing drone instruments
CI build oscope-of / build-check (push) Has been cancelled
The instrument-override / kit feature offered continuous \do_
drones (do_drone, do_body_drone, do_body_gran, do_weather,
do_geo) that have no gate and no doneAction. Driven per-note by
the Pbind they never free and pile up forever (observed 5+
do_body_drone stacked, surviving /matrix/stop).

Drop them from ~matInstChoices, ~matKits and the web mirror;
substitute gated \lp_ sustained voices. ~matApplyInstrument and
~matLoadFile validate against the choices, so saved presets and
kits referencing the drones now fall back to the default voice.
2026-06-28 21:39:56 +02:00
clement 96ea70a645 fix: color editor loop and header align
Extract renderColorDefRows so WS handler never triggers
colordefs/get; add minst-edit-head spacer (22px); clear
cd-audition .on class on modal close.
2026-06-28 21:37:44 +02:00
clement f5527369ca docs: fix color editor loop and header in plan 2026-06-28 21:36:28 +02:00
clement b90d0ad802 feat: web matrix color editor modal 2026-06-28 21:29:32 +02:00
clement a95db5efd7 feat: persist matrix color defs in presets 2026-06-28 21:24:57 +02:00
clement a17e417fa3 feat: matrix color audition loop 2026-06-28 21:19:23 +02:00
clement 51f5bff1bf fix: color def echo uses plural push 2026-06-28 21:16:26 +02:00
L'électron rare ce46a27903 feat: concert launcher boots matrix mode
CI build oscope-of / build-check (push) Has been cancelled
Repurpose AV-Live Concert.app to launch the matrix arranger:
boot SC with MATRIX_ONLY, keep the pose pipeline (feeds the
/pose/* capture-modulation sources), drop the morceau button,
and load a preset (MATRIX_PRESET, default techno_drive) + start
the playhead instead of switching to the concert scene.
2026-06-28 21:16:00 +02:00
clement b154d1cbd4 feat: matrix color def set and push 2026-06-28 21:04:44 +02:00
L'électron rare 7675c2ac11 fix: matrix octave colors and sustained lead
CI build oscope-of / build-check (push) Has been cancelled
Matrix colors 3/5 pushed a dead \octave key; every pitched Pdef
sets \freq directly so the transposition was inaudible. Replace
with a base-\freq multiplier (x2/x0.5) over the Pchain, gated to
pitched voices so drums and instrument overrides keep their pitch.

Add a dedicated sustained \lp_lead SynthDef (gated ASR) and point
Pdef(\lp_lead) at it with \legato so lead notes hold; arp and stab
stay on \lp_pluck.
2026-06-28 21:00:47 +02:00
clement daeb1e6c1e feat: per-voice editable matrix color defs 2026-06-28 20:53:43 +02:00
clement a6dce8e712 docs: matrix color editor implementation plan 2026-06-28 20:42:04 +02:00
clement 1df8b89ac3 merge: reconcile instrument and capture with main
Merge main (presets, loop/seek, per-voice mixer) into the SDD branch.
matVariation combines main per-voice volume amp with the instPair and
modPairs spread; volume is propagated into the mod amp formula. Both
OSCdef sets (mod/capture + loop/seek) and both test blocks are kept.
2026-06-28 20:35:07 +02:00
clement 210a224e68 fix: add matrix mod header spacer
Add .mmod-head spacer to header row to align with voice row's
sticky-right .mmod block, matching .minst-head pattern on left.
Width: 192px (64+64+56 children widths + 4px padding-left).
2026-06-28 20:23:31 +02:00
clement a01d09287b merge: matrix mixer + per-voice volume fix
CI build oscope-of / build-check (push) Has been cancelled
Bring the per-instrument mixer (web) and the matrix \amp respecting
per-voice volume (matrix.scd) from origin/main 6a14b3b. Disjoint from
local commits, clean auto-merge.
2026-06-28 20:16:51 +02:00
clement 8bb1b778ed feat: web matrix capture mod controls
Add per-voice mod controls (source select + target select + depth range)
sticky-right in each matrix row. WS handlers for /matrix/mod (single)
and /matrix/mods (bulk 48-flat). Persist mod state in localStorage
alongside grid/inst. MATRIX_MOD_TARGETS mirrors SC ~matModTargets.
2026-06-28 20:16:12 +02:00
L'électron rare 6a14b3b67d feat: web matrix mixer per-instrument strips
CI build oscope-of / build-check (push) Has been cancelled
2026-06-28 20:10:30 +02:00
clement fd60783047 feat: persist matrix mods in presets 2026-06-28 20:09:35 +02:00
L'électron rare 29e0df3378 fix: matrix variations respect per-voice volume
CI build oscope-of / build-check (push) Has been cancelled
2026-06-28 20:07:06 +02:00
clement 96bb1151a7 fix: guard matrix mod OSC arg 2026-06-28 20:06:49 +02:00
clement 97905fa2e6 docs: guard matrix mod OSC arg in plan 2026-06-28 20:06:24 +02:00
clement 3ece3c9e8e feat: per-voice matrix capture modulation 2026-06-28 20:01:20 +02:00
clement e33796c1b4 feat: matrix capture mod source cache 2026-06-28 19:51:08 +02:00
clement 1b922d446f fix: align matrix header and guard load
- Add 78px instHead spacer in renderMatrix() header row so bar
  numbers align with voice row cells (fixes 78px left drift).
- Remove dead MATRIX_KITS constant (kit dispatch uses data-kit).
- Guard loadMatState grid assignment with length === 16 shape
  check on both object and legacy bare-array branches.
2026-06-28 19:46:42 +02:00
clement d2636d4a12 feat: web matrix instrument selects and kits
Add per-voice instrument <select> (sticky-left) populated from
MATRIX_INST_CHOICES, kit buttons in the matrix transport, contextmenu
right-click cell-clear, and WS handlers for /matrix/instrument and
/matrix/instruments. Persist instrument choices alongside the grid in
localStorage with legacy bare-array backward-compat in loadMatState.
2026-06-28 19:40:33 +02:00
clement 38f1974455 feat: persist instruments in matrix presets 2026-06-28 19:33:56 +02:00
clement 744ba6475a fix: nil instrument and OSC arg guards 2026-06-28 19:28:38 +02:00
clement 528a9398b5 merge: matrix loop region + timeline UI
Bring the editable loop region/seek (matrix.scd) and the web timeline
header (origin/main f9da19f) into the feature branch. Disjoint from the
local commits, clean auto-merge.
2026-06-28 19:27:48 +02:00
clement 2d767e8d98 docs: fix plans nil-instrument overlay bug 2026-06-28 19:25:20 +02:00
L'électron rare f9da19f547 feat: web matrix timeline loop and seek
CI build oscope-of / build-check (push) Has been cancelled
Add 32-bar timeline header above the matrix grid in #tab-matrix.
- Tap a bar: sends /matrix/seek <bar>
- Drag across bars: sends /matrix/loop <min> <max>
- BOUCLE COMPLETE button: resets to /matrix/loop 0 31
- .in-loop highlight from /matrix/loop feedback
- .tl-head moves with /matrix/playhead (O(32))
- Pointer Events API (pointerdown/move/up + setPointerCapture +
  elementFromPoint) — works on desktop mouse and iPad touch
2026-06-28 19:24:01 +02:00
clement 987df60b10 docs: matrix per-instrument color editor design 2026-06-28 19:21:58 +02:00
L'électron rare 529d6c5cd9 feat: matrix loop region and seek 2026-06-28 19:18:17 +02:00
clement 3fb9e0eca0 feat: per-voice matrix instrument + kits 2026-06-28 19:16:05 +02:00
clement b8098de39e merge: matrix-only boot gate
CI build oscope-of / build-check (push) Has been cancelled
Bring the MATRIX_ONLY boot gate (origin/main 46c8823) into the feature
branch. With MATRIX_ONLY set, boot skips the concert/body-play scene so
only the matrix arranger plays. Clean auto-merge.
2026-06-28 19:04:26 +02:00
L'électron rare 46c88236f6 feat: matrix-only boot via MATRIX_ONLY env
CI build oscope-of / build-check (push) Has been cancelled
2026-06-28 19:03:38 +02:00
clement 2bfa846631 docs: matrix instrument + capture-effect plans 2026-06-28 19:03:18 +02:00
clement 128bc3f1b4 merge: creative matrix presets
Bring the 16 full-kit creative matrix presets (origin/main 6482baf)
into feat/matrix-instruments alongside the capture-effect design doc.
Disjoint changes, clean auto-merge. Loader reads the preset dir live,
so the new presets are listable without an SC reboot.
2026-06-28 18:58:53 +02:00
L'électron rare 6482baf22c feat: creative full-kit matrix presets
CI build oscope-of / build-check (push) Has been cancelled
Replace the 14 sparse starter grids with 16 hand-authored arrangements
that use all 16 voices and evolve over the 32 bars via sections
(intro / build / drop / break / final) with fills and accents.

- Styles: techno_drive, acid_journey, trance_euphoria, psytrance_roll,
  detroit_soul, industrial_grind, hardcore_punk, dub_techno, breakbeat,
  minimal_hypno, ambient_intro, peak_drop, rave_stab, tribal_perc,
  bass_science, melodic_builder.
- Every voice featured across the set (ride/rim/tom/reese/bells/sweep
  now used, not empty); peak_drop activates the full kit on the drop.
- generate_presets.py reworked with a sections DSL; prints a voice-
  coverage report (16/16).
2026-06-28 18:54:05 +02:00
clement cff42d0863 docs: matrix capture-effect modulation design 2026-06-28 18:49:50 +02:00
clement d5a1de0938 merge: persistence presets into matrix glow
CI build oscope-of / build-check (push) Has been cancelled
Bring matrix file persistence + 14 morceau-inspired presets (origin/main
16adac4) into macm1 main which had the audio-reactive glow + an extra
matrix-instrument-selection design doc. Disjoint changes, clean auto-merge.

Result: glow + persistence + presets + save/load UI all together.
2026-06-28 18:45:17 +02:00
L'électron rare 16adac4bec merge: matrix glow with persistence presets
CI build oscope-of / build-check (push) Has been cancelled
Reconcile two parallel matrix features built since 3ce4c38:
- macm1 main: matrix audio-reactive glow (/matrix/trig, triggerGlow,
  matEmitTrig, --glow CSS var, matrix_glow.js).
- gitea main: matrix file persistence + 14 morceau-inspired presets
  (/matrix/save|load|list, web save/load UI).

Conflict resolution kept BOTH features:
- control.js: /matrix/list + /matrix/grid handlers AND /matrix/trig.
- control.css: glow-enhanced .mcell.playing AND persistence-bar styles.
- test_matrix.scd: persistence round-trip AND matEmitTrig/matGlow tests.

Verified: SC headless test_matrix TEST PASS (both suites), matrix.scd
P:0 B:0, node --check control.js OK.
2026-06-28 18:41:45 +02:00
clement b65d16843e docs: matrix instrument selection design 2026-06-28 18:39:20 +02:00
L'électron rare b141127a13 feat: matrix presets inspired by morceaux
CI build oscope-of / build-check (push) Has been cancelled
2026-06-28 18:32:42 +02:00
L'électron rare c81f3d8587 feat: web matrix save load ui 2026-06-28 18:31:11 +02:00
L'électron rare 2ed077bbf5 feat: matrix save load presets in sc
CI build oscope-of / build-check (push) Has been cancelled
Add durable file-based persistence for the 16x32 arrangement matrix:
~matSave / ~matLoad / ~matNames helpers with OSC routes /matrix/save,
/matrix/load, /matrix/list; ~matGridPush sends full 512-int grid dump
on load; preset dir scaffolded.  Root-causes fixed during development:
String.== is identity-based in SC (use Symbol storage + \sym literals),
String.copyFromTo -> copyRange, Pipe/pathMatch unreliable headless.
Headless test round-trip: save -> clear -> load -> verify.
2026-06-28 18:24:22 +02:00
clement 9c8c271113 test: cover matGlow path through matVariation 2026-06-28 18:21:06 +02:00
clement b80ba66d4c refactor: use MATRIX_VOICES.length in glow loop 2026-06-28 18:14:18 +02:00
clement 6c7cd45ecd feat: render audio-reactive glow on matrix grid 2026-06-28 18:09:09 +02:00
clement 0069b82875 test: align glow decay test name with bound 2026-06-28 18:06:46 +02:00
clement c483977c34 feat: add matrix glow decay helper 2026-06-28 18:03:12 +02:00
clement f9d64cd7a5 feat: emit per-note matrix trigger for glow 2026-06-28 17:56:58 +02:00
clement df2bceca74 docs: matrix glow implementation plan 2026-06-28 17:53:09 +02:00
clement a484ab943c docs: matrix audio-reactive glow design 2026-06-28 17:47:32 +02:00
clement 3ce4c38f1c Merge remote-tracking branch 'origin/main' into refactor/sc-finish
CI build oscope-of / build-check (push) Has been cancelled
2026-06-28 17:13:21 +02:00
clement 7ca9fd98ff docs: namespace, OSC registry, boot modes 2026-06-28 17:10:30 +02:00
L'électron rare 6843e5aafc fix: matrix re-source only on color change
CI build oscope-of / build-check (push) Has been cancelled
Track last-applied color per voice in ~matLastColor; ~matApplyBar
skips Pdef re-source and re-play when color is unchanged and non-zero,
eliminating envelope re-attacks on sustaining pads/subs/reese/sweeps.
~matStop resets ~matLastColor to -1 so subsequent play re-applies all.
~matVariation now returns nil for color 0 instead of the base pattern.
loadMatState validates each cell is an integer 0..6.
2026-06-28 17:07:04 +02:00
clement 015374af38 fix: keep shared clock alive on scene stop 2026-06-28 17:00:13 +02:00
L'électron rare 2d927107f1 feat: web matrix arranger tab
CI build oscope-of / build-check (push) Has been cancelled
2026-06-28 16:55:59 +02:00
L'électron rare ee7f349359 feat: load matrix after sections
CI build oscope-of / build-check (push) Has been cancelled
Adds step 6i to data_only/boot.scd: loads matrix.scd after
sections.scd, matching the additive load pattern used by every
other data_only feature file.
2026-06-28 16:48:40 +02:00
L'électron rare 2250fe347f feat: matrix arranger engine in sc
Adds sound_algo/data_only/matrix.scd: a 16-voice x 32-bar
song-arrangement matrix with a bar-by-bar playhead Routine.

- Reads base Pdefs from launchpad.scd (additive, does NOT modify them)
- Colors 1-6 map to Pchain overlays: stretch (half/double-time),
  octave shift, amp accent; color 0 = Pdef.stop
- ~matBaseFor captures each voice's source once (lazy cache)
- ~matVariation, ~matApplyBar, ~matPlay, ~matStop, ~matSetCell,
  ~matClear exposed as environment functions
- OSCdefs /matrix/{cell,play,stop,clear} with \mat_* unique keys
- Nil-guarded, idempotent reload; TouchOSC feedback via ~toscSend

Adds test/test_matrix.scd: headless sclang assertions (no server
boot), verifies API wiring, cell write, Pchain return type,
out-of-range guard, and ~matStop no-raise.
2026-06-28 16:48:36 +02:00
clement 53f9111ed8 refactor: scenes and morceaux use setTempo 2026-06-28 16:45:10 +02:00
clement fe7b25e536 refactor: route data-only tempo to setTempo 2026-06-28 16:42:16 +02:00
clement 8d95f3e455 refactor: route live tempo writers to setTempo 2026-06-28 16:39:53 +02:00
clement e9022fab7d refactor: alias secondary clocks to default 2026-06-28 16:37:17 +02:00
clement 222c087610 fix: keep engine boot clean, test owns try
Remove the try-catch wrapper added around s.waitForBoot in
sound_algo/engine.scd — it swallowed real boot errors in
production. Move boot-failure tolerance into
sound_algo/tests/test_tempo.scd instead, where it belongs.
data_only/engine.scd was not affected (no wrapper added).
2026-06-28 16:34:40 +02:00
clement 141ed0e122 feat: single coordinated setTempo 2026-06-28 16:31:26 +02:00
L'électron rare 2f4a80ce2b fix: relay scene feedback to web clients
CI build oscope-of / build-check (push) Has been cancelled
2026-06-28 16:17:36 +02:00
L'électron rare 495c5680bf feat: tosc sections page for song build
CI build oscope-of / build-check (push) Has been cancelled
Add SECTIONS (page 4): 8 slot launch+save buttons, PREV/NEXT nav,
/scene/state receive on the GROUP so sections.lua colors slots
(green=current, blue=filled, dark=empty). Update pager to 4 pages,
add 4 new tests (26 pass), rebuild dist/av-live-control.tosc.
2026-06-28 16:16:14 +02:00
clement 90ded48a57 docs: lot C clocks plan 2026-06-28 16:15:04 +02:00
clement 62957f09d5 docs: lot C clocks unification design 2026-06-28 16:12:00 +02:00
L'électron rare 7b90acf80e feat: web sections tab for live song build
CI build oscope-of / build-check (push) Has been cancelled
2026-06-28 16:10:02 +02:00
L'électron rare 1990fc692f fix: guard scene clear bounds and lparm nil
CI build oscope-of / build-check (push) Has been cancelled
2026-06-28 16:05:55 +02:00
clement 17d78127f6 refactor: fpResolve delegates to noteFor 2026-06-28 15:57:37 +02:00
L'électron rare d7c1049661 fix: valueArray for melody state echo
CI build oscope-of / build-check (push) Has been cancelled
2026-06-28 15:56:42 +02:00
L'électron rare ab88b0f6df feat: load sections after touchosc feedback
Adds boot step 6h: sections.scd loaded after touchosc_feedback.scd
so ~toscSend is available. Net-zero paren balance change (P:-8 B:0).
2026-06-28 15:56:42 +02:00
L'électron rare ca21a4216d feat: live section scene slots in sc
8 snapshot slots (clips armed + seq presets + harmony).
Quantized recall via ~lpArm diff, next/prev chaining,
/scene/state feedback via ~toscSend. Headless test passes.
2026-06-28 15:56:42 +02:00
clement 562ac45d13 refactor: lpNote delegates to noteFor 2026-06-28 15:54:28 +02:00
clement 2777243d5c refactor: ccNote delegates to noteFor 2026-06-28 15:52:31 +02:00
clement 77249049c9 feat: load note helpers in all boot paths 2026-06-28 15:48:54 +02:00
clement fab35a5520 feat: unified note/scale source of truth 2026-06-28 15:43:57 +02:00
clement 2aac8f57f6 docs: lot A plan, unified note helpers 2026-06-28 15:26:34 +02:00
clement 9ad0ddda13 docs: SC harmonization refactor master design
Decompose into 6 incremental lots (A-F), live concert stays
playable. Lot A (unified music helpers) detailed first.
2026-06-28 15:22:21 +02:00
clement bb11311720 Merge remote-tracking branch 'origin/main'
CI build oscope-of / build-check (push) Has been cancelled
2026-06-28 15:18:44 +02:00
L'électron rare 6ec864ed7d fix: reset clip state on web clear
CI build oscope-of / build-check (push) Has been cancelled
2026-06-28 14:59:12 +02:00
L'électron rare 16f68efe01 feat(tosc): clip quantize and queued blink
CI build oscope-of / build-check (push) Has been cancelled
Add 4 /launch/quant buttons (1/2, 1, 1BAR, 2BAR) with constf args
0.5/1/4/8 to LIVE page top bar. Extend pad.lua: onReceiveOSC reads
state int 0-3 (off/playing/queued-launch/queued-stop); onFrame pulses
amber when blinking=true. Test: test_live_page_quant_buttons asserts
CONSTANT/FLOAT args. 22 tests pass; dist rebuilt (8482 bytes).
2026-06-28 14:52:12 +02:00
clement 57abc2ae0f docs: document finger gesture env vars 2026-06-28 14:50:05 +02:00
clement 463bab7665 feat: pinch arms launchpad clips
Pinch toggles launchpad patterns via ~lpArm (concert sounds,
clock sync, per-voice vol) instead of ad-hoc loops. Adds
~fpMode (clips/piano/both) and /control/fp reload.
2026-06-28 14:50:05 +02:00
clement b8f5cc0b2e feat: thumb-finger pinch detection
PinchDetector (hysteresis + refractory, normalized by hand
size) emits /pose/pinch. Publisher picks iPhone Vision hands
when fresh (FINGER_SOURCE). 4 pinch tests.
2026-06-28 14:50:05 +02:00
L'électron rare b071c31b86 feat: web clip quantize and queued states
CI build oscope-of / build-check (push) Has been cancelled
Add /launch/quant selector (0.5/1/4/8 beats, default 1 mes)
to the LIVE tab. Update /armed/ handler to read 4-state int
(0=off 1=playing 2=queued-launch 3=queued-stop), apply .armed
and .queued classes accordingly, sync clipState map. Pad tap
now toggles based on last-known state. CSS blink animation for
.queued (600 ms ease-in-out infinite).
2026-06-28 14:46:52 +02:00
L'électron rare 582d955522 fix: bump clip gen only in active branches
CI build oscope-of / build-check (push) Has been cancelled
Move ~lp[\clipGen] increment inside the four transitions that
schedule or cancel a closure (s==0 launch, s==3 cancel-stop,
s==1 stop, s==2 cancel-launch). No-op re-taps (s==1/2 on launch,
s==0/3 on stop) no longer clobber the pending generation counter.

Also clamp /launch/quant to clip(0.125, 64) instead of max(0.125)
to prevent a runaway large value from locking out launches.
2026-06-28 14:42:59 +02:00
L'électron rare 714c5988c2 feat: emit 4-state clip feedback
~toscArmedPush now iterates all 16 pads plus melseq and rhythmseq,
sending 0/1/2/3 (off/playing/queued-launch/queued-stop) from clipState
with fallback to armed membership for concert-driven clips. Test
extended with clipState in dummy ~lp and clipState read assertion.
2026-06-28 14:31:36 +02:00
L'électron rare ebe15d0804 feat: quantized clip launch stop with queue
Add 4-state machine (off/playing/queued-launch/queued-stop) to ~lpArm.
Launch and stop are quantized to ~lp[\quant] beats (default 4) via
clock.schedAbs(clock.nextTimeOnGrid(q)). Per-clip generation counter
cancels superseded callbacks on re-tap. New env fields: ~lp[\quant],
~lp[\clipState], ~lp[\clipGen]. New /launch/quant OSCdef. ~lp[\armed]
semantics preserved so /launch/clear and concert logic keep working.
2026-06-28 14:31:25 +02:00
L'électron rare 0fa6cac2bf fix: guard suppressEmit and filter feedback relay
CI build oscope-of / build-check (push) Has been cancelled
2026-06-28 14:21:01 +02:00
L'électron rare 91c4c5c599 fix: accent on sequenceurs web tab label 2026-06-28 14:12:42 +02:00
clement 6a286b4f0e feat: prefer iphone vision hands for air-piano
iPhone Vision hands (on-device, .right upright, y already
top-left, rotation-invariant) are stored in a dedicated state
field and used by the strike detector when fresh (FINGER_SOURCE
auto/iphone/mediapipe). More stable than MediaPipe on the
rotated downscaled video. Adds FINGER_DEBUG strike logging.
2026-06-28 14:12:34 +02:00
L'électron rare 6566e7e410 feat: web control 3-tab layout and live feedback
Reorganize control surface into LIVE / FX-HARMONIE-CONCERT /
SEQUENCEURS tabs with pure CSS/JS tab switching.
Default tab is LIVE.

Add engine feedback rendering via WS onmessage:
- /armed/<name>: reconcile armed CSS class from engine state
- /sync/beat: 80 ms flash on beat-dot indicator in header
- /sync/rms: drive master VU bar width from float 0..1
- /seq/rhythm/state: load k/s/h step grid + preset name; guard
  suppressEmit to prevent echo back as /seq/rhythm/set
- /seq/melody/state: load degree inputs; same no-echo guard

All pre-existing OSC sends preserved unchanged.
2026-06-28 14:11:07 +02:00
clement 154db324cf fix: fallback scales for data-only air-piano
data-only boot does not load palette/scales.scd, so
~scaleMinorPent was nil and ~fpResolve crashed on wrapAt.
Embed fallback scale arrays so notes resolve headless.
2026-06-28 14:03:13 +02:00
L'électron rare 8530f92c15 feat: relay engine feedback to web clients
Add a second osc.UDPPort bound to FEEDBACK_PORT_IN (default
9000, matches ~toscPort in SC) that receives engine feedback
OSC (/armed/*, /sync/beat, /sync/rms, /seq/*/state) from SC
and relays each message to all WS clients using the existing
{address,args} JSON shape. Does not update the snapshot cache
(feedback is transient). Existing data port, control-send
path, and WS logic are untouched.

node --check: SYNTAX OK.
2026-06-28 14:02:51 +02:00
L'électron rare 2213b4e53d feat: multicast tosc feedback to clients
Replace single ~tosc NetAddr with ~toscClients Dictionary
(keyed by ip) so feedback OSC reaches all connected clients
(web bridge + iPad) simultaneously. ~toscTouch deduplicates
by IP and logs only on first connection. ~toscSend iterates
the dictionary; no-op when empty.

Test: add assertion that two distinct IPs produce size==2.
Balance: P:0 B:0 on both files. Headless TEST PASS.
2026-06-28 14:02:41 +02:00
clement 217e41c632 feat: OSC remote control for air-piano
Add /control/fp (on/off/scale/inst/sens/octave/debug) so the
air-piano is controllable on a headless sclang, plus a debug
strike log gated by ~fpDebug.
2026-06-28 13:53:35 +02:00
L'électron rare 67fd253de5 fix: pad armed receive on group, lua guards
CI build oscope-of / build-check (push) Has been cancelled
Move /armed/<name> receive msg from arm BUTTON to parent GROUP so
pad.lua onReceiveOSC fires on the node that owns the message (FIX 1).
Remove feedback.lua from master RMS fader; native receive already
drives the VU, onFrame decay was pure downside (FIX 3).
Add Lua 5.1/LuaJIT compat shim for table.unpack in melody_faders
(FIX 2). Document deferred sequencer playhead in README (FIX 4).
Regression test: 16 pad GROUPs carry /armed/* receive; 21 tests pass.
Rebuild dist/av-live-control.tosc.
2026-06-28 13:49:00 +02:00
clement c1b567f116 feat: VIDEO_ROTATE to rotate source frame
Rotate the iPhone/webcam frame before MediaPipe and display
so detection, skeleton overlay and the shown video stay
aligned after the phone is physically rotated. ARKit 3D
world joints are orientation-invariant and untouched.
2026-06-28 13:44:09 +02:00
L'électron rare c90cbb103c docs: touchosc build validate and deploy guide
CI build oscope-of / build-check (push) Has been cancelled
2026-06-28 13:36:31 +02:00
L'électron rare a74d3fb402 fix: reload-safe tosc rms probe + portable test 2026-06-28 13:35:30 +02:00
clement 5c0007f961 docs: clarify fp modulation scope and inst gate 2026-06-28 13:26:56 +02:00
L'électron rare 1d62ff4347 feat: load touchosc feedback after launchpad 2026-06-28 13:23:56 +02:00
L'électron rare 73c17d41f7 feat: sc touchosc feedback sender
Adds sound_algo/data_only/touchosc_feedback.scd: captures the
controlling client IP from inbound /launch* OSC traffic, pushes
/armed/<name> for all 16 pads, /sync/beat each beat on ~lp[clock],
/sync/rms at 20 Hz (server-only, guarded), and echoes
/seq/rhythm/state + /seq/melody/state on preset select. Additive,
nil-guarded, safe to load headless without a booted audio server.

Also adds test/test_touchosc_feedback.scd: headless harness that
builds a dummy ~lp env, loads the feature, asserts the three public
API functions are wired, exercises ~toscArmedPush with nil ~tosc,
and prints TEST PASS.
2026-06-28 13:23:53 +02:00
clement e1cf2af339 fix: guard _vj against missing _vdmx attr
Pre-existing failure: test_pose_bridge_hands builds the
bridge via __new__, so _vj raised AttributeError on _vdmx.
Defensive getattr makes the full suite green.
2026-06-28 13:21:45 +02:00
clement 764bf02dc8 feat: load air-piano and hook hand modulation
Wire finger_piano.scd into both data-only boot paths so
OSCdef /pose/finger and ~fp* API are available at runtime.
Call ~fpModTick on every /pose/hands frame (~30 Hz) for
continuous air-piano modulation. Document FINGER_PIANO env
vars in CLAUDE.md.
2026-06-28 13:17:31 +02:00
clement 4cabf12eac test: assert fpModTick updates fpCut
Replace ghost isNil check with concrete one-pole assertion:
fpCut must equal 0.2 after one tick from dist=1.0, coeff=0.2.
2026-06-28 13:15:23 +02:00
L'électron rare 427341b8a1 test: harden tosc layout osc assertions 2026-06-28 13:12:42 +02:00
clement 858762fe39 feat: air-piano finger note engine in sc 2026-06-28 13:11:13 +02:00
clement 6b7cae277e feat: wire finger strikes into pose publisher 2026-06-28 13:05:21 +02:00
L'électron rare 2ad47c705e feat(touchosc): layout pages lua scripts and dist
Three-page pager (LIVE / FX-HARM-CONCERT / SEQ) with corrected
OSC mapping: scale sends string name not index, octave is absolute
(0/1/2), body-play is /control/doScene "play", arm-mel/rhy send
/launch "melseq"/"rhythmseq". Per-voice VU strip deliberately
omitted (engine sums to bus 0 only). Lua scripts embedded from
gen/lua/*.lua. Dist file committed as the deliverable.
18 tests pass.
2026-06-28 13:03:42 +02:00
L'électron rare 4414bbf168 feat(touchosc): typed partial helpers
Add consti/constf/vali/valf to schema.py so numeric OSC args
carry the right wire type. Partial already supported scale_min/
scale_max; helpers expose them cleanly. Existing API unchanged.
2026-06-28 13:03:31 +02:00
clement 85379be922 test: assert all send_finger osc args
Strengthen test_send_finger_emits_expected_osc to verify all seven OSC
arguments by value (z, tipx, tipy) and their types. Ensures OSC tags and
SC ~fpResolve reader get correct int/float types.
2026-06-28 13:03:10 +02:00
clement 211ee2b323 feat: emit /pose/finger over osc bridge 2026-06-28 13:00:12 +02:00
clement 2b41516a20 feat: add finger strike detector 2026-06-28 12:56:00 +02:00
L'électron rare 9e32e3afd8 feat: tosc xml generator schema + fidelity gate 2026-06-28 12:49:20 +02:00
L'électron rare 048403e92b docs: capture touchosc v3 tosc schema ground truth 2026-06-28 12:49:13 +02:00
clement 8ad309de33 docs: air-piano implementation plan
6 tasks (TDD): Python strike detector + bridge + publisher
wiring; SC note engine + modulation; loading + env docs.
Default instrument follows live ~melodyInst via \live.
2026-06-28 12:44:13 +02:00
clement 2e32dec51c docs: air-piano iphone fingers design spec
Hybrid architecture: Python detects finger strikes from
iPhone hand joints, SC owns musicality (scale, octave,
instrument) and continuous modulation from hand spacing.
2026-06-28 12:34:44 +02:00
L'électron rare 1101758719 docs: touchosc control surface impl plan 2026-06-28 12:21:56 +02:00
L'électron rare 2d05ac9372 docs: touchosc control surface design spec 2026-06-28 12:12:12 +02:00
L'électron rare 7f44ee527c feat: step editor and preset naming
CI build oscope-of / build-check (push) Has been cancelled
2026-06-27 23:58:57 +02:00
L'électron rare 82ac0181a3 feat: selectable melody and rhythm sequencers
CI build oscope-of / build-check (push) Has been cancelled
Add melseq (1 Pdef, 8 melodic sequences, /seq/melody) and rhythmseq
(3 sub-Pdefs rsk/rss/rsh, 8 16-step patterns, /seq/rhythm) to the
launchpad OSC bank.  ~lpArm special-cases "rhythmseq" to arm/disarm
all three voices together.  Web surface gets a Séquenceurs section
with arm pads, selector rows (.sel highlight), and vol sliders.
2026-06-27 23:42:08 +02:00
L'électron rare 73432a0c96 feat: launchpad sync, vol faders, more patterns
CI build oscope-of / build-check (push) Has been cancelled
- ~lpClock helper: use concert TempoClock when concert is running,
  fall back to ~lp[\clock] otherwise; /launch/tempo syncs both clocks
- ~lpVol per-name volume dict (0..1.5); /launch/vol OSCdef; all 10
  existing Pdef \amp wrapped in Pfunc { base * ~lpVol.(name) }
- 6 new SynthDefs + Pdefs: lp_ride, lp_rim, lp_tom (drums),
  lp_reese (Reese bass, Env.asr), lp_bells (additive FM),
  lp_sweep (noise riser, Env.asr)
- UI: 16 pat-cells each with pad button + vol range slider wired
  to /launch/vol; CSS .pat-cell flex column layout
2026-06-27 23:22:57 +02:00
L'électron rare 0f5a9cd098 fix: concert app robust SC boot + web server
CI build oscope-of / build-check (push) Has been cancelled
2026-06-27 23:09:05 +02:00
L'électron rare db2f331bd1 docs: control surface served at /control/
CI build oscope-of / build-check (push) Has been cancelled
2026-06-27 23:02:32 +02:00
L'électron rare c1b0cf07b6 feat(web): control surface page
CI build oscope-of / build-check (push) Has been cancelled
Vanilla JS WS sender + pad armed-state toggle + OSC wiring.
10 pattern pads, tempo/filter ranges, concert/harmony/fx sections.
2026-06-27 22:54:18 +02:00
L'électron rare b145655892 feat(web): bridge WebSocket to OSC out
Add SC_HOST/SC_PORT env config and outbound osc.UDPPort (scPort)
so inbound WS messages {address, args:[...]} are forwarded as typed
OSC packets to the SC engine. JSON.parse and scPort.send are each
guarded by try/catch so a down SC does not crash the bridge.

Add node:test integration test that spawns the server, opens a
loopback UDP listener standing in for SC, sends a WS message, and
asserts the OSC packet arrives with the correct address and args.
Receiver uses metadata:false so args unwrap to plain JS values.
2026-06-27 22:51:55 +02:00
L'électron rare 3ec306fdb2 feat(sound): harmony and fx control OSC
Add 10 OSCdefs to launchpad.scd: /control/harmony/{root,scale,octave,
phrase} mutate ~ccHarmony; /control/fx/{filter,stutter,crash,kick,swell,
breakdown} dispatch to ~ccMaster / ~ccFire. All nil-guarded; scale name
validated against ~ccScales. P:0 B:0.
2026-06-27 22:42:00 +02:00
L'électron rare e23b97f048 feat(sound): launchpad bass melody chord patterns
Add lp_sub/acid/pluck/pad SynthDefs plus lpNote helper and six
pitched Pdefs (sub, acid, arp, lead, stab, pad) armable by name
via the existing /launch OSCdef.
2026-06-27 22:39:52 +02:00
L'électron rare abfe950c25 feat(sound): launchpad drum patterns
Add launchpad.scd with 4 drum SynthDefs (kick/hat/clap/perc) and an
OSC-armable Pdef bank controlled via /launch, /launch/tempo,
/launch/clear. Patterns run on a dedicated TempoClock (~lp[\clock]).
Load it last in boot.scd, after concert_gestures.scd.
2026-06-27 22:35:16 +02:00
L'électron rare aa37b7832c docs: plan web OSC control surface
CI build oscope-of / build-check (push) Has been cancelled
2026-06-27 22:32:00 +02:00
L'électron rare 3d33719f2b feat(sound): trance, psytrance, punkcore morceaux
CI build oscope-of / build-check (push) Has been cancelled
Adds morceaux 12-14 to the concert setlist. 12_trance: supersaw
pad (7-saw chord, gated) + rolling 16th bass + plucky lead arp +
offbeat hat @ 138 BPM. 13_psytrance: interlocking kick+bass (bass
on the 3 off-kick 16ths) + squelchy acid lead + psy zaps @ 145
BPM. 14_punkcore: power chords root+5th (8th downstroke) + d-beat
drums + lead riff @ 156 BPM. All use ~ccNote, ~ccHarmony, wrist
arm mappings, nil-guarded pose reads. boot.scd updated. 3/3 pass
headless tests.
2026-06-27 22:23:26 +02:00
L'électron rare 241b93046b docs: control surface server runs on macm1
CI build oscope-of / build-check (push) Has been cancelled
2026-06-27 22:01:44 +02:00
L'électron rare 4adc118e2b docs: spec web OSC control surface
CI build oscope-of / build-check (push) Has been cancelled
2026-06-27 21:53:44 +02:00
L'électron rare 3b3b1fe51b feat: morceau button; drop gesture + auto-chain
CI build oscope-of / build-check (push) Has been cancelled
2026-06-27 21:37:37 +02:00
L'électron rare 1a6503cb71 fix: concert app boots SC via open command
CI build oscope-of / build-check (push) Has been cancelled
2026-06-27 21:25:56 +02:00
L'électron rare 5343913a7a fix: concert app SC-in-terminal + viz focus
CI build oscope-of / build-check (push) Has been cancelled
2026-06-27 21:23:37 +02:00
L'électron rare a98957ce46 feat: AV-Live Concert.app one-click launcher
CI build oscope-of / build-check (push) Has been cancelled
2026-06-27 21:17:38 +02:00
L'électron rare c7f98c24af feat: spacebar advances concert morceau 2026-06-27 21:14:03 +02:00
L'électron rare a88fc5e643 feat(viz): mirror iphone video for gestures
CI build oscope-of / build-check (push) Has been cancelled
2026-06-27 21:09:23 +02:00
L'électron rare 24613cd809 feat(sound): trance, industrial, detroit morceaux
CI build oscope-of / build-check (push) Has been cancelled
Add 3 new concert morceaux (09-11) to the data_only setlist.
09_trance_pluck: plucky 16th arp, arm-height filter, speed density.
10_industrial: distorted stab + rumble, speed drive, ropen noise.
11_detroit: warm detuned pad triad, string stab on rspeed, hat.
All three headless-tested: 20/20 assertions OK, zero SC errors.
2026-06-27 20:36:45 +02:00
L'électron rare 0d467da088 feat(sound): random morceau chaining + auto-chain
CI build oscope-of / build-check (push) Has been cancelled
2026-06-27 20:13:30 +02:00
L'électron rare c3fdb11e65 fix(sound): filter sweep + edge-trigger gestures
CI build oscope-of / build-check (push) Has been cancelled
2026-06-27 20:09:22 +02:00
L'électron rare cdee717d51 fix(sound): squat empty-skel + stop restore
CI build oscope-of / build-check (push) Has been cancelled
2026-06-27 20:01:20 +02:00
L'électron rare 031bb16e0f feat(sound): melodic body gestures
Add armRaise, headUp, headDown to family:melody in concert_gestures.scd.
armRaise cycles ccHarmony phrase 0-3; headUp/Down toggle octave 1/0.
Nil-guard applied: (s[\joint] ? ())[\coord] everywhere.
2026-06-27 19:47:22 +02:00
L'électron rare abedc53a4c feat(sound): harmonic body gestures
Add 5 harmony-family gestures (stepR/L, leanFwd/Back, tilt) that
mutate ~ccHarmony root/pad/scale. Also fix latent skel nil-deref in
existing FX gestures (stomp, squat, spin, tpose): `(s[\joint]?())[\y]`
pattern prevents doesNotUnderstand on empty skel Event.
2026-06-27 19:42:04 +02:00
L'électron rare 245edacd4f feat(sound): morceaux read ccHarmony for pitch
The 6 pitched concert morceaux now source their notes from the
global ~ccNote / ~ccHarmony instead of baked roots and scale arrays,
so the body's harmonic conducting drives their pitch. Each morceau
keeps its own rhythm, structure, SynthDefs and degree sequence; only
the pitch source changes. Acid line/storm gain a 4-entry phrase table
selected by ~ccHarmony[phrase]; hypno drift's drone base cutoff tracks
~ccHarmony[pad] brightness.
2026-06-27 19:30:50 +02:00
L'électron rare 337f791b7a feat(sound): global harmony state ccHarmony
Add ~ccScales Event, ~ccHarmony idempotent init, and ~ccNote
quantizer (degree + octave offset → midinote). All three tests pass.
2026-06-27 19:16:45 +02:00
L'électron rare a9ce3d58f5 feat(sound): fx dramatic body gestures
Register 5 body-gesture detectors (jump/stomp/squat/spin/tpose) in
sound_algo/data_only/concert_gestures.scd wired to ccFire/ccMaster FX
sinks. boot.scd loads the file after the morceaux block (step 6e).
2026-06-27 19:13:58 +02:00
L'électron rare 1660640d48 feat(sound): concert master event-FX layer
Add concert_fx.scd providing ~ccMaster (filterTo/dip/stutterOn),
one-shot event synths cc_ev_crash/kick/swell, and ~ccFire helper.
Loaded in boot.scd after engine.scd so ~doFilter/~doMaster exist.
2026-06-27 19:10:27 +02:00
L'électron rare 114ebbd99e feat(sound): ccGesture trigger framework 2026-06-27 19:05:41 +02:00
L'électron rare 6b6be7bff2 feat(viz): skeleton joints to SC via pose/skel
Add /pose/skel OSC route emitting nose, shoulders, hips,
knees and ankles (18 floats) from _emit_person. Add
\poseSkel OSCdef in data_feeds.scd storing the 9 joint
positions per pid; cleanup on \poseLeave.
2026-06-27 18:59:49 +02:00
L'électron rare 46d912e526 docs: plan body-trigger framework
CI build oscope-of / build-check (push) Has been cancelled
2026-06-27 18:19:55 +02:00
L'électron rare b487038f30 docs: body triggers + harmonic/melodic scope
CI build oscope-of / build-check (push) Has been cancelled
2026-06-27 18:15:47 +02:00
L'électron rare 15578ec60a docs: spec body-trigger framework (phase 1)
CI build oscope-of / build-check (push) Has been cancelled
Extensible ~ccGesture registry (declarative gesture->event, engine
evaluates with hold/cooldown) so triggers are unlimited. Phase 1:
skeleton data layer (/pose/skel -> ~poseSkel), the framework, a master
event-FX layer (master filter + stutter + event synths), and a first
batch of FX/dramatic gestures (jump/stomp/squat/spin/tpose). Harmonic/
melodic triggers + ~ccHarmony are Phase 2 on the same framework.
2026-06-27 18:12:13 +02:00
L'électron rare 4285d160c3 feat(sound): wrist-backed hands for morceaux
CI build oscope-of / build-check (push) Has been cancelled
At full-body concert distance MediaPipe cannot detect the hands
(/pose/hands all zero), muting every morceau hand mapping. ~ccPose now
fills pose[hands] from the body wrists (~poseWrist) when the hands are
not live, shaped exactly like ~handFeat (wrist height/x, arm spread ->
openness, per-tick wrist motion -> speed). All 8 morceaux are driven by
the arms at distance with NO per-morceau change (they read pose[hands]).
Verified headless.
2026-06-27 17:58:44 +02:00
L'électron rare 883ada7d61 fix(sound): concert advance via body wrists
CI build oscope-of / build-check (push) Has been cancelled
The hand-based arms-crossed gesture could never fire: hand L/R is
position-sorted (rx>=lx always), and at full-body distance MediaPipe
doesnt detect the hands at all (crossing also occludes them). Detect
the advance from the body wrists instead: new /pose/wrist OSCdef ->
~poseWrist, and detectCross fires when the right wrist swaps clearly
left of the left wrist (a clear X), performer centered, wrists at chest
height. Verified headless (wrist-cross advance 1->2).
2026-06-27 17:48:23 +02:00
L'électron rare 1cd8c2e259 fix(sound): dub chords delay tail rings out
CI build oscope-of / build-check (push) Has been cancelled
2026-06-27 17:31:32 +02:00
L'électron rare bcfb86ae53 feat(sound): dub space morceau
Adds 08_dub_space.scd — sub + sparse clicks + reverb outro.
Registers cc_space_sub (gated asr) and cc_space_click (one-shot).
Wires depth->space-amount, rspeed->click trigger. Loaded in boot.scd.
2026-06-27 17:20:16 +02:00
L'électron rare d8ea115a1b feat(sound): dub chords morceau
Add 07_dub_chords.scd: sustained sub (gated asr), dub chord
with CombL delay, deep kick on half notes. Register via
~concertAdd. Wire into boot.scd load list after 06_hard_rave.
2026-06-27 17:17:28 +02:00
L'électron rare 66a16d263b feat(sound): hard rave morceau
Add 06_hard_rave.scd: hoover stabs, hard break, crash on accel spike.
Register in boot.scd load list after 05_hard_pump.
2026-06-27 17:14:38 +02:00
L'électron rare abccc1c7b3 feat(sound): hard pump morceau 2026-06-27 17:11:43 +02:00
L'électron rare 0a3798ea38 feat(sound): acid storm morceau
Add 04_acid_storm.scd: overdriven 303 + hard kick + screech.
Right hand controls cutoff/res/drive; both arms high triggers
screech fill (once per raise). Registered in boot.scd load list.
2026-06-27 17:08:58 +02:00
L'électron rare 79842c8bc0 feat(sound): acid line morceau 2026-06-27 17:05:52 +02:00
L'électron rare 0080cc489f feat(sound): hypno roll morceau 2026-06-27 17:02:02 +02:00
L'électron rare 0cfaa0451c fix(sound): concert gesture without inRange
CI build oscope-of / build-check (push) Has been cancelled
inRange is not defined in the installed SuperCollider (Message not
understood) -> the arms-crossed detector errored at runtime and the
gesture never fired. Replace with explicit >= / <= comparisons.
Verified headless: the gesture now fires (advance/clamp logged).
2026-06-27 16:48:13 +02:00
L'électron rare cc0d5270cb feat(viz): mirror pose OSC to VDMX (VJ)
CI build oscope-of / build-check (push) Has been cancelled
Env-gated VDMX_OSC_HOST (+ VDMX_OSC_PORT, default 1234) forwards the
VJ-useful pose signals (/pose/hands, /pose/kin, /pose/center,
/pose/action, /pose/count) to VDMX OSC input so the body drives any
VDMX parameter. Off by default (no env = no mirror). Verified on macm1:
hands/kin/action received on the VDMX port; None without the env.
2026-06-27 16:43:42 +02:00
L'électron rare c4e4511262 feat(sound): concert engine + hypno drift morceau
Sequential techno setlist engine with crossed-arms gesture to advance
tracks. Adds ~poseCenter OSCdef, scene_concert.scd engine, morceaux/
directory, first morceau (Hypno Drift), and wires into boot/scenes.
2026-06-27 16:36:11 +02:00
L'électron rare 5dcefb3420 docs: plan concert mode (techno setlist)
CI build oscope-of / build-check (push) Has been cancelled
Engine first (Task 1: ~concert + ~poseCenter + Hypno Drift + integration),
then one morceau per task (8 self-contained .load files registering via
~concertAdd with their own \cc_* SynthDefs), then a live smoke. Complete
working SuperCollider per task; headless sclang load/advance tests on
GrosMac, audio tuning at the macm1 smoke.
2026-06-27 16:26:46 +02:00
L'électron rare 42460abf6f docs: spec concert mode (techno setlist)
CI build oscope-of / build-check (push) Has been cancelled
Body-driven techno concert: a sequential setlist of 8 custom-mapped
morceaux (hard/dub/acid/hypnotic) advanced by an arms-crossed gesture.
Concert engine (~concert) + uniform morceau module interface
(start/update/tick/stop + ctx) + crossfade + /pose/center wiring. New
\concert scene alongside body-play. Plan decomposes: engine first, then
morceaux in batches.
2026-06-27 16:18:55 +02:00
L'électron rare 04764b6d6f fix(viz): auto-reconnect iphone USB stream
CI build oscope-of / build-check (push) Has been cancelled
When the iPhone USB stream drops (app backgrounds, USB hiccup, decode
end), the reader thread now reconnects with capped backoff instead of
spinning dead on read()=(False,None) forever. The stale frame is cleared
on drop so read() reports no-frame during the gap (no frozen pose).
Verified on macm1: app kill -> "reconnecting" -> relaunch -> detection
resumes (body=1). Unit tests still 10/10.
2026-06-27 16:07:44 +02:00
L'électron rare 0757450648 feat(sound): boot data-only to body-play scene
CI build oscope-of / build-check (push) Has been cancelled
The data-only boot started the \all scene (all data-web scenes). Boot
directly to \play (body-play) so a body-play session has no data-web
synths at startup. \all and the individual data scenes remain available
via /control/doScene <name>.
2026-06-27 16:03:04 +02:00
L'électron rare 3314b58b69 fix(viz): action-head body3d kp conversion
The body3d / MediaPipe fallback feeding action-head silently failed:
getattr(kp, "x", kp[0]) eagerly evaluated the kp[0] fallback, which
raises TypeError on the non-subscriptable Kp3D dataclass, so
_kp_list_to_array returned None and every body was skipped. This
blocked /pose/kin and /pose/action for any MediaPipe-only mode (webcam
and iphone-usb), so SC body-play never saw a body (hasBody=false ->
no notes). Use attribute access for Kp3D, index fallback only for
indexable rows. Verified: /pose/kin + /pose/action now flow (97 vs 0).
2026-06-27 16:03:04 +02:00
L'électron rare 57e3a200bc fix(viz): harden iphone-usb source
CI build oscope-of / build-check (push) Has been cancelled
Fix 8 defects from whole-branch review (a79bc14..8c4d9f4):

- write_hands param: MediaPipe owns persons_hands in iphone-usb mode;
  IphoneUSBSource gets write_hands=False in multi.py
- Guard cap.start() failure: return early if USB source unavailable
- Stale frame on disconnect: read() returns (False,None) when
  _opened=False after thread exits
- ARKit validity: update only valid joints, matching OSC listener
- Decode exception: except Exception replaces removal-prone av.AVError
- release() unblock: socket.shutdown() before join wakes recv-blocked
  reader immediately
- Log stutter: remove redundant "multi —" prefix from USB log line
- Unit tests: 10 pure no-device tests for _to_annexb + _decode_hands;
  add iphone-usb optional extra (av>=12.0) to pyproject.toml
2026-06-27 15:27:06 +02:00
L'électron rare 8c4d9f4d7b feat(viz): --iphone-usb mode wiring
Add --iphone-usb flag so the pose worker reads frames from
IphoneUSBSource instead of the Mac webcam. Skip Mac-camera TCC
when iphone_usb is set; IphoneUSBSource handles its own usbmuxd
connect. MediaPipe still runs on iPhone frames; arkit_fuse refines
body joints as before.
2026-06-27 14:58:37 +02:00
L'électron rare 50772f3b9b feat(viz): ARKit skeleton + hands into State
Handle TAG_SKELETON and TAG_HANDS in IphoneUSBSource._run.
Skeleton: decode_skeleton() -> (91,3) float32 array into
persons_arkit_joints[pid] + persons_arkit_last_t[pid].
Hands: _decode_hands() parses count:u8 + per-hand chirality:u8
+ 21x(x,y,z) BE f32 into list[list[PoseKp]] for persons_hands,
compatible with HandFeatureExtractor.step().
2026-06-27 14:50:35 +02:00
L'électron rare a79bc14dc3 feat(viz): iphone USB HEVC frame source
cv2-VideoCapture-shaped IphoneUSBSource backed by the iPhone
ARBodyTracker USB stream (usbmuxd). Decodes AVLiveWire HEVC
video to 640x480 BGR frames for MediaPipe. Thread-safe read().
Skeleton and hands (Task 2) dispatched via state but not yet
wired.
2026-06-27 14:40:49 +02:00
L'électron rare 9e3cfc308a docs: plan iphone-usb camera mode
CI build oscope-of / build-check (push) Has been cancelled
Three-task plan: IphoneUSBSource with HEVC video decode (cv2 .read
contract, reusing the de-risked decode), ARKit skeleton + Vision hands
into State, and --iphone-usb wiring in multi.py/main.py. Tests run on
macm1 against the live iPhone; plus a manual live smoke.
2026-06-27 14:35:47 +02:00
L'électron rare b259f5437f docs: spec iphone-usb camera mode
CI build oscope-of / build-check (push) Has been cancelled
Drive data_only_viz entirely from the iPhone over USB: HEVC video ->
PyAV decode -> MediaPipe, ARKit skeleton -> arkit_fuse, Vision hands ->
hand features. IphoneUSBSource implements the cv2 .read() contract so it
drops into multi.py in place of cv2.VideoCapture; --iphone-usb flag.
HEVC decode de-risked (256 frames at ~28fps from real iPhone AUs).
2026-06-27 14:33:30 +02:00
L'électron rare 54d07aca27 feat(viz): iphone USB skeleton to OSC bridge
CI build oscope-of / build-check (push) Has been cancelled
The ARBodyTracker iOS app serves its ARKit 91-joint skeleton over the
device TCP :7000 in AVLiveWire framing, not OSC, so data_only_viz never
received it. This bridge tunnels to :7000 through usbmuxd (ListDevices +
Connect, port byte-swapped), demuxes the AVLiveWire stream (magic AVL1,
19-byte BE header), decodes SkeletonPayload (91 interleaved BE xyz floats
+ 91 validity bytes) and republishes each valid joint as OSC /body3d/kp
pid joint_idx x y z to :57128 for the arkit_fuse POSE_FILTER stage.
Verified on macm1: usbmux connect to device :7000, frames forwarded.
2026-06-27 13:57:17 +02:00
L'électron rare 8d09ae555b fix(sound): stop play-scene via doStopAll
CI build oscope-of / build-check (push) Has been cancelled
Final whole-branch review caught a cross-task defect: ~doScenePlay
started ~bpEngine but registered no cleanup, so ~doStopAll (run by
~doScene at every scene switch, and by \stop) never stopped the
engine -- body-play kept playing over any other scene, breaking the
"\stop coupe tout" contract. Register the stop via ~doRegisterPriv
(name, killFn) like every other scene. Verified on macm1: doScene(\play)
starts the engine, doScene(\stop) stops it.
2026-06-27 13:32:54 +02:00
L'électron rare 8954198648 feat(sound): wire body-play into data-only boot
Load synthdefs_play.scd (after synthdefs.scd + s.sync) and
scene_pose_play.scd (after scene_pose_action.scd + 0.5.wait) in
boot.scd. Add \play case to scenes.scd switch dispatching to
~doScenePlay — calls ~bpEngine[\start]. Integral-boot test on
macm1: 3/3 PASS (bp_lead loaded, ~bpEngine present, engine started).
2026-06-27 13:15:14 +02:00
L'électron rare 8c8de924db fix(sound): free clock on stop, drop dead callback
- stop: call clock.stop + nil to prevent TempoClock leak per cycle
- remove unused ~doScene !? / ~bpRegisterPlay dead block
- clarify ~bpCtlDur comment: period is in beats, not seconds
2026-06-27 13:00:51 +02:00
L'électron rare 2ec59571c9 feat(sound): body-play engine clock arp crossfade 2026-06-27 12:48:24 +02:00
L'électron rare 83ea2be4e6 feat(sound): body-play mapping functions 2026-06-27 12:42:15 +02:00
L'électron rare c2c9753c8c feat(sound): body-play modes and scale quantizer 2026-06-27 12:35:10 +02:00
L'électron rare 2e484ed2ed feat(sound): bp_* SynthDef palette for body-play 2026-06-27 12:21:27 +02:00
L'électron rare 73f85aa276 docs: plan body-play implementation
CI build oscope-of / build-check (push) Has been cancelled
Five-task TDD plan (headless sclang tests) for the body-play module:
bp_* SynthDef palette, modes table + scale quantizer, pure pose->music
mapping functions, the engine (clock/arp/lead/rhythm/crossfade), and
boot wiring with a live smoke step.
2026-06-27 12:14:06 +02:00
L'électron rare 082bb19dd5 docs: spec body-play music generation
CI build oscope-of / build-check (push) Has been cancelled
Design for body-driven musical generation in the data-only patch:
action-head label selects one of three complete modes (scale+tempo+
instruments), the right hand plays a gesture-triggered lead while the
left hand holds a chord an arpeggio plays in rhythm, and body speed
drives the tempo. New scene_pose_play.scd + synthdefs_play.scd
(bp_* palette), separate from the existing pose->FX module.
2026-06-27 12:09:42 +02:00
L'électron rare 83dc910624 fix(sound): recover port 57121 from zombie on boot
CI build oscope-of / build-check (push) Has been cancelled
The data-only boot opened the OSC receive port 57121 but fell back
silently to the lang port when it was busy — so /pose (data_only_viz)
and /data (data_feeds bridge), both sent to 57121, never reached
sclang and the patch looked alive but reacted to nothing. The port is
typically squatted by a zombie: a scsynth or data_feeds bridge launched
by a previous boot via unixCmd inherits the 57121 socket FD and outlives
its sclang parent, so a fresh boot can't bind it. Instead of falling
back, free the stale holder (lsof -ti UDP:57121 | xargs kill -9) and
retry openUDPPort. Verified: a planted zombie on 57121 is killed and the
port recovered on boot ("57121 recupere — sclang listening on 57121").
2026-06-26 19:43:57 +02:00
L'électron rare 480aa79ef4 fix(sound): fix pose-FX underscore closure trap
CI build oscope-of / build-check (push) Has been cancelled
scene_pose_action's Routine died on its first tick with "Non Boolean in
test", killing the whole pose->FX mapping (base scenes still played).
Root cause in ~mapPoseToFx: `avgProbs.collect { _ / n }`. The underscore
inside an explicit { } block does NOT build {|x| x/n} — it builds a
function that RETURNS that function, so collect fills avgProbs with
Functions instead of numbers. The next line, avgProbs.maxIndex, then
compares Functions with `>` (a BinaryOpFunction) inside a boolean test
-> MustBeBooleanError. Replace with the idiomatic element-wise
`avgProbs / n`. Verified in isolation (headless sclang): ~mapPoseToFx
and ~mapHandsToFx now pass with both empty and populated pose dicts.
2026-06-26 17:38:00 +02:00
L'électron rare 8a4a0e688d fix(sound): boot data-only patch output-only
CI build oscope-of / build-check (push) Has been cancelled
scsynth failed to boot under the data-only patch with "AudioDeviceStart
failed" because it opened a default input device that was unavailable
(an iPhone Continuity mic showed up in the device list) and needed a mic
TCC grant. The patch is output-only, so pin numInputBusChannels = 0; the
server now boots cleanly with no mic prompt.

Also nil-guard the hand-coordinate reads in scene_pose_action's hand->FX
map (hf[\lx]/\ly/\rx/\ry could be nil when one hand is absent, matching
the existing ? 0 pattern on lopen/rspeed/dist).

Note: a separate "Non Boolean in test" error still kills the
scene_pose_action routine on its first tick (pose->FX mapping not yet
live); ~poseKin/~poseState/~handFeat are Dictionaries populated by
data_feeds OSCdefs — left to investigate.
2026-06-26 17:22:26 +02:00
L'électron rare 32a4ef2232 fix(viz): load DETRPose config from src.core
CI build oscope-of / build-check (push) Has been cancelled
DETRPoseWorker._load_py_config imported LazyConfig from
src.misc.lazy_config, which the DETRPose repo does not expose (it lives
in src.core, as tools/inference/torch_inf.py uses it). The import raised
ImportError with no fallback, dropping into a hand-rolled exec() that
cannot resolve the lazy config's relative imports ("from .include..."),
so model loading died with KeyError "'__name__' not in globals". Try
src.core first, then the old path, before the exec fallback. DETRPose
now loads on macm1 (HGNetV2 backbone auto-downloads, no exception).
2026-06-26 16:44:35 +02:00
L'électron rare f504ad4f41 fix(viz): repair multihmr remote protocol tests
CI build oscope-of / build-check (push) Has been cancelled
The whole data_only_viz pytest run was aborted at collection because
test_multihmr_remote imported REQ_PAYLOAD_LEN, a constant that had been
dropped from multihmr_remote while RSP_PAYLOAD_LEN stayed — restore it
as the RAW-format request payload size. The test had also drifted from
the protocol: decode_request now returns a third timing field, so the
two unpack sites take (img, K, _); and the backend defaults to async
double-buffering (MULTIHMR_REMOTE_ASYNC), under which infer() returns
None when the out-queue is empty — the two loopback stub tests now pin
MULTIHMR_REMOTE_ASYNC=0 for a deterministic sync round-trip. Suite goes
from aborted-at-collection to 151 passed, 14 skipped.
2026-06-26 15:53:44 +02:00
L'électron rare b40991f7a8 chore: ignore coverage artifacts and venvs
CI build oscope-of / build-check (push) Has been cancelled
Swift test runs were dropping default.profraw files and the data_feeds
venv symlink into the working tree, and the recovery backup dir plus
tooling state (.serena, agent-memory) showed up as untracked noise.
Ignore them so git status stays clean for day-to-day work.
2026-06-26 14:50:17 +02:00
L'électron rare 48d019cc77 fix(avlivebody): apply phase 2 review fixes
The phase 2 code review surfaced four follow-ups left after merge.
The joint accessor indexed body.valid without checking its length,
so a short valid array could crash on out-of-range access; it now
guards i < body.valid.count. The viz mode name list was rebuilt on
every modeName() call, so it is hoisted to a static constant. A
Metal shader compile test is added to catch scene.metal regressions
in CI. The dead SceneView placeholder, superseded by LayeredSceneView,
is removed.
2026-06-26 14:50:07 +02:00
L'électron rare b1e53da2bb fix(avlivebody): track hand face depth to pelvis
CI build oscope-of / build-check (push) Has been cancelled
Drive HandFaceSkeleton.setDepth() from the body pelvis z each frame
(negated ARKit→RealityKit) so hand/face markers follow body depth
instead of rendering at the fixed default plane depth 0.
2026-06-26 14:23:15 +02:00
L'électron rare f9d5614d4d feat(avlivebody): 3d hand and face skeleton
Add HandFaceSkeleton: pools of sphere markers for 21x2 hand
points (cyan) and Vision face landmarks (magenta), placed in
the RealityKit worldAnchor at normalized-to-scene-meter coords.
Wire via SceneController.updateHandFace + pushUniforms.
2026-06-26 14:18:19 +02:00
L'électron rare 0475ea0340 feat(avlivebody): viz mode picker and scene toggle 2026-06-26 14:13:30 +02:00
L'électron rare 28078dbeba feat(avlivebody): feed scene uniforms from stream
Introduce SceneUniformBuilder to derive Metal shader scalars from the
iPhone stream (body/hands) and wire it into ContentView via three
.onReceive handlers (skeletons, hands, face). Face channels stay 0
for v1 pending Vision region-index mapping.
2026-06-26 14:08:37 +02:00
L'électron rare 10873707e4 feat(avlivebody): layered metal plus arview
Add MTKView (SceneRenderer, opaque black) behind a transparent
ARView in a new LayeredSceneView NSViewRepresentable. ARView
background set to .clear with isOpaque=false so the Metal shader
shows through. RenderSettings gains showScene and vizMode; applyAll
drives viz_mode onto the renderer.
2026-06-26 14:03:15 +02:00
L'électron rare 131f6ed5a8 feat(avlivebody): port metal scene renderer
Copy scene.metal from archive, add SceneRenderer.swift (Bundle.main),
stride-guard test (144 B). Xcode compiles .metal in Resources phase
even with buildPhase:resources, so use a postBuildScript with
UNLOCALIZED_RESOURCES_FOLDER_PATH to copy the source as plain text.
2026-06-26 13:57:55 +02:00
L'électron rare 8afc44b2ca docs: plan avlivebody phase 2 shaders
5 tasks: port scene.metal+SceneRenderer, layered MTKView+ARView, uniform feed (isolated for iPhone-derivation swap), viz-mode picker, 3D hand/face skeleton.
2026-06-26 13:28:28 +02:00
L'électron rare dac96481f2 feat(ios): compute budget metrics overlay
CI build oscope-of / build-check (push) Has been cancelled
2026-06-26 12:41:58 +02:00
L'électron rare 598f00ea31 docs: plan iphone compute budget overlay 2026-06-26 12:39:54 +02:00
L'électron rare 5629a7e859 docs: spec iphone compute budget overlay 2026-06-26 12:37:25 +02:00
L'électron rare ca25732265 feat(avlivebody): settings panel and wiring
Add SettingsPanel SwiftUI view bound to all RenderSettings fields.
Wire ContentView: @StateObject settings, applyAll() on appear and
on settings change via objectWillChange, ZStack panel+toggle button.
2026-06-26 12:24:56 +02:00
L'électron rare 5dfbfc7ae9 feat(avlivebody): lights, material and visibility 2026-06-26 12:19:14 +02:00
L'électron rare e7b107a96e feat(avlivebody): render settings state object
Add RenderSettings ObservableObject with Published defaults for
layer visibility, mesh material, video opacity, light intensities,
camera FOV, and panel toggle. Add RenderSettingsTests asserting
all eleven defaults via TDD red-green cycle.
2026-06-26 12:13:55 +02:00
L'électron rare b918eeea16 docs: plan avlivebody phase 1 controls
3 tasks: RenderSettings state, SceneController lights/material/visibility/FOV apply methods, SettingsPanel + ContentView wiring. Wireframe deferred to Phase 2.
2026-06-26 12:10:28 +02:00
L'électron rare 01b0c56ea2 docs: correct avlivewire tags to 4 and 5
CI build oscope-of / build-check (push) Has been cancelled
2026-06-26 12:03:15 +02:00
L'électron rare b4b8139902 feat(ios): vision hands and face over usb 2026-06-26 11:53:45 +02:00
L'électron rare 242c8ec751 feat(avlivebody): consume hands and face frames
Route FrameTag.hands (=4) and FrameTag.face (=5) in USBSkeletonConsumer.
Decode each payload and publish on the main queue, mirroring the
existing .skeleton pattern. Add @Published hands/face properties so
SwiftUI views can observe them directly.
2026-06-26 11:49:38 +02:00
L'électron rare 1e9f711278 fix(avlivewire): encoder prefix + reader hoist
HandsPayload.encoded() wrote min(count,255) in the header but looped
over all hands; use hands.prefix(255) so header and body agree.

Hoist the float reader func f() to the outer hand loop, matching
FacePayload style; declared once per hand instead of per point.
2026-06-26 11:47:21 +02:00
L'électron rare c5d89e740c feat(avlivewire): hands and face frame payloads
Add FrameTag.hands (=4) and FrameTag.face (=5) without renumbering
existing tags. Implement HandsPayload (up to 255 hands, 21 SIMD3
points each, big-endian codec) and FacePayload (confidence + N
SIMD2 landmarks). Both follow the SkeletonPayload/VideoPayload
codec pattern. Five new XCTest round-trip and rejection tests pass.
2026-06-26 11:42:53 +02:00
L'électron rare cf8d24d629 docs: plan avlivebody phase 0 hands face
3 tasks: AVLiveWire hands/face payloads (tags 4/5), Mac consumer routing, iPhone Vision capture + send.
2026-06-26 11:39:42 +02:00
L'électron rare b01a35326b docs: spec avlivebody hands/face + viz UI port
Phase 0: capture hands (Vision hand pose) + face (Vision face landmarks) on the iPhone from AR rear-camera frames, stream over new AVLiveWire tags 3/4. Phase 1: port settings panel + 3 lights + mesh material into avlivebody-mac. Phase 2: 10 Metal shader modes + 3D hand/face skeleton, uniforms fed from the iPhone stream.
2026-06-26 11:31:50 +02:00
L'électron rare 5ed6aeb388 fix(avlivebody): run Multi-HMR off the main thread
CI build oscope-of / build-check (push) Has been cancelled
inferAsync runs the CoreML prediction on a background queue with drop-if-busy; wire() snapshots skeletons on main, infers off-main, then fuses + updates mesh on main. Makes AVBODY_MULTIHMR=1 safe (no run-loop saturation). Default stays skeleton-only.
2026-06-26 11:09:59 +02:00
L'électron rare a8c803403c docs(data-only): french comment in boot load step 2026-06-26 11:07:47 +02:00
L'électron rare f106b35fa3 fix(avlivebody): gate Multi-HMR off by default
CI build oscope-of / build-check (push) Has been cancelled
CoreML Multi-HMR inference ran synchronously on the main thread per video frame, saturating the run loop (beachball). Gate it behind AVBODY_MULTIHMR=1; default to skeleton-only rendering from the iPhone 91-joint ARKit stream.
2026-06-26 10:58:28 +02:00
L'électron rare fe69f99ba2 feat(data-only): neutral FX when hands absent
CI build oscope-of / build-check (push) Has been cancelled
2026-06-26 10:35:55 +02:00
L'électron rare cf6eb9c47e fix(viz): narrow uniform test except + lock write 2026-06-26 10:35:52 +02:00
L'électron rare ff2ef5dfb8 feat(viz): scene reacts to hand features
Add hand_height/openness/speed/dist to SceneUniforms (struct now
24 floats, matching Python pack order). bg_fragment: dist-based zoom
(all modes except hands3d) and post-dispatch color modulation
(luminosity, bloom, turbulence driven by hand pose).
2026-06-26 10:24:48 +02:00
L'électron rare 886b90b84d feat(viz): pack hand features into uniforms
UNIFORM_FLOATS 20→24 (96 bytes, 16-byte aligned). Added hand_height,
hand_openness, hand_speed, hand_dist at slots 19-22 derived from
state.hand_feats. state.py gets hand_feats field; action_head_pub
writes it after each HandFeatureExtractor.step(). Existing 14 uniforms
and wrist xy (15-18) unchanged.
2026-06-26 10:19:10 +02:00
L'électron rare 9ab238bd2d feat(data-only): map hand features to FX 2026-06-26 10:11:49 +02:00
L'électron rare 2a3fdb1d38 feat(data-only): global filter cutoff width drive 2026-06-26 10:05:00 +02:00
L'électron rare 088b233a48 feat(data-only): ingest /pose/hands into handFeat
Add OSCdef(\poseHands) to data_feeds.scd storing lx/ly/lopen/lspeed,
rx/ry/ropen/rspeed/dist into ~handFeat Dictionary (9 floats, pid=0).
Initialize ~handFeat alongside existing ~poseState/~poseKin.

Load scene_pose_action.scd in boot.scd after scenes.scd so ~mapPoseToFx
is defined at runtime (was silently dead before).

Document /pose/hands contract in data_only_viz/README.md.
2026-06-26 09:59:34 +02:00
L'électron rare de2748accb fix(viz): numpy-safe hand filter + tests
- _emit_hands: replace bare `if h` with numpy-safe predicate
  `h is not None and len(h) > 0` in single-person fallback
- test_hand_features: add numpy-row landmark test for
  HandFeatureExtractor.step(), asserting finite cx/cy/openness
- test_action_head_pub: add fallback-path test and two-pid
  once-per-tick invariant test for send_hands
2026-06-26 09:52:20 +02:00
L'électron rare ec2c387091 feat(viz): emit hand features each tick 2026-06-26 09:45:41 +02:00
L'électron rare 7a4f14056a feat(viz): /pose/hands OSC sender 2026-06-26 09:37:13 +02:00
L'électron rare 71d0a47723 feat(viz): hand feature extractor 2026-06-26 09:31:40 +02:00
L'électron rare 52b902d3e3 docs: plan hand-driven data_only modulation
9-task TDD plan: HandFeatureExtractor, /pose/hands OSC, SC handFeat + global filter + mapping, Metal uniforms + scene reaction.
2026-06-26 09:26:45 +02:00
L'électron rare d7c8a3e687 docs: spec hand-driven data_only modulation
Vertical slice: MediaPipe hand features drive both the data_only FX rack (SuperCollider) and the Metal scene via one shared signal. Modulation only, no note generation. Includes reliability fixes (boot loads scene_pose_action, FX stubs wired, finite guards).
2026-06-26 09:20:19 +02:00
L'électron rare 8657805eaf Merge branch 'docs/agents-md-tier-1'
CI build oscope-of / build-check (push) Has been cancelled
2026-06-23 23:01:18 +02:00
L'électron rare c5941b9625 Merge branch 'feat/action-head'
# Conflicts:
#	data_only_viz/multihmr_coreml.py
#	launcher/_archive-AV-Live-Body/Sources/AVLiveBody/Skeleton3DRenderer.swift
2026-06-23 23:01:07 +02:00
L'électron rare df2b068c2b Merge remote-tracking branch 'origin/main' 2026-06-23 22:55:03 +02:00
electron 1b88da7053 chore: point submodule to Gitea 2026-06-03 16:34:05 +02:00
Clément SAILLANT 5c61112826 docs: add AGENTS.md skeleton (#1)
* docs(plans): action-head v3 + branch sync notes

Update plan header :
- v2 (Task 18) + v3 (Task 19) extensions chronology
- Studio train validated, ckpt action_head_v3.pt landed
- Mesh NaN-guard debug trail (commit 4e7101c)
- Branch convergence main == feat/action-head
- Pointers to memories project_action_head_v3, etc.

* feat(av-live): openpos 3D + DINO reid + filter

Three improvements wired end-to-end:

1. Openpos 3D skeleton visible: Skeleton3DRenderer attached to a
   RealityKit AnchorEntity in BodyView, toggled by showSkeleton
   or vizMode==9. PoseOSCListener now parses /pose3d/count and
   /pose3d/kp (plus restored /face/* and /hand/* paths).

2. DINO re-id (dinov2_vits14, ~9 ms ANE forward):
   MeshRigger combines Hungarian IoU with cosine similarity over
   a per-pid embedding history (deque maxlen=10), weighted by
   MULTIHMR_REID_ALPHA (default 0.5). Falls back to pure IoU if
   DINO mlpackage absent or scipy missing. state.last_frame_rgb
   buffer added so the rigger can crop bbox regions for embedding.

3. PoseFilterChain on pose_world_landmarks:
   median (anti-spike) -> Kalman constant-velocity ->
   50 ms lookahead -> IK elbow/knee/ankle clamp. Configurable
   via POSE_FILTER env (default median+kalman+lookahead+ik).
   <2 ms per frame for typical 1-2 persons.

Tests: 5 new in test_dino_reid.py + 6 new in test_pose_filter.py,
all green. Live validated by user: skeleton spawns, mesh stays
stable.

* fix(av-live-body): restore face+hand+3D (f540158)

Three regressions after recent merges, all restored to match the
original f540158 design:

1. FaceHandOverlay was no longer instantiated in ContentView.
   Added back as a SwiftUI Canvas overlay (68 dlib face landmarks
   with mouth slots 48-67, plus 21x2 hand landmarks cyan/magenta).

2. Skeleton3DRenderer was not attached. BodyView now creates an
   AnchorEntity at (0,0,-2.5), instantiates Skeleton3DRenderer
   and ties its visibility to vizMode==9 or showSkeleton toggle.

3. Joint and bone radii bumped to 4.5 cm / 2.2 cm so the 3D
   skeleton actually reads as 3D instead of looking flat.

MeshRenderer exposes pelvisWorld map per pid for future
interconnect uses (not auto-applied -- design keeps mesh and
skeleton each in their own coord space per f540158).

* feat(av-live): wireframe skel + face/hand filter

Skeleton3DRenderer now renders a wireframe: joint radius 1 mm
(quasi-invisible), bone radius 3 mm (line-like). Replaces the
chunky bead armature with a clean filaire silhouette covering
body 33 joints + face 68 dlib + hands 21x2, all 3D.

FaceHandOverlay 2D Canvas removed from ContentView -- face and
hand landmarks now live in the same 3D RealityKit armature as
the body skeleton (Skeleton3DRenderer.applyFace / applyHands,
anchored on nose joint 0 + wrist joints 15/16).

pose_filter.py extended with FaceFilterChain (alpha-beta + 30 ms
lookahead) and HandFilterChain. multi.py wires them after the
2D smoothers, plus ghost rejection (POSE_GHOST_MIN_VISIBLE),
bbox NMS (POSE_NMS_IOU), and pid hysteresis. 10 new tests, all
green.

CoreML perf audit (bench_multihmr_coreml.py): predict() = 99% of
wall-time on FP32. ANE catastrophic for DINOv2 (1300 ms),
INT8 weight quant = no live gain (GPU compute-bound).
6.4-6.8 fps live is the hardware ceiling on this model.
quantize_multihmr_int8.py left in scripts/ for future trials.

* deps(icp): add open3d optional extra + smoke test

Context: Task 1 of the ICP LiDAR <-> SMPL-X fusion plan needs a
point-cloud library to align iPhone LiDAR scans with Multi-HMR
SMPL-X meshes. Open3D's CPU-only ICP is sufficient at the 5-10 Hz
LiDAR cadence.

Approach: Add a dedicated `lidar` optional-dep group so the heavy
dependency stays opt-in. Pin Python to 3.12 implicitly via the
regenerated uv.lock because open3d 0.18-0.19 only ships cp311/cp312
wheels (cp314 absent). Smoke test guards future regressions.

Changes:
- pyproject.toml: new `lidar` extra with `open3d>=0.18,<0.20`
- uv.lock: regenerated with open3d 0.19 + transitive deps
  (scikit-learn, scipy, dash stack, etc.)
- tests/test_open3d_smoke.py: two-test smoke suite
  (PointCloud roundtrip + ICP convergence on translated copy),
  gated by `pytest.importorskip("open3d")`

Impact: Unlocks subsequent ICP fusion tasks (LiDAR ingest, mesh
alignment, transform publication) without forcing open3d on
contributors who only run the base pose pipeline.

* feat(icp): LiDAR TCP frame decoder + tests

* feat(icp): LiDAR TCP socket reader with reconnect

* feat(icp): extrinsic dataclass + JSON persistence

* feat(icp): Kabsch + calibration CLI scaffold

* feat(state): persons_arkit_joints fields

* feat(viz): ARKit 91 -> MP 33 joint map

* feat(viz): iphone OSC listener :57128

* feat(viz): arkit_fuse stage overrides 14 slots

* feat(viz): arkit pelvis z locks cam translation

* feat(viz): iphone OSC listener auto-start

* docs: arkit fusion env vars

* feat(icp): point-to-plane register + reject gate

* feat(icp): partition LiDAR per pid by max-dist

* feat(icp): FusionWorker + State.lidar_points

* feat(icp): wire fusion thread behind ICP_FUSION

Task 9 of the ICP LiDAR plan: integrate the FusionWorker built in
earlier tasks into the live data_only_viz pipeline without
disturbing the existing ARKit pelvis fuse path or the Multi-HMR
worker thread.

A new IcpFusionThread pulls LiDAR frames from LidarTCPReader,
stages them into State, and applies in-place ICP registration on
state.persons_smplx[*].vertices_3d. It runs as a separate daemon
thread parallel to MultiHMRWorker rather than inline per frame —
the autonomous-worker architecture didn't fit the plan's
per-frame call site, so we adapted to a polling thread at 8 Hz.

Activation is opt-in via ICP_FUSION=1 plus ICP_LIDAR_HOST; the
default code path is untouched. Shutdown wired through
applicationWillTerminate_.

MultiHMRWorker.predict_once is added as a documented stub
(NotImplementedError) because the existing PyTorch run loop is
too coupled to the camera and MPS lifecycle for a clean
single-shot extraction. calibrate_lidar.py keeps its placeholder
until a follow-up refactor extracts a pure _infer(rgb) helper.

* test(icp): synthetic latency + convergence bench

* docs(icp): env vars + calibration procedure

* docs(plans): icp lidar mesh + arkit joints

Two complementary fusion plans landed in parallel on 2026-05-14:
- iphone-lidar-multihmr-fusion : ARKit 91 joints -> MP33 fuse stage +
  pelvis z override (already implemented in 7 commits)
- icp-lidar-smplx-fusion : LiDAR mesh point-to-plane ICP onto SMPL-X
  10475 verts (12 tasks executed via subagent-driven-development)

Both paths coexist; joints are sparse+fast (60 Hz), mesh is dense+slow
(5-10 Hz). See docs/ICP_FUSION.md for the integration topology.

* feat(icp): predict_once via CoreML backend

* feat(av-live-body): wire ArkitOSCListener :57129

Receives /body3d/kp from iPhone ARBodyTracker on the diagnostic
port (57129, distinct from Python's 57128 fuse input). Plumbed
through ContentView -> BodyView -> Skeleton3DRenderer so the
ARKit joints can be overlayed alongside Multi-HMR mesh.

* feat(ios): iphone ARBodyTracker swiftpm app

iOS 17+ Swift Package app (.swiftpm) streaming ARKit body
joints via OSC UDP to two destinations:

  :57128 -> data_only_viz/iphone_osc_listener.py
  :57129 -> launcher/AV-Live-Body ArkitOSCListener.swift

Features:
- ARBodyTrackingConfiguration + sceneDepth (LiDAR) when supported
- 91 joints per body, /body3d/kp pid joint_idx x y z
- 30 fps throttle
- SwiftUI UI: Host/Port fields, Start/Stop, live joints-per-second
- Inline OSC encoder (no external dep)

Env mesh (TCP :5500) NOT yet implemented; requires a separate
ARWorldTrackingConfiguration session. ICP fusion path runs on
bench data only until phase 2.

* feat(data-feeds): 10 open-data OSC publisher

* feat(viz): DataFeeds OSC listener + HUD

* chore: gitignore tweaks

* docs: network topology + mDNS hostnames

Add a "Network topology" section to top-level CLAUDE.md doc
the 3-host layout (GrosMac source, Supra-M1 sink via mDNS,
iPhone via Personal Hotspot DHCP).

mDNS is canonical now : AVBODY_HOST and MULTIHMR_REMOTE_HOST
accept hostname.local instead of IPs, so the cluster survives
DHCP rotations on iPhone hotspot (172.20.10.x).

* fix(ios): add NSLocalNetworkUsageDescription

iOS 14+ silently blocks UDP to LAN addresses without this key.
The first time the app tries to send to 192.168.0.159, iOS will
prompt the user to allow Local Network Access; the prompt must
be accepted or the OSC stream never reaches the Mac.

Also adds NSBonjourServices declaring _osc._udp so the system
treats the connection as a recognised service.

* docs: network topology + gitignore hygiene

- CLAUDE.md: add mDNS hostname table (grosmac.local, supra-m1.local,
  iPhone hotspot 172.20.10.x). AVBODY_HOST / MULTIHMR_REMOTE_HOST
  accept hostnames — resilient to DHCP rotation.
- .gitignore: exclude .remember/ tool state and iCloud '* 2'
  collision artifacts.

* feat(ios): ARBody skeleton2D + overlay preview

ARBodySession: publish 2D-projected skeleton snapshot for live
overlay rendering on the iPhone screen alongside the camera feed.
ContentView: SkeletonOverlay drawing on top of the AR view, with
mock T-pose for Xcode previews (useMockBackground, useMockSkeleton).

* docs: iPhone USB body-tracking link design

Brainstormed design for replacing the OSC/network iPhone-Mac
link with a wired USB transport via usbmuxd. iPhone streams
ARKit skeleton + HEVC video; macOS app runs Multi-HMR CoreML
and renders the mesh. Network-free, single native macOS app.

* docs: iPhone USB transport plan (1 of 3)

Bite-sized TDD plan for the network-free USB byte-pipe:
shared AVLiveWire frame format, native usbmux client,
iOS TCP frame server, incremental stream demuxer.

* feat(avlivewire): shared wire package skeleton

* feat(avlivewire): fixed 19-byte frame header codec

Add FrameHeader, a fixed-size binary record so the demuxer can
frame and resync the iPhone USB stream. Layout is big-endian:
4-byte magic AVL1, tag u8, pid i16, timestamp f64, length u32.

The magic prefix lets a reader detect and skip corrupt bytes.
Decoding rejects short buffers and bad magic by returning nil.
Big-endian append/parse helpers are added as Data/UInt extensions
to keep the codec self-contained.

* chore: ignore SwiftPM .build artifacts

Both AVLiveWire and AV-Live-Body produce .build/ on swift
test; ignore them so they never get accidentally staged.

* feat(avlivewire): skeleton and video codecs

Add SkeletonPayload (91 ARKit joints + per-joint validity) and
VideoPayload (one HEVC access unit + keyframe flag) with
big-endian encode/decode. Reuses Task 2 Data/UInt32 helpers.

* feat(avlivewire): incremental stream demuxer

Add StreamDemuxer that accepts arbitrary byte chunks from a
non-frame-aligned stream and emits complete (FrameHeader, Data)
frames, resyncing on the magic prefix after corruption.

* fix(avlivewire): cap demuxer payload length

A corrupt header with a huge UInt32 length made feed buffer
forever waiting for bytes that never arrive. Add an 8 MB max
payload cap; a header exceeding it is treated as corrupt, its
magic is skipped, and the demuxer resyncs on the next frame.

* feat(av-live-body): usbmux message codec

Add USBMuxProtocol, a codec for Apple's usbmuxd request/response
protocol: a 16-byte little-endian header (length, version=1,
message=8 plist, tag) followed by an XML property list.

Wire an AVLiveBodyTests test target into Package.swift (none
existed) so swift test runs the round-trip and header coverage.

* feat(av-live-body): usbmux device client

Add USBClient for usbmux device discovery and connect-to-port,
with an injectable MuxTransport so tests need no real device.

Harden USBMuxProtocol.readLE32 to return an optional with a
bounds check, avoiding an out-of-range crash on truncated data.

* feat(av-live-body): usbmuxd unix socket transport

Add UnixMuxTransport, the production MuxTransport that opens a
blocking AF_UNIX socket to /var/run/usbmuxd. Implements framed
packet reads (4-byte LE length prefix) and raw stream reads for
the tunneled byte stream after a successful Connect.

* fix(av-live-body): harden unix socket transport

Apply four code-review fixes to UnixMuxTransport:
- send() now loops on partial writes and retries on EINTR
  instead of discarding write(2)'s return value.
- Add deinit and an fd = -1 sentinel so close() is
  idempotent and the descriptor cannot leak.
- precondition guards strcpy against sun_path overflow.
- readN() distinguishes EOF from error and retries EINTR.

* feat(ios): USB TCP frame server

Add USBServer: an NWListener on a fixed local TCP port that
usbmuxd tunnels to the tethered Mac. Sends AVLiveWire frames
and exposes a connection-state callback.

* build: depend on shared AVLiveWire package

Both ARBodyTracker (iOS) and AVLiveBody (macOS) now depend on
the local shared/AVLiveWire package so the wire format is
defined once. iOS USBServer imports it; macOS use lands in
Plan 3.

* build(ios): add AVLiveWire package to xcodegen

The xcodegen project did not declare the shared AVLiveWire
package, so USBServer.swift would fail to import it in the
generated Xcode project. Add it as a local package dep.

* test(avlivewire): end-to-end chunked loopback

Feeds 20 framed skeleton payloads through StreamDemuxer in
7-byte chunks (worst-case TCP fragmentation). Fixed a split
range operator from the plan that did not parse.

* fix(ios): guard USBServer listener and payload

Report .idle (not .listening) when NWListener creation fails,
and drop payloads larger than the demuxer's 8 MB cap so the
receiver never silently discards an oversized frame.

* chore: ignore .swiftpm editor state dirs

swift test / Xcode create hidden .swiftpm dirs inside
packages; ignore them so they never get staged.

* docs: iPhone capture plan (2 of 3)

Plan for HEVC video capture (VideoToolbox) over the USB
transport and removal of the legacy OSC sender. Skeleton
USB path already exists; this adds the video half.

* feat(ios): HEVC video capture, drop OSC

Add VideoEncoder (VideoToolbox HEVC) and stream encoded
frames over USB as .video AVLiveWire frames alongside the
skeleton. Remove the legacy OSC/UDP fanout and its host/port
config UI — the iPhone link is now USB-only.

* docs(ios): refresh stale OSC references

ARBodySession header comment and Info.plist usage strings
still described the removed OSC/UDP path; update them to the
USB transport and drop the dead _osc._udp Bonjour service.

* docs: macOS USB consumer plan (3a of 3)

Plan for consuming the iPhone USB stream in AVLiveBody:
USBSkeletonConsumer, VideoDecoder, 91-joint skeleton render.
Multi-HMR dense mesh deferred to Plan 3b.

* feat(av-live-body): USB skeleton consumer

Background usbmux read loop feeding StreamDemuxer; republishes
.skeleton frames as 91-joint ArkitBodyFrames and forwards
.video payloads. Removed stale iCloud collision duplicate
source files that broke the build.

* fix(data-only): CoreML Multi-HMR usage bugs

The CoreML Multi-HMR model was fine; the "0 detections" bug
was caller-side. Add ImageNet normalization in infer() (the
DINOv2 backbone needs it; raw [0,1] input collapsed all
scores) and update stale hardcoded output var names to match
the re-converted mlpackage. Bump the latency test threshold
to the realistic ~140 ms full-model figure.

* feat(av-live-body): HEVC video decoder

VTDecompressionSession decoder for .video VideoPayloads.
Rebuilds the format description from the parameter sets
prepended to keyframe payloads by the iOS VideoEncoder.

* feat(av-live-body): render 91-joint USB skeleton

Complete the long-standing TODO: draw the 91 ARKit/USB
skeleton joints as yellow markers, fed from lastArkit. Spawn
entity trees for ARKit-only pids so the USB skeleton shows
without a MediaPipe pose.

* feat(av-live-body): wire USB consumer to renderer

ContentView owns and starts a USBSkeletonConsumer, threaded
through BodyView into Skeleton3DRenderer.attach. The renderer
subscribes its $bodies into lastArkit, so the iPhone's USB
skeleton drives the on-screen 91-joint markers.

* docs: macOS Multi-HMR mesh plan (3b of 3)

Final plan: bundle the validated FP32 mlpackage, MultiHMRCoreML
Swift wrapper, BodyFusion (ARKit depth correction), mesh
pipeline wiring. Completes the spec.

* docs: AVLiveBody macOS rewrite design

Clean-rewrite spec: fresh native macOS Xcode app for the
iPhone-USB body pipeline. Reuses the tested USB components,
single RealityKit scene (video quad + skeleton + mesh),
drops all legacy MediaPipe/viz/data-feed code.

* docs: AVLiveBody macOS rewrite plan

10-task plan: scaffold the xcodegen app, migrate the USB
pipeline, build the RealityKit scene (video quad, skeleton,
mesh), wire it, archive the legacy app.

* feat(avlivebody-mac): scaffold xcode app

Add an empty buildable native macOS app generated via xcodegen,
sibling of iphone-arbody. Depends on the shared AVLiveWire package.
Later tasks add the USB pipeline and RealityKit scene.

* feat(avlivebody-mac): migrate usb transport

Context: the new native AVLiveBody app needs the proven iPhone-Mac
usbmux transport layer. These files are self-contained, depending
only on AVLiveWire plus Apple system frameworks, so they cross the
rewrite boundary unchanged.

Approach: copy the three transport files and their unit tests
byte-for-byte from launcher/AV-Live-Body, then make the test target
buildable.

Changes:
- Add usb/USBMuxProtocol.swift, usb/USBClient.swift and
  usb/VideoDecoder.swift under Sources/AVLiveBody.
- Add USBMuxProtocolTests.swift and USBClientTests.swift under
  Tests/AVLiveBodyTests.
- Set GENERATE_INFOPLIST_FILE=YES on the AVLiveBodyTests target so
  xcodebuild can code sign the now-populated test bundle.

Impact: the usbmux pipeline is available in the rewrite and its
six unit tests run green under xcodebuild test.

* feat(avlivebody-mac): usb skeleton consumer

Add a cleaned USBSkeletonConsumer that publishes SkeletonPayload
keyed by pid and owns video decoding directly, dropping the legacy
ArkitOSCListener conversion layer.

* fix(avlivebody-mac): guard thread store with lock

Move the `thread` property write inside the stateLock-held
region in start(); t.start() stays outside since the thread
cannot run before start() is called. Removes a latent race.

* feat(avlivebody-mac): multi-hmr and body fusion

Context: Task 4 of the macOS rewrite needs the dense-mesh half of
the pipeline alongside the USB skeleton consumer landed in task 3.

Approach: Add a CoreML wrapper that mirrors the validated Python
reference (data_only_viz/multihmr_coreml.py) and a pure-logic
fusion stage that corrects the mesh pelvis depth using the
LiDAR-precise USB skeleton.

Changes:
- MultiHMRCoreML.swift: 1x3x672x672 ImageNet-normalized image
  input, 1x3x3 cam_K input, K=4 SMPL-X person outputs at
  10475 vertices, det threshold 0.3.
- BodyFusion.swift: stateless fuse(persons, skeletons) overrides
  the highest-score mesh translation.z with the skeleton pelvis Z
  when available, passes through otherwise.
- BodyFusionTests.swift: pelvis override and pass-through cases.

Impact: Unlocks the mesh renderer wiring in later tasks and gives
the macOS app metrically-correct depth in front of the camera.

* fix(avlivebody-mac): load mlmodelc, clarify fusion

Xcode compiles .mlpackage resources to .mlmodelc at build time;
look up the compiled artifact directly and drop the redundant
MLModel.compileModel step. Also rewrite BodyFusion docstring to
match actual single-person pelvis-z override behaviour.

* feat(avlivebody-mac): scene controller + view

RealityKit scene plumbing: SceneController owns ARView, orbital
camera, and holders for VideoQuad/SkeletonEntity/MeshEntity.
SceneView is the SwiftUI NSViewRepresentable bridge.

Build intentionally deferred to T8 (holder types land in T6-T8).

* fix(avlivebody): orbit gesture + setUp guard

Filter NSPanGestureRecognizer state in OrbitTarget.handlePan to
dispatch only on .changed, replacing the Task wrapper with
MainActor.assumeIsolated. Guard SceneController.setUp() with a
didSetUp flag so duplicate makeNSView calls do not re-install
gestures or re-add anchors.

* feat(avlivebody-mac): 91-joint skeleton entity

Yellow marker spheres pooled per pid; ARKit (x,y,z) ->
RealityKit (x,-y,-z). Adapted .systemYellow to NSColor for
macOS RealityKit Material.Color. Build deferred to T8.

* feat(avlivebody-mac): video quad

Flat 1.6x0.9m plane at z=-2m, textured per-frame from
CVPixelBuffer via CIImage -> CGImage -> TextureResource.
Per-frame TextureResource creation is the known perf hot
spot, isolated here for later LowLevelTexture upgrade.

* fix(avlivebody-mac): appkit import for orbit

NSPanGestureRecognizer lives in AppKit on macOS; without the
import the AVLiveBody module failed to emit. T5 leftover
surfaced once T6/T7/T8 made the target compilable.

* feat(avlivebody-mac): smpl-x mesh entity

Render SMPL-X dense meshes (10475 verts) from Multi-HMR with
pooled ModelEntity per person. Triangle indices loaded from the
bundled smplx_faces.bin (flat UInt32 triplets, copied from the
legacy launcher target). xcodegen folder-scanning bundles the
.bin under Contents/Resources/ — no project.yml change needed.

* feat(avlivebody-mac): wire scene + status bar

Replace placeholder window with ContentView wiring
USBSkeletonConsumer, SceneController, MultiHMRCoreML and
BodyFusion per the T9 dataflow plan.

* chore: archive legacy AV-Live-Body

Superseded by avlivebody-mac/ on 2026-05-18. See
docs/superpowers/specs/2026-05-18-avlivebody-macos-rewrite-design.md
for the rewrite design and rationale.

* fix(avlivebody): break onVideoFrame retain cycle

Capture consumer weakly in the onVideoFrame closure so the
USBSkeletonConsumer can be deallocated and its background thread
exits cleanly. Guard the mesh-fusion path when consumer is gone.

* fix(launcher): disable body spawn post-archive

Legacy SwiftPM target archived to launcher/_archive-AV-Live-Body/.
New native Xcode app lives at avlivebody-mac/; no swift run path.
startBodyApp now logs + no-ops with FIXME(rewrite-2026-05-18).

* docs(avlivebody-mac): contributor setup README

Document prerequisites, mlpackage copy, signing xcconfig, and
xcodegen/xcodebuild commands. Points at design spec and plan.

* refactor(avlivebody): axis helper + cleanups

- Extract arkitToRealityKit helper, dedupe 3 call sites.
- Add onDisappear consumer.stop() to terminate USB read loop.
- Replace @State with let for SceneController (stable class id).
- Add NSLog diagnostics in VideoQuad+MeshEntity silent guards.

* fix(avlivebody): ad-hoc signing for local dev

Apple Development cert + Automatic signing makes Xcode demand a
Mac Development cert that no one has. Switch to manual ad-hoc
(CODE_SIGN_IDENTITY = -) so any contributor can build. Drop
hardened runtime; re-enable for distribution builds.

* feat(arbody): keep iphone awake while streaming

iOS auto-lock tears down the USBServer TCP listener within
seconds, breaking AVLiveBody Mac-side connect. Disable the
idle timer for the lifetime of ContentView, restore on exit.

* docs: add AGENTS.md skeleton
2026-05-21 12:41:33 +02:00
L'électron rare 68a56057c4 docs: add AGENTS.md skeleton 2026-05-21 12:35:00 +02:00
L'électron rare 986d03c09d feat(arbody): keep iphone awake while streaming
iOS auto-lock tears down the USBServer TCP listener within
seconds, breaking AVLiveBody Mac-side connect. Disable the
idle timer for the lifetime of ContentView, restore on exit.
2026-05-18 22:30:07 +02:00
L'électron rare 85589f2627 fix(avlivebody): ad-hoc signing for local dev
Apple Development cert + Automatic signing makes Xcode demand a
Mac Development cert that no one has. Switch to manual ad-hoc
(CODE_SIGN_IDENTITY = -) so any contributor can build. Drop
hardened runtime; re-enable for distribution builds.
2026-05-18 22:16:39 +02:00
L'électron rare a22027edb7 refactor(avlivebody): axis helper + cleanups
- Extract arkitToRealityKit helper, dedupe 3 call sites.
- Add onDisappear consumer.stop() to terminate USB read loop.
- Replace @State with let for SceneController (stable class id).
- Add NSLog diagnostics in VideoQuad+MeshEntity silent guards.
2026-05-18 22:06:48 +02:00
L'électron rare a46cbd471c docs(avlivebody-mac): contributor setup README
Document prerequisites, mlpackage copy, signing xcconfig, and
xcodegen/xcodebuild commands. Points at design spec and plan.
2026-05-18 22:05:39 +02:00
L'électron rare 0ee7ddddcd fix(launcher): disable body spawn post-archive
Legacy SwiftPM target archived to launcher/_archive-AV-Live-Body/.
New native Xcode app lives at avlivebody-mac/; no swift run path.
startBodyApp now logs + no-ops with FIXME(rewrite-2026-05-18).
2026-05-18 22:05:21 +02:00
L'électron rare 3b5f29e5c7 fix(avlivebody): break onVideoFrame retain cycle
Capture consumer weakly in the onVideoFrame closure so the
USBSkeletonConsumer can be deallocated and its background thread
exits cleanly. Guard the mesh-fusion path when consumer is gone.
2026-05-18 21:56:11 +02:00
L'électron rare 9e1482e29d chore: archive legacy AV-Live-Body
Superseded by avlivebody-mac/ on 2026-05-18. See
docs/superpowers/specs/2026-05-18-avlivebody-macos-rewrite-design.md
for the rewrite design and rationale.
2026-05-18 21:54:48 +02:00
L'électron rare ea9c96f716 feat(avlivebody-mac): wire scene + status bar
Replace placeholder window with ContentView wiring
USBSkeletonConsumer, SceneController, MultiHMRCoreML and
BodyFusion per the T9 dataflow plan.
2026-05-18 21:51:40 +02:00
L'électron rare 8cf3d90073 feat(avlivebody-mac): smpl-x mesh entity
Render SMPL-X dense meshes (10475 verts) from Multi-HMR with
pooled ModelEntity per person. Triangle indices loaded from the
bundled smplx_faces.bin (flat UInt32 triplets, copied from the
legacy launcher target). xcodegen folder-scanning bundles the
.bin under Contents/Resources/ — no project.yml change needed.
2026-05-18 21:46:18 +02:00
L'électron rare 6dab2bcf72 fix(avlivebody-mac): appkit import for orbit
NSPanGestureRecognizer lives in AppKit on macOS; without the
import the AVLiveBody module failed to emit. T5 leftover
surfaced once T6/T7/T8 made the target compilable.
2026-05-18 21:46:14 +02:00
L'électron rare 6aea7e0230 feat(avlivebody-mac): video quad
Flat 1.6x0.9m plane at z=-2m, textured per-frame from
CVPixelBuffer via CIImage -> CGImage -> TextureResource.
Per-frame TextureResource creation is the known perf hot
spot, isolated here for later LowLevelTexture upgrade.
2026-05-18 21:43:27 +02:00
L'électron rare 819c9688e7 feat(avlivebody-mac): 91-joint skeleton entity
Yellow marker spheres pooled per pid; ARKit (x,y,z) ->
RealityKit (x,-y,-z). Adapted .systemYellow to NSColor for
macOS RealityKit Material.Color. Build deferred to T8.
2026-05-18 21:43:27 +02:00
L'électron rare 177214d913 fix(avlivebody): orbit gesture + setUp guard
Filter NSPanGestureRecognizer state in OrbitTarget.handlePan to
dispatch only on .changed, replacing the Task wrapper with
MainActor.assumeIsolated. Guard SceneController.setUp() with a
didSetUp flag so duplicate makeNSView calls do not re-install
gestures or re-add anchors.
2026-05-18 21:43:03 +02:00
L'électron rare 76985a2ae9 feat(avlivebody-mac): scene controller + view
RealityKit scene plumbing: SceneController owns ARView, orbital
camera, and holders for VideoQuad/SkeletonEntity/MeshEntity.
SceneView is the SwiftUI NSViewRepresentable bridge.

Build intentionally deferred to T8 (holder types land in T6-T8).
2026-05-18 21:39:05 +02:00
L'électron rare 010b5caaa9 fix(avlivebody-mac): load mlmodelc, clarify fusion
Xcode compiles .mlpackage resources to .mlmodelc at build time;
look up the compiled artifact directly and drop the redundant
MLModel.compileModel step. Also rewrite BodyFusion docstring to
match actual single-person pelvis-z override behaviour.
2026-05-18 21:38:02 +02:00
L'électron rare c0834ed7ab feat(avlivebody-mac): multi-hmr and body fusion
Context: Task 4 of the macOS rewrite needs the dense-mesh half of
the pipeline alongside the USB skeleton consumer landed in task 3.

Approach: Add a CoreML wrapper that mirrors the validated Python
reference (data_only_viz/multihmr_coreml.py) and a pure-logic
fusion stage that corrects the mesh pelvis depth using the
LiDAR-precise USB skeleton.

Changes:
- MultiHMRCoreML.swift: 1x3x672x672 ImageNet-normalized image
  input, 1x3x3 cam_K input, K=4 SMPL-X person outputs at
  10475 vertices, det threshold 0.3.
- BodyFusion.swift: stateless fuse(persons, skeletons) overrides
  the highest-score mesh translation.z with the skeleton pelvis Z
  when available, passes through otherwise.
- BodyFusionTests.swift: pelvis override and pass-through cases.

Impact: Unlocks the mesh renderer wiring in later tasks and gives
the macOS app metrically-correct depth in front of the camera.
2026-05-18 21:33:12 +02:00
L'électron rare 87982efb0b fix(avlivebody-mac): guard thread store with lock
Move the `thread` property write inside the stateLock-held
region in start(); t.start() stays outside since the thread
cannot run before start() is called. Removes a latent race.
2026-05-18 19:24:54 +02:00
L'électron rare ddb4ee3e9d feat(avlivebody-mac): usb skeleton consumer
Add a cleaned USBSkeletonConsumer that publishes SkeletonPayload
keyed by pid and owns video decoding directly, dropping the legacy
ArkitOSCListener conversion layer.
2026-05-18 19:20:59 +02:00
L'électron rare db33e74c15 feat(avlivebody-mac): migrate usb transport
Context: the new native AVLiveBody app needs the proven iPhone-Mac
usbmux transport layer. These files are self-contained, depending
only on AVLiveWire plus Apple system frameworks, so they cross the
rewrite boundary unchanged.

Approach: copy the three transport files and their unit tests
byte-for-byte from launcher/AV-Live-Body, then make the test target
buildable.

Changes:
- Add usb/USBMuxProtocol.swift, usb/USBClient.swift and
  usb/VideoDecoder.swift under Sources/AVLiveBody.
- Add USBMuxProtocolTests.swift and USBClientTests.swift under
  Tests/AVLiveBodyTests.
- Set GENERATE_INFOPLIST_FILE=YES on the AVLiveBodyTests target so
  xcodebuild can code sign the now-populated test bundle.

Impact: the usbmux pipeline is available in the rewrite and its
six unit tests run green under xcodebuild test.
2026-05-18 19:15:54 +02:00
L'électron rare d8aa727887 feat: add counter-rotating wireframe shells
Layer E draws two concentric wireframe shells of the sphere with
additive blending: an outer blue shell breathing with bass and an
inner magenta shell pulsing with kick, spinning opposite ways.
2026-05-18 19:13:11 +02:00
L'électron rare 950cb29628 feat(avlivebody-mac): scaffold xcode app
Add an empty buildable native macOS app generated via xcodegen,
sibling of iphone-arbody. Depends on the shared AVLiveWire package.
Later tasks add the USB pipeline and RealityKit scene.
2026-05-18 19:05:10 +02:00
L'électron rare 360a0745de docs: AVLiveBody macOS rewrite plan
10-task plan: scaffold the xcodegen app, migrate the USB
pipeline, build the RealityKit scene (video quad, skeleton,
mesh), wire it, archive the legacy app.
2026-05-18 19:00:56 +02:00
L'électron rare 555efa1b4b docs: AVLiveBody macOS rewrite design
Clean-rewrite spec: fresh native macOS Xcode app for the
iPhone-USB body pipeline. Reuses the tested USB components,
single RealityKit scene (video quad + skeleton + mesh),
drops all legacy MediaPipe/viz/data-feed code.
2026-05-18 18:54:26 +02:00
L'électron rare 914e52deaf feat: add toggleable wireframe layer
A fourth layer draws the icosphere as a wireframe, displaced by the
same relief and rendered as bright unlit colormap lines just above
the skin. Toggled with key 4.
2026-05-18 18:43:31 +02:00
L'électron rare 7fc4f41f4e feat: 3D relief, lighting and reactive motion
The spectrogram now extrudes the geometry alongside the waveform, the
skin is lit with a screen-space normal, and the sphere tumbles and
pulses with the kick and signal energy.
2026-05-18 18:39:14 +02:00
L'électron rare e33375f7ac fix: render point cloud as real GL points
drawVertices on the indexed skin mesh gave a degenerate gl_PointCoord,
so the round-point discard killed every fragment. Use a dedicated
OF_PRIMITIVE_POINTS mesh, float it 5% outside the skin, enlarge points.
2026-05-18 18:05:50 +02:00
L'électron rare af66ecaecd docs: macOS Multi-HMR mesh plan (3b of 3)
Final plan: bundle the validated FP32 mlpackage, MultiHMRCoreML
Swift wrapper, BodyFusion (ARKit depth correction), mesh
pipeline wiring. Completes the spec.
2026-05-18 18:04:31 +02:00
L'électron rare 73f8f75771 feat(av-live-body): wire USB consumer to renderer
ContentView owns and starts a USBSkeletonConsumer, threaded
through BodyView into Skeleton3DRenderer.attach. The renderer
subscribes its $bodies into lastArkit, so the iPhone's USB
skeleton drives the on-screen 91-joint markers.
2026-05-18 17:51:50 +02:00
L'électron rare 8099b79ec8 feat(av-live-body): render 91-joint USB skeleton
Complete the long-standing TODO: draw the 91 ARKit/USB
skeleton joints as yellow markers, fed from lastArkit. Spawn
entity trees for ARKit-only pids so the USB skeleton shows
without a MediaPipe pose.
2026-05-18 17:49:22 +02:00
L'électron rare e06f53b11e fix: pause sphere auto-rotation while dragging
Auto-rotation ran unconditionally, so it kept spinning during a
camera-orbit drag. Accumulate a frame-rate-independent angle only
when the mouse is not pressed.
2026-05-18 17:48:10 +02:00
L'électron rare b6a85e24fa feat(av-live-body): HEVC video decoder
VTDecompressionSession decoder for .video VideoPayloads.
Rebuilds the format description from the parameter sets
prepended to keyframe payloads by the iOS VideoEncoder.
2026-05-18 17:47:59 +02:00
L'électron rare 3ec973d2ce fix(data-only): CoreML Multi-HMR usage bugs
The CoreML Multi-HMR model was fine; the "0 detections" bug
was caller-side. Add ImageNet normalization in infer() (the
DINOv2 backbone needs it; raw [0,1] input collapsed all
scores) and update stale hardcoded output var names to match
the re-converted mlpackage. Bump the latency test threshold
to the realistic ~140 ms full-model figure.
2026-05-18 17:46:33 +02:00
L'électron rare 8c24db8e03 feat(av-live-body): USB skeleton consumer
Background usbmux read loop feeding StreamDemuxer; republishes
.skeleton frames as 91-joint ArkitBodyFrames and forwards
.video payloads. Removed stale iCloud collision duplicate
source files that broke the build.
2026-05-18 17:45:22 +02:00
L'électron rare 131c93f885 docs: macOS USB consumer plan (3a of 3)
Plan for consuming the iPhone USB stream in AVLiveBody:
USBSkeletonConsumer, VideoDecoder, 91-joint skeleton render.
Multi-HMR dense mesh deferred to Plan 3b.
2026-05-18 17:43:11 +02:00
L'électron rare 94124f974e docs: document oscope-sphere project
Add the project CLAUDE.md and README covering the build, the stereo
channel convention, the three layers, controls, and the 16 MS/s
dual-channel hardware constraint.
2026-05-18 17:38:16 +02:00
L'électron rare 30fa7276c8 refactor: share sphere shader uniform binding
drawSkin and drawPoints duplicated six uniform-setup lines. Extract
them into a private bindUniforms helper so future uniforms are added
in one place.
2026-05-18 17:36:37 +02:00
L'électron rare a8be68adff feat: add point-cloud sphere layer
Render the icosphere vertices as rounded points sharing the skin's
displacement and colormap, switched by a renderMode uniform and
toggled with key 3.
2026-05-18 17:32:29 +02:00
L'électron rare 35b18f6ca5 docs(ios): refresh stale OSC references
ARBodySession header comment and Info.plist usage strings
still described the removed OSC/UDP path; update them to the
USB transport and drop the dead _osc._udp Bonjour service.
2026-05-18 17:28:56 +02:00
L'électron rare 1af056dbf1 feat(ios): HEVC video capture, drop OSC
Add VideoEncoder (VideoToolbox HEVC) and stream encoded
frames over USB as .video AVLiveWire frames alongside the
skeleton. Remove the legacy OSC/UDP fanout and its host/port
config UI — the iPhone link is now USB-only.
2026-05-18 17:27:34 +02:00
L'électron rare 9b82a90579 feat: add stereo waveform orbit rings
Two perpendicular line-loop rings orbit the sphere, the blue ring
modulated by CH1 and the orange ring by CH2, toggled with key 2.
2026-05-18 17:27:07 +02:00
L'électron rare 39457dde41 docs: iPhone capture plan (2 of 3)
Plan for HEVC video capture (VideoToolbox) over the USB
transport and removal of the legacy OSC sender. Skeleton
USB path already exists; this adds the video half.
2026-05-18 17:23:38 +02:00
L'électron rare eeb5726103 feat: displace sphere with live waveform
Upload the per-channel waveform as a texture and displace icosphere
vertices radially in the vertex shader: CH1 drives the northern
hemisphere, CH2 the southern.
2026-05-18 17:22:19 +02:00
L'électron rare 4cca03db9f fix: log sphere shader load failure
ofShader::load returned bool was discarded, so a failed shader load
rendered black with no diagnostic. Log an error on failure and
document the global ofDisableArbTex state change.
2026-05-18 17:19:25 +02:00
L'électron rare fddb31e3ee feat: render spectrogram sphere skin
Context: Task 5 of oscope-sphere - add the SphereViz layer A skin
and full ofApp wiring so the app produces a visual output.

Approach: Build an icosphere (5 subdivisions) skinned by a scrolling
2-channel spectrogram texture uploaded each frame via ofFloatPixels.
GLSL 150 shaders compute spherical UV from vertex direction so the
texture wraps seamlessly; scroll is handled by a fract() offset in
the fragment shader to avoid re-uploading geometry.

Changes:
- bin/data/shaders/sphere.vert: spherical UV projection from position
- bin/data/shaders/sphere.frag: magma/viridis colormaps + scroll
- src/SphereViz.h/.cpp: icosphere mesh, shader, texture management,
  pushSpectrogramColumn() wraps SpectrogramBuffer for dual-channel
- src/ofApp.h: full member declarations (camera, analyzers, flags)
- src/ofApp.cpp: setup/update/draw/HUD/keyPressed wired end-to-end;
  HantekDevice probed at startup, falls back to DemoSignal on failure

Impact: app renders a rotating spectrogram globe in demo mode with
orbit camera, colormap toggle, freeze, and per-layer visibility keys.
2026-05-18 17:12:19 +02:00
L'électron rare 5e58c0c4fe chore: ignore .swiftpm editor state dirs
swift test / Xcode create hidden .swiftpm dirs inside
packages; ignore them so they never get staged.
2026-05-18 17:11:35 +02:00
L'électron rare 4463ca0d43 fix(ios): guard USBServer listener and payload
Report .idle (not .listening) when NWListener creation fails,
and drop payloads larger than the demuxer's 8 MB cap so the
receiver never silently discards an oversized frame.
2026-05-18 17:10:51 +02:00
L'électron rare 04fb49aae3 chore: ignore openframeworks info.plist artifact
The openFrameworks build auto-generates openFrameworks-Info.plist at
the project root. It is a build artifact, not a source file.
2026-05-18 17:09:04 +02:00
L'électron rare c8139a2bea test(avlivewire): end-to-end chunked loopback
Feeds 20 framed skeleton payloads through StreamDemuxer in
7-byte chunks (worst-case TCP fragmentation). Fixed a split
range operator from the plan that did not parse.
2026-05-18 17:06:43 +02:00
L'électron rare 921ae9cd42 build: add openFrameworks app skeleton
Copy oscope-of's Makefile and libusb-aware config.make, add a window
bootstrap and a placeholder ofApp so the GL build is verified before
any feature code lands.
2026-05-18 17:05:44 +02:00
L'électron rare 20d91c037f build(ios): add AVLiveWire package to xcodegen
The xcodegen project did not declare the shared AVLiveWire
package, so USBServer.swift would fail to import it in the
generated Xcode project. Add it as a local package dep.
2026-05-18 17:04:59 +02:00
L'électron rare 4b6386ab5a build: depend on shared AVLiveWire package
Both ARBodyTracker (iOS) and AVLiveBody (macOS) now depend on
the local shared/AVLiveWire package so the wire format is
defined once. iOS USBServer imports it; macOS use lands in
Plan 3.
2026-05-18 17:03:37 +02:00
L'électron rare 7211ca8393 feat(ios): USB TCP frame server
Add USBServer: an NWListener on a fixed local TCP port that
usbmuxd tunnels to the tethered Mac. Sends AVLiveWire frames
and exposes a connection-state callback.
2026-05-18 16:15:28 +02:00
L'électron rare f79ce95f78 test: verify demo signal phase continuity
The old test only checked two blocks differ, true of any non-DC
signal. Replace it with a split-equals-whole identity that genuinely
proves next() is deterministic and continuous across call boundaries.
2026-05-18 16:14:08 +02:00
L'électron rare 9eff9daad8 fix(av-live-body): harden unix socket transport
Apply four code-review fixes to UnixMuxTransport:
- send() now loops on partial writes and retries on EINTR
  instead of discarding write(2)'s return value.
- Add deinit and an fd = -1 sentinel so close() is
  idempotent and the descriptor cannot leak.
- precondition guards strcpy against sun_path overflow.
- readN() distinguishes EOF from error and retries EINTR.
2026-05-18 16:11:36 +02:00
L'électron rare 9fd856744d feat: add synthetic demo signal source
Pure-C++ continuous two-channel generator used when no scope is
connected: CH1 frequency sweep, CH2 steady tone plus noise. Adds the
run_tests.sh runner covering all three pure-C++ units.
2026-05-18 16:10:24 +02:00
L'électron rare 549a6b53a6 fix: guard spectrogram buffer against height=2
A buffer of height 2 made half-1 zero, dividing by zero in pushColumn
and writing NaN into the column. Clamp the denominator and add a
regression test for the 2-row case.
2026-05-18 16:07:31 +02:00
L'électron rare 7949f50920 feat(av-live-body): usbmuxd unix socket transport
Add UnixMuxTransport, the production MuxTransport that opens a
blocking AF_UNIX socket to /var/run/usbmuxd. Implements framed
packet reads (4-byte LE length prefix) and raw stream reads for
the tunneled byte stream after a successful Connect.
2026-05-18 16:07:24 +02:00
L'électron rare ebed40c98a feat(av-live-body): usbmux device client
Add USBClient for usbmux device discovery and connect-to-port,
with an injectable MuxTransport so tests need no real device.

Harden USBMuxProtocol.readLE32 to return an optional with a
bounds check, avoiding an out-of-range crash on truncated data.
2026-05-18 16:03:30 +02:00
L'électron rare b8d76ccb27 feat: add rolling spectrogram column buffer
Pure-C++ store that log-resamples both channels' FFT magnitudes into
scrolling columns, CH1 north and CH2 south, high frequencies toward
the poles. No GL dependency, fully unit-tested.
2026-05-18 16:03:29 +02:00
L'électron rare e70595d81a feat(av-live-body): usbmux message codec
Add USBMuxProtocol, a codec for Apple's usbmuxd request/response
protocol: a 16-byte little-endian header (length, version=1,
message=8 plist, tag) followed by an XML property list.

Wire an AVLiveBodyTests test target into Package.swift (none
existed) so swift test runs the round-trip and header coverage.
2026-05-18 15:58:15 +02:00
L'électron rare 1848368bc2 build: scaffold oscope-sphere project
Context: oscope-sphere is a new openFrameworks app (sibling of
oscope-of) that will render audio analysis on a 3D sphere. This
commit establishes the project tree and reuses the mature capture
and analysis layer from oscope-of.

Approach: Copy four proven source files byte-for-byte from
oscope-of/src/ (no modifications), create the standard oF directory
layout, and add a standalone clang++ test harness that validates two
design assumptions without requiring openFrameworks installed.

Changes:
- Create oscope-sphere/{src,tests,bin/data/shaders} directories
- Copy FFT.h/cpp, AudioAnalyzer.h/cpp, ScopeData.h,
  HantekDevice.h/cpp from oscope-of/src (unmodified)
- Add addons.make and .gitignore for oF build system
- Add tests/check.h: lightweight CHECK/REPORT macros
- Add tests/test_analysis.cpp: two tests (FFT peak at bin 64,
  per-channel isolation via dual-analyzer trick)

Impact: Test harness compiles with plain clang++ -std=c++17 and
passes 5/5 checks, confirming FFT correctness and AudioAnalyzer
channel isolation before any oF integration work begins.
2026-05-18 15:58:14 +02:00
L'électron rare 630b4aa6c0 fix(avlivewire): cap demuxer payload length
A corrupt header with a huge UInt32 length made feed buffer
forever waiting for bytes that never arrive. Add an 8 MB max
payload cap; a header exceeding it is treated as corrupt, its
magic is skipped, and the demuxer resyncs on the next frame.
2026-05-18 15:53:44 +02:00
L'électron rare d7f3e3a2c1 docs: add oscope-sphere implementation plan
Nine-task TDD plan: three pure-C++ units unit-tested with clang++
(spectrogram buffer, demo signal, reused FFT/analyzer checks) then
five openFrameworks GL units verified visually, building the stereo
sphere layer by layer.
2026-05-18 15:52:22 +02:00
L'électron rare e7b3b63f5f feat(avlivewire): incremental stream demuxer
Add StreamDemuxer that accepts arbitrary byte chunks from a
non-frame-aligned stream and emits complete (FrameHeader, Data)
frames, resyncing on the magic prefix after corruption.
2026-05-18 15:49:08 +02:00
L'électron rare ec4be7bd1f feat(avlivewire): skeleton and video codecs
Add SkeletonPayload (91 ARKit joints + per-joint validity) and
VideoPayload (one HEVC access unit + keyframe flag) with
big-endian encode/decode. Reuses Task 2 Data/UInt32 helpers.
2026-05-18 15:44:11 +02:00
L'électron rare 66ad49fe43 docs: add oscope-sphere design spec
Brainstorming output for a new AV-Live sibling project: a minimal
openFrameworks app rendering a dual-channel Hantek 6022 capture as a
single 3D sphere with three composable layers (spectrogram skin +
waveform displacement, orbit rings, point cloud). Reuses oscope-of's
capture and analysis layer.
2026-05-18 15:39:05 +02:00
L'électron rare 0ab7c56036 chore: ignore SwiftPM .build artifacts
Both AVLiveWire and AV-Live-Body produce .build/ on swift
test; ignore them so they never get accidentally staged.
2026-05-18 15:38:48 +02:00
L'électron rare 077a7f707f feat(avlivewire): fixed 19-byte frame header codec
Add FrameHeader, a fixed-size binary record so the demuxer can
frame and resync the iPhone USB stream. Layout is big-endian:
4-byte magic AVL1, tag u8, pid i16, timestamp f64, length u32.

The magic prefix lets a reader detect and skip corrupt bytes.
Decoding rejects short buffers and bad magic by returning nil.
Big-endian append/parse helpers are added as Data/UInt extensions
to keep the codec self-contained.
2026-05-18 15:34:45 +02:00
L'électron rare b15dc75ad5 feat(avlivewire): shared wire package skeleton 2026-05-18 15:28:42 +02:00
L'électron rare d9fb4ac503 docs: iPhone USB transport plan (1 of 3)
Bite-sized TDD plan for the network-free USB byte-pipe:
shared AVLiveWire frame format, native usbmux client,
iOS TCP frame server, incremental stream demuxer.
2026-05-18 15:21:10 +02:00
L'électron rare bb06f349c7 docs: iPhone USB body-tracking link design
Brainstormed design for replacing the OSC/network iPhone-Mac
link with a wired USB transport via usbmuxd. iPhone streams
ARKit skeleton + HEVC video; macOS app runs Multi-HMR CoreML
and renders the mesh. Network-free, single native macOS app.
2026-05-18 15:16:25 +02:00
L'électron rare 698fe9f7da feat(ios): ARBody skeleton2D + overlay preview
ARBodySession: publish 2D-projected skeleton snapshot for live
overlay rendering on the iPhone screen alongside the camera feed.
ContentView: SkeletonOverlay drawing on top of the AR view, with
mock T-pose for Xcode previews (useMockBackground, useMockSkeleton).
2026-05-14 13:03:07 +02:00
L'électron rare f951aacc9a docs: network topology + gitignore hygiene
- CLAUDE.md: add mDNS hostname table (grosmac.local, supra-m1.local,
  iPhone hotspot 172.20.10.x). AVBODY_HOST / MULTIHMR_REMOTE_HOST
  accept hostnames — resilient to DHCP rotation.
- .gitignore: exclude .remember/ tool state and iCloud '* 2'
  collision artifacts.
2026-05-14 13:03:06 +02:00
L'électron rare 95c3bb5687 fix(ios): add NSLocalNetworkUsageDescription
iOS 14+ silently blocks UDP to LAN addresses without this key.
The first time the app tries to send to 192.168.0.159, iOS will
prompt the user to allow Local Network Access; the prompt must
be accepted or the OSC stream never reaches the Mac.

Also adds NSBonjourServices declaring _osc._udp so the system
treats the connection as a recognised service.
2026-05-14 12:52:14 +02:00
L'électron rare a769bcd7ae docs: network topology + mDNS hostnames
Add a "Network topology" section to top-level CLAUDE.md doc
the 3-host layout (GrosMac source, Supra-M1 sink via mDNS,
iPhone via Personal Hotspot DHCP).

mDNS is canonical now : AVBODY_HOST and MULTIHMR_REMOTE_HOST
accept hostname.local instead of IPs, so the cluster survives
DHCP rotations on iPhone hotspot (172.20.10.x).
2026-05-14 12:47:51 +02:00
L'électron rare d122c93d19 chore: gitignore tweaks 2026-05-14 12:38:00 +02:00
L'électron rare 89e1be4803 feat(viz): DataFeeds OSC listener + HUD 2026-05-14 12:37:59 +02:00
L'électron rare 6848c798cc feat(data-feeds): 10 open-data OSC publisher 2026-05-14 12:37:59 +02:00
L'électron rare d3e6297091 feat(ios): iphone ARBodyTracker swiftpm app
iOS 17+ Swift Package app (.swiftpm) streaming ARKit body
joints via OSC UDP to two destinations:

  :57128 -> data_only_viz/iphone_osc_listener.py
  :57129 -> launcher/AV-Live-Body ArkitOSCListener.swift

Features:
- ARBodyTrackingConfiguration + sceneDepth (LiDAR) when supported
- 91 joints per body, /body3d/kp pid joint_idx x y z
- 30 fps throttle
- SwiftUI UI: Host/Port fields, Start/Stop, live joints-per-second
- Inline OSC encoder (no external dep)

Env mesh (TCP :5500) NOT yet implemented; requires a separate
ARWorldTrackingConfiguration session. ICP fusion path runs on
bench data only until phase 2.
2026-05-14 12:32:53 +02:00
L'électron rare 7d0711b3f6 feat(av-live-body): wire ArkitOSCListener :57129
Receives /body3d/kp from iPhone ARBodyTracker on the diagnostic
port (57129, distinct from Python's 57128 fuse input). Plumbed
through ContentView -> BodyView -> Skeleton3DRenderer so the
ARKit joints can be overlayed alongside Multi-HMR mesh.
2026-05-14 12:32:53 +02:00
L'électron rare d077f6564e feat(icp): predict_once via CoreML backend 2026-05-14 12:23:28 +02:00
L'électron rare 166d7b4c6c docs(plans): icp lidar mesh + arkit joints
Two complementary fusion plans landed in parallel on 2026-05-14:
- iphone-lidar-multihmr-fusion : ARKit 91 joints -> MP33 fuse stage +
  pelvis z override (already implemented in 7 commits)
- icp-lidar-smplx-fusion : LiDAR mesh point-to-plane ICP onto SMPL-X
  10475 verts (12 tasks executed via subagent-driven-development)

Both paths coexist; joints are sparse+fast (60 Hz), mesh is dense+slow
(5-10 Hz). See docs/ICP_FUSION.md for the integration topology.
2026-05-14 12:17:28 +02:00
L'électron rare 4db441d3a0 docs(icp): env vars + calibration procedure 2026-05-14 12:16:26 +02:00
L'électron rare 132b69ef1d test(icp): synthetic latency + convergence bench 2026-05-14 12:14:59 +02:00
L'électron rare e11f54eb4b feat(icp): wire fusion thread behind ICP_FUSION
Task 9 of the ICP LiDAR plan: integrate the FusionWorker built in
earlier tasks into the live data_only_viz pipeline without
disturbing the existing ARKit pelvis fuse path or the Multi-HMR
worker thread.

A new IcpFusionThread pulls LiDAR frames from LidarTCPReader,
stages them into State, and applies in-place ICP registration on
state.persons_smplx[*].vertices_3d. It runs as a separate daemon
thread parallel to MultiHMRWorker rather than inline per frame —
the autonomous-worker architecture didn't fit the plan's
per-frame call site, so we adapted to a polling thread at 8 Hz.

Activation is opt-in via ICP_FUSION=1 plus ICP_LIDAR_HOST; the
default code path is untouched. Shutdown wired through
applicationWillTerminate_.

MultiHMRWorker.predict_once is added as a documented stub
(NotImplementedError) because the existing PyTorch run loop is
too coupled to the camera and MPS lifecycle for a clean
single-shot extraction. calibrate_lidar.py keeps its placeholder
until a follow-up refactor extracts a pure _infer(rgb) helper.
2026-05-14 12:13:37 +02:00
L'électron rare c5e1e9f289 feat(icp): FusionWorker + State.lidar_points 2026-05-14 12:10:09 +02:00
L'électron rare 38a9a96180 feat(icp): partition LiDAR per pid by max-dist 2026-05-14 12:07:16 +02:00
L'électron rare 9b7c9823dc feat(icp): point-to-plane register + reject gate 2026-05-14 12:04:07 +02:00
L'électron rare 2219ecdf75 docs: arkit fusion env vars 2026-05-14 12:03:55 +02:00
L'électron rare ba854b4c0c feat(viz): iphone OSC listener auto-start 2026-05-14 12:03:55 +02:00
L'électron rare 82826e5c68 feat(viz): arkit pelvis z locks cam translation 2026-05-14 12:03:55 +02:00
L'électron rare a3256a6e32 feat(viz): arkit_fuse stage overrides 14 slots 2026-05-14 12:03:55 +02:00
L'électron rare bcd60c82d0 feat(viz): iphone OSC listener :57128 2026-05-14 12:03:55 +02:00
L'électron rare 1b1ff62f5f feat(viz): ARKit 91 -> MP 33 joint map 2026-05-14 12:03:55 +02:00
L'électron rare 8d6b36c523 feat(state): persons_arkit_joints fields 2026-05-14 12:03:55 +02:00
L'électron rare 3085e86c70 feat(icp): Kabsch + calibration CLI scaffold 2026-05-14 12:01:45 +02:00
L'électron rare ecc499296e feat(icp): extrinsic dataclass + JSON persistence 2026-05-14 11:59:24 +02:00
L'électron rare 9e71e7d757 feat(icp): LiDAR TCP socket reader with reconnect 2026-05-14 11:57:13 +02:00
L'électron rare d9bb3c3f68 feat(icp): LiDAR TCP frame decoder + tests 2026-05-14 11:54:49 +02:00
L'électron rare 0967faecb6 deps(icp): add open3d optional extra + smoke test
Context: Task 1 of the ICP LiDAR <-> SMPL-X fusion plan needs a
point-cloud library to align iPhone LiDAR scans with Multi-HMR
SMPL-X meshes. Open3D's CPU-only ICP is sufficient at the 5-10 Hz
LiDAR cadence.

Approach: Add a dedicated `lidar` optional-dep group so the heavy
dependency stays opt-in. Pin Python to 3.12 implicitly via the
regenerated uv.lock because open3d 0.18-0.19 only ships cp311/cp312
wheels (cp314 absent). Smoke test guards future regressions.

Changes:
- pyproject.toml: new `lidar` extra with `open3d>=0.18,<0.20`
- uv.lock: regenerated with open3d 0.19 + transitive deps
  (scikit-learn, scipy, dash stack, etc.)
- tests/test_open3d_smoke.py: two-test smoke suite
  (PointCloud roundtrip + ICP convergence on translated copy),
  gated by `pytest.importorskip("open3d")`

Impact: Unlocks subsequent ICP fusion tasks (LiDAR ingest, mesh
alignment, transform publication) without forcing open3d on
contributors who only run the base pose pipeline.
2026-05-14 11:51:12 +02:00
L'électron rare 6d2e23c222 Merge branch 'main' into recovery/wireframe-skel 2026-05-14 11:47:47 +02:00
L'électron rare 57da0670e6 feat(sound_algo): restore VELIB SYTADIN HUB'EAU feeds
Restore 166 lines of feed implementations (VELIB Paris, SYTADIN
trafic Ile-de-France, HUB'EAU water, GCN seismic, GDELT events,
SWPC space weather, RTE eco2mix) that existed in the local canon
but were missing from origin/main after the 2026-05-13 cherry-pick
recovery cycle. Verified unique via diff against fresh clone.
2026-05-14 11:47:40 +02:00
L'électron rare b6cd8cf4c7 docs: RC0.1+ architecture + env vars
README header pinned to v0.1.0-rc1. New section "RC0.1+
distributed Multi-HMR architecture" with ASCII diagram of the
M5 capture host <-> macm1 compute host pipeline (TCP :57140
pyobjc CoreML, JPEG q80, async double-buffer, MeshRigger 27 fps
perceived) and the unified 3D armature wireframe on macm1
AVLiveBody.

Env var reference table : MULTIHMR_BACKEND, MULTIHMR_REMOTE_*,
MULTIHMR_SERVER_BACKEND, MULTIHMR_LOOP_FPS, AVBODY_HOST,
MEDIAPIPE_DELEGATE, POSE_FILTER, MULTIHMR_REID*.

Root CLAUDE.md ; extended with the same env vars + file
locations for mesh_rigger, Skeleton3DRenderer, pose_filter,
dino_reid, multihmr_server, scene.metal.

Observed ceilings under MLX contention on macm1 : 25 fps loop,
9 fps fresh predict, 27 fps perceived via the rigger.
2026-05-14 02:46:32 +02:00
L'électron rare bd46f6e052 perf(multi-hmr): lift self-throttle + fresh fps
Two changes that take loop fps from 9.8 to 20+ without touching
the predict cost :

1. MultiHMRWorker default target_fps 10 -> 30. The Python loop was
   self-capping at 10 fps via sleep(period - dt) regardless of the
   backend. With the remote async pipeline (queue maxsize=2) the
   actual fresh-predict rate stays at the macm1 server ceiling
   (~9 fps under MLX contention), but the loop now submits frames
   at 30 Hz and re-emits MeshRigger-interpolated state at the same
   cadence -- the mesh becomes visually fluid at the loop rate even
   when fresh predicts are slower. Tunable via MULTIHMR_LOOP_FPS.

2. Short-circuit reused-humans : when infer() returns None (queue
   empty), skip dedup/tracker/One-Euro reprocessing and just sleep
   to the next slot. Saves ~3-5 ms CPU per idle iteration and
   avoids advancing the smoother on identical state.

Heartbeat log extended with (fresh=Y) so we can distinguish loop
rate (visualiser feed) from real predict rate. Bench remote macm1
under MLX contention : hb[coreml]: 25.0 fps (fresh=9.0).

Root cause for the predict gap 53 vs 87 ms : MLX servers
mlx_lm.server :8502/8503 + LoRA on macm1 burn ~30 ms of unified
memory bandwidth per Multi-HMR predict. Not a software fix.
2026-05-14 02:43:31 +02:00
L'électron rare 1828d7ca3e feat(av-live): scenes react + finger joints
Two new capabilities on top of RC0.1 :

1. Pose drives Metal scenes via 12 new SceneUniforms (144 bytes total,
   36 floats): mouth_open, eye_open_l/r, head_tilt, head_yaw,
   finger_pinch_l/r, body_x/y/z, body_height, arm_spread, pose_velocity.
   BodyView.updateNSView derives them from PoseOSCListener.faces /
   hands / body3d with EMA smoothing (alpha=0.7). Five scenes wired :
   storm (velocity+tilt), plasma (mouth+yaw), kaleido (arm_spread
   segments), starfield (pinch -> star density), bars (height+eye).

2. Multi-HMR mlpackage extended with 6th output : 127 SMPL-X joints
   (4, 127, 3) incl. 30 finger joints (indices 25-54). TracedMHMR
   stacks the joints already computed in smpl_layer.py. Output
   names shifted (var_2420 etc.); constants updated in
   multihmr_coreml.py and multihmr_server.py. Propagation to
   client backends + Skeleton3DRenderer finger override remains TODO.
2026-05-14 02:31:10 +02:00
L'électron rare 67302e7ca2 perf(av-live): pyobjc + GPU MediaPipe + queues
Three perf optimizations stacked on top of the distributed pipeline:

1. multihmr_server.py ported to pyobjc CoreML.framework direct
   (drops the ~30 ms coremltools.MLModel.predict overhead). Compiles
   the mlpackage to .mlmodelc on load, then predicts via
   MLDictionaryFeatureProvider + MLMultiArray ctypes memcpy. Toggle
   MULTIHMR_SERVER_BACKEND=coremltools for fallback. setup script
   installs pyobjc-framework-CoreML on the macm1 venv.

2. MediaPipe Holistic now uses GPU Metal delegate on M5. Required
   wrapping camera frames as SRGBA (4-channel) instead of SRGB --
   the Metal CVPixelBuffer path rejects 3-channel formats. Bench
   M5 standalone : pose 6.7 -> 2.9 ms, face 4.0 -> 1.0 ms, hand
   6.1 -> 3.2 ms. Frees ~10 ms CPU per frame for OSC + rigger.

3. Remote client queues bumped maxsize 1 -> 2/3 (in/out) to absorb
   jitter without stalling capture.
2026-05-14 02:17:29 +02:00
L'électron rare 9838da3137 feat(av-live): distributed Multi-HMR + fused mesh
Multi-HMR inference offloaded to a remote macm1 (M1 Max, 32-core
GPU) via a custom TCP protocol on :57140. JPEG q=80 compression
(~80 KB vs 1.35 MB raw) + double-buffer async pipeline (3-thread
server reader/worker/writer, single-buffer client) overlaps net
I/O with predict. Live: 9.8 fps Multi-HMR keyframe (vs 6.8 fps
M5 local CoreML), 27 fps perceived via MeshRigger.

Distributed display option : AVBODY_HOST env routes the TCP mesh
and UDP OSC streams to a remote AVLiveBody. The Swift launcher
runs on macm1 in the user GUI session (via osascript do shell
script over SSH), receiving the full mesh+skeleton+face+hand
stream from M5 capture.

Skeleton3DRenderer now bundles body 33 joints + dlib 68 face
landmarks + 21x2 hand landmarks into a single procedural
LowLevelMesh (line topology) per person : 143 vertices, 288 line
indices, 4 draw calls. Anatomical connectors body nose -> face
nose bridge and body wrists -> hand wrists.

MediaPipe delegate is selectable via MEDIAPIPE_DELEGATE=gpu|cpu
(default cpu after observed Metal GpuBuffer crashes on macOS).

Multi-HMR ViT-L checkpoint also tested : 350 ms on macm1 GPU
(2.9 fps) -- too slow to ship, ViT-S remains default. Conversion
script generalized via MULTIHMR_CKPT_NAME env.
2026-05-14 02:02:33 +02:00
L'électron rare 91f4a46ceb feat(av-live): wireframe skel + face/hand filter
Skeleton3DRenderer now renders a wireframe: joint radius 1 mm
(quasi-invisible), bone radius 3 mm (line-like). Replaces the
chunky bead armature with a clean filaire silhouette covering
body 33 joints + face 68 dlib + hands 21x2, all 3D.

FaceHandOverlay 2D Canvas removed from ContentView -- face and
hand landmarks now live in the same 3D RealityKit armature as
the body skeleton (Skeleton3DRenderer.applyFace / applyHands,
anchored on nose joint 0 + wrist joints 15/16).

pose_filter.py extended with FaceFilterChain (alpha-beta + 30 ms
lookahead) and HandFilterChain. multi.py wires them after the
2D smoothers, plus ghost rejection (POSE_GHOST_MIN_VISIBLE),
bbox NMS (POSE_NMS_IOU), and pid hysteresis. 10 new tests, all
green.

CoreML perf audit (bench_multihmr_coreml.py): predict() = 99% of
wall-time on FP32. ANE catastrophic for DINOv2 (1300 ms),
INT8 weight quant = no live gain (GPU compute-bound).
6.4-6.8 fps live is the hardware ceiling on this model.
quantize_multihmr_int8.py left in scripts/ for future trials.
2026-05-14 01:06:27 +02:00
L'électron rare c4bc5c2e5a fix(av-live-body): restore face+hand+3D (f540158)
Three regressions after recent merges, all restored to match the
original f540158 design:

1. FaceHandOverlay was no longer instantiated in ContentView.
   Added back as a SwiftUI Canvas overlay (68 dlib face landmarks
   with mouth slots 48-67, plus 21x2 hand landmarks cyan/magenta).

2. Skeleton3DRenderer was not attached. BodyView now creates an
   AnchorEntity at (0,0,-2.5), instantiates Skeleton3DRenderer
   and ties its visibility to vizMode==9 or showSkeleton toggle.

3. Joint and bone radii bumped to 4.5 cm / 2.2 cm so the 3D
   skeleton actually reads as 3D instead of looking flat.

MeshRenderer exposes pelvisWorld map per pid for future
interconnect uses (not auto-applied -- design keeps mesh and
skeleton each in their own coord space per f540158).
2026-05-14 00:45:31 +02:00
L'électron rare 7ed2e2764a feat(av-live): openpos 3D + DINO reid + filter
Three improvements wired end-to-end:

1. Openpos 3D skeleton visible: Skeleton3DRenderer attached to a
   RealityKit AnchorEntity in BodyView, toggled by showSkeleton
   or vizMode==9. PoseOSCListener now parses /pose3d/count and
   /pose3d/kp (plus restored /face/* and /hand/* paths).

2. DINO re-id (dinov2_vits14, ~9 ms ANE forward):
   MeshRigger combines Hungarian IoU with cosine similarity over
   a per-pid embedding history (deque maxlen=10), weighted by
   MULTIHMR_REID_ALPHA (default 0.5). Falls back to pure IoU if
   DINO mlpackage absent or scipy missing. state.last_frame_rgb
   buffer added so the rigger can crop bbox regions for embedding.

3. PoseFilterChain on pose_world_landmarks:
   median (anti-spike) -> Kalman constant-velocity ->
   50 ms lookahead -> IK elbow/knee/ankle clamp. Configurable
   via POSE_FILTER env (default median+kalman+lookahead+ik).
   <2 ms per frame for typical 1-2 persons.

Tests: 5 new in test_dino_reid.py + 6 new in test_pose_filter.py,
all green. Live validated by user: skeleton spawns, mesh stays
stable.
2026-05-14 00:30:42 +02:00
L'électron rare aad56e5bf9 docs(plans): action-head v3 + branch sync notes
Update plan header :
- v2 (Task 18) + v3 (Task 19) extensions chronology
- Studio train validated, ckpt action_head_v3.pt landed
- Mesh NaN-guard debug trail (commit 4e7101c)
- Branch convergence main == feat/action-head
- Pointers to memories project_action_head_v3, etc.
2026-05-14 00:14:13 +02:00
L'électron rare c938b3230c docs(plans): action-head v3 + branch sync notes
Update plan header :
- v2 (Task 18) + v3 (Task 19) extensions chronology
- Studio train validated, ckpt action_head_v3.pt landed
- Mesh NaN-guard debug trail (commit 4e7101c)
- Branch convergence main == feat/action-head
- Pointers to memories project_action_head_v3, etc.
2026-05-14 00:14:10 +02:00
L'électron rare 82eceb8989 chore(main): drop stray convert_dinov2.py
Vieux script DINOv2->CoreML obsolete, jamais reference par feat. Canonical : convert_coreml.py + multihmr_coreml.py backend.
2026-05-14 00:11:15 +02:00
L'électron rare 531363dea3 chore(main): sync working tree to feat/action-head
Apres cherry-pick des 14 commits action-head, le working tree main
divergeait encore de feat/action-head sur 21 fichiers a cause des
resolutions modify/delete qui avaient pris la mauvaise version
(c52271e botched merge avait drop ces fichiers, le cherry-pick
voyait alors "modify in feat / deleted in main" et conservait la
mauvaise version dans certains cas).

Cette commit synchronise le working tree main vers feat/action-head
en checkout-ant tous les fichiers depuis feat. Equivalent fonctionnel
a un fast-forward merge sans toucher l'historique.

Files restored : Skeleton3DRenderer.swift, FaceHandOverlay.swift,
test_multihmr_coreml.py, test_pose_bridge_*.py (4 test files).
Files updated : action_head.py, action_head_pub.py, multi.py,
pose_bridge.py, state.py, extract_j3d_offline.py, training/*.py,
SettingsPanel.swift, data_feeds.scd, et plus.

Result : main == feat/action-head sur le working tree, historique
granulaire preserve via les cherry-picks precedents.
2026-05-14 00:09:54 +02:00
L'électron rare 4fd80311e8 feat(av-live-body): mirror webcam preview
CATransform3D scale x=-1 on the AVCaptureVideoPreviewLayer so the
user sees themselves like in a mirror (left in reality = left on
screen). Overlays (skeleton/face/hand/mesh) keep their raw camera
coordinates for now; align via X-flip if user requests it.
2026-05-14 00:08:25 +02:00
L'électron rare c17163536e feat(data-only-viz): action-head v3 hands+lips
Extends the action-head feature pipeline from v2 (302-D) to v3 (428-D).

- Replace placeholder SMPLX_FINGERTIP_VERTS with canonical vertex IDs
  from smplx.vertex_ids (lthumb/lindex/lmiddle/lring/lpinky, mirrored R)
- Add HANDS_KP_* constants (21 kp/hand, 42 total, 126-D flat block)
- FEATURE_DIM: 302 -> 428; hands_kp block inserted at [288:414]
- FeatureExtractor.from_buffer gains hands_kp param (42, 3),
  zero-padded when absent
- ActionHead.step gains hands_kp param, threads to from_buffer
- _read_sources returns 5-tuples with hands_kp42x3 per person
- MediaPipe FaceMesh inner-lip (idx 13/14) used for mouth_open;
  fallback to SMPL-X v3d lip vertices when face not available
- _build_hands_map and _build_face_mouth_map helpers added
- dataset.py: RawFrame/WindowRow/DatasetRow gain hands_kp fields
- train_action_head.py: reads hands_kp_stack per step, zeros if absent
- extract_j3d_offline.py: writes zero-filled hands_kp in jsonl output
- Tests: FEATURE_DIM 302->428, param bound 80k->100k, +4 new tests
2026-05-14 00:08:25 +02:00
L'électron rare b9f623118a perf(multi-hmr): autocast opt-in
MULTIHMR_AUTOCAST=1 enables MPS mixed precision for the ViT-S
backbone. Tested 2026-05-13: slower than fp32 baseline (400ms vs
270ms) -- overhead cast within forward exceeds matmul savings on
M5. Off by default; FP16 .half() crashes MPS matmul accumulator,
left out entirely.

apple_vision_pose face parser short-circuits to return immediately
since pyobjc 11 cannot dereference VNFaceLandmarkRegion2D
pointsInImageOfSize_ result. Removes ObjCPointerWarning spam at
30 fps (9 regions per face).
2026-05-14 00:08:24 +02:00
L'électron rare 089b6d464e feat(data-only-viz): action-head v2 fingers+face
Extend action-head to 32 joints (body22 + 10 fingertips),
10 SMPL-X expression PCA scalars, and mouth_open distance.
FEATURE_DIM 201→302. MIRROR_MAP extended to 32. Dataset,
augment, training, publisher, offline extractor all updated.
2026-05-14 00:08:24 +02:00
L'électron rare e3bc47853d feat(av-live): wire Apple Vision body pose on ANE
Add a parallel-pose worker selector (env AV_LIVE_PARALLEL_POSE)
defaulting to "both": runs Apple Vision body 2D on ANE alongside
MediaPipe Holistic on CPU XNNPACK, while Multi-HMR PyTorch streams
the dense mesh on MPS. Modes "apple_vision" or "mediapipe" to pick
one. Body keypoints land in state.persons_body either way.

Face landmark parser via pyobjc remains blocked: pointAtIndex_
selector arity confuses pyobjc 11 and pointsInImageOfSize_ returns
an opaque PyObjCPointer with no address handle. Keep the
ANE-detected count for logging, fall back to MediaPipe face/hand
fin landmarks until a Swift bridge is in place.
2026-05-14 00:08:24 +02:00
L'électron rare 5a988686ab docs(action-head): post-impl deviations + README
- plans/2026-05-13-action-head.md : note SUPERSEDED sur
  Task 11 + Task 14 (pivot publisher thread, pas de worker
  refactor), header status + decisions.
- specs/2026-05-13-action-head-design.md : status implemente,
  deviations cataloguees en haut, note inline section worker.
- data_only_viz/CLAUDE.md : section action-head ajoutee avec
  pipeline complet capture->train->live + ref tests.
2026-05-14 00:08:23 +02:00
L'électron rare 306aa64b30 fix(data-only-viz): action-head review fixes
Adresse final review du feature action-head :
- action_head_pub.py + extract_j3d_offline.py : CoreMLArray
  wraps numpy mais n'a pas __array__ ; unwrap via .numpy()
  avant np.asarray pour eviter object-array silencieux
  quand persons_smplx vient du backend CoreML. extract_j3d
  ramene depuis main (manquait sur feat suite au merge c52271e).
- train_on_studio.sh : TRAIN_ARGS quote defensivement via
  printf %q + reject single quotes pour eviter injection
  via le payload single-quoted sur bastion.
2026-05-14 00:07:19 +02:00
L'électron rare b052d09d2e merge: feat/action-head into main
CoreML FP32 fix, hybrid mesh rigging, action-head v3, mirror webcam, Apple Vision body pose, MediaPipe offline extract.
2026-05-14 00:06:14 +02:00
L'électron rare 06f2a55f08 fix(coreml): FP32 mlpackage + Hungarian pid
CoreML mlpackage reverted to FLOAT32 compute_precision: the FP16
build, even with the roma branchless rotmat patch, visibly
degraded the mesh on extreme poses (atan2 near theta=pi + SMPL-X
decoder drift). FP32 stays 2.3x faster than PyTorch MPS (139 ms
vs 270 ms; 8 fps live with 3 workers).

MeshRigger pid matching now uses Hungarian assignment on bbox IoU
with sticky cache (new_iou >= 0.30 AND old_iou < 0.15 to switch).
Replaces fragile pelvis-distance heuristic. 5 new tests pass.
2026-05-14 00:01:03 +02:00
L'électron rare 4e7101c54e fix(multi-hmr): NaN/Inf guard on v3d output
Multi-HMR PyTorch MPS sort occasionnellement des v3d avec NaN/Inf
ou vertices a magnitudes extremes (>5 m). Ces frames glissaient
sans guard jusqu'a AVLiveBody et apparaissaient comme pics/trous
sur le mesh.

Approche : np.isfinite check sur v3d + transl_np, skip person si
invalide. Plus sanity clamp sur |v3d|.max() > 5.0 m : humains ~2 m,
au-dela = garbage du modele, skip aussi. Le receiver Swift garde
le dernier good mesh pendant les frames skip via retain_window
500 ms (dc7de90).

Tradeoff : on perd la frame entiere du pid au lieu de juste les
verts NaN. Acceptable car corruption est globale au tensor v3d
quand elle arrive (jamais juste 2-3 verts isoles).
2026-05-13 23:59:35 +02:00
L'électron rare a44a6ac70f fix(multi-hmr): NaN/Inf guard on v3d output
Multi-HMR PyTorch MPS sort occasionnellement des v3d avec NaN/Inf
ou vertices a magnitudes extremes (>5 m). Ces frames glissaient
sans guard jusqu'a AVLiveBody et apparaissaient comme pics/trous
sur le mesh.

Approche : np.isfinite check sur v3d + transl_np, skip person si
invalide. Plus sanity clamp sur |v3d|.max() > 5.0 m : humains ~2 m,
au-dela = garbage du modele, skip aussi. Le receiver Swift garde
le dernier good mesh pendant les frames skip via retain_window
500 ms (dc7de90).

Tradeoff : on perd la frame entiere du pid au lieu de juste les
verts NaN. Acceptable car corruption est globale au tensor v3d
quand elle arrive (jamais juste 2-3 verts isoles).
2026-05-13 23:58:11 +02:00
L'électron rare 87b76a42d0 feat(data-only-viz): MESH_RIG=0 env toggle
Permet de desactiver le rigger 30 fps (mesh_rigger.py, ajoute en
2c8094c) via env var MESH_RIG=0 pour debugger les deformations
mesh sans changer le code.
2026-05-13 23:53:48 +02:00
L'électron rare 52588b9910 fix(coreml): roma branchless rotmat -> rotvec
Root cause of v3d/transl NaN identified: roma.rotmat_to_rotvec
uses torch.empty + 8 index_put_ on a buffer that CoreML mlprogram
translates as scatter_nd over a garbage-initialised tensor. Cells
that the scatter chain misses keep NaN; the subsequent quat /
norm propagates NaN to every vertex.

Patch: branchless atan2 formulation (stack/clamp/norm/atan2 only),
no torch.empty, no index_put_. Precision drift vs roma original:
2.26e-6 L_inf on random batches. Mlpackage now validates all
outputs finite (1.27e-4 L_inf vs PyTorch eager on v3d).

Bench standalone: 65 ms median FP16 (15.3 fps, target met).
Live with 3 parallel workers: 8 fps Multi-HMR keyframe rate
(2.3x speedup vs PyTorch MPS baseline 3.5 fps); rigging still
ships at 15-20 fps perceived.

Output names shifted post-patch (var_2541 -> var_2412 etc) so
multihmr_coreml.py constants updated.
2026-05-13 23:45:20 +02:00
L'électron rare 4717da385c feat(sound-algo): action-head live FX scene
Add scene_pose_action.scd to map real-time action-head pose metrics
to live effect parameters. Reads ~poseState and ~poseKin dicts
(populated by data_feeds.scd OSC handlers) and drives:
- speed -> drive amount (0..1)
- accel -> filter cutoff (200 Hz..6 kHz)
- symmetry -> stereo width (0..1)
- label argmax -> reverb/compressor presets (debout/assise/danse)

Manual load only (eval block-by-block in SC IDE). Validates P:0 B:0.
2026-05-13 23:45:05 +02:00
L'électron rare 6af220dd59 feat(data-only-viz): MediaPipe offline extract
Add standalone MP4 extractor for action-head v3 training data via
MediaPipe HolisticLandmarker (VIDEO mode). Unlike extract_j3d_offline.py
(SMPL-X path), writes real hands_kp (42, 3) and mouth_open from face
landmarks instead of zeros.

- scripts/extract_mediapipe_offline.py: full pipeline (open video,
  iterate frames, map body33/face/hands -> j3d32 + hands_kp42 +
  mouth_open, write jsonl rows matching dataset.py schema)
- tests/test_extract_mediapipe_offline.py: 8 pure-numpy unit tests;
  no mediapipe runtime required

Enables hand-aware training from recorded footage without SMPL-X
or GPU at extraction time.
2026-05-13 23:31:52 +02:00
L'électron rare 1f623fe331 feat(av-live-body): mirror webcam preview
CATransform3D scale x=-1 on the AVCaptureVideoPreviewLayer so the
user sees themselves like in a mirror (left in reality = left on
screen). Overlays (skeleton/face/hand/mesh) keep their raw camera
coordinates for now; align via X-flip if user requests it.
2026-05-13 23:27:30 +02:00
L'électron rare beb94d2a4c feat(data-only-viz): action-head v3 hands+lips
Extends the action-head feature pipeline from v2 (302-D) to v3 (428-D).

- Replace placeholder SMPLX_FINGERTIP_VERTS with canonical vertex IDs
  from smplx.vertex_ids (lthumb/lindex/lmiddle/lring/lpinky, mirrored R)
- Add HANDS_KP_* constants (21 kp/hand, 42 total, 126-D flat block)
- FEATURE_DIM: 302 -> 428; hands_kp block inserted at [288:414]
- FeatureExtractor.from_buffer gains hands_kp param (42, 3),
  zero-padded when absent
- ActionHead.step gains hands_kp param, threads to from_buffer
- _read_sources returns 5-tuples with hands_kp42x3 per person
- MediaPipe FaceMesh inner-lip (idx 13/14) used for mouth_open;
  fallback to SMPL-X v3d lip vertices when face not available
- _build_hands_map and _build_face_mouth_map helpers added
- dataset.py: RawFrame/WindowRow/DatasetRow gain hands_kp fields
- train_action_head.py: reads hands_kp_stack per step, zeros if absent
- extract_j3d_offline.py: writes zero-filled hands_kp in jsonl output
- Tests: FEATURE_DIM 302->428, param bound 80k->100k, +4 new tests
2026-05-13 23:26:14 +02:00
L'électron rare 623c47983d perf(multi-hmr): autocast opt-in
MULTIHMR_AUTOCAST=1 enables MPS mixed precision for the ViT-S
backbone. Tested 2026-05-13: slower than fp32 baseline (400ms vs
270ms) -- overhead cast within forward exceeds matmul savings on
M5. Off by default; FP16 .half() crashes MPS matmul accumulator,
left out entirely.

apple_vision_pose face parser short-circuits to return immediately
since pyobjc 11 cannot dereference VNFaceLandmarkRegion2D
pointsInImageOfSize_ result. Removes ObjCPointerWarning spam at
30 fps (9 regions per face).
2026-05-13 23:24:06 +02:00
L'électron rare 2c8094c06c feat(av-live): hybrid mesh rigging 30 fps
MeshRigger module : entre deux keyframes Multi-HMR (~3.5 fps mesh
dense PyTorch MPS), on translate rigidement le mesh keyframe via le
delta pelvis 2D Apple Vision (30 fps body ANE) projete a profondeur
constante. SMPLXTCPSender bumpe a 30 fps target et applique le rig
sur chaque tick. Verifie live : 27 fps TCP soutenu, 100% rigged,
keyframe Multi-HMR a 3.2 fps -> ~8x speedup perceptuel dans la
fenetre RealityKit AVLiveBody.

Limitations connues :
- Translation seule (pas de rotation ni de LBS articule)
- Pelvis 2D delta projete a Z constant du keyframe
- Pas de matching d'identite robuste Vision <-> Multi-HMR : on prend
  la personne Vision la plus proche du pelvis keyframe projete
2026-05-13 23:17:22 +02:00
L'électron rare aedcb0f01b feat(data-only-viz): action-head v2 fingers+face
Extend action-head to 32 joints (body22 + 10 fingertips),
10 SMPL-X expression PCA scalars, and mouth_open distance.
FEATURE_DIM 201→302. MIRROR_MAP extended to 32. Dataset,
augment, training, publisher, offline extractor all updated.
2026-05-13 23:15:12 +02:00
L'électron rare 28d562b11c feat(av-live): wire Apple Vision body pose on ANE
Add a parallel-pose worker selector (env AV_LIVE_PARALLEL_POSE)
defaulting to "both": runs Apple Vision body 2D on ANE alongside
MediaPipe Holistic on CPU XNNPACK, while Multi-HMR PyTorch streams
the dense mesh on MPS. Modes "apple_vision" or "mediapipe" to pick
one. Body keypoints land in state.persons_body either way.

Face landmark parser via pyobjc remains blocked: pointAtIndex_
selector arity confuses pyobjc 11 and pointsInImageOfSize_ returns
an opaque PyObjCPointer with no address handle. Keep the
ANE-detected count for logging, fall back to MediaPipe face/hand
fin landmarks until a Swift bridge is in place.
2026-05-13 23:07:48 +02:00
L'électron rare 31ba587a63 docs(action-head): post-impl deviations + README
- plans/2026-05-13-action-head.md : note SUPERSEDED sur
  Task 11 + Task 14 (pivot publisher thread, pas de worker
  refactor), header status + decisions.
- specs/2026-05-13-action-head-design.md : status implemente,
  deviations cataloguees en haut, note inline section worker.
- data_only_viz/CLAUDE.md : section action-head ajoutee avec
  pipeline complet capture->train->live + ref tests.
2026-05-13 23:02:54 +02:00
L'électron rare 2a732faffc fix(data-only-viz): action-head review fixes
Adresse final review du feature action-head :
- action_head_pub.py + extract_j3d_offline.py : CoreMLArray
  wraps numpy mais n'a pas __array__ ; unwrap via .numpy()
  avant np.asarray pour eviter object-array silencieux
  quand persons_smplx vient du backend CoreML. extract_j3d
  ramene depuis main (manquait sur feat suite au merge c52271e).
- train_on_studio.sh : TRAIN_ARGS quote defensivement via
  printf %q + reject single quotes pour eviter injection
  via le payload single-quoted sur bastion.
2026-05-13 23:02:29 +02:00
L'électron rare c52271e574 merge: feat/action-head into main
Merges face+hand+3D pose bridge and ActionHead training work into
main. Conflict resolution kept both:
- mains keyboard NSEvent monitor (no system beeps) + p alias
- feats Skeleton3DRenderer anchor wiring
- mains /pose/skel flat OSC path AND feats /face/*, /hand/*, /pose3d/*
2026-05-13 22:46:34 +02:00
L'électron rare b5e9139317 feat(data-only-viz): ActionHead publisher thread
Standalone publisher polls state at 30 Hz, extracts 22-joint
positions from either persons_smplx (vertex anchors) or
persons_body3d (MediaPipe 33→22 map), runs ActionHead.step()
per pid, and emits /pose/action + /pose/kin + lifecycle OSC.

- action_head_pub.py: ActionHeadPublisher thread with dedup
  via smplx_last_t / pose_last_t; purges lost pids
- tests/test_action_head_pub.py: 4 unit tests (39 total pass)
- multi.py: import + instantiate + start publisher in __init__
2026-05-13 22:41:31 +02:00
L'électron rare 5013a1916d docs(coreml-probe): note NaN bug deferred
Add comment in TracedMHMR.forward documenting that CoreML conversion
produces all-NaN on v3d/transl while PyTorch eager works. Tested FP32,
K_inv closed-form, simplified subtract+divide projection, nan_to_num
masking. Root cause is an op-level mistranslation in the v3d/transl
path; needs sub-wrapper bisection. Workaround: PyTorch backend.
2026-05-13 22:37:31 +02:00
L'électron rare a5c793f39c feat(data-only-viz): action capture webcam script
Implement Task 10 of action-head plan. Records webcam frames +
timestamps for action-head training using OpenCV. Outputs MP4 video
+ timestamp text file to ~/.cache/av-live-action/raw/ with 672x672
square crop, configurable fps/session/camera, interactive q-quit.
2026-05-13 22:36:19 +02:00
L'électron rare f540158f45 feat(av-live): face+hand+3D pose to launcher
Stream MediaPipe Holistic face landmarks (68 dlib subset of 478),
hand landmarks (21 left + 21 right), and pose world landmarks (33
3D xyz meters) over OSC :57126 to AVLiveBody. Launcher renders
face/hand as SwiftUI Canvas overlay and the 3D skeleton as a
RealityKit armature (sphere joints + cylinder bones, color per
chain) toggled via p / mode openpos.

Multi-HMR worker now also starts MediaPipe Multi in parallel so
both the dense SMPL-X mesh (TCP 57130, PyTorch backend) and the
skeleton/face/hand streams (OSC 57126) feed the launcher from
one Python process.

Launcher AppDelegate forces .regular activation so SwiftPM
binaries actually show their WindowGroup without a bundle.

Tests: 9 new pytest cases (4 body3d + 5 face/hand), all green.

CoreML conversion still produces NaN on v3d/transl; PyTorch
backend is the working path for now.
2026-05-13 22:34:42 +02:00
L'électron rare b53c748704 feat(data-only-viz): action-head review TUI
Add interactive console TUI for manual label review of
auto-labeled action datasets. Displays ASCII skeleton,
kinetics, and proposed label with confidence. User can
accept proposed label, choose manual override (1/2/3),
skip, or quit. Reads auto-labeled JSONL and writes
validated rows to reviewed dataset.
2026-05-13 22:34:26 +02:00
L'électron rare 0ecb2c3d3b fix(data-only-viz): studio rsync excludes + extras
rsync excludes .venv/__pycache__/.pytest_cache + uv sync ajoute
extra multihmr (torch). End-to-end valide: smoke 160 windows
3 epochs MPS studio en ~4s, ckpt rsync back OK.
2026-05-13 22:32:14 +02:00
L'électron rare a1ea343ff0 fix(data-only-viz): studio ssh quoting + abs paths
Le precedent printf %q sur-quotait $HOME -> mkdir recevait 0 arg.
On utilise des chemins absolus /Users/clems/av-live-action/* cote
studio et single-quotes pour proteger des bastion expansions.
2026-05-13 22:24:24 +02:00
L'électron rare dfafd23d5a feat(sound-algo): action-head pose OSC handlers
Add OSCdef handlers for /pose/action, /pose/kin, /pose/enter,
/pose/leave routes emitted by data_only_viz pose_bridge. Store
person state and kinematics in ~poseState and ~poseKin dicts.
2026-05-13 22:23:17 +02:00
L'électron rare c381d0c4e7 feat(data-only-viz): pose_bridge action+kin OSC
Ajoute 4 methodes OSC pour action_head et localisation cinetique:

send_action(pid, label_idx, probs, t_now, force) envoie /pose/action
avec [pid, label_idx, prob_0, prob_1, prob_2] pour les 3 classes.

send_kin(pid, kin, t_now, force) envoie /pose/kin avec [pid, kin[0],
kin[1], kin[2]] pour angles de poignets/coudes.

send_enter/send_leave envoient /pose/enter et /pose/leave pour cycle
vie des personnes.

Throttle reuse _period/_last_t existants; force=True bypass throttle.
2026-05-13 22:18:08 +02:00
L'électron rare e292ed7ef3 feat(data-only-viz): studio train wrapper
Wrapper bash : rsync dataset+code grosmac->studio via bastion
electron-server, exec uv run train_action_head --device mps sur
M3 Ultra, rsync checkpoint back. SSH direct cassee depuis reboot
studio 2026-05-12 ; route via bastion documentee dans CLAUDE.md.
2026-05-13 22:07:34 +02:00
L'électron rare 9d67426b2c feat(data-only-viz): action-head eval script
Add evaluation script to compute test accuracy, confusion matrix, and
inference latency on a trained action-head checkpoint. Reuses existing
WindowDataset and model infrastructure from training pipeline.

Falls back to evaluating on full dataset if test split is empty
(edge case with <4 sessions).
2026-05-13 22:05:15 +02:00
L'électron rare 459f3e9fe7 feat(av-live-body): skeleton 3D + fix beep touches
Context:
- User demande : faire afficher le mesh open-pos et le rendre
  plus ameliore
- Signale aussi : les touches ne fonctionne pas j'ai le beep
- Phase 2 fusion AV-Live-Body : rendu skeleton 3D dans la
  fenetre RealityKit, alimente par MediaPipe Multi via OSC

Approach:
- Etendre /pose/skel OSC pour shipper les 33 keypoints flat
- Rendu RealityKit pur (UnlitMaterial spheres + cylindres)
- Remplacer keyboardShortcut SwiftUI sans modifier (qui beep)
  par NSEvent.addLocalMonitorForEvents qui consomme l'event

Changes:
- pose_bridge.py emet /pose/skel pid + 33 (x,y,c) vers :57126
- PoseOSCListener gagne PoseFrame.skeleton + parser /pose/skel
- SkeletonOverlay (nouveau fichier) : 33 spheres + 35 cylindres
  bones BlazePose, palette par pid, conf < 0.3 cache joint/bone
- BodyView : Coordinator.skeletonOverlay attache au bodyAnchor,
  update() visible si vizMode == 9 OU showSkeleton toggle
- BodyView : NSEvent monitor capture S C V M W 0-9, retour nil
- AVLiveBodyApp : keyboardShortcut modifiers [.command] partout

Impact:
- Mode openpos affiche le squelette 3D live, palette par pid
- Touches single-key (S / 0-9 / C / V / M / W) marchent sans
  beep grace au NSEvent monitor qui consomme l'event
- Cmd+S Cmd+0-9 Cmd+C/V/M/W accessibles via menubar
2026-05-13 21:44:46 +02:00
L'électron rare 744bc4a8a4 feat(data-only-viz): action-head training loop
Add train_action_head.py with WindowDataset, class-weighted
CrossEntropy, AdamW optimizer, per-epoch train/val loop, and
best-val-acc checkpoint saving. Add smoke tests verifying
2-epoch run and checkpoint loadability via ActionHead.

- WindowDataset computes position/velocity/accel features inline
- _class_weights balances imbalanced label distribution
- train() returns history dict (train/val loss and acc)
- CLI entry point for --device mps/cuda/cpu production runs
2026-05-13 21:43:51 +02:00
L'électron rare 9e7a9f8fd4 feat(data-only-viz): Multi-HMR CoreML backend
Convert Multi-HMR ViT-S 672 to CoreML mlpackage and wire as optional
worker backend via MULTIHMR_BACKEND=coreml env var.

Inference path uses pyobjc + native CoreML framework (Python 3.14 has
no libcoremlpython binding). Conversion done in a separate Py 3.12
venv; einsum cascade patched (camera intrinsics broadcast + smplx
landmarks) via setup_multihmr.sh, idempotent on re-clone.

Bench: 28 ms mock, 100-170 ms live (~13 fps, 4x PyTorch MPS). ANE
compile fails on this model; CPU+GPU is the sweet spot.
2026-05-13 21:42:43 +02:00
L'électron rare 24d1e85b7d feat(data-only-viz): action-head augmentations
Implement on-the-fly spatial and temporal augmentations for multi-HMR
j3d windows: mirror_x (left/right joint swap + x-flip), add_noise,
time_stretch (linear resampling), rotate_y. Task 7 of action-head plan.
All 30 tests pass (26 prior + 4 new augment tests).
2026-05-13 21:40:22 +02:00
L'électron rare f0f79b3478 merge: main into feat/action-head
Brings 9ab82e4 (MediaPipe Multi default + rich pose OSC) +
96a326d (av-live-body scene Metal) + 214b154 (HUD + menus).
Conflicts on duplicate launcher Swift commits (667f63c vs
96a326d) resolved with -X theirs (prefer main version, since
214b154 builds on it).
2026-05-13 21:36:08 +02:00
L'électron rare 214b1545c4 feat(av-live-body): HUD + menus + pose -> scene
C - HUD coin haut-gauche + touches actives :
- HUDOverlay.swift : statut mode actif, count personnes, list
  touches actives (S/0-9/M/W/C/V), positions head/wrists de la
  1ere personne. allowsHitTesting(false).

Menus + raccourcis SwiftUI complets :
- App > Toggle Settings   (S)
- Calques > Toggle Webcam (C) / Scene Metal (V) /
            Maillage SMPL-X (M) / Fil de fer (W)
- Modes visuels > 0..9 -> storm/.../openpos
Tous propages via NotificationCenter (.toggleSettings,
.toggleLayer, .setVizMode).

B - Pose drive scene uniforms :
- BodyView observe PoseOSCListener en plus de RenderSettings.
- updateNSView pousse pose_count, pose_alive, hand_l_x/y,
  hand_r_x/y dans SceneRenderer.uniforms.
- Mode hands3d (#8) reagit a la position des poignets de la
  1ere personne detectee. Mode openpos (#9) idem.

A - Dashboard data-only : 0 fix code, le probleme etait
bridge.py orphelins multiples + cache navigateur.
2026-05-13 21:34:26 +02:00
L'électron rare 96a326d3c6 feat(av-live-body): scene Metal + pose OSC
Phase 1 fusion : AV-Live-Body absorbe la couche Metal viz et
ecoute les data pose via OSC.

Metal scene (10 viz modes :  storm, tunnel, plasma, kaleido,
voronoi, metaballs, starfield, bars, hands3d, openpos) :
- Resources/scene.metal copie depuis data_only_viz, compile au
  runtime via MTLLibrary.makeLibrary(source).
- SceneRenderer.swift : MTKViewDelegate qui rebuild
  SceneUniforms (20 floats, miroir du struct Metal) et drive
  bg_pipeline (full-screen triangle).
- BodyView : nouveau MTKView entre la cam preview et l'ARView,
  zPosition intermediaire, alpha pour laisser passer la cam.
- RenderSettings : showScene + vizMode (0..9), picker 10
  boutons numerotes dans SettingsPanel + libelle du mode actif
  affiche dans la row 'Scene Metal (<name>)'.

Pose OSC :
- PoseOSCListener.swift : UDP listener :57126, parser OSC
  minimal (i, f, s args), @MainActor dispatch des Published.
  Stocke un PoseFrame par pid (center, head, wrists, sho_span,
  yaw, pitch) avec GC 2 s.
- data_only_viz/pose_bridge.py : 2e SimpleUDPClient broadcast
  vers 127.0.0.1:57126 (try/except OSError pour silencer si
  AVLiveBody pas la). Throttle 30 Hz partage.

Phase 2 (futur) : rendu skeleton entities RealityKit (spheres +
cylindres) consommant PoseOSCListener.persons.

Package.swift : ajout Resources/scene.metal en .copy.
2026-05-13 21:23:48 +02:00
L'électron rare 667f63c385 feat(av-live-body): scene Metal + pose OSC
Phase 1 fusion : AV-Live-Body absorbe la couche Metal viz et
ecoute les data pose via OSC.

Metal scene (10 viz modes :  storm, tunnel, plasma, kaleido,
voronoi, metaballs, starfield, bars, hands3d, openpos) :
- Resources/scene.metal copie depuis data_only_viz, compile au
  runtime via MTLLibrary.makeLibrary(source).
- SceneRenderer.swift : MTKViewDelegate qui rebuild
  SceneUniforms (20 floats, miroir du struct Metal) et drive
  bg_pipeline (full-screen triangle).
- BodyView : nouveau MTKView entre la cam preview et l'ARView,
  zPosition intermediaire, alpha pour laisser passer la cam.
- RenderSettings : showScene + vizMode (0..9), picker 10
  boutons numerotes dans SettingsPanel + libelle du mode actif
  affiche dans la row 'Scene Metal (<name>)'.

Pose OSC :
- PoseOSCListener.swift : UDP listener :57126, parser OSC
  minimal (i, f, s args), @MainActor dispatch des Published.
  Stocke un PoseFrame par pid (center, head, wrists, sho_span,
  yaw, pitch) avec GC 2 s.
- data_only_viz/pose_bridge.py : 2e SimpleUDPClient broadcast
  vers 127.0.0.1:57126 (try/except OSError pour silencer si
  AVLiveBody pas la). Throttle 30 Hz partage.

Phase 2 (futur) : rendu skeleton entities RealityKit (spheres +
cylindres) consommant PoseOSCListener.persons.

Package.swift : ajout Resources/scene.metal en .copy.
2026-05-13 21:22:57 +02:00
L'électron rare 7f0ac97a21 fix(data-only-viz): restore action-head module 2026-05-13 21:21:06 +02:00
L'électron rare 9ab82e47aa feat(pose): MediaPipe Multi default + rich OSC
User demande detection mesh visage + main + interaction
sonore plus riche + openpos toujours actif. Pipeline :

main.py priorite pose worker reordonnee :
  0. Multi-HMR (flag opt-in)
  1. MediaPipe Multi (DEFAUT) : 33 body + 478 face + 21x2 hands
     x 4 personnes. Active la couche mesh_pipe pour rendre face
     triangulee + hands triangulees + body skeleton ensemble.
  2. Apple Vision (fallback body-only ANE).

main.py auto-engage openpos #9 quand persons_body non vide via
un NSTimer 2 Hz autoOpenpos. Lock manuel 8s quand l'utilisateur
appuie sur azertyuiop. Revient en storm (#0) quand plus
aucune personne detectee.

pose_bridge.py enrichi :
  /pose/wrist_speed pid l|r vx vy   (velocity normalisee)
  /pose/torso_yaw pid yaw            (rotation epaules)
  /pose/body_pitch pid pitch         (inclinaison verticale)
  /pose/face pid mouth_open eye_open (FACE_MESH 478)
  /pose/hand pid l|r openness px py  (HAND 21)
  /pose/active pid activity_0_1      (mean conf)

multi.py passe persons_face / persons_hands au PoseSoundBridge.
2026-05-13 21:02:27 +02:00
L'électron rare a199c50297 feat(data-only-viz): action auto-labeler rules
Problem: Action classification (debout/assise/danse) requires rule-based
labeling before neural training. Task 5 of action-head plan.

Approach: Heuristic rules on j3d posture + kinetics (speed/accel):
- Hip height + knee angle → seated vs. standing
- Joint velocity → static vs. dancing
- Confidence scoring for ambiguous windows

Changes:
- action_head.py: scaffold with FeatureExtractor (kinetics, knee angle)
- autolabel.py: AutoLabelConfig, autolabel_window(), autolabel_dataset()
  CLI glue (raw frames jsonl → windowed labeled dataset jsonl)
- test_autolabel.py: 4 TDD tests (debout, assise, danse, ambiguous)

Impact: Enables dataset creation pipeline (extract_j3d → auto-label →
manual review → train ActionHead GRU).
2026-05-13 20:59:47 +02:00
L'électron rare b410023d03 feat(data-only-viz): dataset jsonl+windows
Implements core dataset infrastructure for action-head training:
- RawFrame: JSONL frame parsing (ts, session, pid, j3d)
- sliding_windows: temporal windows by (session, pid)
- DatasetRow: labeled windows with confidence/validation
- write/load_dataset_jsonl: JSONL numpy array serialization
- split_by_session: stratified train/val/test by session

Tests verify load→windows→write→load→split data flows.
2026-05-13 20:53:31 +02:00
L'électron rare 2d92cceef9 feat(data-only-viz): dataset jsonl+windows
Implements core dataset infrastructure for action-head training:
- RawFrame: JSONL frame parsing (ts, session, pid, j3d)
- sliding_windows: temporal windows by (session, pid)
- DatasetRow: labeled windows with confidence/validation
- write/load_dataset_jsonl: JSONL numpy array serialization
- split_by_session: stratified train/val/test by session

Tests verify load→windows→write→load→split data flows.
2026-05-13 20:50:13 +02:00
L'électron rare bd3dd89511 fix(feeds): 3 warnings externes
3 fixes pour reduire le bruit de logs des sources publiques :

wikimedia.py : ajoute User-Agent descriptif. WMF EventStreams
refuse l'UA httpx par defaut avec 403 Forbidden. Le 1.0+URL
descriptif est obligatoire pour le rate-limiting et les abuse
reports.

netzfrequenz.py : backoff exponentiel cap a 5 min sur les ws
disconnect. La source (mainsfrequenz.de) est NXDOMAIN depuis
mai 2026 ; on logguait toutes les 3 s soit ~28 800/jour. Avec
backoff x1.6 et log uniquement attempt==1 ou attempt%10, on
descend a ~30 log/jour.

config.data-only.toml : opensky poll_seconds 20 -> 60. L'API
anonyme est credit-based (10 req/min budget). 60s reste
raisonnable pour le viz tout en restaurant le credit.
2026-05-13 20:49:23 +02:00
L'électron rare 50343be6ec docs(plans): bench CoreML M5 vs M1 Max
Comparatif backbone DINOv2 ViT-S 672 sur deux Apple Silicon :

Hardware    | CoreML GPU | CoreML ALL | PyTorch MPS
M5 (16 GB)  | 25.1 ms    | 157.3 ms   | 274.7 ms
M1 Max(32G) | 63.3 ms    | 154.1 ms   | 110.6 ms

M5 GPU 2.5x plus rapide en CoreML (per-core efficiency vs
32 cores M1 Max). M1 Max 2.5x plus rapide en PyTorch MPS
(M5 souffrait probablement de throttle accumule).

ANE handicape les deux (~155 ms vs 25-63 ms GPU) — DINOv2
ViT-S non ANE-friendly.

macM1 workers MLX pauses via SIGSTOP/SIGCONT pour bench
isole.
2026-05-13 20:31:33 +02:00
L'électron rare 17d93fd351 docs(plans): action-head implementation plan
16 bite-sized tasks covering ActionHead model, training, dataset
pipeline (capture -> extract -> autolabel -> review), eval, OSC
wiring, sound_algo handlers, and end-to-end gate.
2026-05-13 20:31:12 +02:00
L'électron rare ee86990195 feat(pose): enrichir card dashboard
data_feeds/feeds/pose.py emet maintenant /data/pose/stats
avg_conf avg_size cx_bar cy_bar apres chaque batch :
- avg_conf : moyenne des confidences keypoints (0..1)
- avg_size : moyenne des bbox surface w*h normalisee
  (proxy distance camera ; 1.0 = cadre plein)
- cx_bar, cy_bar : barycentre des centres de bboxes
  (utile pour piloter des FX qui suivent l'occupation)

Dashboard handler 'pose' enrichi (wide card) :
- count en valeur principale
- subline : conf % + occup. % cadre + centre (cx, cy)
- bordure warn si avg_conf < 0.4
- bboxes stockees dans c._persons pour overlay futur
2026-05-13 20:29:26 +02:00
L'électron rare bcb259d045 fix(data-only): touches clavier hors focus
addLocalMonitorForEventsMatchingMask reagit uniquement quand
l'app a le focus, ce qui empechait les touches azertyuiop /
qsdfghjklm de marcher des qu'on cliquait dans un terminal.

Ajoute un addGlobalMonitor parallele (read-only, AppKit
contrainte) qui delegue a _on_key sans retourner l'event
(le local monitor reste en charge du discard).

Force aussi activateIgnoringOtherApps + makeKeyAndOrderFront
+ makeFirstResponder au boot, pour que le local monitor
recoive les events tout de suite sans necessiter un clic
initial dans la fenetre.

LOG.debug ajoute pour tracer chaque touche recue.
2026-05-13 20:28:12 +02:00
L'électron rare 46d619199b docs(specs): action-head design v1
Real-time action classifier (debout/assise/danse) on top of
Multi-HMR j3d. GRU streaming, train windowed/infer streaming,
auto-label + manual review dataset, Studio train -> M5 deploy.
OSC schema enrichi: /pose/action + /pose/kin (kinetics scalars).
2026-05-13 20:24:57 +02:00
L'électron rare 2a26e2dfd4 feat(data-only): mode openpos #9 (skeleton)
Ajoute un 10e viz_mode 'openpos' dans data_only_viz pour
afficher le squelette multi-personne sur un fond minimal qui
met en valeur les corps detectes.

state.py :
- viz_mode_names complete avec 'openpos' a l'index 9
- KEYMAP_VIDEO : touche 'p' -> openpos (slot precedemment libre)

shaders/scene.metal :
- nouveau mode_openpos : fond radial sombre avec une grille
  de points discrete + pulsation legere sur ~rms. Le bg sert
  juste a faire ressortir le skel_pipeline rendu PAR-DESSUS
  par le renderer (qui beneficie deja du depth_fog + palette
  par pid + epaisseur confiance-adaptee).
- bg_fragment dispatcher : mode == 9 -> mode_openpos

web/public/control.html :
- 10e bouton 'openpos' dans la grille #vizmodes.
2026-05-13 20:19:47 +02:00
L'électron rare 9d15c74c01 docs: READMEs sync (20 feeds, 3 modes, web)
Root README.md :
- Hero updated : 20 feeds, SMPL-X body mesh, 3 launch modes
- Section Real-world feeds reorganisee en 7 categories avec
  les 9 nouvelles sources (openmeteo, openaq, iss, volcano,
  social_buzz, gdelt, wikimedia, tides, atc)
- Section Web data-only ajoutee : dashboard / map / control
  sur :3211 + sync SC <- web_bridge.scd sur :57125
- Liste des launch modes : Full / Data-only / Body Mesh
- Architecture details : 4 nouvelles entrees

data_feeds/README.md :
- Intro : 20 sources (vs 11 avant)
- Table OSC schema completee avec les 9 nouvelles routes

launcher/README.md : refactor complet
- Section 3 modes avec leur stack
- AV-Live-Body panneau de reglages (5 sections)
- Table de routage process spawnes
- Carte OSC ports 57121-57130

data_only_viz/web/README.md : nouveau fichier
- Architecture diagram bidir
- Description detaillee des 3 pages
- Variables d'env + protocole WebSocket
2026-05-13 20:05:15 +02:00
L'électron rare 55a0390c77 perf(data-only-viz): CoreML T3 patch 10 diagonal
Patch 10 cumule : aten::diagonal override pour supporter offset=0
dim1=1 dim2=2 sur tensor rank-3 (B, N, N). Utilise par
roma.rotmat_to_rotvec invoque dans Multi-HMR head.

Implementation : reshape (B, N, N) -> (B, N*N), gather indices
diagonale aplaties [0, N+1, 2N+2, ...] axis=1.

Cascade T3 status : 10 patches actifs, conversion progresse.
Blocker 11 = reshape size mismatch ([1, 51, 3] -> [204, 3, 1])
= incoherence model-side post topk (facteur 4 = K=num_persons).
Plus une op manquante, du model surgery requis.

T3 roadmap mis a jour avec investigation Step B1-B3 pour reprise.
2026-05-13 20:03:44 +02:00
L'électron rare a4706d68d8 perf(data-only-viz): CoreML T3 4 patches de plus
Cascade T3 progression (9 patches cumules) :
6. tile no-op pour reps=[]
7. new_ones converter (aten::new_ones manquait)
8. concat 0d->1d auto-promote
9. clamp_min/max avec dtype promotion

Bloque maintenant a aten::diagonal avec offset/dim non-default.
Cascade encore en cours, on continue.
2026-05-13 20:01:41 +02:00
L'électron rare 4fd37b4faf feat(data-only): GUIs synchronisees + audit
Web data-only fully wired contre les 20 feeds :

dashboard.js : 8 nouveaux handlers (gdelt, wikimedia, tides,
moon, atc, pose, mempool, github, rte_eco2mix). Sparklines +
seuils alert/warn/green coherents.

map.js : marker GDELT (purple) en plus des seismes/foudre/
avions/ISS/volcans, TTL 60s comme les autres ephemeres.

control.html : nouvelle section 'Modes visuels (oF / Metal)'
avec 9 boutons (storm/tunnel/plasma/kaleido/voronoi/metaballs/
stars/bars/hands3d). Le handler scene-btn est splitte par
container (#scenes vs #vizmodes) pour ne pas melanger
l'etat actif.

server.js : whitelist /control/viz* + routing par path. Les
/control/viz* partent vers oF (:57123), tout le reste vers
SC (:57121). Nouveau env OF_PORT_OUT.

Audit fixes :
- LaunchMode displayName Data-only : 'SC + oF + feeds, no web'
  -> 'SC + Metal viz + feeds + web' (l'ancien libelle datait
  d'avant le toggle Web UI dans dataOnly).
- SettingsPanel : toggle Squelette etiquete '(a venir)' tant
  que le rendu n'est pas implemente cote MeshRenderer.
2026-05-13 19:56:17 +02:00
L'électron rare b361c1c09b docs(plans): CoreML hybrid backbone + T3 roadmap
Deux plans complementaires post-cascade T3 :

1. hybrid-backbone : approche pragmatique (CoreML backbone seul
   + PyTorch head). 2.5 h cible, 2-3x speedup sans toucher au
   head. Le probe v4 .mlpackage retourne cls_token mais on a
   besoin de patch tokens (get_intermediate_layers) -> Task 1
   re-convertit un wrapper adapte.

2. t3-roadmap : sous-plan dedies pour reprendre la cascade Task 3
   en session multi-jour. Documente les 5 patches deja faits +
   blocker actif (tile reps=0) + cascade probable estimee
   (6-12 h focused).

Recommandation : tenter hybrid d'abord, T3 full si insuffisant.
2026-05-13 19:56:01 +02:00
L'électron rare c650915f8a perf(data-only-viz): CoreML T3 add auto_val patch
Patch supplementaire pour Task 3 cascade :
- _auto_val coerce ndarray ndim>0 size=1 vers 0-d ndarray
  (resout ValueError type_double zero-rank)

Etat T3 a l'arret (5 patches cumules) :
1. apply_threshold -> apply_topk
2. interpolate_pos_encoding -> buffer fige
3. inverse_perspective_projection -> closed-form K_inv
4. _cast non-0d
5. _auto_val type_double coercion (ce commit)

Reste : tile/reps length 0 (next), puis cascade probable.
Session dediee multi-jour requise pour terminer T3-T4.
2026-05-13 19:53:07 +02:00
L'électron rare 651187f097 perf(data-only-viz): CoreML T2 done T3 partial
Task 2 — apply_topk(K=4) validee comme drop-in remplacant
apply_threshold dans la tete Multi-HMR. Cosine sim = 1.000000
sur 4/4 detections image reelle, MAE 2-4 mm.
Script : scripts/probe_head_topk.py.

Task 3 — full Multi-HMR convert tente. Patches appliques :
1. apply_threshold -> apply_topk monkey-patch
2. backbone.encoder.interpolate_pos_encoding -> buffer fige
3. utils.camera.inverse_perspective_projection -> closed-form

jit.trace OK, mais conversion coremltools echoue sur op
suivantes en cascade. Estimation restante : 1-2 j.
Script : scripts/coreml_full_probe.py.

Le breakthrough probe v4 backbone seul 11.8x reste la victoire.
Task 4 worker integration bloquee tant que T3 pas complete.
2026-05-13 19:44:16 +02:00
L'électron rare 7162f76d6f perf(data-only-viz): CoreML probe v4 = 11.8x
Conversion DINOv2 ViT-S 672x672 reussie avec 2 patches :
(1) pre-calcul interpolate_pos_encoding en buffer fige,
(2) patch coremltools _cast pour val non-0d (bug ops.py:3048).

Bench M5 50 iter :
  CoreML CPU_AND_GPU : 25.1 ms (40 fps)
  PyTorch MPS        : 274.7 ms (3.6 fps)
  Speedup            : 11.8x

ANE compute units ralentit (CPU_AND_NE=157ms vs GPU=25ms). Le
vrai gain CoreML = graph compile MPSGraph + op fusion, pas ANE.

Probe reproductible : data_only_viz/scripts/coreml_probe.py.
2026-05-13 19:33:27 +02:00
L'électron rare e0cf814e72 feat(sound-algo): pont OSC web data-only
Nouveau fichier control/web_bridge.scd, charge par
boot.data-only.scd apres data_feeds.scd. Ferme la boucle :

Entrants (port 57121, depuis data_only_viz/web /control.html) :
- /control/master_gain f      master amp sur ~mainGroup
- /control/cutoff f           LPF sur ~fxGroup
- /control/reso f             resonance LPF
- /control/reverb_mix f       mix reverb
- /control/delay_time f       delay time
- /control/delay_feedback f   delay feedback (clamp ~0.95)
- /control/tempo f            TempoClock.default.tempo_(bpm/60)
- /scene/play  s              ~dataScene.(name) — meme contrat
                              que les keymaps qsdfghjklm
- /xy/x f   /xy/y f           pad XY -> ~fxGroup.set(\xyMod, ...)

Sortants (port 57125 -> server.js -> WS clients) a 4 Hz :
- /sync/bpm f                 TempoClock.default.tempo * 60
- /sync/beat i                compteur monotone
- /sync/rms f                 ~rmsLevel.value si dispo
- /sync/voices i              ~activeVoiceCount.value si dispo

State partage dans ~webState pour debug REPL.

Le pont est best-effort cote SC : si ~mainGroup / ~fxGroup ne
sont pas encore crees, les set() sont silencieusement skip.
2026-05-13 19:32:27 +02:00
L'électron rare af0504f713 chore(data-only-viz): SMPLer-X T1 abandon
Tentative T1 du plan smplerx-arm-port : install extra OK,
mmcv-lite Config shim OK, mais cascade de modules mmcv 1.x
manquants depasse largement le budget (Config, deprecated_api_warning,
17 funcs mmcv.cnn, bricks.registry 12 sub-reg, puis mmcv.runner
50+ classes, et la cascade continue).

C'est la migration mmcv 1.x -> 2.x complete a refaire a la
main : ~1-2 semaines effort propre, pas 5-6 h. Abandon final.

Shim partiel _smplerx_shims.py committe comme socle si reprise
future. Fork electron-rare/SMPLer-X + submodule restent en place.
2026-05-13 19:27:55 +02:00
L'électron rare 1fed0ad485 docs(plans): SMPLer-X ARM Mac port plan 2026-05-13 19:22:05 +02:00
L'électron rare 788f1fe0aa feat(data-only): page Controle web bidir
Le serveur Express :3211 devient bidirectionnel :
- IN :57124  data_feeds bridge.py -> feeds vers WS broadcast
- IN :57125  SuperCollider sync (BPM/RMS/voies) -> WS broadcast
- OUT :57121 navigateur -> SC pour /control/* /scene/* /xy/*

Nouvelle page /control.html (3 colonnes) :
- Sliders synth/mix : master_gain, cutoff, reso, reverb_mix,
  delay_time, delay_feedback, tempo. Chaque slider envoie son
  path OSC en temps reel sur drag.
- 10 boutons scenes audio (cavity, geo, body, weather, flight,
  pulse, quiet, all, full, stop) : meme contrat que les
  keymaps qsdfghjklm du Metal viz. Toggle visuel actif.
- XY pad : drag/clic envoie /xy/x et /xy/y dans [0,1] (Y
  inverse pour up=1). Cursor pink avec ombre + grille 4x4.
- Retour SC : labels BPM / Beat / RMS / Voies actives mis a
  jour quand SC pousse /sync/* sur :57125.

Liens 'Controle' ajoutes dans dashboard.html et map.html.
2026-05-13 19:21:31 +02:00
L'électron rare b21651dbe6 feat(data-only-viz): SMPLer-X fork + submodule
Fork electron-rare/SMPLer-X (S-Lab License 1.0, non-comm
seulement) ajoute en git submodule a third_party/SMPLer-X.
Setup script utilise le submodule au lieu de cloner upstream.
pyproject : nouvel extra 'smplerx' (torch + mmcv-lite +
ultralytics + smplx + timm). NOTICE.md documente la posture
non-commerciale heritee de Multi-HMR et SMPLer-X.

Le portage inference ARM Mac est l'etape suivante : SMPLer-X
vendorise sa propre mmpose (transformer_utils/mmpose), donc
seul mmcv.Config a besoin d'un substitut (mmcv-lite). Le
detecteur mmdet sera remplace par YOLO Ultralytics.

NOT YET DONE: smpler_x_worker.py (model load + inference,
~1 j) ; bench camera ; integration main.py.
2026-05-13 19:20:20 +02:00
L'électron rare 60112b0790 feat(data-feeds): GDELT, Wikipedia, Tides, ATC
4 nouvelles sources open-data publiques, sans cle :

- gdelt : GDELT 2.0 events CSV.zip toutes les 15 min ; emet
  /event lat lon tone country eid + /batch n countries avg_tone.
  Dedup par GLOBALEVENTID, ring buffer 8192.
- wikimedia : EventStreams SSE firehose recentchange (toutes
  langues). Sample ~5% des edits + rate per second toutes
  les 5 s. Reconnect auto sur disconnect.
- tides : NOAA CO-OPS water level observe + predit pour une
  station (Boston par defaut, station code configurable) +
  phase lunaire calculee (Conway approx, sans dep). Poll 6 min.
- atc : LiveATC hub listeners scraping (KJFK/KLAX/KSFO/KORD/
  EGLL/LFPG par defaut). Polite 500ms entre hubs, total
  poll 5 min.
2026-05-13 19:17:36 +02:00
L'électron rare ae5d814128 feat(data-only): enable 5 feeds dormants
netzfrequenz, rte_eco2mix, mempool, github, pose passent a
enabled=true. Seul gcn reste off (exige auth NASA client_id +
client_secret obligatoires, pas de fallback anonyme).

Warnings connus apres ce toggle :
- netzfrequenz : ws mainsfrequenz.de down depuis mai 2026 -> spam
  d'erreurs jusqu'a fallback gridradar/swissgrid
- pose : fonctionne uniquement quand bridge.py tourne dans le
  bundle launcher (TCC camera prompt impossible en CLI pur)
- rte_eco2mix : credentials vides -> probable echec si l'API
  data.rte-france.com exige OAuth
- github : sans token, rate-limit 60 req/h -> marche par bursts
2026-05-13 19:13:49 +02:00
L'électron rare 1e2d9314a7 docs(plans): 3 plans body-mesh + contrainte M5
A. CoreML : update probe v2 result (pos-embed surgery
   suffit pas, blocker reste op 17/610). Recommande pivot.
B. Studio offload : plan ecrit puis MARQUE DEFERRED apres
   contrainte user "<8 GB RAM sur M5, on-device only".
C. Modern body mesh survey : 10 modeles 2025-2026 filtres
   sur M5 8 GB constraint. SMPLer-X-S = candidat #1
   (claim 64 ms M5, 30M params, SMPL-X drop-in).
D. Studio comme rig training (deploy M5) : 4 voies, recos
   en cas de besoin de retraining/distillation.
2026-05-13 19:09:47 +02:00
L'électron rare f581cc3f55 perf(data-only-viz): motion gate + probe CoreML
Skip Multi-HMR si diff thumbnail 112x112 < 5.0 (sur 0-255).
Mesure terrain : 74/78 frames skipped en 5s sur scene
statique = ~95% compute saved sans degrader le mesh visuel.
Flag CLI --motion-gate.

torch.compile teste, plante runtime sur shape arithmetic
(FakeTensor erreur), abandonne avec note.

CoreML Task 1 probe (DINOv2 ViT-S backbone seul) execute :
conversion echoue sur l'interpolate_pos_encoding (tensor
to Python scalar). Plan mis a jour avec verdict 2-3 jours
de chirurgie minimum, pas 1 jour. Recommandation pivot :
Studio M3 Ultra offload comme alternative.
2026-05-13 19:01:52 +02:00
L'électron rare dc7de90b18 perf(av-live-body): lag + popin interp fixes
alpha 0.5 -> 0.25 : lerp s'etale sur la frame Python
au lieu de converger en 100 ms puis figer. Persistance
entite 500 ms : une frame Multi-HMR ratee n'efface plus
le mesh. Skip recompute normales pendant interp ticks
(stale ~150 ms invisible vs 24 ms/tick economises).
2026-05-13 18:53:29 +02:00
L'électron rare 278ed55f02 perf(data-only-viz): better tracking + dedup
Tracker: constant-velocity prediction (cap 5 frames),
max_miss 8 -> 30 (~10s @ 3 fps), IoU thresh 0.20 -> 0.15.
Dedup: combined 2D IoU > 0.55 AND 3D pelvis < 20 cm (was
0.40 / 30 cm, too aggressive — fused adjacent people).
det_thresh default 0.30 -> 0.15, nms_kernel_size 1 -> 5.
E2E with 3 people: 19 -> 4 distinct pids in 30 s,
persons/frame 1.0 -> 2.0+. CLI: --det-thresh,
--nms-kernel-size.
2026-05-13 18:53:29 +02:00
L'électron rare b88dd6ceef docs(plans): Multi-HMR CoreML conversion plan 2026-05-13 18:25:44 +02:00
L'électron rare 0293cdea17 perf(av-live-body): 30 fps mesh interpolation
Lerp SMPL-X vertices between Multi-HMR frames (Python at ~3 fps)
via SceneEvents.Update subscription. Throttle to 30 fps (1 tick
of 2) with alpha=0.5 to keep MainActor cost bounded. Skip normal
recompute during interp ticks — stale ~150 ms is invisible vs
the 24 ms/tick CPU cost it saves.
2026-05-13 18:25:44 +02:00
L'électron rare 0eebcb3aec feat(data-only): dashboard web + carte monde
Pipeline web data-only complet :

Cote feeds, OSC target :57124 ajoute au profil data-only.toml
pour que le bridge.py diffuse vers : SC (57121) + oF (57123)
+ Web (57124).

Cote web (data_only_viz/web/) :
- server.js : Express :3211 + WS broadcast + osc UDP :57124
  parse les paths /data/<feed>/<sub> en JSON et diffuse
- public/dashboard.html : grille de cards live avec sparklines
  SVG vanilla (USGS, SWPC Kp/wind/Bz/X-ray, Blitz, OpenSky,
  Bluesky, meteo, air, ISS, volcans, social_buzz, grid)
- public/map.html : Leaflet dark fullscreen avec markers
  ephemeres (60s TTL) pour seismes/foudre/avions/volcans +
  marker ISS persistant qui suit la position

Cote launcher :
- ProcessManager : startDataWeb / stopDataWeb + openDataDashboard
  / openDataMap, port :3211 hardcode, script attendu a
  metalVizDir/web/server.js
- MenuBarContent : nouvelle row 'Dashboard data-only' en mode
  .dataOnly et .bodyMesh + boutons Dashboard/Carte monde quand
  le serveur tourne
- stopAll inclut dataWebProc
2026-05-13 16:50:54 +02:00
L'électron rare 39d8739f4c feat(data-feeds): 5 nouvelles sources data-only
Ajoute 5 feeds open-data sans cle, branches sur le profil
config.data-only.toml :

- openmeteo : meteo locale (temp, vent, humidite, pression, pluie)
  via api.open-meteo.com, poll 10 min, lat/lon configurables
- openaq : qualite de l'air autour d'une position (PM2.5, PM10,
  NO2, O3) via openaq.org v3, poll 15 min, radius configurable
- iss : position ISS temps reel via wheretheiss.at, poll 5 s,
  emet aussi un event /pass quand la station entre dans un
  rayon configurable autour de l'observateur
- volcano : eruptions actives 7-jours via volcano.si.edu (GVP),
  poll 1 h, dedup par eventid, emet /eruption + /active count
- social_buzz : Reddit /r/all hot + HackerNews top, poll 1 min,
  emet score/comments moyens par source + /pulse normalise

Tous les feeds suivent le pattern Context.send(sub, *args).
2026-05-13 14:44:50 +02:00
L'électron rare 8e05bc5751 perf(data-only-viz): swap Multi-HMR to ViT-S 672
Multi-HMR ViT-S is the only S variant published (672x672 only).
Camera bench on M5 MPS: 3.8 fps median (228 ms/frame), no speedup
vs ViT-L — bottleneck is SMPL-X head, not DINOv2 backbone.
Heartbeat moved before no-detect early-return so FPS metric stays
meaningful with empty camera view.
2026-05-13 14:27:16 +02:00
L'électron rare 29ea203c67 feat(av-live-body): rendu fil de fer (toggle UI)
Le toggle 'Fil de fer' du panel etait un placeholder UI sans
rendu. Implementation maintenant fonctionnelle :

- loadFaces precompute les indices d'aretes : pour chaque
  triangle (a,b,c) on emet 3 lignes (a,b)(b,c)(c,a). 62724
  aretes total pour 20908 triangles SMPL-X.
- makeEntity cree un ModelEntity sibling avec un LowLevelMesh
  en topologie .line, UnlitMaterial blanc, attache comme child.
- updateMeshVertices ecrit les memes positions dans le buffer
  du wireframe (seulement si actif).
- applyWireframeSetting toggle entity.isEnabled sur tous les
  wireframes vivants.
- BodyView.updateNSView appelle applyWireframeSetting avec
  settings.showWireframe a chaque tick.
2026-05-13 13:56:56 +02:00
L'électron rare c631f62a27 chore: gitignore ML weights + Timer cleanup 2026-05-13 13:47:14 +02:00
L'électron rare 1ed006966c fix(sound-algo): merge 00_load into single TLB
BASE PATH block (TLB:1) and CHARGER TOUT block (TLB:2) shared the
same ~base auto-detect preamble. Merged into one block so .load
evaluates the full loader, not just the base-path setter.
2026-05-13 13:45:59 +02:00
L'électron rare 300d501f67 docs(sound-algo): SynthDef count 90 to 1099 2026-05-13 13:44:32 +02:00
L'électron rare a276d00bc6 feat(launcher): AV-Live-Body row dans menubar
Ajoute une ProcessRow AV-Live-Body toujours visible dans le
menubar (sauf en mode bodyMesh ou la ligne est deja affichee).
L'utilisateur peut maintenant lancer la fenetre RealityKit a la
demande depuis n'importe quel mode pour acceder au panel de
reglages visuels (touche S).

Le guard 'useMultiHMR' dans startBodyApp est supprime : sans
worker Multi-HMR la fenetre affichera juste la cam et un fond
vide pour le mesh, mais le panel reste utilisable pour tester
les sliders / toggles.
2026-05-13 13:43:58 +02:00
L'électron rare 9cbcbd2025 chore(oscope): remove dead settings.json fields 2026-05-13 13:43:48 +02:00
L'électron rare c924847dfc docs(osc): document dual /data/pose listeners 2026-05-13 13:42:58 +02:00
L'électron rare d4ac1da686 chore(oscope): remove dead /oscope/* listeners
/oscope/fx/<name> and /oscope/glitch handlers deleted from
OscClient.cpp — grep across sound_algo/, data_only_viz/,
launcher/, data_feeds/, web_realart/ (*.scd *.py *.swift
*.js *.html) found zero emitters.

OscClient::fx() now always returns its fallback; consumeGlitchPulse()
always returns false — same observable behaviour since these paths
were never populated at runtime.

Local build not verified: openFrameworks not symlinked on GrosMac
(../../../libs/openFrameworksCompiled missing). Change is a pure
handler removal with no logic side-effects.
2026-05-13 13:40:46 +02:00
L'électron rare 1ec57ae3f9 fix(sound-algo): remove hardcoded user paths 2026-05-13 13:38:35 +02:00
L'électron rare daba927b1e test(state): SMPLXPerson ndarray contract 2026-05-13 13:35:37 +02:00
L'électron rare 44bc0f0d28 test(multihmr): is_available + lock discipline 2026-05-13 13:34:10 +02:00
L'électron rare e3ad38eafd test(smplx-tcp): timeout + reconnect lifecycle 2026-05-13 13:31:34 +02:00
L'électron rare fcd7857c44 test(smplx-tcp): truly non-contiguous fixture 2026-05-13 13:30:01 +02:00
L'électron rare fca86269e2 feat(av-live-body): SettingsPanel en francais
Le projet est dirige en francais (CLAUDE.md root), donc l'UI
utilisateur suit. Traduction des libelles visibles :
- Reglages AV-Live-Body / Couches / Webcam / Maillage / Lumieres / Vue
- Maillage SMPL-X / Fil de fer / Squelette (articulations)
- Opacite / Metallique / Rugosite
- Principale / Remplissage / Contre-jour
- Champ de vision / Luminosite du fond

Les commentaires et identifiants Swift restent en anglais
conformement aux conventions du repo.
2026-05-13 13:28:47 +02:00
L'électron rare 0b8a21e6aa test(smplx-tcp): serialize round-trip + offsets 2026-05-13 13:27:51 +02:00
L'électron rare 72006d92b7 feat(av-live-body): layers section in settings
Restructures SettingsPanel into 5 sections : Layers / Webcam /
Mesh / Lights / View. Adds explicit toggles for each visual
layer with a pink icon when active : webcam, mesh fill,
wireframe, skeleton joints. The two new layers (wireframe and
skeleton) are wired into RenderSettings but not yet rendered ;
the toggles are the contract for the next renderer pass.

Panel is now scrollable + larger (320x620) to fit the extra
controls cleanly.
2026-05-13 13:26:12 +02:00
L'électron rare 041c6b42be chore(viz): add per-stage perf timers 2026-05-13 13:22:21 +02:00
L'électron rare dce27dc5a2 fix(main): fail fast on missing multihmr ckpt 2026-05-13 13:19:18 +02:00
L'électron rare 085d5806e4 refactor(smplx): drop unused joints_3d field 2026-05-13 13:17:08 +02:00
L'électron rare be6d0b39b0 perf(smplx): numpy tobytes serialization
Replace 10475-iteration struct.pack loop with
np.ascontiguousarray(...).tobytes() — one C call per field.
SMPLXPerson fields now typed as np.ndarray; worker stops
converting to Python tuples. Wire format byte-identical.
2026-05-13 13:14:04 +02:00
L'électron rare c2777fc6f9 feat(av-live-body): live settings panel
Adds RenderSettings ObservableObject (cam opacity, bg brightness,
mesh metallic/roughness/visible, 3 light intensities, FOV, panel
visibility) and a SettingsPanel SwiftUI overlay with sliders and
toggles for each.

ContentView wraps BodyView in a ZStack with a corner button + an
on-receive keyboard handler ('S') that toggles the panel. BodyView
applies every setting in updateNSView : preview opacity, container
bg, ARView FOV, the three DirectionalLight intensities, and
MeshRenderer.applyMaterialSettings rebuilds SimpleMaterial with
the new metallic/roughness on each call.

BodyView also tightens the layer chain that was visually ambiguous :
arView.layer.isOpaque = false (clear background actually honored by
the compositor) and preview.zPosition = -100 so the cam stays
strictly behind the AR layer.
2026-05-13 13:13:57 +02:00
L'électron rare 62fd30e936 fix(smplx-tcp): add timeout + TCP_NODELAY 2026-05-13 13:11:24 +02:00
L'électron rare d266a98ef2 perf(av-live-body): drop stale frames on backlog 2026-05-13 13:09:27 +02:00
L'électron rare 8294c56486 fix(av-live-body): cap TCP buffer at 8 MiB 2026-05-13 13:06:09 +02:00
L'électron rare b0d3ec15aa fix(smplx): cpu fallback when mps unavailable 2026-05-13 13:02:48 +02:00
L'électron rare 722208e0eb docs(plans): body mesh perf + tests + cleanup
Three plans from the 2026-05-13 audit follow-up:
- body-mesh-perf-safety (8 tasks): critical fixes + numpy
  serializer + drop joints_3d + headless fail-fast +
  instrumentation
- smplx-test-coverage (4 tasks): TCP serialize, lifecycle,
  worker availability, dataclass contract
- av-live-cleanup (8 tasks): sound_algo paths, OSC orphans,
  doublons, settings.json, README, TLB, gitignore, Timer

Execution order: 1 -> 3 -> 2.
2026-05-13 13:00:07 +02:00
L'électron rare 67dc83b34e feat(av-live-body): cam overlay + 3-pt lighting
BodyView keeps the AVCaptureVideoPreviewLayer behind the ARView
but drops its opacity to 0.35 so the SMPL-X mesh stays the
dominant visual subject while the webcam frame provides spatial
context.

The ARView gets a three-light rig (key 4000 lm warm from upper
right, fill 1500 lm cool from upper left, rim 2000 lm white from
behind) so the per-vertex normals computed in MeshRenderer give
visible shading instead of flat color blobs.

Also captures two follow-up plans : body-mesh-perf-safety and
smplx-test-coverage, both stay as docs only for now.
2026-05-13 12:59:12 +02:00
L'électron rare 352eab18cc feat(launcher): refine bodyMesh + dataOnly modes
- bodyMesh keeps SC + data_feeds (audio + sonification) in addition
  to Multi-HMR worker headless + AV-Live-Body. Only oscope-of and
  the web UI are stopped.
- dataOnly now keeps the web UI (the user wants Hydra/control panel
  available even in the Metal-viz-only mode). Only oscope-of is
  stopped on transition.
- MenuBar shows Web UI row in both .full and .dataOnly, and a
  Data Feeds row in all three modes (with a distinct subtitle in
  bodyMesh that flags it as sonification-only).
- ModePicker subtitles updated to reflect the new contract.
2026-05-13 12:56:01 +02:00
L'électron rare c49f171334 feat(launcher): add 'Body Mesh' launch mode
A third LaunchMode (.bodyMesh) joins .full and .dataOnly. It runs
the minimal pipeline : Multi-HMR worker in headless mode + the
Swift AV-Live-Body app, nothing else. No SuperCollider, no
oscope-of, no data feeds, no web UI.

applyModeTransition tears down everything else when switching in
and forces useMultiHMR = true so startMetalViz takes the headless
+ AV-Live-Body branch.

MenuBarContent renders a focused two-row layout in this mode :
'Multi-HMR worker' + 'AV-Live-Body'. ModePickerWindow gets a
pink figure.walk card alongside the two existing ones.
2026-05-13 12:51:58 +02:00
L'électron rare b6badea216 fix(av-live-body): per-vertex normals for shading
The mesh was rendered as flat color blobs because LowLevelMesh
had only a position attribute, leaving SimpleMaterial (which is
lit) without normals to compute shading. Each face ends up with
identical lighting, no depth cue.

createLowLevelMesh now declares a second vertex buffer for
normals (semantic .normal, bufferIndex 1, same float3 stride).
updateMeshVertices recomputes per-vertex normals each frame by
accumulating triangle normals and normalising, then writes the
buffer in place. Cost is O(faces + verts) per person : ~63 k
cross products for 10 475 verts / 20 908 triangles, comfortably
under a millisecond on M5.

Result : the SMPL-X mesh now has visible relief instead of a
chromatic silhouette.
2026-05-13 12:44:42 +02:00
L'électron rare 934bf99ba7 chore(viz): address final review nits
- renderer: replace magic literal 10 with 2 * SKEL_VERT_FLOATS (x2)
- renderer: remove dead MESH_MAX_TRIS outer guard; add per-person break
  after body/face/hand loops when MESH_MAX_VERTS reached
- test: modernize __builtins__ pattern to use builtins module directly
2026-05-13 12:43:27 +02:00
L'électron rare e5e133a26b fix(av-live-body): coord conversion + winding flip
Three coupled changes so the SMPL-X mesh actually shows up in the
RealityKit ARView when fed from Multi-HMR :

- Multi-HMR camera frame is y-down z-forward, RealityKit is
  y-up z-back. We flip v.y and v.z in updatePersons, leaving
  the entity at the origin since v3d is already absolute camera
  coordinates (Python sender ships them post-translation).
- Flipping y and z reverses chirality, so the front-facing
  triangles become back-facing and backface culling hides the
  whole mesh. We swap each triangle's i1/i2 in loadFaces.
- A DirectionalLight is added in BodyView so SimpleMaterial,
  which is lit, doesn't render the mesh as a black silhouette.

Logged once per pid when an entity is first created : the
bounding box of the converted vertices (helps spot scale or
sign issues in the future).
2026-05-13 12:43:13 +02:00
L'électron rare 18368b2ded feat(detrpose): expose model size as CLI flag
DETRPose ships in three sizes (n/s/l); previously switching required
editing the DEFAULT_MODEL_SIZE constant in source. Add _VALID_SIZES and
_configure_model_size() to DETRPoseWorker for validated size selection,
and wire --detrpose-model-size into the argparse CLI in main.py.
2026-05-13 12:31:11 +02:00
L'électron rare a1b3ba1f91 refactor(smplx): lazy import torch 2026-05-13 12:26:18 +02:00
L'électron rare ae72d963a4 chore(deps): add pytest as dev dependency
Adds the pytest dev-group used by the upcoming tech-debt cleanup
tasks (test_nlf_worker_bailout, test_renderer_allocations) that
will need 'uv run pytest' to work in CI / locally.
2026-05-13 12:24:08 +02:00
L'électron rare 498ca2be70 fix(renderer): cap mesh writes at MESH_MAX_VERTS 2026-05-13 12:23:38 +02:00
L'électron rare d033282aa3 perf(renderer): preallocate mesh cpu buffer 2026-05-13 12:19:07 +02:00
L'électron rare 052d1d0927 feat(layout): single-window mode + headless py
User wants a single visible window. data_only_viz now skips
NSWindow / Metal / HUD entirely when --multi-hmr is set, keeping
only the OSC listener and the Multi-HMR worker that streams
vertices to AV-Live-Body. AV-Live-Body keeps the camera preview
behind the SMPL-X mesh and stays the sole UI surface.

Tradeoff: the generative Metal scenes (storm, tunnel, plasma...)
and the multi-person skeleton overlay drawn by data_only_viz are
not visible in multi-hmr mode. They remain available by running
the launcher without the Multi-HMR toggle.
2026-05-13 12:18:59 +02:00
L'électron rare a69d096d15 fix(camera): strict built-in filter both sides
Both the Python AVCapture path and the Swift CameraPreviewLayer
now enforce the same two-step filter :
 1. deviceType == .builtInWideAngleCamera
 2. localizedName must NOT contain 'iPhone', 'GSM', 'Desk View',
    or 'Continuity'

The second clause matters because under some macOS configurations
(Continuity Camera promoted to widescreen, iPhone mirroring),
externally tethered phone cameras can advertise themselves with
the same primary deviceType.

Swift side also logs via NSLog so the picked device shows up in
Console.app even when stdout is buffered.
2026-05-13 12:01:14 +02:00
L'électron rare 20acbeb704 perf(renderer): preallocate skeleton cpu buffer
Replace per-frame floats list + struct.pack in _update_skeleton
with a preallocated numpy.float32 staging buffer (_skel_cpu_buf),
eliminating the dynamic allocation on every draw call (~60/s).
2026-05-13 11:58:32 +02:00
L'électron rare 5eefe7baa3 feat(av-live-body): webcam preview behind mesh
Adds CameraPreviewLayer that runs its own AVCaptureSession on the
built-in webcam and exposes the AVCaptureVideoPreviewLayer.

BodyView now layers : an NSView with the preview layer as backing
+ an ARView on top with transparent background. The result is the
live webcam visible behind the SMPL-X meshes — useful for debug
and for the performative angle the user wants.

macOS lets multiple processes open the same camera simultaneously
(each with its own AVCaptureSession), so this coexists fine with
the Python Multi-HMR worker that consumes the camera too.
2026-05-13 11:57:59 +02:00
L'électron rare 146fc335cf feat(multihmr): native AVFoundation capture
Replaces cv2.VideoCapture by a pyobjc AVFoundation wrapper that
selects the webcam by device type (BuiltInWideAngleCamera) rather
than by an opaque cv2 index, fixing the case where the Continuity
Camera (iPhone) was picked up because cv2's enumeration order does
not match AVCaptureDeviceDiscoverySession's.

_av_capture.AVCapture exposes a cv2-like .read(timeout_s) -> (ok,
BGR HxWx3 uint8). Internally :
- AVCaptureSession + AVCaptureVideoDataOutput
- delegate marked with @objc.python_method
- global GCD queue resolved via libdispatch.dylib (the historical
  delegate-on-main-queue crash mentioned in apple_vision_pose is
  avoided this way)
- CVPixelBufferGetBaseAddress returns an objc.varlist ; we use its
  .as_buffer(n) -> memoryview for the zero-copy numpy view

Measured 30 fps on the MacBook Pro built-in at 1920x1080. The
camera_index flag still works but now means 'index in the
AVFoundation enumeration', which is deterministic.
2026-05-13 11:54:31 +02:00
L'électron rare f58f1d40e8 fix(nlf): bail out after persistent inference failures
Add FAIL_THRESHOLD=30 counter: after 30 consecutive inference
failures (NotImplementedError or any Exception), log once at ERROR
and exit the loop instead of spinning at full CPU. Reset on success.
2026-05-13 11:49:59 +02:00
L'électron rare 0d1e97865f docs(plan): data_only_viz tech debt cleanup plan
4 findings : silent NLF inference loop, per-frame allocations in
the Metal renderer, unguarded top-level torch import, hardcoded
DETRPose model size. Out of scope here, kept for future cleanup.
2026-05-13 11:46:21 +02:00
L'électron rare cfab3c908a fix(multihmr): cv2 brightness probe + heartbeat
cv2.VideoCapture indices on Mac don't follow the AVFoundation
discovery order, so the previous auto-pick still ended up on
the iPhone Continuity stream. resolve_camera_index now probes
each cv2 index, reads a frame, and prefers the one whose mean
luminance is >= 50 (rejects black / standby streams from a
locked iPhone or Desk View).

multi_hmr_worker emits a 5 s heartbeat with the observed fps
and the average detected persons per frame, so 'no detection'
becomes visible in the log instead of staying silent.
2026-05-13 11:46:01 +02:00
L'électron rare 511881f56d feat(multihmr): camera selection + --camera-index
The cv2.VideoCapture index on Mac is unpredictable when multiple
cameras are plugged in (Continuity from iPhone, Desk View, external
USB). Adds _camera_select.py that enumerates AVFoundation devices
and picks BuiltInWideAngleCamera by default, mirroring the logic
already used in apple_vision_pose.

Wired through main.py with a --camera-index flag (default -1 = auto)
and MultiHMRWorker honors it. Existing logs now report the chosen
index so the picked device is visible in the launch trace.
2026-05-13 11:41:28 +02:00
L'électron rare b6b381bc29 docs(plans): mark multihmr implemented + keep NLF
Banner on the Multi-HMR plan flipped from OBSOLETE back to
IMPLEMENTED after the NLF re-pivot (NLF TorchScript bundles
a CUDA-hardcoded YOLO detector incompatible with Mac CPU/MPS).
NLF plan stays in tree for the historical analysis.
2026-05-13 11:28:58 +02:00
L'électron rare a682985065 docs(multihmr): final README + scaffold status
MULTIHMR_README.md documents the setup, the launch paths
(launcher toggle vs manual), the architecture diagram, the
deviations from the original plan (NLF abandoned, demo.py
bypassed, v3d used directly), expected FPS on M5 MPS, the
cache layout, and a debug table.

multi_hmr_scaffold.md gets a banner flagging the pipeline
as implemented and pointing at the new README.
2026-05-13 11:25:27 +02:00
L'électron rare 500abb937f perf(av-live-body): use LowLevelMesh for verts
Each frame the Python sender pushes ~125 KB of vertices per person.
The previous MeshRenderer rebuilt the MeshResource from scratch
every frame, costing a copy + driver upload for each entity.

LowLevelMesh (RealityKit, macOS 15+) keeps a persistent GPU vertex
buffer that we mutate in place via withUnsafeMutableBytes. The
faces buffer is written once at entity creation, and we tag each
ModelEntity with a PidComponent so updateMeshVertices can look up
its LowLevelMesh without touching the entities-by-pid dictionary
from the render path.

Package.swift bumped to swift-tools 6.0 / macOS 15 for the
LowLevelMesh.parts.replaceAll API ; the target stays in Swift 5
language mode so the existing closures don't need Sendable
annotations.
2026-05-13 11:23:35 +02:00
L'électron rare 7378b879fa feat(launcher): Multi-HMR toggle + body spawn
ProcessManager gains a 'useMultiHMR' UserDefault and a 'AV-Live-Body'
process pair (startBodyApp / stopBodyApp). When useMultiHMR is on,
startMetalViz appends '--multi-hmr' to the Python args and, 0.8 s
later, launches the SwiftPM AV-Live-Body executable from
launcher/AV-Live-Body so the RealityKit listener is ready when the
TCP sender connects on :57130.

MenuBarContent adds a Switch under Metal Viz (visible only in
data-only mode) plus a row for AV-Live-Body once it is running.
stopAll terminates the body app along with the rest of the stack.
2026-05-13 11:20:46 +02:00
L'électron rare c61d25a8b8 fix(multihmr): bypass demo.py + add missing assets
Path to a working model load on Mac :
- demo.py imports multi_hmr_anny (needs private 'anny' pkg) and
  utils.render (needs pyrender + OpenGL offscreen). We skip both
  by stubbing the modules in sys.modules and loading Model
  directly from model.py.
- utils/__init__.py pulls in roma/trimesh/pillow/tqdm ; added
  to the multihmr extra.
- SMPLX_DIR and MEAN_PARAMS are relative paths in
  utils/constants.py ; worker chdir's into the repo for the
  load, restores cwd in finally. Setup script symlinks
  multi-hmr/models -> ../models and downloads
  smpl_mean_params.npz from openmmlab-share.

Smoke test : ViT-L checkpoint loads in 4 s, 317 M params,
48 missing / 16 unexpected state-dict keys (normal cross-arch
drift — SMPL_Layer-side, not the backbone).
2026-05-13 11:18:15 +02:00
L'électron rare d0c29d9a7f feat(av-live-body): RealityKit + TCP listener app
Adds a standalone SwiftPM executable that renders SMPL-X meshes
in a RealityKit ARView and ingests vertices from the Python TCP
sender on :57130.

Components :
- AVLiveBodyApp + ContentView (SwiftUI App entry, @main)
- BodyView (NSViewRepresentable wrapper around ARView with a
  fixed perspective camera 2 m back)
- MeshRenderer (@MainActor ObservableObject ; reads
  smplx_faces.bin from Bundle.module ; maintains a ModelEntity
  per pid ; rebuilds MeshResource each frame, immutable)
- OSCServer (Network.framework NWListener ; parses the SMPX
  binary frame protocol and emits SMPLXPersonData)

Resources are bundled inside Sources/AVLiveBody/Resources/
because SwiftPM rejects '..' traversal in resource paths.

Smoke test : 'nc -zv 127.0.0.1 57130' succeeds after launch.
2026-05-13 11:11:46 +02:00
L'électron rare ed2e0eb9fe feat(main): add --multi-hmr flag + priority wiring
Wires the Multi-HMR + TCP sender path as priority 0 of
_start_pose_worker so '--multi-hmr' routes the webcam through
the SMPL-X mesh pipeline instead of the Apple Vision / MediaPipe
fallback chain.

The flag is opt-in : without it, behaviour stays identical to
the existing pose-detection chain.
2026-05-13 11:06:25 +02:00
L'électron rare 8cd4c1bf8e feat(multihmr): add TCP sender for SMPL-X verts
Streams each frame's SMPL-X persons to a localhost listener on
:57130 using a length-prefixed binary frame (~126 KB per person:
magic, pid, confidence, transl, 10 betas, 10 expression, 10475
xyz floats).

UDP would split each frame across 90+ MTUs ; TCP fragmentation
is the right primitive. The sender retries the connection every
second and logs only once every 5 s while the Swift listener is
absent.
2026-05-13 11:04:36 +02:00
L'électron rare 358f00d040 feat(multihmr): add worker (PyTorch MPS + tracker)
Multi-HMR forward pass on the Mac webcam ; multi-person SMPL-X
vertices flow into State.persons_smplx with persistent IDs.

Plan deviated from the real upstream API : load_model lives in the
demo module (not model.utils), the model expects camera intrinsics
K (not just an image tensor), and each human already exposes the
decoded SMPL-X vertices under 'v3d' / joints under 'j3d', so the
SMPLXDecoder is only used for tests now.

One Euro smoothing is applied on shape and expression coefficients
only ; vertices/joints are taken raw because Multi-HMR already gives
temporally consistent meshes per frame.
2026-05-13 11:03:46 +02:00
L'électron rare 403bb20d53 feat(smplx): add decoder wrapper + neutral test
Adds SMPLXDecoder around smplx.SMPLXLayer with two modes : full decode
from Multi-HMR params (betas+pose+expression+transl) and decode_neutral
for T-pose smoke tests.

Two implementation notes from the test bring-up :
- model_path file -> use file.parent as model_folder (smplx tacks on
  SMPLX_<GENDER>.<ext> internally, single level not double).
- pose tensors are rotation matrices (B, N, 3, 3) ; passing zeros
  collapses the mesh. decode_neutral uses identity matrices.
2026-05-13 11:01:17 +02:00
L'électron rare 2fdda086c2 fix(oscope-of): correct OSC listen port to 57123
Runtime port is 57123 (per bin/data/settings.json) and
all emitters (sound_algo bridge, data_feeds, data_only_viz,
launcher) target 57123. Several internal docs and the
default in ofApp.h still claimed 57122. Align them so the
default matches reality even if settings.json is missing.

Fixes:
- src/ofApp.h:267 default oscListenPort_
- src/OscClient.h:5 header comment
- README.md (3 occurrences, incl. settings.json sample)
- docs/OSC_PROTOCOL.md (5 occurrences incl. diagram)
2026-05-13 10:57:15 +02:00
L'électron rare b639ce7823 docs(readme): refresh stats + add mermaid
- Reconcile counts with actual code:
  * tracks 345 -> 368 (23 albums x 16)
  * backgrounds: 26 ModelVis 3D + 18 ShaderVis + 3 C++
    scenes (Tunnel, VectorCubes, SphereWave)
  * PLY meshes 35 -> 27
  * shaders: 33 GLSL pairs (~65 files) made explicit
  * data_feeds 9 -> 11 modules (incl. GCN + YOLO pose)
- Add dedicated section for data_only_viz: Metal pose viz,
  7 backends, NLF SMPL mesh, OSC bridge back to sclang.
- Add 3 mermaid diagrams alongside ASCII fallbacks:
  top-level arch, audio reactivity, pose pipeline.
- Update architecture table + OSC port list.
2026-05-13 10:57:09 +02:00
L'électron rare 0825b35463 docs: add nested CLAUDE.md tree
Root CLAUDE.md (overview, where-to-look, conventions) plus
nested files for data_only_viz, oscope-of, launcher.
sound_algo already had its own nested tree.

Sized for the ~150-instruction budget: root 43 lines,
each nested 43-52 lines, telegraphic style.
2026-05-13 10:56:55 +02:00
L'électron rare ef288a7664 feat(state): add SMPLXPerson + persons_smplx field
Adds the SMPL-X person record (vertices, joints, translation, betas,
expression) and the corresponding list + last-timestamp fields on the
shared State. Mirrors the NLFPerson shape so renderers can switch
backends without touching the upstream pipeline.
2026-05-13 10:49:57 +02:00
L'électron rare 6932de5e74 feat(smplx): extract face topology to binary
User provided SMPLX_NEUTRAL.npz locally so we can generate the real
faces file. Adds the extraction script and the produced binary
(20908 triangles x 3 indices x int32 LE = 250896 bytes) consumed
by the upcoming AV-Live-Body RealityKit target.
2026-05-13 10:48:59 +02:00
L'électron rare e1880053c8 multihmr: add deps + setup script
Adds the `multihmr` extra (smplx, einops, torchgeometry, ...) and
a setup script that clones the Naver Multi-HMR repo and downloads
multiHMR_896_L (ViT-L, 1.28 GB) with a HuggingFace fallback.

NLF was abandoned because its bundled YOLO detector is TorchScript
traced with CUDA hardcoded (aten::empty_strided fails on CPU/MPS).
Multi-HMR is pure PyTorch and stays MPS-friendly.

SMPL-X NEUTRAL.npz still requires manual registration on
smpl-x.is.tue.mpg.de (MPII academic license).
2026-05-13 10:44:13 +02:00
L'électron rare 304b58b553 nlf: add worker (TorchScript MPS + tracker) 2026-05-13 10:20:19 +02:00
L'électron rare 2c858bfc52 state: add NLFPerson dataclass + persons_nlf field 2026-05-13 10:13:02 +02:00
L'électron rare eb8afd5bac nlf: extract SMPL face topology to binary 2026-05-13 10:11:04 +02:00
L'électron rare cba128a079 nlf: add deps + setup script for checkpoints 2026-05-13 09:46:42 +02:00
L'électron rare a46b408fee docs: multihmr realitykit body mesh plan
Implementation plan for Multi-HMR + RealityKit body
mesh visualizer (generated 2026-05-13).
2026-05-13 09:34:34 +02:00
L'électron rare 1c6bf1039c feat(web-realart): favicon + WebGL scene overhaul
Add favicon.svg. Refactor WebGL app.js with new scene
structure. Add OSC feed display to audio/hydra/index pages.
Update docker-compose ports and service config.
2026-05-13 09:34:19 +02:00
L'électron rare 8237be9b15 chore(viz): gitignore __pycache__ in data-only-viz
Remove accidentally committed bytecode files.
Add .gitignore to exclude __pycache__ and .venv.
2026-05-13 09:34:13 +02:00
L'électron rare 0497a8951a feat(viz): python+metal data-only visualizer
New standalone visualizer: OSC listener, pose bridge,
Apple Vision / CoreML / YOLO pose backends, Euro filter,
fine analysis, mesh topology, holistic renderer.
Metal shader (scene.metal) for GPU-accelerated drawing.
2026-05-13 09:34:01 +02:00
L'électron rare ee434b4b96 feat(sound-algo): data-only mode engine
Add boot.data-only.scd and data_only/ scene engine.
Add data_only_program.scd to live/ folder.
Update data_feeds.scd and example 16 for new OSC paths.
2026-05-13 09:33:56 +02:00
L'électron rare f0a38b3cca feat(oscope): webcam visualizer + pose OSC routing
Extend OscClient with pose address prefix support.
Update WebcamVis to forward skeleton keypoints via OSC.
2026-05-13 09:33:51 +02:00
L'électron rare fac0f7e055 feat(launcher): data-only mode + mode picker
Add ModePickerWindow.swift for live vs data-only.
Update MenuBarContent and ProcessManager for modes.
Bump Info.plist version, update app entry point.
2026-05-13 09:33:47 +02:00
L'électron rare 8913393e4c feat(data-feeds): data-only profile + feeds
Add config.data-only.toml for headless bridge mode.
Update .gitignore to exclude .venv, __pycache__, *.pt.
Improve USGS, SWPC, RTE, GitHub, and pose feeds:
error handling, rate limiting, keypoint emission.
Refactor bridge.py to support -c config file flag.
2026-05-13 09:33:36 +02:00
L'électron rare 4d32e61bae chore: gitignore model weights + claude settings
Add *.pt and .playwright-mcp/ to root .gitignore.
Update .claude/settings.json with latest permissions.
2026-05-13 09:33:24 +02:00
L'électron rare 02de5302c9 feat: add realart web application with Hydra, WebGL, and OSC data feeds
- Implemented Hydra visualizations in `web_realart/public/hydra/app.js` and `index.html`.
- Created WebGL visualizer using Three.js and WebGPU in `web_realart/public/webgl/app.js` and `index.html`.
- Developed main landing page in `web_realart/public/index.html` to showcase different visualizations.
- Set up WebSocket server in `web_realart/server.js` to relay OSC messages to web clients.
- Added health check API endpoint for monitoring server status.
2026-05-11 07:21:04 +02:00
L'électron rare 0d189a2139 feat: add real-time data feeds for various sources
- Implemented new data feeds for Blitzortung, Bluesky, GCN, GitHub, Mempool, Netzfrequenz, OpenSky, RTE éCO2mix, SWPC, USGS.
- Introduced utility functions for rate limiting and hashing.
- Established OSC communication for data reception and processing.
- Created SuperCollider definitions for handling incoming data and generating audio output based on real-time data.
- Added configuration management for API credentials and polling intervals.
2026-05-11 06:59:44 +02:00
792 changed files with 120886 additions and 170 deletions
+67
View File
@@ -0,0 +1,67 @@
{
"permissions": {
"allow": [
"Bash(awk 'BEGIN{p=0;b=0;tlb=0;depth=0} /^\\\\\\(/{if\\(depth==0\\)tlb++; depth++} {for\\(i=1;i<=length\\($0\\);i++\\){c=substr\\($0,i,1\\); if\\(c==\"\\(\"\\)p++; else if\\(c==\"\\)\"\\)p--; else if\\(c==\"[\"\\)b++; else if\\(c==\"]\"\\)b--}} END{print \"P:\" p \" B:\" b \" TLB:\" tlb}' sound_algo/control/data_feeds.scd)",
"Bash(awk '{for\\(i=1;i<=length\\($0\\);i++\\){c=substr\\($0,i,1\\); if\\(c==\"\\(\"\\)p++; else if\\(c==\"\\)\"\\)p--; else if\\(c==\"[\"\\)b++; else if\\(c==\"]\"\\)b--}} END{print \"P:\" p \" B:\" b}' sound_algo/examples/16_data_feeds.scd sound_algo/control/data_feeds.scd)",
"Bash(awk '{for\\(i=1;i<=length\\($0\\);i++\\){c=substr\\($0,i,1\\); if\\(c==\"\\(\"\\)p++; else if\\(c==\"\\)\"\\)p--; else if\\(c==\"[\"\\)b++; else if\\(c==\"]\"\\)b--}} END{print \"P:\" p \" B:\" b}' sound_algo/control/data_feeds.scd)",
"Bash(find . -name \"*.yml\" -path \"*workflows*\" 2>/dev/null *)",
"Read(//Applications/**)",
"Bash(swift build *)",
"Bash(./build.sh)",
"Bash(open build/AVLiveLauncher.app)",
"Bash(defaults delete *)",
"Bash(tccutil reset *)",
"Bash(awk '{for\\(i=1;i<=length\\($0\\);i++\\){c=substr\\($0,i,1\\); if\\(c==\"\\(\"\\)p++; else if\\(c==\"\\)\"\\)p--; else if\\(c==\"[\"\\)b++; else if\\(c==\"]\"\\)b--}} END{print FILENAME\": P:\" p \" B:\" b}' sound_algo/boot.data-only.scd sound_algo/live/data_only_program.scd)",
"Bash(awk '{for\\(i=1;i<=length\\($0\\);i++\\){c=substr\\($0,i,1\\); if\\(c==\"\\(\"\\)p++; else if\\(c==\"\\)\"\\)p--; else if\\(c==\"[\"\\)b++; else if\\(c==\"]\"\\)b--}} END{print \"P:\" p \" B:\" b}' sound_algo/boot.data-only.scd)",
"Bash(awk '{for\\(i=1;i<=length\\($0\\);i++\\){c=substr\\($0,i,1\\); if\\(c==\"\\(\"\\)p++; else if\\(c==\"\\)\"\\)p--}; if\\(p<0||b<0\\) print NR\": \"$0\" [p=\"p\"]\"}' sound_algo/live/data_only_program.scd)",
"Bash(awk '{ *)",
"Bash(awk '{l=$0; i=index\\(l,\"//\"\\); if\\(i>0\\)l=substr\\(l,1,i-1\\); *)",
"Bash(open launcher/build/AVLiveLauncher.app)",
"Bash(brew search *)",
"Bash(brew info *)",
"Bash(ls /Applications/of_v* 2>/dev/null | head -3 *)",
"Read(//opt/homebrew/Caskroom/**)",
"Bash(awk '{for\\(i=1;i<=length\\($0\\);i++\\){c=substr\\($0,i,1\\); if\\(c==\"{\"\\)o++; else if\\(c==\"}\"\\)cl++}} END{print \"metal braces O:\" o \" C:\" cl}' data_only_viz/shaders/scene.metal)",
"Bash(data_only_viz/.venv/bin/python -c ' *)",
"Bash(data_only_viz/.venv/bin/python -m data_only_viz.main -v)",
"Bash(echo \"PID=$!\")",
"Bash(kill 73822)",
"Bash(timeout 3 netstat -an)",
"Bash(netstat -an -p udp)",
"Bash(host mainsfrequenz.de)",
"Bash(host www.mainsfrequenz.de)",
"Bash(echo \"feeds PID=$!\")",
"Bash(/Applications/SuperCollider.app/Contents/MacOS/sclang sound_algo/data_only/boot.scd)",
"Bash(awk '{print $2, $11, $12, $13}')",
"Bash(xargs -r kill -9)",
"Bash(kill -9 95574 95565 95564)",
"Bash(awk '{for\\(i=1;i<=length\\($0\\);i++\\){c=substr\\($0,i,1\\); if\\(c==\"{\"\\)o++; else if\\(c==\"}\"\\)cl++}} END{print \"{:\" o \" }:\" cl}' data_only_viz/shaders/scene.metal)",
"Bash(data_only_viz/.venv/bin/python)",
"Bash(open /Users/electron/Documents/Projets/AV-Live/launcher/build/AVLiveLauncher.app)",
"Bash(awk '{printf \"%-7s %s\\\\n\", $2, substr\\($0, index\\($0,$11\\)\\)}')",
"Bash(ps -p 23891 -o pid,etime,command)",
"Bash(awk '{print $2,$11,$12,$13,$14}')",
"Bash(data_only_viz/.venv/bin/python -c \"import data_only_viz.main; print\\('main OK'\\)\")",
"Bash(awk '{printf \"%-6s %s\\\\n\", $2, substr\\($0, index\\($0,$11\\)\\)}')",
"Bash(awk '{print $2, $11, $12}')",
"Bash(awk '{print $2, $11, $12, $13, $14}')",
"Bash(xargs -r -n1 ps -o pid,etime,pcpu= -p)",
"Bash(data_only_viz/.venv/bin/python -c \"from data_only_viz import main; print\\('main OK'\\)\")",
"Bash(data_only_viz/.venv/bin/python -m data_only_viz.main -v --pose)",
"Bash(defaults write *)",
"Bash(awk '{for\\(i=1;i<=length\\($0\\);i++\\){c=substr\\($0,i,1\\); if\\(c==\"{\"\\)o++; else if\\(c==\"}\"\\)cl++}} END{print \"{:\"o\" }:\"cl}' data_only_viz/shaders/scene.metal)",
"Bash(ps -p 76056 -o pcpu,pmem)",
"Bash(gh release *)",
"Bash(data_only_viz/.venv/bin/python -c \"from data_only_viz import detrpose; print\\('import OK'\\); print\\('is_available:', detrpose.is_available\\(\\)\\); print\\('REPO_DIR:', detrpose.REPO_DIR\\); print\\('CKPT:', detrpose.DEFAULT_CKPT\\)\")",
"Bash(/Users/electron/Documents/Projets/AV-Live/data_only_viz/.venv/bin/python -c \"import Vision; print\\(dir\\(Vision\\)\\)\")",
"Bash(/Users/electron/Documents/Projets/AV-Live/data_only_viz/.venv/bin/python -c ' *)",
"Bash(data_only_viz/.venv/bin/python -m data_only_viz.scripts.convert_coreml)",
"Bash(ps -p 28282 -o pcpu,pmem)",
"Bash(/tmp/av-coreml-export/bin/python -c \"import coremltools; print\\(coremltools.__version__\\)\")",
"Bash(/tmp/av-coreml-export/bin/python -c ' *)",
"Bash(xcrun -sdk macosx metal -c /Users/electron/Documents/Projets/AV-Live/data_only_viz/shaders/scene.metal -o /tmp/scene.air)",
"Bash(sed -i '' 's/A\\\\.c < 0\\\\.3 or B\\\\.c < 0\\\\.3 or C\\\\.c < 0\\\\.3/A.c < 0.15 or B.c < 0.15 or C.c < 0.15/' data_only_viz/renderer.py)",
"Bash(sed -i '' 's/A\\\\.c < 0\\\\.3 or B\\\\.c < 0\\\\.3: continue/A.c < 0.15 or B.c < 0.15: continue/g' data_only_viz/renderer.py)"
]
}
}
+44
View File
@@ -10,6 +10,19 @@ launcher/**/xcuserdata/
launcher/**/*.xcuserstate
launcher/**/Package.resolved
# iphone-arbody — xcodeproj is generated by xcodegen, Local.xcconfig
# carries personal Apple Developer Team ID. Both are local-only.
iphone-arbody/ARBodyTracker.xcodeproj/
iphone-arbody/Config/Local.xcconfig
iphone-arbody/**/xcuserdata/
iphone-arbody/**/*.xcuserstate
iphone-arbody/build/
iphone-arbody/DerivedData/
# SwiftPM build + editor artifacts
.build/
.swiftpm/
# openFrameworks — on garde les shaders + settings.json pour qu'ils
# arrivent sur les autres machines, mais on ignore les binaires.
oscope-of/bin/*
@@ -28,8 +41,39 @@ oscope-of/Project.xcconfig
sound_algo/synthdefs/*.scsyndef
sound_algo/*.log
# ML model weights downloaded at runtime
*.pt
*.ckpt
*.safetensors
*.mlpackage
*.onnx
*.gguf
# Claude MCP artifacts
.playwright-mcp/
# Editors
.idea/
.vscode/
*.swp
*~
# tool session state (claude-mem / remember skill)
.remember/
.serena/
.claude/agent-memory/
# Swift coverage artifacts
*.profraw
# local python venvs (dirs or symlinks)
**/.venv
# corrupted recovery backups
AV-Live-corrupted-*/
# macOS / iCloud collision artifacts (auto-created on rename)
*\ 2
*\ 2.*
*\ 3
*\ 3.*
+3
View File
@@ -0,0 +1,3 @@
[submodule "third_party/SMPLer-X"]
path = third_party/SMPLer-X
url = https://git.saillant.cc/electron-rare/SMPLer-X.git
+83
View File
@@ -0,0 +1,83 @@
# AGENTS.md
Guidance for AI coding agents (Claude Code, Aider, Cursor, etc.) working in this repo.
## Project
`AV-Live` — live-coding audio-visual performance system: SuperCollider sound engine, openFrameworks visualiser driven by a Hantek 6022BL oscilloscope, and a SwiftUI menubar launcher orchestrating everything. Public, GPL-3. Repo `electron-rare/AV-Live`, branch `main`. Multi-host: GrosMac (source), macm1 (sink / Multi-HMR + Apple Vision ANE), iPhone 16 Pro (ARKit/LiDAR pub).
## Tech stack (per sub-project)
| Sub-project | Stack |
|-------------|-------|
| `sound_algo/` | SuperCollider (sclang + scsynth), 1099 SynthDefs, 345 tracks |
| `oscope-of/` | openFrameworks C++, libusb (Hantek bulk), GLSL 150 / GL 3.2 core |
| `launcher/` | SwiftUI menubar app, Swift Package Manager |
| `data_only_viz/` | Python 3.11+ via `uv`, native Metal (pyobjc), multi-backend pose |
| `data_feeds/` | Python data ingestion |
| `web_realart/` | Node.js, Express, OSC bridge |
| `avlivebody-mac/` | SwiftUI body-tracking client (ARKit/SMPL-X mesh, ad-hoc signed for local dev) |
| `iphone-arbody/` | iOS app, ARBodyTracker, publishes `/body3d/kp` via OSC |
## Commands
```bash
# Python sub-projects (uv only)
cd data_only_viz && uv sync && uv run python -m data_only_viz
cd data_feeds && uv sync
# openFrameworks
cd oscope-of && make -j
# Web bridge
cd web_realart && npm install && npm start
# Swift
open launcher/Package.swift # or xcodebuild from CLI
open avlivebody-mac/avlivebody.xcodeproj
```
## Conventions
- Commits: subject ≤ 50 chars, body ≤ 72, no underscore in scope, no AI attribution, never `--no-verify` (hooks enforce).
- Branches: `feat/<name>`, `fix/<name>`, `docs/<name>`, `refactor/<name>`, `chore/<name>`.
- Language: French to the user, English in code/comments/commits.
- No emojis in code/docs/commits unless explicitly requested.
- Python: **always `uv`** (never pip/poetry/conda directly).
- `.gitignore` already excludes `*.pt`, `*.ckpt`, `*.safetensors`, `*.mlpackage` at root — don't commit weights.
- License: GPL-3 (whole repo) — keep new files under a compatible license header when adding third-party code.
## File layout
- `sound_algo/` — SC sound engine (own `CLAUDE.md`)
- `oscope-of/` — visualiser
- `launcher/` — macOS menubar
- `data_only_viz/` — pose / mesh / body tracking pipeline (Metal)
- `data_feeds/` — data ingestion
- `web_realart/` — web UI + OSC bridge
- `avlivebody-mac/`, `iphone-arbody/` — body-tracking clients
- `shared/` — cross-sub-project assets
- `third_party/` — vendored deps (CHECK before adding to root deps)
- `tools/` — helper scripts
- `docs/superpowers/plans/` — in-flight plans/specs
- `AV-Live-corrupted-20260514/` — quarantined corrupted snapshot, do not touch
## Domain-specific gotchas
- **mDNS hostnames are required** (`grosmac.local`, `supra-m1.local`) for `AVBODY_HOST` / `MULTIHMR_REMOTE_HOST`. They resist DHCP changes (iPhone hotspot reassigns 172.20.10.x routinely).
- **`POSE_FILTER` chain ordering is load-bearing**: default is `median+kalman+lookahead+ik`. Extras must be inserted at the right stage — `one_euro_joints` BEFORE kalman, `one_euro_bones` AFTER SMPL-X fusion in `multi.py`. `arkit_fuse` overrides 14 body slots with ARKit ARSkeleton3D from iOS app via `/body3d/kp` on `:57128` (always-on listener).
- **`ICP_FUSION=1`** requires `ICP_LIDAR_HOST` (iPhone IP), `ICP_LIDAR_PORT` (default 5500, iPhone ARMesh TCP), and an extrinsic JSON at `~/.config/av-live/lidar_extrinsic.json`. See `docs/ICP_FUSION.md`.
- **iPhone OSC port `57128`** is hardcoded as the publish target for `/body3d/kp` — don't reassign.
- **`avlivebody-mac` requires ad-hoc signing for local dev** (fixed in `85589f2`). Don't strip the signing identity.
- **`onVideoFrame` retain cycle in avlivebody** was fixed in `3b5f29e` — when adding new frame callbacks, mind the strong-self capture.
- **AVLive-Body legacy** has been archived (`9e1482e`); the canonical client is `avlivebody-mac`. Don't reintroduce paths to the old project.
- **macm1 = sink** (Multi-HMR CoreML + Apple Vision ANE + SMPL-X TCP); GrosMac = source. Mind the direction when wiring new OSC topics.
- **Each major sub-project has its own `CLAUDE.md`** — closest wins. Put cross-cutting rules here, sub-project specifics in the nested file.
## When in doubt
- Read root `CLAUDE.md` and the nested `CLAUDE.md` of the sub-project you're editing.
- Recent commits: `git log --oneline -20`.
- Plans: `docs/superpowers/plans/`.
- Cluster context: `~/CLAUDE.md` (GrosMac / macm1 / iPhone topology).
- For sound: read `sound_algo/CLAUDE.md` before touching SynthDefs.
+110
View File
@@ -0,0 +1,110 @@
# AV-Live
Live coding audio-visual performance system : moteur SuperCollider, visualiseur openFrameworks piloté par un oscilloscope Hantek 6022BL, app menubar macOS qui orchestre le tout. **RC0.1+ (tag `v0.1.0-rc1`)** ajoute le pipeline Multi-HMR distribué M5 ↔ macm1 sur LAN gigabit + 10 scènes Metal pose-réactives + unified 3D armature wireframe (body+face+hands).
## Communication
Toujours répondre en français à l'utilisateur. Code, commentaires de code, commits et docs en anglais.
## Stack par sous-projet
| Sous-projet | Stack |
|-------------|-------|
| `sound_algo/` | SuperCollider (sclang + scsynth), 1099 SynthDefs, 345 tracks |
| `oscope-of/` | openFrameworks C++, libusb (Hantek bulk), GLSL 150 GL 3.2 core |
| `launcher/` | SwiftUI menubar app, Swift Package Manager |
| `data_only_viz/` | Python 3.11+ via `uv`, Metal natif (pyobjc), multi-backends pose |
| `web_realart/` | Node.js, Express, OSC bridge |
## Where to Look
| Tâche | Emplacement |
|-------|-------------|
| Ajouter / modifier un SynthDef, track, palette | `sound_algo/` (déjà documenté en nested) |
| Toucher au visualiseur, shaders, FFT, Hantek | `oscope-of/` |
| Modifier l'app menubar macOS | `launcher/` |
| Détection pose / mesh / body tracking | `data_only_viz/` |
| Bridge web / UI de live coding | `web_realart/` |
| Plans / specs en cours | `docs/superpowers/plans/` |
| Multi-HMR remote server pyobjc | `data_only_viz/scripts/multihmr_server.py` (tourne sur macm1) |
| Mesh dense rigger 27 fps perçu | `data_only_viz/mesh_rigger.py` |
| 3D wireframe armature (body+face+hands) | `launcher/AV-Live-Body/Sources/AVLiveBody/Skeleton3DRenderer.swift` |
| Pose → Metal scenes uniforms | `launcher/AV-Live-Body/Sources/AVLiveBody/BodyView.swift` + `Resources/scene.metal` |
| Filter chain (median + Kalman + lookahead + IK) | `data_only_viz/pose_filter.py` |
| DINO re-id pid matching | `data_only_viz/dino_reid.py` |
## RC0.1+ environment variables
| Env | Default | Effect |
|-----|---------|--------|
| `MULTIHMR_BACKEND` | `pytorch` | `pytorch`, `coreml`, `remote` |
| `MULTIHMR_REMOTE_HOST` | `127.0.0.1` | macm1 IP for remote inference |
| `MULTIHMR_REMOTE_JPEG` | `1` | JPEG q=80 on the wire |
| `MULTIHMR_REMOTE_ASYNC` | `1` | client double-buffer queue |
| `MULTIHMR_SERVER_BACKEND` | `pyobjc` | server: `pyobjc` or `coremltools` |
| `MULTIHMR_LOOP_FPS` | `30` | Python worker loop target_fps |
| `AVBODY_HOST` | `127.0.0.1` | route TCP mesh + OSC to remote AVLiveBody |
| `MEDIAPIPE_DELEGATE` | `gpu` | Metal SRGBA (faster); `cpu` fallback for stability |
| `POSE_FILTER` | `median+kalman+lookahead+ik` | filter chain stages |
| `MULTIHMR_REID` | `dino` | DINO cosine matching, `iou` fallback |
## Network topology
| Host | mDNS | IP (DHCP) | Role |
|------|------|-----------|------|
| GrosMac M5 | `grosmac.local` | LAN | Source + visualisation (AVLiveBody + data_only_viz + data_feeds) |
| macm1 M1 Max | `supra-m1.local` | `192.168.0.175` | Sink (Multi-HMR CoreML + Apple Vision ANE + SMPL-X TCP) |
| iPhone 16 Pro | (Personal Hotspot) | DHCP | ARKit/LiDAR pub via OSC `/body3d/kp` |
`AVBODY_HOST` / `MULTIHMR_REMOTE_HOST` accept mDNS hostnames — résiste aux changements DHCP (notamment iPhone hotspot 172.20.10.x).
## Environment variables
| Env | Default | Effect |
|-----|---------|--------|
| `POSE_FILTER` | `median+kalman+lookahead+ik` | filter chain stages — extra: `one_euro_joints` (joint-space CHI 2012 One Euro, inserted before kalman), `one_euro_bones` (bone-vector One Euro applied after SMPL-X fusion in multi.py), `arkit_fuse` (overrides 14 body slots with ARKit ARSkeleton3D from the iOS app, expects /body3d/kp on :57128) |
| `IPHONE_OSC_PORT` | `57128` | UDP port the iPhone ARBodyTracker app pushes /body3d/kp to (always-on listener in data_only_viz) |
| `VIDEO_ROTATE` | `none` | rotate the source video frame before MediaPipe + display: `none`/`ccw`/`cw`/`180`. Applied at multi.py read; pose detection + skeleton overlay stay aligned. ARKit 3D joints (gravity-aligned world) are unaffected |
| `ICP_FUSION` | `0` | `1` to enable LiDAR↔SMPL-X ICP fusion (cf. `docs/ICP_FUSION.md`) |
| `ICP_LIDAR_HOST` | _(unset)_ | iPhone ARBodyTracker IP when `ICP_FUSION=1` |
| `ICP_LIDAR_PORT` | `5500` | iPhone ARMesh TCP port |
| `ICP_LIDAR_EXTRINSIC` | `~/.config/av-live/lidar_extrinsic.json` | extrinsic JSON path |
| `FINGER_PIANO` | `0` | `1` enables air-piano finger strike emission from data_only_viz |
| `FINGER_STRIKE_VEL` | `0.02` | downward relative-velocity threshold for a strike (normalized image units/frame) |
| `FINGER_STRIKE_REFRACTORY_MS` | `120` | min ms between strikes per finger |
| `FINGER_SOURCE` | `auto` | hand source for finger gestures: `auto` (iPhone Vision hands if fresh, else MediaPipe), `iphone`, `mediapipe` |
| `FINGER_DEBUG` | `0` | `1` logs each detected strike/pinch (hand, finger) — for threshold tuning |
| `PINCH_RATIO_ON` | `0.50` | thumb-to-finger distance / hand size below which a pinch engages (0.50 validated live 2026-07-02 for ring/pinky reach) |
| `PINCH_RATIO_OFF` | `0.65` | distance/size above which the pinch re-arms (hysteresis) |
| `PINCH_REFRACTORY_MS` | `250` | min ms between pinches per finger |
| `PINCH_MARGIN` | `0.05` | closest-wins margin between nearest and runner-up fingertip (0.05 validated live: 0.20 rejected middle/ring/pinky pinches) |
| `PINCH_EXT_RATIO` | `1.35` | tip-to-wrist distance / hand size above which a finger counts as extended (open-hand pinch gate) |
| `PINCH_EXT_MIN` | `1` | min extended non-pinching fingers required for a pinch to engage; `0` disables the open-hand gate (1 validated live: inner-finger pinches curl neighbours) |
| `PINCH_DEBOUNCE_FRAMES` | `3` | consecutive qualifying frames before a pinch engage fires (release stays immediate); `1` disables |
| `HAND_CONF_MIN` | `0.45` | min Vision hand confidence to draw (raised from 0.3; applies to both overlay and side panels) |
| `HAND_PERSIST_FRAMES` | `3` | consecutive frames a hand track must match before being drawn (1 = off, all hands drawn immediately) |
| `HAND_SWAP_LR` | `0` | Safety knob: invert the Vision chirality interpretation when routing hands (gestures + panels). Chirality validated correct live 2026-07-02; default `0`. Set `1` if a future iPhone app build delivers inverted chirality |
| `HAND_NEAR_MIN` | `0.10` | Minimum wrist-to-middle-MCP distance (normalised) for a hand to trigger pinch/strike. Hands smaller than this are gated out. Display panels are unaffected |
| `HAND_PERSIST_GRACE` | `2` | grace frames before dropping a hand track (0 = strict per-frame) |
| `HAND_NEAR_OFF` | `0.08` | hand size hysteresis off-threshold for gesture slot stabilizer |
| `HAND_HOLD_FRAMES` | `2` | gesture slot: frames to hold last hand on Vision miss |
| `HAND_FACE_MIN` | `0.5` | palm-spread ratio threshold — gesture slot arms only when hand_facing >= this (palm-facing ~0.7-1.0, side-on < 0.4; 0.8× hysteresis for off) |
| `ARKIT_BONE_MAX` | `0.5` | max ARKit body bone length in normalized units; longer bones are skipped (tracking-loss garbage filter) |
| `PINCH_ENABLE` | `0` | enable pinch gesture detection |
| `VIZ_HUD` | `0` | show debug HUD overlay |
| `CONCERT_MIRROR` | `1` | mirror video horizontally (!=0 enables) |
## Conventions globales
- Python : **uv** systématiquement (jamais pip/poetry/conda directs).
- Pas d'emojis dans code/docs/commits sauf demande explicite.
- Commits : sujet ≤ 50 char, body ≤ 72 char/ligne, pas d'attribution AI, pas de `--no-verify`, pas d'underscore dans le scope (hooks enforcent).
- `*.pt`, `*.ckpt`, `*.safetensors`, `*.mlpackage` exclus par `.gitignore` racine.
## Agent Workflow
Explore localise → librarian lit (>500 lignes) → tu raisonnes → general-purpose implémente → validator vérifie. Lance les tâches indépendantes en parallèle.
## Guidance imbriquée
Chaque sous-projet majeur a son propre `CLAUDE.md`. Claude charge automatiquement le plus proche du fichier édité (« closest wins »). N'ajouter ici que ce qui s'applique à TOUS les sous-projets.
+40
View File
@@ -0,0 +1,40 @@
# AV-Live Third-Party Notices
AV-Live includes or depends on the following third-party works,
which are licensed under their own terms.
## Body Mesh Recovery Models
### Multi-HMR (NAVER, 2025)
- **License**: NAVER Non-Commercial License
(https://github.com/naver/multi-hmr/blob/main/LICENSE.txt)
- **Use**: Non-commercial only. Body mesh recovery from camera input.
- **Integration**: model checkpoint downloaded at runtime to
`~/.cache/av-live-multihmr/`. No source code vendored.
### SMPLer-X (S-Lab, ECCV 2024)
- **License**: S-Lab License 1.0
(`third_party/SMPLer-X/LICENSE` after submodule init)
- **Use**: Non-commercial only. Whole-body mesh recovery (SMPL-X).
- **Integration**: vendored as git submodule at
`third_party/SMPLer-X` (fork `electron-rare/SMPLer-X`, kept in
sync with `caizhongang/SMPLer-X` or its current upstream).
- **Modifications in fork**: minimal patches for ARM macOS Python
3.14 inference (replace mmdet detector with Ultralytics YOLO,
shim `mmcv.Config` via `mmcv-lite`).
## Implication
**AV-Live integrates non-commercial-only model code from Multi-HMR
and SMPLer-X.** Commercial deployment (paid performances, packaged
installations sold to clients) requires either :
1. Obtaining commercial licenses from NAVER and S-Lab respectively, OR
2. Replacing both integrations with MIT/Apache-licensed alternatives
(see `docs/superpowers/plans/2026-05-13-modern-body-mesh-survey.md`
for candidates like Fast-SAM-3D-Body which is MIT-licensed).
The AV-Live source code itself (sound_algo, oscope-of, launcher,
data_only_viz integration glue) is under GPL-3.0 (see root LICENSE).
+252 -34
View File
@@ -1,49 +1,191 @@
# AV-Live
> **Live coding audio-visual performance system** built around a SuperCollider sound engine, an openFrameworks oscilloscope visualizer driven by a real Hantek 6022BL USB scope, and a macOS menubar launcher that ties it all together.
**Release : `v0.1.0-rc1` (2026-05-14)** — distributed Multi-HMR (M5 ↔ macm1 LAN), unified 3D armature openpos, hybrid mesh rigger 27 fps perceived, 10 pose-reactive Metal scenes. See [RC0.1+ architecture](#rc01-distributed-multi-hmr-architecture) below.
> **Live coding audio-visual performance system** built around a SuperCollider sound engine, an openFrameworks oscilloscope visualizer driven by a real Hantek 6022BL USB scope, a macOS menubar launcher, and a Metal-native pose / body-mesh visualizer that listens to the same audio bus.
>
> 15 scripted demoparties, 41 fullscreen visual backgrounds, 35 3D parametric meshes, 14 retro OS pixel-art tributes, 1099 SynthDefs across 345 tracks, all driven by FFT analysis of the actual audio physically passing through the scope probes.
> 15 scripted demoparties · ~47 fullscreen visuals (26 3D parametric meshes + 18 procedural shaders + 3 dedicated C++ scenes) · 5 OS pixel-art shaders · 14 retro OS logos · 33 GLSL shader pairs (~65 files) · 1099 SynthDefs across 368 tracks (23 albums × 16) · **20 real-world data feeds** · 7 pose-estimation backends · **SMPL-X body mesh** (10 475 vertices via Multi-HMR + RealityKit) · **3 launch modes** (Full / Data-only / Body Mesh) — all reactive to the audio physically passing through the scope probes.
## Top-level architecture
ASCII (always renders):
```
AV-LIVE
┌──────────────────────────────────────────┐
│ AVLiveLauncher.app (menubar) │
└────┬───────────────┬──────────────┬──────┘
│ │
┌───────▼───────┐ ┌────▼─────┐ ┌────▼─────────────┐
│ sclang + │ node │ oscope-of (oF)
│ scsynth │ web ui
│ sound_algo/ │ :3000 │ Hantek bulk USB
│ 1099 SynthDef │ FFT 2048 audio
│ 345 tracks 41 backgrounds
│ humanize+sig │ │ │ │ 15 demoparties │
└───────┬───────┘ └────┬─────┘ └──────┬───────────┘
:57121 OSC :3000 HTTP :57123 OSC in
│ │ │
└───────────────┴──────────────┘
└────┬─────────────────────────┬──────┬───
│ │
┌───────▼────── ┌───────┐ ┌──────▼───┐ ┌▼────────────┐
│ sclang + │ node │ │ oscope-of│ │ data_only_viz
│ scsynth │ web ui │ │ (oF C++) │ │ (Metal py)
│ sound_algo/ │ :3000 │ │ Hantek │ │ webcam pose
│ 1099 SynthDef │ FFT 2048 │ │ 7 backends
│ 368 tracks │ │ │ ~47 bg │ │ NLF / SMPL
└───────┬──────┘ └───┬────┘ └──────┬───┘ └───┬─────────┘
:57121 OSC :3000 HTTP :57123 OSC :57123 OSC out
│ │ ▲ │
└────────────┴─────────────┴──────────────┘
data_feeds/ (11 sources OSC)
```
Mermaid (rendered on GitHub):
```mermaid
flowchart TB
L["AVLiveLauncher.app (menubar SwiftUI)"]
SC["sound_algo/<br/>sclang + scsynth<br/>1099 SynthDefs · 368 tracks"]
W["sound_algo/web/<br/>node :3000"]
OF["oscope-of/<br/>openFrameworks C++<br/>Hantek 6022BL · FFT 2048<br/>~47 backgrounds · 33 shader pairs"]
DOV["data_only_viz/<br/>Metal natif (pyobjc)<br/>7 pose backends · NLF/SMPL mesh"]
DF["data_feeds/<br/>11 real-world sources"]
WR["web_realart/<br/>WebGPU + Web Audio + Hydra"]
L -->|spawn| SC
L -->|spawn| W
L -->|spawn| OF
L -->|spawn| DOV
SC -->|OSC :57123| OF
SC -->|OSC :57123| DOV
DOV -->|OSC pose →| SC
DF -->|OSC :57121| SC
DF -->|OSC :57123| OF
DF -->|OSC :57124| WR
W <-->|WebSocket| SC
```
## Features
### 🎵 Sound engine — `sound_algo/`
- **1099 SynthDefs** auto-loaded (acid_v1..v19, bass808_v1..v19, lead_v1..v19, kicks, drums, world, fx, master)
- **23 albums × 15 tracks = 345 tracks** ready to play
- **1099 SynthDefs** auto-loaded (acid_v1..v19, bass808_v1..v19, lead_v1..v19, kicks, drums, world, fx, master) — 1059 synth + 40 fx confirmed
- **23 albums × 16 tracks = 368 tracks** ready to play (album letters A-W)
- **Subtle humanize** : timing ±3.5 ms, velocity ±10 %, micro-detune ±3 cents per note
- **Synth rotation** : cycles through 19 versions of each SynthDef every 8 notes
- **Auto layering** : 3 voice layers parallel to melody/harmony/acid
- **15 album signatures** with per-album palette, FX drives, layer config
- **OSC bridge** to oscope-of on :57123 (kick/snare/melody/lead/bass/bpm)
- **Web UI** on :3000 with live coding control surface
- **OSC bridge** to oscope-of and data_only_viz on `:57123` (kick/snare/melody/lead/bass/bpm/album)
- **Web UI** on `:3000` with live coding control surface (Express + WebSocket bridge)
- **18 example scripts** in `sound_algo/examples/00..17_*.scd`
### 📺 Visualizer — `oscope-of/`
- **Hantek 6022BL** real USB oscilloscope capture (1-48 MS/s, 2 channels, 8-bit)
- **AudioAnalyzer** : downsamples Hantek to 48 kHz, FFT 2048 (~23 Hz/bin), bands bass/lowMid/mid/treble/kick/snare
- **41 backgrounds** : tunnel 3D, starfield, metaballs, voronoi, twister bars, plasma FBM, rotozoom, truchet kaleidoscope, SDF tunnel raymarched, KIFS fractal, fire, perspective grid, tunnel cubes, caustics, vortex, octahedron chrome, vector cubes, plus Möbius/Klein/trefoil/Lucy/helix/catenoid/Boys/penrose/sphere/icosahedron/dodecahedron/torus/twisted torus/lemniscate/hopf/Lorenz attractor/Enneper/hopf link/supershape/gear/cone/pyramid/rose3D/geosphere/DNA helix/sphere wave 3D
- **Hantek 6022BL** real USB oscilloscope capture (148 MS/s, 2 channels, 8-bit) via libusb bulk transfers
- **AudioAnalyzer** : downsamples Hantek to 48 kHz, FFT 2048 (~23 Hz/bin), bands bass / lowMid / mid / treble / kick / snare
- **~47 fullscreen backgrounds** (enum `BgKind` in `src/ofApp.h:98-113`, 53 enum values) :
- **26 ModelVis 3D parametric meshes** : Möbius, Klein, trefoil, twisted torus, Lucy, helix, catenoid, Boys, lemniscate, Penrose, sphere, icosahedron, dodecahedron, torus, supershape, Lorenz attractor, Hopf, Enneper, Hopf link, gear, cone, pyramid, rose3D, geosphere, DNA helix, hyperboloid
- **18 ShaderVis procedural** : metaballs, voronoi, twister bars, plasma FBM, rotozoom, truchet kaleido, SDF tunnel raymarched, KIFS fractal, fire, perspective grid, tunnel cubes, caustics, vortex, octahedron chrome, Boing ball, Mode7, plasma C64, dot tunnel
- **3 dedicated C++ scenes** : TunnelVis, VectorCubesVis, SphereWaveVis
- **+ starfield** (DemoFx) and **+ live webcam** (WebcamVis)
- **5 OS pixel-art shaders** : Workbench (Amiga), Mac OS Classic, Atari Fuji, Ocean Loader, Win95
- **14 OS logo PNGs** with demoscene FX (mirror reflection, ghost trail, RGB chroma, twister wobble, scanlines, glitch tear) : Win 1.0/3.11/95, Lotus 1-2-3, MS-DOS, Apple Rainbow, Amiga WB, NeXTSTEP, BeOS, OS/2 Warp, IRIX, ZX Spectrum, C64, Atari
- **10 scroller styles** : Classic, Wavy3D, Rainbow, Mirror, Glitch, Neon, Cascade, Chrome, Bouncy, Squashy
- **Demoscene FX** : sine scroller from `greetings.txt`, copper bars, logo bobs, starfield with motion blur
- **Post-FX shader chain** : ACES tone-map, bloom, chromatic aberration, hue rotation, scanlines, vignette, grain, pixelate, kaleido, feedback FBO, glitch displacement, all GLSL 150 GL 3.2 core
- **33 GLSL shader pairs (~65 files)** : 14 backgrounds + 5 OS pixel-art + 9 post-FX/transitions + 5 mesh/3D + shared utilities (`postfx.vert`, `transition.vert`), all GLSL 150 GL 3.2 core
- **Post-FX shader chain** : ACES tone-map, bloom, chromatic aberration, hue rotation, scanlines, vignette, grain, pixelate, kaleido, feedback FBO, glitch displacement
### 🤸 Pose / body mesh — `data_only_viz/` *(new)*
Metal-native fullscreen visualizer (pyobjc, MTKView, ~60 fps) driven by webcam pose detection. Listens to the same OSC bus as `oscope-of` (`:57123`) so audio sync and data feeds drive its shaders identically, **and** ships pose features back to `sound_algo` on `:57121` to close the loop (the dancer steers the synths).
- **7 pose backends** (drop-in via `pyproject.toml` extras) :
| Backend | Module | Model | Status |
|---------|--------|-------|--------|
| MediaPipe Holistic | `holistic.py` | 33 body + 478 face + 42 hand kp | stable |
| YOLOv8-pose | `pose.py` | Ultralytics, 17 COCO kp | stable (MPS) |
| Apple Vision | `apple_vision_pose.py` | VNDetectHumanBodyPoseRequest (ANE) | macOS only |
| Core ML pose | `coreml_pose.py` | YOLO11n-pose CoreML, AVCapture zero-copy | stable |
| DETRPose | `detrpose.py` | DETR transformer, COCO 17 kp, multi-person | manual clone + ckpt |
| NLF | `nlf_worker.py` | SMPL body mesh, 6890 vertices, TorchScript | **CUDA-only** (CPU/MPS bricked) |
| MediaPipe multi | `multi.py` | Pose/Face/Hand ×4 personnes | stable |
- **Renderer** : Metal pipelines compiled at runtime (`shaders/*.metal`), `bg_pipeline` (full-screen FBM) + `skel_pipeline` (skeleton lines). SMPL face topology shipped as binary (`mesh_topology.py`) for RealityKit-compatible mesh rendering.
- **Tracker** : One Euro Filter on keypoints + IoU multi-person association (`scipy.linear_sum_assignment`, ByteTrack-like).
- **OSC out → sclang** `:57121` : `/pose/count`, `/pose/center`, `/pose/wrist`, `/pose/head`, `/pose/sho_span`, `/pose/limb_span`.
- **OSC out → AVLiveBody** `:57126` UDP (mode openpos, mode 9 / touche `p`) : `/pose/skel`, `/face/kp` (68 dlib landmarks), `/hand/kp` (21 × 2 hands), `/pose3d/kp` (33 MediaPipe pose_world_landmarks 3D meters).
- **TCP out → AVLiveBody** `:57130` : SMPL-X dense mesh (10475 verts) frame-packed binary, 30 Hz rigged interpolation between Multi-HMR keyframes.
- **Thread-safe state** : `state.py` exposes `State.lock()` ; dataclasses `PoseKp`, `Kp3D`, `SMPLXPerson`, multi-person container.
## RC0.1+ distributed Multi-HMR architecture
`feat/action-head` + tag `v0.1.0-rc1` (2026-05-14). The body-mesh pipeline is now **distributed across two Apple-silicon Macs over LAN gigabit** :
```
M5 (capture host) macm1 (compute host)
┌─────────────────────────────────┐ ┌──────────────────────────────────┐
│ Caméra MacBook Pro │ │ Multi-HMR server :57140 (pyobjc) │
│ data_only_viz/ │ JPEG q80 │ multihmr_full_672_s.mlpackage │
│ ├─ multi_hmr_worker ├──TCP────▶│ ├─ Pyobjc direct CoreML.fwk │
│ │ backend=remote (async) │ │ ├─ ~87 ms predict (M1 Max GPU) │
│ ├─ MultiHMRRemoteBackend │◀──RSP───┤ └─ 6 outputs (v3d 10475, │
│ │ queue maxsize 2/3 │ │ transl, scores, betas, │
│ │ JPEG encode q80 │ │ expression, joints 127) │
│ │ │ │ │
│ ├─ MediaPipe Holistic │ │ AVLiveBody display (RealityKit) │
│ │ Metal GPU delegate │ │ ├─ MeshRenderer (TCP :57130) │
│ │ pose 33 + face 478 + │ /pose/* │ │ SMPL-X dense, low-level │
│ │ hand 21×2 ├──UDP────▶│ │ mesh, 30 fps rigged interp │
│ ├─ pose_bridge (UDP :57126) │ /face/* │ │ │
│ ├─ smplx_tcp (TCP :57130, 30 Hz) │ /hand/* │ ├─ Skeleton3DRenderer (OSC) │
│ ├─ MeshRigger (Hungarian + DINO │ /pose3d/│ │ 1 fused LowLevelMesh / │
│ │ re-id, sticky pid 0.30/0.15) │ │ │ person : body 33 + face │
│ └─ PoseFilterChain │ │ │ 68 + hand 21×2 = 143 vts │
│ (median + Kalman CV + │ │ │ 288 line indices │
│ lookahead + IK clamps) │ │ └─ 10 Metal scenes (storm, │
│ │ │ tunnel, plasma, kaleido, │
│ TCP loop fps : 25 │ │ voronoi, metaballs, │
│ Multi-HMR fresh fps : 9 │ │ starfield, bars, hands3d, │
│ MeshRigger perceived : 27 │ │ openpos), all consume │
│ │ │ pose uniforms (mouth, │
└─────────────────────────────────┘ │ velocity, head_tilt, │
│ arm_spread, eye_open, │
│ finger_pinch, body_xyz) │
└──────────────────────────────────┘
```
### Key technical wins (commits in `feat/action-head`)
| Commit | Win |
|--------|-----|
| `5800156` | Multi-HMR ViT-S/672 → CoreML mlpackage (FP32, 6 outputs) |
| `52588b9` | roma.rotmat_to_rotvec → branchless atan2 (fixed all-NaN v3d/transl bug) |
| `4e7101c` | NaN/Inf guard on v3d before TCP ship |
| `2c8094c` | MeshRigger : 27 fps perceived via Hungarian pid match + Vision pelvis delta |
| `1f623fe` | AVLiveBody mirror webcam preview (CATransform3D scale -1) |
| `9838da3` | Remote inference protocol (TCP :57140) + multi-buffer async client |
| `67302e7` | Pyobjc server (drops coremltools overhead) + MediaPipe Metal GPU SRGBA |
| `1828d7c` | 12 new SceneUniforms (mouth, eye, head, finger, body) drive 5 scenes |
| `bd46f6e` | Lift Python self-throttle 10 → 30 fps loop, fresh fps metric |
| latest | 10 Metal scenes pose-reactive + SMPL-X 127 joints output (finger props) |
### Environment toggles
| Env var | Default | Effect |
|---------|---------|--------|
| `MULTIHMR_BACKEND` | `pytorch` | `pytorch`, `coreml`, `remote` |
| `MULTIHMR_REMOTE_HOST` | `127.0.0.1` | macm1 IP (gigabit LAN) |
| `MULTIHMR_REMOTE_JPEG` | `1` | JPEG q=80 compression on the wire |
| `MULTIHMR_REMOTE_ASYNC` | `1` | client double-buffer queue (maxsize 2/3) |
| `MULTIHMR_SERVER_BACKEND` | `pyobjc` | server : `pyobjc` or `coremltools` |
| `MULTIHMR_LOOP_FPS` | `30` | Python loop target_fps (formerly capped at 10) |
| `AVBODY_HOST` | `127.0.0.1` | route TCP mesh + OSC pose to a remote AVLiveBody |
| `MEDIAPIPE_DELEGATE` | `cpu` | `gpu` Metal SRGBA (faster but IOSurface flake on M5) |
| `POSE_FILTER` | `median+kalman+lookahead+ik` | toggle filter chain stages |
| `MULTIHMR_REID` | `dino` if mlpackage present | `dino` (cosine match) or `iou` |
| `MULTIHMR_REID_ALPHA` | `0.5` | IoU vs cosine weight (0=DINO only, 1=IoU only) |
### Hardware ceilings observed (M5 + M1 Max 32c GPU, LAN gigabit)
| Path | Predict | Live loop | Mesh rigged perceived |
|------|---------|-----------|------------------------|
| M5 local (PyTorch MPS) | 270 ms | 3.5 fps | 27 fps |
| M5 local (CoreML FP32) | 139 ms | 6.8 fps | 27 fps |
| Remote macm1 (idle GPU) | 53 ms | ~18 fps | 27 fps |
| Remote macm1 (under MLX contention) | 87 ms | 25 fps loop / 9 fps fresh | 27 fps |
| Studio M3 Ultra (80c GPU, *training only*) | est. 35 ms (~30 fps) | — | — |
Hard ceiling on macm1 ≈ 18 fps fresh predict (unified memory bandwidth + CoreML sync overhead) ; further gains require moving MLX servers off macm1 or quantising the model.
### 🎬 Demoparties — 15 narrative demos
Each demoparty is a multi-act scripted show with unique scroller text, background sequence, scroller style, and SuperCollider album track :
@@ -66,7 +208,7 @@ Each demoparty is a multi-act scripted show with unique scroller text, backgroun
| `F9` | CUBE STORM | 8 | Q neurofunk_dnb |
| `F10` | GRAND FINAL | 8 | L future_garage |
Each act lasts 12-55 s, transitions between acts trigger flash + glitch postfx + audio swap. Demos loop on their first act after the last.
Each act lasts 1255 s, transitions between acts trigger flash + glitch postfx + audio swap. Demos loop on their first act after the last.
### 🎛 Live FX control (Mac AZERTY FR friendly)
- `a z e r t y u i o p` — 10 background scenes (BoingBall, Tunnel, Metaballs, Voronoi, Twister, KIFS, Fire, Mode7, Octahedron, PlasmaC64)
@@ -78,8 +220,32 @@ Each act lasts 12-55 s, transitions between acts trigger flash + glitch postfx +
- `Espace` — manual glitch pulse
- `F1``F5` — fullscreen / GUI / postFx / autoglitch / reload shaders
### 🌐 Real-world data feeds — `data_feeds/` + `web_realart/` + `data_only_viz/web/`
- **20 feed modules** in `data_feeds/feeds/` ingested by `bridge.py` and broadcast as OSC `/data/<source>/<sub>` to SC (`:57121`), oF (`:57123`) and the web bridge (`:57124`) :
- **Geophysique** : USGS quakes · Smithsonian GVP volcanoes · Blitzortung lightning
- **Meteo / Air** : Open-Meteo (temp/wind/rain) · OpenAQ (PM2.5/PM10/NO2/O3)
- **Espace** : NOAA SWPC (solar wind, Bz IMF, Kp, X-ray) · ISS position (wheretheiss.at) · GCN astrophysics
- **Mobilite** : OpenSky ADS-B · LiveATC listeners
- **Energie / reseau** : Mainsfrequenz.de · RTE eCO2mix · NOAA tides + moon phase
- **Social / numerique** : Bluesky firehose · Reddit /r/all + HackerNews top · Wikipedia EventStream · GDELT 2.0 events · GitHub · Bitcoin mempool
- **Pose / webcam** : YOLOv8-pose
- **SC presets** `sound_algo/examples/16_data_feeds.scd` and `17_data_feeds_more.scd` map each source to synthesis : Schumann cavity drone (foudre), aurora additive pad (Bz/wind/Kp), Netzfrequenz pulse kick, RTE 8-op carbon FM, OpenSky granular swarm
- **Web standalone** `web_realart/` ports the visualizers and synths to the browser for `real.art.saillant.cc` :
- **WebGPU + three.js TSL** (r171) globe with quake/strike/flight particles (auto-fallback WebGL2)
- **Web Audio** ports of 5 SC SynthDef presets (cavity, mix, geo, aurora, pulse)
- **Hydra** with 7 data-driven patches (aurora, quake, lightning, flightmap, gridpulse, solarwind, bskyrain)
- All three layers share `feeds_client.js` (`window.feeds`) over one WebSocket
- **Web data-only** `data_only_viz/web/` (port `:3211`, Express + WebSocket) — **bidirectional** OSC bridge for the Data-only mode :
- **`/dashboard.html`** — live cards + SVG sparklines for all 19 active feeds (USGS, GDELT, Wiki, ISS, tides + moon, ATC, ...) with severity classes (alert / warn / green)
- **`/map.html`** — Leaflet dark fullscreen with markers ephemeres for geocoded feeds (quakes, lightning, planes, volcanoes, GDELT events) + persistent ISS marker
- **`/control.html`** — 3-pane control surface : 7 synth/mix sliders (master, cutoff, reso, reverb, delay, tempo), 10 audio scene buttons (`/scene/play`), 9 visual mode buttons routed to oF (`/control/vizMode``:57123`), XY pad (`/xy/{x,y}`)
- **SC retour** : `sound_algo/control/web_bridge.scd` listens on `:57121` for `/control/*` `/scene/*` `/xy/*` and pushes `/sync/bpm|beat|rms|voices` to web on `:57125` at 4 Hz
### 📡 Audio reactivity pipeline
ASCII :
```
Hantek probes → bulk USB → ScopeRing CH1/CH2 (1-48 MS/s, 8-bit)
@@ -96,6 +262,38 @@ Hantek probes → bulk USB → ScopeRing CH1/CH2 (1-48 MS/s, 8-bit)
drives every visualizer parameter
```
Mermaid :
```mermaid
flowchart LR
H["Hantek 6022BL<br/>2× probes"] -->|bulk USB| R["ScopeRing<br/>CH1/CH2 148 MS/s 8-bit"]
R --> A["AudioAnalyzer.update()"]
A --> D["Downsample box-filter<br/>→ 48 kHz mono"]
D --> F["Hann + FFT 2048<br/>~23 Hz/bin"]
F --> B["Bands :<br/>bass · lowMid · mid · treble<br/>+ kick & snare transients"]
B --> V["Visualizer params<br/>(backgrounds, post-FX, scroller)"]
```
### 🤸 Pose pipeline — `data_only_viz` *(new)*
```mermaid
flowchart LR
CAM["Webcam<br/>AVCaptureSession"] --> BE{"Backend<br/>(pyproject extras)"}
BE -->|holistic| MP["MediaPipe Holistic"]
BE -->|pose| YO["YOLOv8-pose"]
BE -->|coreml_pose| CM["CoreML ANE"]
BE -->|apple_vision_pose| AV["Apple Vision"]
BE -->|detrpose| DT["DETRPose"]
BE -->|nlf| NLF["NLF SMPL 6890v<br/>CUDA only"]
MP & YO & CM & AV & DT & NLF --> KP["Keypoints / Mesh"]
KP --> EF["One Euro Filter"]
EF --> TR["Tracker IoU<br/>linear_sum_assignment"]
TR --> ST["State (lock)<br/>PoseKp · NLFPerson"]
ST --> MET["Metal renderer<br/>bg + skel pipelines"]
ST --> OSC["OSC /pose/* →<br/>sclang :57121"]
SCIN["sclang OSC :57123<br/>/sync /data"] --> ST
```
## Quick start
### macOS install
@@ -107,7 +305,7 @@ cd AV-Live
# 2. Dependencies
brew install --cask supercollider
brew install libusb node
brew install libusb node uv
# openFrameworks 0.12 : https://openframeworks.cc/download/ (extract to ~/of)
# 3. Build the launcher (universal binary arm64+x86_64)
@@ -118,19 +316,32 @@ ln -s "$PWD/../oscope-of" ~/of/apps/myApps/oscope-of
cd ~/of/apps/myApps/oscope-of
make OF_ROOT=$HOME/of -j4 Release
# 5. Run
# 5. (optional) data_only_viz Metal pose viz
cd ../../../data_only_viz
uv sync # base (Metal + OSC + tracker)
uv sync --extra pose # + MediaPipe / YOLOv8 / Ultralytics
# uv sync --extra nlf # + NLF SMPL body mesh (CUDA only)
uv run python -m data_only_viz.main
# 6. Run the whole stack
open AV-Live/launcher/build/AVLiveLauncher.app
```
The launcher autostarts `sclang` + `oscope-of` + `node` (web UI) on launch. Open the menubar icon for status, logs, restart controls.
The launcher autostarts `sclang` + the relevant visualizers + the web bridges depending on the **3 launch modes** offered by the picker at startup :
- **Full AV-Live** — sclang + oscope-of + sound_algo web UI (`:3000`) + data_feeds (full profile)
- **Data-only** — sclang + `data_only_viz` Metal viz + data_feeds (data-only profile) + sound_algo web UI + **data-only dashboard** (`:3211` with `/dashboard.html`, `/map.html`, `/control.html`)
- **Body Mesh** — sclang + `data_only_viz` (headless, Multi-HMR worker only) + **`AVLiveBody`** Swift app (RealityKit, SMPL-X mesh + webcam overlay + live `RenderSettings` panel `S`) + data_feeds + dashboard data-only
Open the menubar icon for per-process Start/Stop, logs, and the "AV-Live-Body" launch button available from any mode.
### Without Hantek hardware
oscope-of works without the scope plugged in. It falls back to synthesized signals from the OSC `/sync/*` metadata sent by `sound_algo`. You lose the audio-derived FFT but the demoscene visuals + narrative demos all still play.
`oscope-of` works without the scope plugged in. It falls back to synthesized signals from the OSC `/sync/*` metadata sent by `sound_algo`. You lose the audio-derived FFT but the demoscene visuals + narrative demos all still play. `data_only_viz` runs identically since it only needs the OSC bus + a webcam.
## Demoparty mode
Press a digit (1-9, 0) or F-key (F6-F10) to launch a 1-3 minute scripted demoparty. Each one is a journey through audio + visual transitions, with greetings to the demoscene legends along the way (Fairlight, Razor 1911, ASD, Conspiracy, Farbrausch, TBL, Mercury, TPOLM, Loonies, Hokuto Force, IQ, Knighty, Smash, Gargaj…).
Press a digit (19, 0) or F-key (F6F10) to launch a 13 minute scripted demoparty. Each one is a journey through audio + visual transitions, with greetings to the demoscene legends along the way (Fairlight, Razor 1911, ASD, Conspiracy, Farbrausch, TBL, Mercury, TPOLM, Loonies, Hokuto Force, IQ, Knighty, Smash, Gargaj…).
The `0` GREETINGS demo cycles through 18 acts including Boing Ball, plasma C64, dot tunnel, twister bars, copper bars, fractal KIFS, Möbius topology, then a parade of OS logos (Win 1.0, Lotus 1-2-3, Atari, Apple, OS/2 Warp, IRIX, ZX Spectrum), ending on a roll call of demoscene groups.
@@ -140,14 +351,21 @@ The `0` GREETINGS demo cycles through 18 acts including Boing Ball, plasma C64,
|-----------|------|------|
| Sound engine | `sound_algo/` | SuperCollider 3.14+, ~12k LOC `.scd` |
| Sound web UI | `sound_algo/web/` | Node Express + WebSocket bridge OSC↔HTTP |
| Visualizer | `oscope-of/` | openFrameworks 0.12, GLSL 150, ~6k LOC C++ |
| Launcher | `launcher/` | SwiftUI universal app, sentinel-based restart |
| Visualizer | `oscope-of/` | openFrameworks 0.12, GLSL 150 GL 3.2 core, ~6k LOC C++ |
| Pose / body mesh | `data_only_viz/` | Python 3.11+ / `uv`, Metal natif via pyobjc, 7 pose backends |
| Launcher | `launcher/` | SwiftUI universal app, sentinel-based restart, ProcessManager `@MainActor` |
| Real-world feeds | `data_feeds/` | Python `uv`, 20 async feed modules, `bridge.py` OSC fan-out (3 targets) |
| Data-only web | `data_only_viz/web/` | Node Express `:3211` + WS bidir bridge OSC, dashboard / map / control |
| Body mesh app | `launcher/AV-Live-Body/` | Swift 6 + RealityKit, SMPL-X 10 475 verts, LowLevelMesh + wireframe |
| Multi-HMR worker | `data_only_viz/multi_hmr_worker.py` | PyTorch MPS, AVCaptureSession native, TCP sender :57130 |
| Web standalone | `web_realart/` | Node Express + ws, WebGPU three.js r171 + Web Audio + Hydra |
| Audio FFT | `oscope-of/src/AudioAnalyzer.{h,cpp}` | downsample + Hann + FFT 2048 |
| 3D models | `oscope-of/bin/data/models/` | 35 PLY parametric meshes |
| 3D models | `oscope-of/bin/data/models/` | 27 PLY parametric meshes |
| Sprites | `oscope-of/bin/data/sprites/` | 14 PNG OS logos + 5 SVG sources |
| Shaders | `oscope-of/bin/data/shaders/` | 33 GLSL frag+vert shaders |
| Shaders | `oscope-of/bin/data/shaders/` | 33 GLSL pairs (~65 files) frag+vert |
| Metal shaders | `data_only_viz/shaders/` | `.metal`, runtime-compiled |
OSC ports : `scsynth :57110` · `sclang :57121` · `node web :57122` · `oscope-of :57123` · `http :3000`.
OSC ports : `scsynth :57110` · `sclang :57121` · `node web :57122` · `oscope-of :57123` (source: `oscope-of/bin/data/settings.json`) · `web_realart :57124` · `http :3000`.
## License
+4
View File
@@ -0,0 +1,4 @@
*.xcodeproj/
Config/Local.xcconfig
.build/
.swiftpm/
@@ -0,0 +1,3 @@
// Copy to Config/Local.xcconfig and set your Apple Developer Team ID.
// Config/Local.xcconfig is gitignored.
DEVELOPMENT_TEAM = YOUR_TEAM_ID
+8
View File
@@ -0,0 +1,8 @@
#include? "Local.xcconfig"
MACOSX_DEPLOYMENT_TARGET = 15.0
SWIFT_VERSION = 5.10
// Manual ad-hoc signing for local dev (no Apple Mac Development cert
// required). Override here or via target settings for distribution.
CODE_SIGN_STYLE = Manual
CODE_SIGN_IDENTITY = -
+62
View File
@@ -0,0 +1,62 @@
# AVLiveBody (macOS)
Native macOS Xcode app that renders SMPL-X body meshes in RealityKit
from the USB iPhone body-tracking pipeline (ARBodyTracker -> Multi-HMR
worker -> AVLiveBody scene).
## Prerequisites
- macOS 15+
- Xcode 16+
- `xcodegen``brew install xcodegen`
## First-time setup
1. Copy the CoreML model into the app resources (required, gitignored,
~195 MB). Without it the app degrades to skeleton-only rendering:
```
cp -R ~/.cache/av-live-multihmr/multihmr_full_672_s.mlpackage \
avlivebody-mac/Sources/AVLiveBody/Resources/
```
2. Create your local xcconfig and set your signing team:
```
cp Config/Local.xcconfig.example Config/Local.xcconfig
# Edit Config/Local.xcconfig:
# DEVELOPMENT_TEAM = <your Apple Developer Team ID>
```
## Build
Generate the Xcode project (run after every `project.yml` change) then
open or build from the CLI:
```
cd avlivebody-mac
xcodegen generate
open AVLiveBody.xcodeproj
```
CLI build / test:
```
xcodebuild -project AVLiveBody.xcodeproj -scheme AVLiveBody \
-destination 'platform=macOS' build
xcodebuild -project AVLiveBody.xcodeproj -scheme AVLiveBody \
-destination 'platform=macOS' test
```
## Runtime requirements
A tethered iPhone running the matching `ARBodyTracker` iOS app over USB
is required for body input. See `iphone-arbody/` for the iOS side.
## Architecture
- Design spec:
`docs/superpowers/specs/2026-05-18-avlivebody-macos-rewrite-design.md`
- Implementation plan:
`docs/superpowers/plans/2026-05-18-avlivebody-macos-rewrite.md`
@@ -0,0 +1,139 @@
import Cocoa
import CoreVideo
import SwiftUI
/// Forces a regular, keyboard-focusable foreground app.
final class AppDelegate: NSObject, NSApplicationDelegate {
func applicationDidFinishLaunching(_ notification: Notification) {
NSApp.setActivationPolicy(.regular)
NSApp.activate()
}
}
@main
struct AVLiveBodyApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self)
private var appDelegate
var body: some Scene {
WindowGroup {
ContentView()
.frame(minWidth: 900, minHeight: 600)
}
}
}
@MainActor
struct ContentView: View {
@StateObject private var consumer = USBSkeletonConsumer()
@StateObject private var settings = RenderSettings()
@State private var prevPelvis: SIMD3<Float>?
private let controller = SceneController()
private let renderer = SceneRenderer.make()
/// Multi-HMR mesh inference (CoreML, ~150-300 ms/frame) is OFF by
/// default: it would saturate the main thread and beachball the UI.
/// Skeleton-only rendering (iPhone 91-joint ARKit stream) otherwise.
/// Enable on a capable, properly-threaded host with AVBODY_MULTIHMR=1.
private let multiHMR: MultiHMRCoreML? = {
guard ProcessInfo.processInfo.environment["AVBODY_MULTIHMR"] == "1"
else { return nil }
return MultiHMRCoreML()
}()
/// Placeholder intrinsics until a `.meta` frame supplies real ones.
private let cameraK: [Float] = [
672, 0, 336, 0, 672, 336, 0, 0, 1,
]
var body: some View {
ZStack(alignment: .topTrailing) {
LayeredSceneView(controller: controller, renderer: renderer,
showScene: settings.showScene)
StatusBar(consumer: consumer)
.frame(maxWidth: .infinity, alignment: .top)
HStack(alignment: .top) {
Spacer()
if settings.showPanel {
SettingsPanel(settings: settings)
.transition(.move(edge: .trailing))
}
Button {
withAnimation { settings.showPanel.toggle() }
} label: {
Image(systemName: "slider.horizontal.3")
.padding(8)
}
.buttonStyle(.borderedProminent)
.padding(8)
}
}
.onAppear { wire(); applyAll() }
.onDisappear { consumer.stop() }
.onReceive(consumer.$skeletons) { skeletons in
controller.updateSkeleton(skeletons)
pushUniforms()
}
.onReceive(consumer.$hands) { _ in pushUniforms() }
.onReceive(consumer.$face) { _ in pushUniforms() }
.onReceive(settings.objectWillChange) { _ in
// objectWillChange fires before the value updates; apply on
// the next runloop tick so we read the new values.
DispatchQueue.main.async { applyAll() }
}
}
private func applyAll() {
controller.setSkeletonVisible(settings.showSkeleton)
controller.setMeshVisible(settings.showMesh)
controller.setVideoVisible(settings.showVideo)
controller.setVideoOpacity(settings.videoOpacity)
controller.updateMeshMaterial(metallic: settings.meshMetallic,
roughness: settings.meshRoughness)
controller.setLightIntensities(key: settings.keyIntensity,
fill: settings.fillIntensity,
rim: settings.rimIntensity)
controller.setFieldOfView(settings.fieldOfView)
if let r = renderer {
r.uniforms.viz_mode = Float(settings.vizMode)
}
}
private func pushUniforms() {
guard let r = renderer else { return }
r.uniforms.viz_mode = Float(settings.vizMode)
SceneUniformBuilder.fill(&r.uniforms,
skeletons: consumer.skeletons,
hands: consumer.hands,
face: consumer.face,
prevPelvis: &prevPelvis)
controller.updateHandFace(hands: consumer.hands,
face: consumer.face,
depth: Float(-(prevPelvis?.z ?? 0)))
}
private func wire() {
let controller = self.controller
let multiHMR = self.multiHMR
let cameraK = self.cameraK
consumer.onVideoFrame = { [weak consumer] pixelBuffer in
MainActor.assumeIsolated {
controller.updateVideo(pixelBuffer)
guard let consumer, let hmr = multiHMR else { return }
// Snapshot skeletons on main; run the heavy CoreML
// prediction off-main (drops frames if busy), then hop
// back to main for the light fuse + mesh UI update.
let skeletons = consumer.skeletons
hmr.inferAsync(pixelBuffer, cameraK: cameraK) { raw in
DispatchQueue.main.async {
MainActor.assumeIsolated {
let fused = BodyFusion.fuse(
persons: raw,
skeletons: skeletons)
controller.updateMesh(fused)
}
}
}
}
}
consumer.start()
}
}
@@ -0,0 +1,10 @@
import Foundation
import simd
/// ARKit/Multi-HMR world coords (y up, z back) -> RealityKit world
/// coords (y up, z forward). Apply to every vertex/translation that
/// crosses from source pipeline space into the scene.
@inline(__always)
func arkitToRealityKit(_ v: SIMD3<Float>) -> SIMD3<Float> {
SIMD3<Float>(v.x, -v.y, -v.z)
}
@@ -0,0 +1,51 @@
// avlivebody-mac/Sources/AVLiveBody/HandFaceSkeleton.swift
import AVLiveWire
import AppKit
import RealityKit
import simd
@MainActor
final class HandFaceSkeleton {
let root = Entity()
private static let r: Float = 0.008
private static let spanX: Float = 1.2 // maps [0,1] -> [-0.6,0.6] m
private static let spanY: Float = 0.9
private let mesh = MeshResource.generateSphere(radius: r)
private let handMat = SimpleMaterial(color: NSColor.cyan, isMetallic: false)
private let faceMat = SimpleMaterial(color: NSColor.magenta, isMetallic: false)
private var handPool: [ModelEntity] = []
private var facePool: [ModelEntity] = []
private var depth: Float = 0
func setDepth(_ z: Float) { depth = z }
func update(hands: HandsPayload?, face: FacePayload?) {
let hpts = (hands?.hands ?? []).flatMap { $0.points }
layout(&handPool, count: hpts.count, mat: handMat)
for (i, p) in hpts.enumerated() { place(handPool[i], p.x, p.y) }
for i in hpts.count..<handPool.count { handPool[i].isEnabled = false }
let fpts = face?.points ?? []
layout(&facePool, count: fpts.count, mat: faceMat)
for (i, p) in fpts.enumerated() { place(facePool[i], p.x, p.y) }
for i in fpts.count..<facePool.count { facePool[i].isEnabled = false }
}
private func place(_ e: ModelEntity, _ nx: Float, _ ny: Float) {
// normalized top-left [0,1] -> centered meters, y up
e.transform.translation = SIMD3<Float>(
(nx - 0.5) * Self.spanX,
(0.5 - ny) * Self.spanY,
depth)
e.isEnabled = true
}
private func layout(_ pool: inout [ModelEntity], count: Int,
mat: SimpleMaterial) {
while pool.count < count {
let e = ModelEntity(mesh: mesh, materials: [mat])
root.addChild(e); pool.append(e)
}
}
}
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleName</key><string>AVLiveBody</string>
<key>CFBundleIdentifier</key><string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleExecutable</key><string>$(EXECUTABLE_NAME)</string>
<key>CFBundlePackageType</key><string>APPL</string>
<key>CFBundleShortVersionString</key><string>1.0</string>
<key>CFBundleVersion</key><string>1</string>
<key>LSMinimumSystemVersion</key><string>15.0</string>
<key>NSCameraUsageDescription</key>
<string>Receives the tethered iPhone camera over USB.</string>
<key>NSLocalNetworkUsageDescription</key>
<string>Connects to the tethered iPhone over USB (usbmuxd).</string>
</dict>
</plist>
@@ -0,0 +1,51 @@
// avlivebody-mac/Sources/AVLiveBody/LayeredSceneView.swift
import AppKit
import MetalKit
import RealityKit
import SwiftUI
/// Layered container: MTKView (Metal shader bg, transparent) behind the
/// SceneController's transparent ARView. The owner holds the SceneRenderer
/// so it can push uniforms each frame.
struct LayeredSceneView: NSViewRepresentable {
let controller: SceneController
let renderer: SceneRenderer?
let showScene: Bool
func makeNSView(context: Context) -> NSView {
controller.setUp()
let container = NSView(frame: .zero)
container.wantsLayer = true
container.layer?.backgroundColor = NSColor.black.cgColor
if let renderer {
let mtk = MTKView(frame: container.bounds,
device: MTLCreateSystemDefaultDevice())
mtk.delegate = renderer
mtk.colorPixelFormat = .bgra8Unorm
mtk.framebufferOnly = false
mtk.layer?.isOpaque = true
mtk.clearColor = MTLClearColor(red: 0, green: 0, blue: 0, alpha: 1)
mtk.preferredFramesPerSecond = 60
mtk.autoresizingMask = [.width, .height]
container.addSubview(mtk)
context.coordinator.mtk = mtk
}
let arView = controller.arView
arView.frame = container.bounds
arView.autoresizingMask = [.width, .height]
arView.wantsLayer = true
arView.layer?.isOpaque = false
arView.layer?.backgroundColor = NSColor.clear.cgColor
container.addSubview(arView) // front
return container
}
func updateNSView(_ nsView: NSView, context: Context) {
context.coordinator.mtk?.isHidden = !showScene
}
func makeCoordinator() -> Coordinator { Coordinator() }
final class Coordinator { var mtk: MTKView? }
}
@@ -0,0 +1,79 @@
import AppKit
import Foundation
import RealityKit
import simd
/// Renders SMPL-X dense body meshes (10475 vertices) from Multi-HMR.
/// Triangle indices come from the bundled `smplx_faces.bin`
/// (flat UInt32 triplets).
@MainActor
final class MeshEntity {
let root = Entity()
private static let vertexCount = 10475
private let faces: [UInt32]
private var pools: [Int: ModelEntity] = [:]
private var material = SimpleMaterial(
color: NSColor(white: 0.8, alpha: 1.0),
roughness: 0.5, isMetallic: false)
init() {
faces = MeshEntity.loadFaces()
}
func update(_ persons: [MultiHMRPerson]) {
for (idx, person) in persons.enumerated() {
let entity = pools[idx] ?? {
let e = ModelEntity()
root.addChild(e)
pools[idx] = e
return e
}()
guard let mesh = buildMesh(person.vertices) else { continue }
entity.model = ModelComponent(mesh: mesh,
materials: [material])
let t = person.translation
entity.transform.translation = arkitToRealityKit(t)
entity.isEnabled = true
}
for idx in pools.keys where idx >= persons.count {
pools[idx]?.isEnabled = false
}
}
func setVisible(_ visible: Bool) { root.isEnabled = visible }
func setMaterial(metallic: Bool, roughness: Double) {
material = SimpleMaterial(
color: NSColor(white: 0.8, alpha: 1.0),
roughness: .float(Float(roughness)), isMetallic: metallic)
for entity in pools.values {
entity.model?.materials = [material]
}
}
private func buildMesh(_ verts: [SIMD3<Float>])
-> MeshResource? {
guard verts.count == Self.vertexCount, !faces.isEmpty else {
NSLog("MeshEntity: vertex count mismatch %d (expected %d), faces=%d",
verts.count, Self.vertexCount, faces.count)
return nil
}
var descriptor = MeshDescriptor(name: "smplx")
descriptor.positions = MeshBuffer(verts.map(arkitToRealityKit))
descriptor.primitives = .triangles(faces)
return try? MeshResource.generate(from: [descriptor])
}
private static func loadFaces() -> [UInt32] {
guard let url = Bundle.main.url(
forResource: "smplx_faces", withExtension: "bin"),
let data = try? Data(contentsOf: url) else {
NSLog("MeshEntity: smplx_faces.bin missing")
return []
}
return data.withUnsafeBytes { raw in
Array(raw.bindMemory(to: UInt32.self))
}
}
}
@@ -0,0 +1,35 @@
// avlivebody-mac/Sources/AVLiveBody/RenderSettings.swift
import SwiftUI
/// Live visual settings for AVLiveBody. ContentView observes this and
/// pushes changes to SceneController. Defaults chosen so the scene is
/// lit and everything visible out of the box.
@MainActor
final class RenderSettings: ObservableObject {
// Layer visibility
@Published var showSkeleton: Bool = true
@Published var showMesh: Bool = true
@Published var showVideo: Bool = true
// Mesh material
@Published var meshMetallic: Bool = false
@Published var meshRoughness: Double = 0.6
// Video quad
@Published var videoOpacity: Double = 1.0
// Lights (RealityKit DirectionalLight intensities)
@Published var keyIntensity: Double = 4000
@Published var fillIntensity: Double = 1500
@Published var rimIntensity: Double = 2000
// Camera
@Published var fieldOfView: Double = 55
// Panel visibility
@Published var showPanel: Bool = false
// Metal shader background (Phase 2)
@Published var showScene: Bool = true
@Published var vizMode: Int = 0 // 0..9
}
@@ -0,0 +1,613 @@
// scene.metal — fond reactif aux flux data_feeds + skeleton overlay.
//
// 9 modes visuels style demoscene 2023+ (raymarching SDF, fractales,
// parallax, palette IQ). Reactivite open-data via SceneUniforms.
// 0 storm fbm tissu palette Kp/Bz + lightning flash
// 1 tunnel raymarched tube avec anneaux translucents (wind, RMS)
// 2 plasma volumetric noise palette IQ (Kp, social_rate)
// 3 kaleido fractal KIFS 6-fold rotation 3D (flare, time)
// 4 voronoi cellular 3D crystal sphere (lightning, RMS)
// 5 metaballs raymarched SDF metaballs colored shading (RMS, beat)
// 6 starfield galaxy spiral parallax + god rays (wind, kp)
// 7 bars 3D pillars en perspective avec depth fog (RMS+social)
// 8 hands3d raymarching mandelbox-like + hands camera control
#include <metal_stdlib>
using namespace metal;
struct SceneUniforms {
float time;
float rms;
float kp_norm;
float netz_dev;
float lightning_flash;
float flare;
float wind_norm;
float bz_norm;
float social_rate;
float pose_alive;
float pose_count;
float width;
float height;
float viz_mode;
float hand_l_x;
float hand_l_y;
float hand_r_x;
float hand_r_y;
// ---- 2026-05-14 face / hand / body3d derivatives ----
// mouth_open : |lip51.y - lip57.y| normalized 0..~0.15
// eye_open_l/r : H/W ratio of eye bbox, ~0.15 (closed) .. 0.45 (open)
// head_tilt : atan2(eye_R.y-eye_L.y, eye_R.x-eye_L.x) rad
// head_yaw : nose.y - eyes_mid.y (proxy pitch, normalized)
// finger_pinch_l/r: simd_distance(thumb_tip[4], index_tip[8]) px-norm
// body_x/y/z : pelvis world meters (MediaPipe hip-relative)
// body_height : |hip.y - head.y| world meters
// arm_spread : |wristL.x - wristR.x| world meters
// pose_velocity : EMA(|pelvis(t) - pelvis(t-1)|), alpha=0.3
float mouth_open;
float eye_open_l;
float eye_open_r;
float head_tilt;
float head_yaw;
float finger_pinch_l;
float finger_pinch_r;
float body_x;
float body_y;
float body_z;
float body_height;
float arm_spread;
float pose_velocity;
float _pad0;
float _pad1;
float _pad2;
float _pad3;
float _pad4;
};
struct VsOut {
float4 position [[position]];
float2 uv;
};
vertex VsOut bg_vertex(uint vid [[vertex_id]]) {
float2 p = float2((vid << 1) & 2, vid & 2);
VsOut o;
o.position = float4(p * 2.0 - 1.0, 0.0, 1.0);
o.uv = p;
return o;
}
// ===== Helpers ====================================================
float hash21(float2 p) {
p = fract(p * float2(123.34, 456.21));
p += dot(p, p + 45.32);
return fract(p.x * p.y);
}
float hash31(float3 p) {
p = fract(p * 0.1031);
p += dot(p, p.yzx + 33.33);
return fract((p.x + p.y) * p.z);
}
float noise2(float2 p) {
float2 i = floor(p);
float2 f = fract(p);
float a = hash21(i);
float b = hash21(i + float2(1, 0));
float c = hash21(i + float2(0, 1));
float d = hash21(i + float2(1, 1));
float2 u = f * f * (3.0 - 2.0 * f);
return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);
}
float fbm(float2 p) {
float v = 0.0, a = 0.5;
for (int i = 0; i < 5; ++i) { v += a * noise2(p); p *= 2.13; a *= 0.5; }
return v;
}
// Palette cosinusoidale IQ : 3 tons doux
float3 palIQ(float t, float3 a, float3 b, float3 c, float3 d) {
return a + b * cos(6.28318 * (c * t + d));
}
// Rotations
float3 rotY(float3 p, float a) {
float c = cos(a), s = sin(a);
return float3(c * p.x + s * p.z, p.y, -s * p.x + c * p.z);
}
float3 rotX(float3 p, float a) {
float c = cos(a), s = sin(a);
return float3(p.x, c * p.y - s * p.z, s * p.y + c * p.z);
}
float3 rotZ(float3 p, float a) {
float c = cos(a), s = sin(a);
return float3(c * p.x - s * p.y, s * p.x + c * p.y, p.z);
}
// SDF primitives
float sdSphere(float3 p, float r) { return length(p) - r; }
float sdBox(float3 p, float3 b) {
float3 q = abs(p) - b;
return length(max(q, 0.0)) + min(max(q.x, max(q.y, q.z)), 0.0);
}
float sdTorus(float3 p, float2 t) {
float2 q = float2(length(p.xz) - t.x, p.y);
return length(q) - t.y;
}
float smin(float a, float b, float k) {
float h = clamp(0.5 + 0.5 * (b - a) / k, 0.0, 1.0);
return mix(b, a, h) - k * h * (1.0 - h);
}
float vignette(float2 p) {
return 1.0 - smoothstep(0.6, 1.5, length(p));
}
// ===== Modes =======================================================
// ---- 0 storm : tissu fbm reactif + bloom-fake ----
// Pose mods : pose_velocity boost intensity, head_tilt shifts hue.
float3 mode_storm(float2 p, constant SceneUniforms& U) {
float storm = saturate(U.kp_norm * 1.0 + max(-U.bz_norm, 0.0) * 0.5
+ U.pose_velocity * 2.0);
float speed = 0.08 + U.wind_norm * 1.5 + U.pose_velocity * 3.0;
float zoom = 1.8 - U.rms * 1.2;
float n = fbm(p * zoom + float2(U.time * speed, U.time * speed * 0.7));
n = pow(n, 1.2 - U.rms * 0.5);
float netz = sin(U.time * 50.0 + U.netz_dev * 800.0) * 0.06;
float hue_shift = U.head_tilt * 0.25;
float3 base = palIQ(n + storm * 0.5 + hue_shift,
float3(0.10, 0.05, 0.20),
float3(0.40, 0.30, 0.55),
float3(1.0, 1.0, 1.0),
float3(0.0, 0.33, 0.67));
float bloom = smoothstep(0.7, 1.0, n);
float velocity_boost = 1.0 + U.pose_velocity * 1.5;
return (base * (n * 1.4 + 0.3) + netz + U.rms * 1.2
+ bloom * 0.7) * velocity_boost
+ float3(1.0, 0.55, 0.1) * U.flare * 1.4
+ float3(U.lightning_flash * 0.7);
}
// ---- 1 tunnel : raymarched cylindrical tube avec anneaux ----
float3 mode_tunnel(float2 p, constant SceneUniforms& U) {
// Pseudo-3D tunnel: r/theta + scrolling z
float r = length(p);
float a = atan2(p.y, p.x);
float z = U.time * (1.5 + U.wind_norm * 8.0 + U.rms * 4.0);
// Repeat depth
float d = 1.0 / max(r, 0.04) + z;
// anneaux + spirale
float ring = sin(d * 4.0) * 0.5 + 0.5;
float spiral = sin(a * (8.0 + U.kp_norm * 6.0) + d * 0.6);
float v = ring * (0.4 + 0.6 * spiral);
// Iris central
v *= smoothstep(0.05, 0.20, r);
float3 base = palIQ(d * 0.06 + U.time * 0.05,
float3(0.15, 0.05, 0.35),
float3(0.55, 0.25, 0.35),
float3(1.0, 1.0, 0.8),
float3(0.0, 0.10, 0.20));
float3 col = base * v;
// Chromatic aberration fake : sample displaced
float chrom = U.lightning_flash * 0.15;
col.r *= 1.0 + chrom; col.b *= 1.0 - chrom;
return col + float3(1.0, 0.7, 0.3) * U.flare * 1.5
+ float3(U.lightning_flash * 0.6);
}
// ---- 2 plasma : volumetric noise palette IQ ----
// Pose mods : mouth_open modulates spatial frequency; head_yaw shifts hue.
float3 mode_plasma(float2 p, constant SceneUniforms& U) {
float t = U.time * (0.5 + U.rms * 1.5);
float freq_boost = 1.0 + U.mouth_open * 12.0;
// 3 octaves de sin/cos en composition
float v = sin(p.x * 4.0 * freq_boost + t)
+ sin(p.y * 5.0 * freq_boost - t * 1.2)
+ sin((p.x + p.y) * 3.5 + t * 0.7)
+ sin(length(p) * (8.0 + U.kp_norm * 4.0) - t * 1.8);
v = v * 0.25 + 0.5;
// Fake volumetric "depth" : repeat layers
float layer2 = sin(p.x * 2.0 - t * 0.5) * sin(p.y * 2.5 + t * 0.7);
v = mix(v, v * 0.5 + 0.5 * (layer2 + 1.0) * 0.5, 0.35);
float hue_offset = U.head_yaw * 0.5;
float3 col = palIQ(v + hue_offset,
float3(0.5),
float3(0.5),
float3(1.0, 1.0, 1.0),
float3(0.0, 0.33, 0.67));
col *= 0.8 + U.kp_norm * 0.7 + U.social_rate * 0.5
+ U.mouth_open * 1.0;
return col + float3(0.6, 0.3, 1.0) * U.lightning_flash * 0.5;
}
// ---- 3 kaleido : KIFS fractal 6-fold avec rot 3D fake ----
// Pose mods : arm_spread drives segment count (4..16).
float3 mode_kaleido(float2 p, constant SceneUniforms& U) {
float ang = U.time * 0.15 + U.flare * 2.0;
float c = cos(ang), s = sin(ang);
p = float2(c * p.x - s * p.y, s * p.x + c * p.y);
float r = length(p);
float a = atan2(p.y, p.x);
float seg_count = clamp(ceil(4.0 + U.arm_spread * 8.0), 3.0, 16.0);
float seg = 6.28318 / seg_count;
a = abs(fmod(a + seg * 0.5, seg) - seg * 0.5);
float2 q = float2(cos(a), sin(a)) * r;
// Iteration KIFS-like
float scale = 1.0;
for (int i = 0; i < 4; ++i) {
q = abs(q) - 0.35;
if (q.y > q.x) q = q.yx;
q *= 1.5; scale *= 1.5;
}
float v = length(q) / scale;
float n = fbm(q * 3.0 + U.time * 0.2);
float3 col = palIQ(v + n * 0.3,
float3(0.20, 0.10, 0.30),
float3(0.55, 0.40, 0.50),
float3(1.0, 1.0, 0.5),
float3(0.0, 0.25, 0.50));
col = col * (1.0 - exp(-v * 6.0));
return col * (0.8 + U.rms * 1.0)
+ float3(1.0, 0.6, 0.2) * U.flare * 1.2;
}
// ---- 4 voronoi : 3D crystalline cellular ----
float3 mode_voronoi(float2 p, constant SceneUniforms& U) {
// 3D voronoi : on echantillonne dans une grille 3D animee
float t = U.time * (0.4 + U.rms * 1.0);
float3 P = float3(p * 3.5, t);
float3 ip = floor(P);
float3 fp = fract(P);
float d1 = 10.0, d2 = 10.0;
for (int z = -1; z <= 1; ++z)
for (int y = -1; y <= 1; ++y)
for (int x = -1; x <= 1; ++x) {
float3 g = float3(float(x), float(y), float(z));
float3 o = float3(hash31(ip + g + 13.0),
hash31(ip + g + 71.0),
hash31(ip + g + 47.0));
o = 0.5 + 0.5 * sin(t + 6.28 * o);
float3 dv = g + o - fp;
float d = dot(dv, dv);
if (d < d1) { d2 = d1; d1 = d; }
else if (d < d2) { d2 = d; }
}
d1 = sqrt(d1); d2 = sqrt(d2);
float edge = smoothstep(0.0, 0.04, d2 - d1); // walls between cells
float face = smoothstep(0.0, 0.6, d1);
float3 base = palIQ(d1,
float3(0.05, 0.08, 0.20),
float3(0.45, 0.35, 0.55),
float3(1.0, 1.0, 0.6),
float3(0.2, 0.3, 0.0));
return base * (1.0 - face) + float3(1.0) * (1.0 - edge) * 0.5
+ U.lightning_flash * 0.8;
}
// ---- 5 metaballs : raymarched SDF ----
float metaballs_dist(float3 p, constant SceneUniforms& U) {
float t = U.time * 0.7;
float d = 100.0;
for (int k = 0; k < 5; ++k) {
float fk = float(k);
float3 c = float3(
sin(t * (0.6 + 0.13 * fk) + fk * 1.7) * 1.2,
cos(t * (0.5 + 0.11 * fk) + fk * 2.1) * 1.0,
sin(t * (0.4 + 0.09 * fk) + fk * 3.0) * 0.8
);
float radius = 0.45 + 0.15 * U.rms + 0.05 * sin(t + fk);
d = smin(d, sdSphere(p - c, radius), 0.45);
}
return d;
}
float3 mode_metaballs(float2 p, constant SceneUniforms& U) {
float3 ro = float3(0, 0, -3.5);
float3 rd = normalize(float3(p, 1.5));
float t = 0.0;
float glow = 0.0;
int i;
for (i = 0; i < 64; ++i) {
float3 pos = ro + rd * t;
float d = metaballs_dist(pos, U);
if (d < 0.01) break;
glow += 0.02 / (1.0 + d * d * 4.0);
t += d * 0.9;
if (t > 8.0) break;
}
float3 col = float3(0);
if (t < 8.0) {
float3 pos = ro + rd * t;
// normal via gradient
float2 e = float2(0.001, 0);
float3 n = normalize(float3(
metaballs_dist(pos + e.xyy, U) - metaballs_dist(pos - e.xyy, U),
metaballs_dist(pos + e.yxy, U) - metaballs_dist(pos - e.yxy, U),
metaballs_dist(pos + e.yyx, U) - metaballs_dist(pos - e.yyx, U)));
float3 lightDir = normalize(float3(0.6, 0.8, -0.5));
float lambert = max(0.0, dot(n, lightDir));
float fres = pow(1.0 - max(0.0, dot(n, -rd)), 2.0);
col = palIQ(pos.x * 0.3 + pos.y * 0.2 + U.time * 0.1,
float3(0.2, 0.0, 0.3),
float3(0.5, 0.5, 0.4),
float3(1.0),
float3(0.0, 0.33, 0.67)) * lambert;
col += float3(0.3, 0.7, 1.0) * fres * (0.7 + U.kp_norm);
}
col += float3(0.2, 0.6, 1.0) * glow * 1.5;
return col + U.lightning_flash * 0.6;
}
// ---- 6 starfield : galaxy spiral + parallax ----
// Pose mods : finger_pinch L+R drives star density per layer.
float3 mode_starfield(float2 p, constant SceneUniforms& U) {
float warp = U.time * (1.5 + U.wind_norm * 6.0);
float pinch = saturate((U.finger_pinch_l + U.finger_pinch_r) * 2.0);
int stars_per_layer = 30 + int(pinch * 70.0); // 30..100
// 3 layers of stars at different speeds
float3 col = float3(0);
for (int L = 0; L < 3; ++L) {
float speed = (1.0 + float(L) * 0.5);
float scale = 6.0 + float(L) * 4.0;
for (int k = 0; k < 100; ++k) {
if (k >= stars_per_layer) break;
float fk = float(k + L * 50);
float r0 = hash21(float2(fk, 7.0 + float(L)));
float a0 = hash21(float2(fk, 17.0 + float(L))) * 6.28;
// Spirale galactique
float angle = a0 + r0 * 4.0;
float dist = fract(r0 + warp * 0.04 * speed) * 1.6;
float2 q = float2(cos(angle + dist * 1.5),
sin(angle + dist * 1.5)) * dist;
float d = length(p - q);
float bright = smoothstep(0.012 / speed, 0.0, d);
col += float3(0.5 + r0 * 0.5, 0.7 - r0 * 0.3, 1.0) * bright
* (1.4 - dist) * (1.0 / speed);
}
}
// God rays subtils depuis le centre
float ang = atan2(p.y, p.x);
float rays = 0.5 + 0.5 * sin(ang * 8.0 + U.time);
col += float3(0.3, 0.4, 0.7) * rays * (1.0 - length(p)) * 0.15
* (0.5 + U.kp_norm);
return col + U.flare * float3(1.0, 0.5, 0.2) * 0.4;
}
// ---- 7 bars : 3D pillars en perspective ----
float3 mode_bars(float2 p, constant SceneUniforms& U) {
// Pseudo-3D : barres "horizontales" qui s'eloignent
int nbars = 24;
float t = U.time * 0.4;
float3 col = float3(0);
// Sky gradient
float3 sky = mix(float3(0.05, 0.0, 0.15), float3(0.25, 0.1, 0.35),
p.y * 0.5 + 0.5);
col = sky;
for (int i = 0; i < nbars; ++i) {
float fi = float(i) / float(nbars);
// Position en profondeur (z = 0 proche, 1 loin)
float z = fract(fi + t * (0.15 + U.rms * 0.3));
float perspective = 1.0 / (z + 0.1);
float y_base = -0.6 + z * 1.2; // ligne d'horizon
// Hauteur barre depend du bin "i" via hash + RMS
float h0 = hash21(float2(float(i), 0.0));
float h = sin(t * (0.5 + h0 * 4.0) + float(i)) * 0.5 + 0.5;
// body_height (0..~1.8 m) + eye_open avg modulate bar height
float eyes = (U.eye_open_l + U.eye_open_r) * 0.5;
h = h * (0.3 + U.rms * 1.5 + U.social_rate * 0.4
+ U.body_height * 0.6 + eyes * 0.8);
h = clamp(h, 0.02, 0.95);
float bar_top = y_base + h * perspective * 0.3;
// Largeur = 1 / nbars perspective
float bx = (fi - 0.5) * perspective * 1.5;
float bw = 0.5 / float(nbars) * perspective;
if (abs(p.x - bx) < bw &&
p.y > y_base && p.y < bar_top) {
float3 c = palIQ(fi,
float3(0.5), float3(0.5),
float3(1.0, 1.0, 0.5),
float3(0.0, 0.33, 0.67));
// Fog selon z
c *= 1.0 - z * 0.6;
col = mix(col, c, 1.0 - z * 0.3);
}
}
// Grille du sol scanline
float floor_y = -0.6;
if (p.y < floor_y) {
float depth = (floor_y - p.y) * 4.0;
float grid = step(0.95, fract(p.x * 8.0 / max(depth, 0.1)));
grid += step(0.95, fract(depth * 4.0 + t));
col += float3(0.2, 0.3, 0.6) * grid * 0.4;
}
return col + U.flare * float3(1.0, 0.5, 0.2) * 0.3;
}
// ---- 8 hands3d : voyage 3D pilote par les mains ----
float map_hands(float3 p, constant SceneUniforms& U) {
float3 q = fmod(p + 2.0, 4.0) - 2.0;
float d = length(q) - 0.6;
float pulse = 0.8 + U.rms * 0.6;
d = min(d, length(p) - pulse);
d += sin(p.x * 2.0 + U.time) * 0.15 * U.kp_norm;
return d;
}
float3 mode_hands3d(float2 p, constant SceneUniforms& U) {
float hl_active = (abs(U.hand_l_x) + abs(U.hand_l_y)) > 0.01 ? 1.0 : 0.0;
float hr_active = (abs(U.hand_r_x) + abs(U.hand_r_y)) > 0.01 ? 1.0 : 0.0;
float3 cam_pos = float3(
U.hand_l_x * 5.0,
U.hand_l_y * 3.0,
-U.time * (1.5 + U.hand_l_y * 4.0 * hl_active)
);
float yaw = U.hand_r_x * 1.2 * hr_active;
float pitch = -U.hand_r_y * 0.8 * hr_active;
float3 rd = normalize(float3(p.x, p.y, 1.5));
rd = rotX(rd, pitch);
rd = rotY(rd, yaw);
float t = 0.0, glow = 0.0;
for (int i = 0; i < 64; ++i) {
float3 pos = cam_pos + rd * t;
float d = map_hands(pos, U);
if (d < 0.005) break;
glow += 0.02 / (1.0 + d * d * 8.0);
t += d * 0.85;
if (t > 30.0) break;
}
float3 col = float3(0);
if (t < 30.0) {
float3 pos = cam_pos + rd * t;
float fog = 1.0 - saturate(t / 30.0);
col = float3(
0.5 + 0.5 * sin(pos.x * 0.4 + U.time),
0.5 + 0.5 * sin(pos.y * 0.5 + U.time * 1.3),
0.5 + 0.5 * sin(pos.z * 0.3 + U.time * 0.7)
) * fog;
}
col += float3(0.2, 0.6, 1.0) * glow * 1.5;
col += float3(1.0, 0.5, 0.0) * U.flare * 0.8;
return col;
}
// ---- 9 openpos : fond minimal radial pour faire ressortir le squelette ----
// Le rendu des joints + bones se fait par le skel_pipeline rendu PAR-DESSUS
// (cf renderer.py). On laisse juste un degrade radial sombre pour le contraste.
float3 mode_openpos(float2 p, constant SceneUniforms& U) {
float r = length(p);
// Centre legerement plus clair, bords sombres. Touche de couleur
// chaude au centre selon rms pour reagir a la musique.
float3 inner = float3(0.05, 0.05, 0.10) + float3(0.30, 0.12, 0.18) * U.rms;
float3 outer = float3(0.01, 0.01, 0.02);
float3 col = mix(inner, outer, smoothstep(0.0, 1.4, r));
// Grille de points discrete pour donner une ref de profondeur
float2 g = fmod(p * 12.0, 2.0) - 1.0;
float dot_grid = exp(-dot(g, g) * 6.0) * 0.04;
col += float3(dot_grid);
// Pulsation legere sur le kick / drop
col *= 1.0 + U.rms * 0.4;
return col;
}
// ===== Fragment dispatcher =========================================
fragment float4 bg_fragment(VsOut in [[stage_in]],
constant SceneUniforms& U [[buffer(0)]]) {
float2 uv = in.uv;
float2 p = uv * 2.0 - 1.0;
p.x *= U.width / U.height;
int mode = int(U.viz_mode + 0.5);
float3 color;
if (mode == 1) color = mode_tunnel(p, U);
else if (mode == 2) color = mode_plasma(p, U);
else if (mode == 3) color = mode_kaleido(p, U);
else if (mode == 4) color = mode_voronoi(p, U);
else if (mode == 5) color = mode_metaballs(p, U);
else if (mode == 6) color = mode_starfield(p, U);
else if (mode == 7) color = mode_bars(p, U);
else if (mode == 8) color = mode_hands3d(p, U);
else if (mode == 9) color = mode_openpos(p, U);
else color = mode_storm(p, U);
// Flash global + vignette
color += float3(U.lightning_flash * 1.2);
color *= vignette(p);
// Tone mapping doux (Reinhard)
color = color / (1.0 + color);
// Gamma
color = pow(color, float3(0.85));
// Alpha pour transparence quand pose active (webcam visible dessous)
// Overlay vidéo : translucide même sans pose (la webcam doit rester
// visible en fond). Pose active = encore plus translucide.
float alpha = mix(0.55, 0.25, U.pose_alive);
alpha = max(alpha, U.lightning_flash * 0.8);
alpha = max(alpha, U.flare * 0.6);
return float4(color, alpha);
}
// ===== Skeleton overlay ============================================
struct SkelIn {
float3 pos [[attribute(0)]]; // x,y dans NDC, z profondeur (~ -0.5..+0.5)
float conf [[attribute(1)]];
float pid [[attribute(2)]]; // person_id (0..9)
};
struct SkelOut {
float4 position [[position]];
float conf;
float pid;
float depth;
};
// Projection perspective douce : eloigne avec z, garde NDC en x,y
vertex SkelOut skel_vertex(SkelIn in [[stage_in]],
constant SceneUniforms& U [[buffer(1)]]) {
SkelOut o;
float z = clamp(in.pos.z, -1.0, 1.0);
// Perspective : plus z augmente, plus le point est loin → scale < 1
// RMS pulse fait respirer la profondeur
float pulse = 1.0 + U.rms * 0.25;
float persp = 1.0 / (1.0 + z * 0.8);
float2 xy = in.pos.xy * persp * pulse;
o.position = float4(xy, 0.0, 1.0);
o.conf = in.conf;
o.pid = in.pid;
o.depth = z;
return o;
}
// Palette 6 couleurs par personne (turquoise, magenta, jaune, ambre, lilas, vert)
constant float3 PERSON_COLORS[6] = {
float3(0.0, 1.0, 0.85), // 0 turquoise
float3(1.0, 0.3, 0.7), // 1 magenta
float3(1.0, 0.9, 0.2), // 2 jaune
float3(1.0, 0.55, 0.1), // 3 ambre
float3(0.7, 0.5, 1.0), // 4 lilas
float3(0.4, 1.0, 0.3), // 5+ vert (mains)
};
// ===== Mesh overlay (triangles face/hand/body) =====================
// Reuse meme layout que skel : pos.xyz + conf + pid.
vertex SkelOut mesh_vertex(SkelIn in [[stage_in]],
constant SceneUniforms& U [[buffer(1)]]) {
SkelOut o;
float z = clamp(in.pos.z, -1.0, 1.0);
float pulse = 1.0 + U.rms * 0.25;
float persp = 1.0 / (1.0 + z * 0.8);
float2 xy = in.pos.xy * persp * pulse;
o.position = float4(xy, 0.0, 1.0);
o.conf = in.conf;
o.pid = in.pid;
o.depth = z;
return o;
}
fragment float4 mesh_fragment(SkelOut in [[stage_in]]) {
int pid = int(in.pid + 0.5);
pid = ((pid % 6) + 6) % 6;
float3 col = PERSON_COLORS[pid];
float c = saturate(in.conf);
// Saturation boost : couleurs vives quand pose detectee
col = mix(col, col * 1.6, c);
// Fog par profondeur (proche = plus lumineux)
float depth_fog = 1.0 - clamp(in.depth + 0.5, 0.0, 1.0) * 0.5;
col *= depth_fog;
// Alpha TRES VISIBLE quand confiance haute : 0.85 sur skin, 0.3 fade
return float4(col, mix(0.3, 0.85, c));
}
fragment float4 skel_fragment(SkelOut in [[stage_in]]) {
// Skeleton ULTRA visible quand pose detectee : couleur vive + opaque
int pid = int(in.pid + 0.5);
pid = ((pid % 6) + 6) % 6; // modulo positif
float3 col = PERSON_COLORS[pid] * 1.4; // saturation boost
float c = saturate(in.conf);
// Depth fog : eclaircit ce qui est proche, eteint ce qui est loin
float depth_fog = 1.0 - clamp(in.depth + 0.5, 0.0, 1.0) * 0.6;
col *= depth_fog * (0.5 + 0.5 * c);
// Alpha plein-opaque quand confiance haute (= squelette ultra net)
return float4(col, mix(0.5, 1.0, c));
}
@@ -0,0 +1,172 @@
import AppKit
import Foundation
import CoreVideo
import RealityKit
import simd
import AVLiveWire
/// Owns the single RealityKit scene: the video quad, the body root,
/// and an orbital camera. The app calls `updateVideo/updateSkeleton/
/// updateMesh` from the main queue.
@MainActor
final class SceneController {
let arView = ARView(frame: .zero)
private let cameraAnchor = AnchorEntity(world: .zero)
private let camera = PerspectiveCamera()
private let worldAnchor = AnchorEntity(world: .zero)
private let keyLight = DirectionalLight()
private let fillLight = DirectionalLight()
private let rimLight = DirectionalLight()
private(set) var videoQuad: VideoQuad?
private(set) var skeleton: SkeletonEntity?
private(set) var mesh: MeshEntity?
private(set) var handFace: HandFaceSkeleton?
/// Orbital camera state.
private var orbitYaw: Float = 0
private var orbitPitch: Float = 0
private var orbitRadius: Float = 3.0
private var didSetUp = false
func setUp() {
guard !didSetUp else { return }
didSetUp = true
arView.environment.background = .color(.clear)
arView.layer?.isOpaque = false
arView.layer?.backgroundColor = NSColor.clear.cgColor
arView.scene.addAnchor(worldAnchor)
keyLight.light.intensity = 4000
keyLight.orientation = simd_quatf(angle: .pi / 6,
axis: SIMD3(1, 0, 0))
let keyA = AnchorEntity(world: SIMD3<Float>(1, 2, -1))
keyA.addChild(keyLight)
arView.scene.addAnchor(keyA)
fillLight.light.intensity = 1500
fillLight.light.color = NSColor(red: 0.7, green: 0.8,
blue: 1.0, alpha: 1.0)
let fillA = AnchorEntity(world: SIMD3<Float>(-2, 1, -2))
fillA.addChild(fillLight)
arView.scene.addAnchor(fillA)
rimLight.light.intensity = 2000
let rimA = AnchorEntity(world: SIMD3<Float>(0, 1, -5))
rimA.addChild(rimLight)
arView.scene.addAnchor(rimA)
camera.camera.fieldOfViewInDegrees = 55
cameraAnchor.addChild(camera)
arView.scene.addAnchor(cameraAnchor)
applyCamera()
let q = VideoQuad()
worldAnchor.addChild(q.entity)
videoQuad = q
let s = SkeletonEntity()
worldAnchor.addChild(s.root)
skeleton = s
let m = MeshEntity()
worldAnchor.addChild(m.root)
mesh = m
let hf = HandFaceSkeleton()
worldAnchor.addChild(hf.root)
handFace = hf
installOrbitGestures()
}
func updateVideo(_ pixelBuffer: CVPixelBuffer) {
videoQuad?.update(pixelBuffer)
}
func updateSkeleton(_ skeletons: [Int: SkeletonPayload]) {
skeleton?.update(skeletons)
}
func updateMesh(_ persons: [MultiHMRPerson]) {
mesh?.update(persons)
}
func updateHandFace(hands: HandsPayload?, face: FacePayload?,
depth: Float) {
handFace?.setDepth(depth)
handFace?.update(hands: hands, face: face)
}
func setSkeletonVisible(_ v: Bool) { skeleton?.setVisible(v) }
func setMeshVisible(_ v: Bool) { mesh?.setVisible(v) }
func setVideoVisible(_ v: Bool) { videoQuad?.setVisible(v) }
func setVideoOpacity(_ v: Double) { videoQuad?.setOpacity(v) }
func updateMeshMaterial(metallic: Bool, roughness: Double) {
mesh?.setMaterial(metallic: metallic, roughness: roughness)
}
func setLightIntensities(key: Double, fill: Double, rim: Double) {
keyLight.light.intensity = Float(key)
fillLight.light.intensity = Float(fill)
rimLight.light.intensity = Float(rim)
}
func setFieldOfView(_ deg: Double) {
camera.camera.fieldOfViewInDegrees = Float(deg)
}
// MARK: - Orbital camera
private func applyCamera() {
let cy = cos(orbitYaw), sy = sin(orbitYaw)
let cp = cos(orbitPitch), sp = sin(orbitPitch)
let pos = SIMD3<Float>(orbitRadius * cp * sy,
orbitRadius * sp,
orbitRadius * cp * cy)
cameraAnchor.transform.translation = pos
camera.look(at: .zero, from: pos, relativeTo: nil)
}
private func installOrbitGestures() {
let pan = NSPanGestureRecognizer(
target: OrbitTarget.shared, action: #selector(
OrbitTarget.handlePan(_:)))
OrbitTarget.shared.controller = self
arView.addGestureRecognizer(pan)
}
fileprivate func orbit(dx: Float, dy: Float) {
orbitYaw += dx * 0.01
orbitPitch = max(-1.4, min(1.4, orbitPitch + dy * 0.01))
applyCamera()
}
}
/// Bridges the AppKit pan gesture to `SceneController.orbit`.
final class OrbitTarget: NSObject {
static let shared = OrbitTarget()
weak var controller: SceneController?
private var last: CGPoint = .zero
@objc func handlePan(_ g: NSPanGestureRecognizer) {
switch g.state {
case .began:
last = g.translation(in: g.view)
case .changed:
let p = g.translation(in: g.view)
let dx = Float(p.x - last.x)
let dy = Float(p.y - last.y)
last = p
MainActor.assumeIsolated {
self.controller?.orbit(dx: dx, dy: -dy)
}
default:
break
}
}
}
@@ -0,0 +1,118 @@
import Foundation
import Metal
import MetalKit
/// Metal renderer for the 10 background viz modes (storm, tunnel,
/// plasma, kaleido, voronoi, metaballs, starfield, bars, hands3d,
/// openpos). Compiles the bundled scene.metal at runtime; sits as the
/// backing layer under the transparent ARView.
final class SceneRenderer: NSObject, MTKViewDelegate {
/// Mirror of scene.metal SceneUniforms (36 floats, 144 B). Field
/// order MUST match the shader exactly.
struct SceneUniforms {
var time: Float = 0
var rms: Float = 0
var kp_norm: Float = 0
var netz_dev: Float = 0
var lightning_flash: Float = 0
var flare: Float = 0
var wind_norm: Float = 0
var bz_norm: Float = 0
var social_rate: Float = 0
var pose_alive: Float = 0
var pose_count: Float = 0
var width: Float = 1280
var height: Float = 720
var viz_mode: Float = 0
var hand_l_x: Float = 0
var hand_l_y: Float = 0
var hand_r_x: Float = 0
var hand_r_y: Float = 0
var mouth_open: Float = 0
var eye_open_l: Float = 0
var eye_open_r: Float = 0
var head_tilt: Float = 0
var head_yaw: Float = 0
var finger_pinch_l: Float = 0
var finger_pinch_r: Float = 0
var body_x: Float = 0
var body_y: Float = 0
var body_z: Float = 0
var body_height: Float = 0
var arm_spread: Float = 0
var pose_velocity: Float = 0
var _pad0: Float = 0
var _pad1: Float = 0
var _pad2: Float = 0
var _pad3: Float = 0
var _pad4: Float = 0
}
private let device: MTLDevice
private let commandQueue: MTLCommandQueue
private let bgPipeline: MTLRenderPipelineState
private let uniformsBuffer: MTLBuffer
private var startTime: CFTimeInterval = CACurrentMediaTime()
var uniforms = SceneUniforms()
static func make() -> SceneRenderer? { SceneRenderer(failable: ()) }
private init?(failable: Void) {
guard let dev = MTLCreateSystemDefaultDevice(),
let queue = dev.makeCommandQueue() else { return nil }
self.device = dev
self.commandQueue = queue
guard let url = Bundle.main.url(forResource: "scene",
withExtension: "metal"),
let source = try? String(contentsOf: url, encoding: .utf8) else {
NSLog("SceneRenderer: scene.metal missing from bundle")
return nil
}
let lib: MTLLibrary
do { lib = try dev.makeLibrary(source: source,
options: MTLCompileOptions()) }
catch { NSLog("SceneRenderer: compile error %@",
String(describing: error)); return nil }
guard let vfn = lib.makeFunction(name: "bg_vertex"),
let ffn = lib.makeFunction(name: "bg_fragment") else {
NSLog("SceneRenderer: bg_vertex/bg_fragment missing")
return nil
}
let pd = MTLRenderPipelineDescriptor()
pd.vertexFunction = vfn
pd.fragmentFunction = ffn
pd.colorAttachments[0].pixelFormat = .bgra8Unorm
do { self.bgPipeline =
try dev.makeRenderPipelineState(descriptor: pd) }
catch { NSLog("SceneRenderer: pipeline failed %@",
String(describing: error)); return nil }
guard let buf = dev.makeBuffer(
length: MemoryLayout<SceneUniforms>.stride,
options: .storageModeShared) else { return nil }
self.uniformsBuffer = buf
super.init()
}
func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) {
uniforms.width = Float(size.width)
uniforms.height = Float(size.height)
}
func draw(in view: MTKView) {
uniforms.time = Float(CACurrentMediaTime() - startTime)
uniformsBuffer.contents().bindMemory(
to: SceneUniforms.self, capacity: 1).pointee = uniforms
guard let rpd = view.currentRenderPassDescriptor,
let drawable = view.currentDrawable,
let cb = commandQueue.makeCommandBuffer(),
let enc = cb.makeRenderCommandEncoder(descriptor: rpd)
else { return }
enc.setRenderPipelineState(bgPipeline)
enc.setFragmentBuffer(uniformsBuffer, offset: 0, index: 0)
enc.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 3)
enc.endEncoding()
cb.present(drawable)
cb.commit()
}
}
@@ -0,0 +1,82 @@
// avlivebody-mac/Sources/AVLiveBody/SceneUniformBuilder.swift
import AVLiveWire
import simd
enum SceneUniformBuilder {
// ARKit defaultBody3D indices (best-effort constants ARSkeletonDefinition
// is iOS-only and cannot be queried here; verify indices against the
// iPhone's 91-joint order if channels read zero unexpectedly; the j()
// guard degrades any out-of-range or invalid joint to 0 gracefully).
private static let hipsIdx = 0
private static let headIdx = 51
private static let lWristIdx = 32
private static let rWristIdx = 7
static func fill(_ u: inout SceneRenderer.SceneUniforms,
skeletons: [Int: SkeletonPayload],
hands: HandsPayload?,
face: FacePayload?,
prevPelvis: inout SIMD3<Float>?) {
u.pose_count = Float(skeletons.count)
u.pose_alive = skeletons.isEmpty ? 0 : 1
if let body = skeletons.values.first {
func j(_ i: Int) -> SIMD3<Float>? {
(i < body.joints.count && i < body.valid.count
&& body.valid[i]) ? body.joints[i] : nil
}
if let hips = j(hipsIdx) {
u.body_x = hips.x; u.body_y = hips.y; u.body_z = hips.z
if let prev = prevPelvis {
let v = simd_length(hips - prev)
u.pose_velocity = u.pose_velocity * 0.7 + v * 0.3
}
prevPelvis = hips
if let head = j(headIdx) {
u.body_height = abs(head.y - hips.y)
}
}
if let lw = j(lWristIdx), let rw = j(rWristIdx) {
u.arm_spread = abs(lw.x - rw.x)
}
}
// Hands (Phase-0 Vision, normalized [0,1]).
u.hand_l_x = 0; u.hand_l_y = 0; u.hand_r_x = 0; u.hand_r_y = 0
u.finger_pinch_l = 0; u.finger_pinch_r = 0
for hand in hands?.hands ?? [] {
let c = centroid(hand.points) // (x,y)
let pinch = dist(hand.points, 4, 8) // thumb_tip..index_tip
if hand.isRight {
u.hand_r_x = c.x; u.hand_r_y = c.y; u.finger_pinch_r = pinch
} else {
u.hand_l_x = c.x; u.hand_l_y = c.y; u.finger_pinch_l = pinch
}
}
// Face (Phase-0 Vision allPoints). Region-based derivation is
// deferred to v2 the Vision face-region index mapping is not yet
// established. Leave all face scalars at 0 intentionally for v1.
if let f = face, f.points.count > 8 {
let ys = f.points.map { $0.y }, xs = f.points.map { $0.x }
let h = (ys.max() ?? 0) - (ys.min() ?? 0)
let w = (xs.max() ?? 0) - (xs.min() ?? 0)
u.mouth_open = 0 // set from outer/inner-lip points at impl time
u.eye_open_l = 0; u.eye_open_r = 0
u.head_tilt = 0; u.head_yaw = 0
_ = (h, w)
}
}
private static func centroid(_ p: [SIMD3<Float>]) -> SIMD2<Float> {
guard !p.isEmpty else { return .zero }
var s = SIMD2<Float>(0, 0)
for v in p { s.x += v.x; s.y += v.y }
return s / Float(p.count)
}
private static func dist(_ p: [SIMD3<Float>], _ a: Int, _ b: Int)
-> Float {
guard a < p.count, b < p.count else { return 0 }
return simd_length(SIMD2(p[a].x, p[a].y) - SIMD2(p[b].x, p[b].y))
}
}
@@ -0,0 +1,86 @@
// avlivebody-mac/Sources/AVLiveBody/SettingsPanel.swift
import SwiftUI
/// Collapsible right-side control panel bound to RenderSettings.
struct SettingsPanel: View {
@ObservedObject var settings: RenderSettings
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 14) {
Text("Reglages").font(.headline)
group("Couches") {
Toggle("Squelette", isOn: $settings.showSkeleton)
Toggle("Mesh", isOn: $settings.showMesh)
Toggle("Video", isOn: $settings.showVideo)
}
group("Mesh") {
Toggle("Metallique", isOn: $settings.meshMetallic)
slider("Rugosite", $settings.meshRoughness,
0...1)
}
group("Lumieres") {
slider("Principale", $settings.keyIntensity,
0...10000)
slider("Remplissage", $settings.fillIntensity,
0...10000)
slider("Contre-jour", $settings.rimIntensity,
0...10000)
}
group("Vue") {
slider("Champ de vision", $settings.fieldOfView,
20...120)
slider("Opacite video", $settings.videoOpacity,
0...1)
}
group("Scene") {
Toggle("Fond shader", isOn: $settings.showScene)
Picker("Mode", selection: $settings.vizMode) {
ForEach(0..<10, id: \.self) { i in
Text(Self.modeName(i)).tag(i)
}
}
.pickerStyle(.menu)
}
}
.padding(16)
}
.frame(width: 280)
.background(.ultraThinMaterial)
}
private static let modeNames = ["storm","tunnel","plasma","kaleido",
"voronoi","metaballs","starfield","bars","hands3d","openpos"]
static func modeName(_ i: Int) -> String {
modeNames.indices.contains(i) ? "\(i) \(modeNames[i])" : "\(i)"
}
@ViewBuilder
private func group(_ title: String,
@ViewBuilder _ content: () -> some View)
-> some View {
VStack(alignment: .leading, spacing: 6) {
Text(title).font(.subheadline).foregroundStyle(.secondary)
content()
}
}
private func slider(_ label: String,
_ value: Binding<Double>,
_ range: ClosedRange<Double>) -> some View {
VStack(alignment: .leading, spacing: 2) {
HStack {
Text(label).font(.caption)
Spacer()
Text(String(format: "%.2f", value.wrappedValue))
.font(.caption).foregroundStyle(.secondary)
}
Slider(value: value, in: range)
}
}
}
@@ -0,0 +1,58 @@
import AVLiveWire
import AppKit
import Foundation
import RealityKit
import simd
/// Renders 91-joint skeletons as yellow marker spheres. One marker
/// pool per pid. ARKit world coords -> RealityKit space (x, -y, -z).
@MainActor
final class SkeletonEntity {
let root = Entity()
private static let jointCount = 91
private static let markerRadius: Float = 0.012
private var pools: [Int: [ModelEntity]] = [:]
private let mesh = MeshResource.generateSphere(radius: markerRadius)
private let material = SimpleMaterial(
color: NSColor.systemYellow, roughness: 0.6, isMetallic: false)
func setVisible(_ visible: Bool) { root.isEnabled = visible }
func update(_ skeletons: [Int: SkeletonPayload]) {
// Drop pools for pids no longer present.
for pid in pools.keys where skeletons[pid] == nil {
pools[pid]?.forEach { $0.removeFromParent() }
pools.removeValue(forKey: pid)
}
for (pid, payload) in skeletons {
let pool = pools[pid] ?? makePool()
pools[pid] = pool
let n = min(Self.jointCount, payload.joints.count,
payload.valid.count)
for i in 0..<n {
let marker = pool[i]
if payload.valid[i] {
let j = payload.joints[i]
marker.transform.translation = arkitToRealityKit(j)
marker.isEnabled = true
} else {
marker.isEnabled = false
}
}
}
}
private func makePool() -> [ModelEntity] {
var pool: [ModelEntity] = []
pool.reserveCapacity(Self.jointCount)
for _ in 0..<Self.jointCount {
let e = ModelEntity(mesh: mesh, materials: [material])
e.isEnabled = false
root.addChild(e)
pool.append(e)
}
return pool
}
}
@@ -0,0 +1,22 @@
import SwiftUI
/// A thin overlay showing the USB connection state.
struct StatusBar: View {
@ObservedObject var consumer: USBSkeletonConsumer
var body: some View {
HStack(spacing: 6) {
Circle()
.fill(consumer.connected ? Color.green : Color.orange)
.frame(width: 9, height: 9)
Text(consumer.connected
? "iPhone connected (USB)"
: "waiting for iPhone…")
.font(.caption)
.foregroundStyle(.white)
Spacer()
}
.padding(8)
.background(.black.opacity(0.5))
}
}
@@ -0,0 +1,60 @@
import AppKit
import CoreImage
import CoreVideo
import Foundation
import RealityKit
/// A flat plane at the back of the scene, textured with the iPhone
/// camera video. `update(_:)` is called on the main queue per frame.
@MainActor
final class VideoQuad {
let entity = ModelEntity()
private let ciContext = CIContext()
private var opacity: Double = 1.0
/// Plane is 1.6 m wide, 16:9; positioned 2 m behind the body.
private static let width: Float = 1.6
private static let height: Float = 0.9
private static let zBack: Float = -2.0
init() {
let plane = MeshResource.generatePlane(
width: Self.width, height: Self.height)
var material = UnlitMaterial()
material.color = .init(tint: .white)
entity.model = ModelComponent(mesh: plane,
materials: [material])
entity.transform.translation =
SIMD3<Float>(0, 0, Self.zBack)
}
func setVisible(_ visible: Bool) { entity.isEnabled = visible }
func setOpacity(_ value: Double) {
opacity = value
// Re-tint current material; `update(_:)` will also use `opacity`.
if var mat = entity.model?.materials.first as? UnlitMaterial {
mat.color = .init(tint: NSColor(white: 1, alpha: CGFloat(value)))
entity.model?.materials = [mat]
}
}
/// Replace the plane's texture from a decoded camera frame.
func update(_ pixelBuffer: CVPixelBuffer) {
let ci = CIImage(cvPixelBuffer: pixelBuffer)
guard let cg = ciContext.createCGImage(
ci, from: ci.extent) else { return }
guard let texture = try? TextureResource(
image: cg, options: .init(semantic: .color)) else {
NSLog("VideoQuad: TextureResource creation failed (%dx%d)",
CVPixelBufferGetWidth(pixelBuffer),
CVPixelBufferGetHeight(pixelBuffer))
return
}
var material = UnlitMaterial()
material.color = .init(
tint: NSColor(white: 1, alpha: CGFloat(opacity)),
texture: .init(texture))
entity.model?.materials = [material]
}
}
@@ -0,0 +1,29 @@
import AVLiveWire
import Foundation
import simd
/// Overrides the highest-scoring Multi-HMR mesh's pelvis depth with
/// the first valid USB skeleton pelvis z. Single-person assumption:
/// with multiple skeletons in the dict the source pelvis is arbitrary
/// (dict iteration order). Pure, stateless unit-testable.
enum BodyFusion {
/// ARSkeleton3D joint 0 = root (hips), per ARSkeletonDefinition.defaultBody3D.
static let pelvisJoint = 0
static func fuse(persons: [MultiHMRPerson],
skeletons: [Int: SkeletonPayload])
-> [MultiHMRPerson] {
let pelvisZs: [Float] = skeletons.values.compactMap { s in
guard pelvisJoint < s.valid.count,
s.valid[pelvisJoint] else { return nil }
return s.joints[pelvisJoint].z
}
guard !pelvisZs.isEmpty,
let primaryIdx = persons.indices.max(by: {
persons[$0].score < persons[$1].score
}) else { return persons }
var out = persons
out[primaryIdx].translation.z = pelvisZs[0]
return out
}
}
@@ -0,0 +1,184 @@
import CoreML
import CoreVideo
import CoreImage
import Foundation
/// One detected SMPL-X body from Multi-HMR.
struct MultiHMRPerson {
var vertices: [SIMD3<Float>] // 10475 SMPL-X verts, model space
var translation: SIMD3<Float> // pelvis translation
var score: Float
}
/// CoreML wrapper around the bundled `multihmr_full_672_s.mlpackage`.
/// Mirrors `data_only_viz/multihmr_coreml.py`: two MLMultiArray inputs
/// (`image` 1x3x672x672 ImageNet-normalized, `cam_K` 1x3x3), fixed
/// K=4 person outputs.
final class MultiHMRCoreML {
static let inputSize = 672
static let vertexCount = 10475
static let maxPersons = 4
private static let detThreshold: Float = 0.3
private static let normMean: [Float] = [0.485, 0.456, 0.406]
private static let normStd: [Float] = [0.229, 0.224, 0.225]
private let model: MLModel
private let ciContext = CIContext()
/// Serial background queue + busy flag for `inferAsync`: keep the
/// ~150-300 ms CoreML prediction off the main thread and drop frames
/// that arrive while one is in flight.
private let inferQueue = DispatchQueue(
label: "cc.saillant.avlivebody.multihmr", qos: .userInitiated)
private let inferLock = NSLock()
private var inferBusy = false
/// Loads the bundled model. Returns nil if the resource or load
/// fails callers fall back to skeleton-only rendering.
init?() {
guard let url = Bundle.main.url(
forResource: "multihmr_full_672_s",
withExtension: "mlmodelc") else {
NSLog("MultiHMRCoreML: mlpackage resource missing")
return nil
}
let cfg = MLModelConfiguration()
cfg.computeUnits = .cpuAndGPU
do {
model = try MLModel(contentsOf: url, configuration: cfg)
} catch {
NSLog("MultiHMRCoreML: load failed %@",
String(describing: error))
return nil
}
}
/// Async inference: runs `infer` on a background queue, DROPS the
/// frame if a prior prediction is still in flight, and calls
/// `completion` with the result ON THE BACKGROUND QUEUE (the caller
/// hops back to the main thread for UI). Prevents the per-frame
/// CoreML prediction from saturating the run loop.
func inferAsync(_ pixelBuffer: CVPixelBuffer,
cameraK: [Float],
completion: @escaping ([MultiHMRPerson]) -> Void) {
inferLock.lock()
if inferBusy { inferLock.unlock(); return }
inferBusy = true
inferLock.unlock()
inferQueue.async { [weak self] in
guard let self else { return }
let result = self.infer(pixelBuffer, cameraK: cameraK)
self.inferLock.lock()
self.inferBusy = false
self.inferLock.unlock()
completion(result)
}
}
/// Run inference on one camera frame. `cameraK` is the 3x3 camera
/// intrinsics row-major.
func infer(_ pixelBuffer: CVPixelBuffer,
cameraK: [Float]) -> [MultiHMRPerson] {
guard let image = makeImageInput(pixelBuffer),
let k = makeKInput(cameraK) else { return [] }
let inputs: [String: MLFeatureValue] = [
"image": MLFeatureValue(multiArray: image),
"cam_K": MLFeatureValue(multiArray: k),
]
guard let provider = try? MLDictionaryFeatureProvider(
dictionary: inputs),
let out = try? model.prediction(from: provider) else {
return []
}
return parse(out)
}
// MARK: - Input preprocessing
/// `CVPixelBuffer` -> [1,3,672,672] Float32, RGB, ImageNet-normed.
private func makeImageInput(_ pb: CVPixelBuffer) -> MLMultiArray? {
let n = Self.inputSize
// Resize to n x n BGRA via CoreImage.
let ci = CIImage(cvPixelBuffer: pb)
let sx = CGFloat(n) / ci.extent.width
let sy = CGFloat(n) / ci.extent.height
let scaled = ci.transformed(
by: CGAffineTransform(scaleX: sx, y: sy))
var dst: CVPixelBuffer?
CVPixelBufferCreate(kCFAllocatorDefault, n, n,
kCVPixelFormatType_32BGRA, nil, &dst)
guard let dst else { return nil }
ciContext.render(scaled, to: dst)
CVPixelBufferLockBaseAddress(dst, .readOnly)
defer { CVPixelBufferUnlockBaseAddress(dst, .readOnly) }
guard let base = CVPixelBufferGetBaseAddress(dst) else {
return nil
}
let rowBytes = CVPixelBufferGetBytesPerRow(dst)
let px = base.assumingMemoryBound(to: UInt8.self)
guard let arr = try? MLMultiArray(
shape: [1, 3, NSNumber(value: n), NSNumber(value: n)],
dataType: .float32) else { return nil }
let ptr = arr.dataPointer.assumingMemoryBound(to: Float.self)
let plane = n * n
for y in 0..<n {
for x in 0..<n {
let p = y * rowBytes + x * 4 // BGRA
let b = Float(px[p]) / 255.0
let g = Float(px[p + 1]) / 255.0
let r = Float(px[p + 2]) / 255.0
let idx = y * n + x
ptr[idx] =
(r - Self.normMean[0]) / Self.normStd[0]
ptr[plane + idx] =
(g - Self.normMean[1]) / Self.normStd[1]
ptr[2 * plane + idx] =
(b - Self.normMean[2]) / Self.normStd[2]
}
}
return arr
}
/// 9 row-major intrinsics -> [1,3,3] Float32.
private func makeKInput(_ k: [Float]) -> MLMultiArray? {
guard k.count == 9,
let arr = try? MLMultiArray(
shape: [1, 3, 3], dataType: .float32) else { return nil }
let ptr = arr.dataPointer.assumingMemoryBound(to: Float.self)
for i in 0..<9 { ptr[i] = k[i] }
return arr
}
// MARK: - Output parsing
private func parse(_ out: MLFeatureProvider) -> [MultiHMRPerson] {
guard let v3d = out.featureValue(for: "var_2420")?
.multiArrayValue,
let transl = out.featureValue(for: "var_2423")?
.multiArrayValue,
let scores = out.featureValue(for: "var_2436")?
.multiArrayValue else { return [] }
var persons: [MultiHMRPerson] = []
let vc = Self.vertexCount
for k in 0..<Self.maxPersons {
let score = scores[k].floatValue
if score < Self.detThreshold { continue }
var verts = [SIMD3<Float>](
repeating: .zero, count: vc)
let base = k * vc * 3
for i in 0..<vc {
let o = base + i * 3
verts[i] = SIMD3(v3d[o].floatValue,
v3d[o + 1].floatValue,
v3d[o + 2].floatValue)
}
let tb = k * 3
persons.append(MultiHMRPerson(
vertices: verts,
translation: SIMD3(transl[tb].floatValue,
transl[tb + 1].floatValue,
transl[tb + 2].floatValue),
score: score))
}
return persons
}
}
@@ -0,0 +1,135 @@
import Foundation
import Darwin
/// Transport abstraction over the usbmuxd Unix socket. The real
/// implementation wraps a `socket(AF_UNIX)`; tests inject a mock.
protocol MuxTransport {
func send(_ data: Data)
func receivePacket() -> Data?
func close()
}
/// usbmux client: device discovery + connect-to-port. After a
/// successful `connect`, the same transport carries the raw tunneled
/// byte stream from the device.
final class USBClient {
private let transport: MuxTransport
private var tag: UInt32 = 0
init(transport: MuxTransport) {
self.transport = transport
}
func listDevices() -> [Int] {
tag += 1
transport.send(USBMuxProtocol.encode(
plist: ["MessageType": "ListDevices"], tag: tag))
guard let reply = transport.receivePacket(),
let plist = USBMuxProtocol.decode(reply),
let list = plist["DeviceList"] as? [[String: Any]]
else { return [] }
return list.compactMap { $0["DeviceID"] as? Int }
}
/// Returns true once the transport is tunneled to `port` on the
/// device. usbmux wants the TCP port in big-endian order.
func connect(deviceID: Int, port: UInt16) -> Bool {
tag += 1
let swapped = Int((port << 8) | (port >> 8))
transport.send(USBMuxProtocol.encode(plist: [
"MessageType": "Connect",
"DeviceID": deviceID,
"PortNumber": swapped,
], tag: tag))
guard let reply = transport.receivePacket(),
let plist = USBMuxProtocol.decode(reply),
let number = plist["Number"] as? Int
else { return false }
return number == 0
}
}
/// Production transport: blocking AF_UNIX socket to usbmuxd.
final class UnixMuxTransport: MuxTransport {
private var fd: Int32 = -1
init?(path: String = "/var/run/usbmuxd") {
fd = socket(AF_UNIX, SOCK_STREAM, 0)
guard fd >= 0 else { return nil }
var addr = sockaddr_un()
addr.sun_family = sa_family_t(AF_UNIX)
precondition(path.utf8.count < 104,
"usbmuxd socket path exceeds sun_path limit")
_ = path.withCString { src in
withUnsafeMutablePointer(to: &addr.sun_path) {
$0.withMemoryRebound(to: CChar.self, capacity: 104) {
strcpy($0, src)
}
}
}
let size = socklen_t(MemoryLayout<sockaddr_un>.size)
let ok = withUnsafePointer(to: &addr) {
$0.withMemoryRebound(to: sockaddr.self, capacity: 1) {
Darwin.connect(fd, $0, size)
}
}
if ok != 0 { Darwin.close(fd); return nil }
}
func send(_ data: Data) {
guard fd >= 0 else { return }
data.withUnsafeBytes { buf in
guard let base = buf.baseAddress else { return }
var off = 0
while off < data.count {
let w = Darwin.write(fd, base.advanced(by: off),
data.count - off)
if w <= 0 {
if w < 0 && errno == EINTR { continue }
break
}
off += w
}
}
}
/// Read one usbmux packet: 4-byte LE length prefix then body.
func receivePacket() -> Data? {
guard let head = readN(4) else { return nil }
guard let len = USBMuxProtocol.readLE32(head, 0) else { return nil }
let total = Int(len)
guard total >= 16, let rest = readN(total - 4) else { return nil }
return head + rest
}
/// Read raw tunneled bytes after a successful Connect.
func readStream(max: Int = 65536) -> Data? {
readN(max, exact: false)
}
private func readN(_ n: Int, exact: Bool = true) -> Data? {
var buf = [UInt8](repeating: 0, count: n)
var got = 0
while got < n {
let r = buf.withUnsafeMutableBytes {
Darwin.read(fd, $0.baseAddress!.advanced(by: got), n - got)
}
if r < 0 {
if errno == EINTR { continue }
return got > 0 && !exact ? Data(buf[0..<got]) : nil
}
if r == 0 { // EOF peer closed
return got > 0 && !exact ? Data(buf[0..<got]) : nil
}
got += r
if !exact { break }
}
return Data(buf[0..<got])
}
deinit { close() }
func close() {
if fd >= 0 { Darwin.close(fd); fd = -1 }
}
}
@@ -0,0 +1,38 @@
import Foundation
/// Codec for the usbmuxd request/response protocol. 16-byte
/// little-endian header (length, version=1, message=8, tag) then an
/// XML property list.
enum USBMuxProtocol {
static func encode(plist: [String: Any], tag: UInt32) -> Data {
let body = (try? PropertyListSerialization.data(
fromPropertyList: plist, format: .xml, options: 0))
?? Data()
var d = Data()
appendLE32(&d, UInt32(16 + body.count)) // length
appendLE32(&d, 1) // version
appendLE32(&d, 8) // message: plist
appendLE32(&d, tag)
d.append(body)
return d
}
static func decode(_ packet: Data) -> [String: Any]? {
guard packet.count >= 16 else { return nil }
let body = packet.dropFirst(16)
return (try? PropertyListSerialization.propertyList(
from: body, options: [], format: nil)) as? [String: Any]
}
static func appendLE32(_ d: inout Data, _ v: UInt32) {
for i in 0..<4 { d.append(UInt8((v >> (8 * i)) & 0xFF)) }
}
static func readLE32(_ d: Data, _ offset: Int) -> UInt32? {
guard offset >= 0, d.count >= offset + 4 else { return nil }
let b = [UInt8](d)
var v: UInt32 = 0
for i in 0..<4 { v |= UInt32(b[offset + i]) << (8 * i) }
return v
}
}
@@ -0,0 +1,123 @@
import AVLiveWire
import Combine
import CoreVideo
import Foundation
/// Connects to the tethered iPhone over USB (usbmuxd), demuxes the
/// AVLiveWire stream, republishes skeleton payloads (keyed by pid)
/// and forwards decoded camera frames. Blocking transport runs on a
/// dedicated background thread; only `@Published` writes hop to main.
final class USBSkeletonConsumer: ObservableObject {
/// 91-joint skeleton payloads keyed by pid.
@Published var skeletons: [Int: SkeletonPayload] = [:]
@Published var connected = false
/// Latest Vision hand landmarks from the iPhone (tag=4).
@Published var hands: HandsPayload?
/// Latest Vision face landmarks from the iPhone (tag=5).
@Published var face: FacePayload?
/// Called on the main queue for every decoded camera frame.
var onVideoFrame: ((CVPixelBuffer) -> Void)?
/// TCP port the iPhone `USBServer` listens on.
static let devicePort: UInt16 = 7000
private let videoDecoder = VideoDecoder()
private let stateLock = NSLock()
private var running = false
private var thread: Thread?
init() {
videoDecoder.onFrame = { [weak self] pixelBuffer in
DispatchQueue.main.async {
self?.onVideoFrame?(pixelBuffer)
}
}
}
private var isRunning: Bool {
stateLock.lock(); defer { stateLock.unlock() }
return running
}
func start() {
stateLock.lock()
if running { stateLock.unlock(); return }
running = true
let t = Thread { [weak self] in self?.loop() }
t.name = "cc.avlive.usbconsumer"
thread = t
stateLock.unlock()
t.start()
}
func stop() {
stateLock.lock(); running = false; stateLock.unlock()
}
private func loop() {
while isRunning {
guard let transport = UnixMuxTransport() else {
NSLog("USBSkeletonConsumer: no usbmuxd; retry")
Thread.sleep(forTimeInterval: 1.0); continue
}
let client = USBClient(transport: transport)
let devices = client.listDevices()
guard let dev = devices.first,
client.connect(deviceID: dev,
port: Self.devicePort) else {
NSLog("USBSkeletonConsumer: no device; retry")
transport.close()
Thread.sleep(forTimeInterval: 1.0); continue
}
NSLog("USBSkeletonConsumer: connected to device %d", dev)
publishConnected(true)
var demux = StreamDemuxer()
while isRunning {
guard let chunk = transport.readStream(),
!chunk.isEmpty else { break }
for frame in demux.feed(chunk) { route(frame) }
}
transport.close()
publishConnected(false)
NSLog("USBSkeletonConsumer: disconnected")
if isRunning { Thread.sleep(forTimeInterval: 1.0) }
}
}
private func route(_ frame: StreamDemuxer.Frame) {
switch frame.header.tag {
case .skeleton:
guard let payload =
SkeletonPayload(decoding: frame.payload) else { return }
let pid = Int(frame.header.pid)
DispatchQueue.main.async { [weak self] in
self?.skeletons[pid] = payload
}
case .video:
guard let payload =
VideoPayload(decoding: frame.payload) else { return }
videoDecoder.decode(payload)
case .hands:
guard let payload = HandsPayload(decoding: frame.payload)
else { return }
DispatchQueue.main.async { [weak self] in
self?.hands = payload
}
case .face:
guard let payload = FacePayload(decoding: frame.payload)
else { return }
DispatchQueue.main.async { [weak self] in
self?.face = payload
}
case .meta:
break
}
}
private func publishConnected(_ value: Bool) {
DispatchQueue.main.async { [weak self] in
self?.connected = value
}
}
}
@@ -0,0 +1,184 @@
import AVLiveWire
import CoreMedia
import CoreVideo
import Foundation
import VideoToolbox
/// HEVC decoder. Feed `VideoPayload`s in; receive `CVPixelBuffer`s via
/// `onFrame`. Keyframe payloads must carry the VPS/SPS/PPS parameter
/// sets prepended as 4-byte-length-prefixed NAL units (the layout the
/// iOS `VideoEncoder` emits); the decoder (re)builds its format
/// description from those.
final class VideoDecoder {
var onFrame: ((CVPixelBuffer) -> Void)?
private var session: VTDecompressionSession?
private var formatDesc: CMVideoFormatDescription?
/// Decode one access unit.
func decode(_ payload: VideoPayload) {
var au = payload.data
if payload.isKeyframe {
let (params, rest) = Self.splitParameterSets(au)
if !params.isEmpty {
rebuildFormat(params)
}
au = rest
}
guard let fmt = formatDesc, !au.isEmpty else { return }
if session == nil { makeSession(fmt) }
guard let session, let block = Self.blockBuffer(au) else {
return
}
var sample: CMSampleBuffer?
var sampleSize = au.count
guard CMSampleBufferCreateReady(
allocator: kCFAllocatorDefault, dataBuffer: block,
formatDescription: fmt, sampleCount: 1,
sampleTimingEntryCount: 0, sampleTimingArray: nil,
sampleSizeEntryCount: 1, sampleSizeArray: &sampleSize,
sampleBufferOut: &sample) == noErr, let sample else {
return
}
VTDecompressionSessionDecodeFrame(
session, sampleBuffer: sample, flags: [],
infoFlagsOut: nil) { [weak self] status, _, image, _, _ in
guard status == noErr, let image else { return }
self?.onFrame?(image)
}
}
func stop() {
if let session { VTDecompressionSessionInvalidate(session) }
session = nil
formatDesc = nil
}
deinit { stop() }
// MARK: - Helpers
/// Leading 4-byte-length-prefixed NAL units of HEVC parameter-set
/// type (VPS=32, SPS=33, PPS=34) are split from the frame data.
/// Returns (parameterSetData, frameData).
private static func splitParameterSets(_ data: Data)
-> (Data, Data) {
let bytes = [UInt8](data)
var offset = 0
var paramEnd = 0
while offset + 4 <= bytes.count {
let len = (Int(bytes[offset]) << 24)
| (Int(bytes[offset + 1]) << 16)
| (Int(bytes[offset + 2]) << 8)
| Int(bytes[offset + 3])
let nalStart = offset + 4
guard len > 0, nalStart + len <= bytes.count else { break }
let nalType = (Int(bytes[nalStart]) >> 1) & 0x3F
if nalType == 32 || nalType == 33 || nalType == 34 {
offset = nalStart + len
paramEnd = offset
} else {
break
}
}
return (data.prefix(paramEnd),
data.suffix(from: data.startIndex
.advanced(by: paramEnd)))
}
private func rebuildFormat(_ paramData: Data) {
var sets: [[UInt8]] = []
let bytes = [UInt8](paramData)
var offset = 0
while offset + 4 <= bytes.count {
let len = (Int(bytes[offset]) << 24)
| (Int(bytes[offset + 1]) << 16)
| (Int(bytes[offset + 2]) << 8)
| Int(bytes[offset + 3])
let start = offset + 4
guard len > 0, start + len <= bytes.count else { break }
sets.append(Array(bytes[start..<start + len]))
offset = start + len
}
guard sets.count >= 3 else { return }
var fmt: CMFormatDescription?
let status = withParameterSetPointers(sets) { pBuf, sBuf in
CMVideoFormatDescriptionCreateFromHEVCParameterSets(
allocator: kCFAllocatorDefault,
parameterSetCount: sets.count,
parameterSetPointers: pBuf,
parameterSetSizes: sBuf,
nalUnitHeaderLength: 4, extensions: nil,
formatDescriptionOut: &fmt)
}
if status == noErr, let fmt {
formatDesc = fmt
if let session { VTDecompressionSessionInvalidate(session) }
session = nil
}
}
/// Build the C-style parallel arrays of parameter-set pointers and
/// sizes that `CMVideoFormatDescriptionCreateFromHEVCParameterSets`
/// requires, keeping the backing storage alive for the call.
private func withParameterSetPointers(
_ sets: [[UInt8]],
_ body: (UnsafePointer<UnsafePointer<UInt8>>,
UnsafePointer<Int>) -> OSStatus) -> OSStatus {
func recurse(_ index: Int,
_ ptrs: inout [UnsafePointer<UInt8>],
_ sizes: inout [Int]) -> OSStatus {
if index == sets.count {
return ptrs.withUnsafeBufferPointer { pBuf in
sizes.withUnsafeBufferPointer { sBuf in
body(pBuf.baseAddress!, sBuf.baseAddress!)
}
}
}
return sets[index].withUnsafeBufferPointer { buf in
ptrs.append(buf.baseAddress!)
sizes.append(buf.count)
return recurse(index + 1, &ptrs, &sizes)
}
}
var ptrs: [UnsafePointer<UInt8>] = []
var sizes: [Int] = []
ptrs.reserveCapacity(sets.count)
sizes.reserveCapacity(sets.count)
return recurse(0, &ptrs, &sizes)
}
private func makeSession(_ fmt: CMVideoFormatDescription) {
let attrs: [CFString: Any] = [
kCVPixelBufferPixelFormatTypeKey:
kCVPixelFormatType_32BGRA,
]
VTDecompressionSessionCreate(
allocator: kCFAllocatorDefault, formatDescription: fmt,
decoderSpecification: nil,
imageBufferAttributes: attrs as CFDictionary,
outputCallback: nil, decompressionSessionOut: &session)
}
private static func blockBuffer(_ data: Data) -> CMBlockBuffer? {
var block: CMBlockBuffer?
guard CMBlockBufferCreateWithMemoryBlock(
allocator: kCFAllocatorDefault, memoryBlock: nil,
blockLength: data.count,
blockAllocator: kCFAllocatorDefault,
customBlockSource: nil, offsetToData: 0,
dataLength: data.count, flags: 0,
blockBufferOut: &block) == noErr, let block else {
return nil
}
var ok = false
data.withUnsafeBytes { raw in
if let base = raw.baseAddress,
CMBlockBufferReplaceDataBytes(
with: base, blockBuffer: block,
offsetIntoDestination: 0,
dataLength: data.count) == noErr { ok = true }
}
return ok ? block : nil
}
}
@@ -0,0 +1,29 @@
import XCTest
import AVLiveWire
@testable import AVLiveBody
final class BodyFusionTests: XCTestCase {
private func skeleton(pelvisZ: Float) -> SkeletonPayload {
var p = SkeletonPayload()
p.joints[0] = SIMD3(0, 0, pelvisZ)
p.valid[0] = true
return p
}
func testPelvisDepthOverride() {
let mesh = MultiHMRPerson(
vertices: [SIMD3<Float>](repeating: .zero, count: 1),
translation: SIMD3(0, 0, -1.0), score: 0.9)
let fused = BodyFusion.fuse(
persons: [mesh], skeletons: [0: skeleton(pelvisZ: -2.5)])
XCTAssertEqual(fused[0].translation.z, -2.5, accuracy: 1e-4)
}
func testPassthroughWhenNoSkeleton() {
let mesh = MultiHMRPerson(
vertices: [SIMD3<Float>](repeating: .zero, count: 1),
translation: SIMD3(0, 0, -1.0), score: 0.9)
let fused = BodyFusion.fuse(persons: [mesh], skeletons: [:])
XCTAssertEqual(fused[0].translation.z, -1.0, accuracy: 1e-4)
}
}
@@ -0,0 +1,21 @@
// avlivebody-mac/Tests/AVLiveBodyTests/RenderSettingsTests.swift
import XCTest
@testable import AVLiveBody
@MainActor
final class RenderSettingsTests: XCTestCase {
func testDefaults() {
let s = RenderSettings()
XCTAssertTrue(s.showSkeleton)
XCTAssertTrue(s.showMesh)
XCTAssertTrue(s.showVideo)
XCTAssertFalse(s.meshMetallic)
XCTAssertEqual(s.meshRoughness, 0.6, accuracy: 1e-9)
XCTAssertEqual(s.videoOpacity, 1.0, accuracy: 1e-9)
XCTAssertEqual(s.keyIntensity, 4000, accuracy: 1e-9)
XCTAssertEqual(s.fillIntensity, 1500, accuracy: 1e-9)
XCTAssertEqual(s.rimIntensity, 2000, accuracy: 1e-9)
XCTAssertEqual(s.fieldOfView, 55, accuracy: 1e-9)
XCTAssertFalse(s.showPanel)
}
}
@@ -0,0 +1,8 @@
import XCTest
@testable import AVLiveBody
final class SceneUniformsTests: XCTestCase {
func testStride() {
XCTAssertEqual(MemoryLayout<SceneRenderer.SceneUniforms>.stride, 144)
}
}
@@ -0,0 +1,22 @@
// avlivebody-mac/Tests/AVLiveBodyTests/ShaderCompileTests.swift
import XCTest
import Metal
@testable import AVLiveBody
final class ShaderCompileTests: XCTestCase {
func testSceneMetalCompiles() throws {
let here = URL(fileURLWithPath: #filePath) // .../Tests/AVLiveBodyTests/ShaderCompileTests.swift
let shader = here
.deletingLastPathComponent() // AVLiveBodyTests
.deletingLastPathComponent() // Tests
.deletingLastPathComponent() // avlivebody-mac
.appendingPathComponent("Sources/AVLiveBody/Resources/scene.metal")
let src = try String(contentsOf: shader, encoding: .utf8)
guard let dev = MTLCreateSystemDefaultDevice() else {
throw XCTSkip("no Metal device")
}
let lib = try dev.makeLibrary(source: src, options: MTLCompileOptions())
XCTAssertNotNil(lib.makeFunction(name: "bg_vertex"))
XCTAssertNotNil(lib.makeFunction(name: "bg_fragment"))
}
}
@@ -0,0 +1,49 @@
import XCTest
@testable import AVLiveBody
/// In-memory stand-in for the usbmuxd Unix socket.
final class MockMuxTransport: MuxTransport {
var sent: [Data] = []
var canned: [Data] = []
func send(_ data: Data) { sent.append(data) }
func receivePacket() -> Data? {
canned.isEmpty ? nil : canned.removeFirst()
}
func close() {}
}
final class USBClientTests: XCTestCase {
func testListDevicesParsesDeviceIDs() {
let mock = MockMuxTransport()
mock.canned = [USBMuxProtocol.encode(plist: [
"DeviceList": [
["DeviceID": 42,
"Properties": ["ConnectionType": "USB"]],
]], tag: 0)]
let client = USBClient(transport: mock)
let devices = client.listDevices()
XCTAssertEqual(devices, [42])
}
func testConnectSendsConnectRequest() {
let mock = MockMuxTransport()
mock.canned = [USBMuxProtocol.encode(
plist: ["MessageType": "Result", "Number": 0], tag: 0)]
let client = USBClient(transport: mock)
let ok = client.connect(deviceID: 42, port: 7000)
XCTAssertTrue(ok)
let req = USBMuxProtocol.decode(mock.sent.last!)
XCTAssertEqual(req?["MessageType"] as? String, "Connect")
XCTAssertEqual(req?["DeviceID"] as? Int, 42)
XCTAssertEqual(req?["PortNumber"] as? Int,
Int((UInt16(7000) << 8) | (UInt16(7000) >> 8)))
}
func testConnectFailsOnNonZeroResult() {
let mock = MockMuxTransport()
mock.canned = [USBMuxProtocol.encode(
plist: ["MessageType": "Result", "Number": 3], tag: 0)]
let client = USBClient(transport: mock)
XCTAssertFalse(client.connect(deviceID: 1, port: 7000))
}
}
@@ -0,0 +1,27 @@
import XCTest
@testable import AVLiveBody
final class USBMuxProtocolTests: XCTestCase {
func testEncodeWrapsPlistWith16ByteHeader() {
let body: [String: Any] = ["MessageType": "ListDevices"]
let packet = USBMuxProtocol.encode(plist: body, tag: 3)
XCTAssertGreaterThan(packet.count, 16)
XCTAssertEqual(USBMuxProtocol.readLE32(packet, 0).map(Int.init),
packet.count)
XCTAssertEqual(USBMuxProtocol.readLE32(packet, 4), 1)
XCTAssertEqual(USBMuxProtocol.readLE32(packet, 8), 8)
XCTAssertEqual(USBMuxProtocol.readLE32(packet, 12), 3)
}
func testDecodeRoundTrip() {
let packet = USBMuxProtocol.encode(
plist: ["MessageType": "Result", "Number": 0], tag: 1)
let decoded = USBMuxProtocol.decode(packet)
XCTAssertEqual(decoded?["MessageType"] as? String, "Result")
XCTAssertEqual(decoded?["Number"] as? Int, 0)
}
func testDecodeRejectsShortPacket() {
XCTAssertNil(USBMuxProtocol.decode(Data([0, 1, 2])))
}
}
+61
View File
@@ -0,0 +1,61 @@
name: AVLiveBody
options:
bundleIdPrefix: cc.saillant
deploymentTarget:
macOS: "15.0"
createIntermediateGroups: true
configFiles:
Debug: Config/Shared.xcconfig
Release: Config/Shared.xcconfig
packages:
AVLiveWire:
path: ../shared/AVLiveWire
targets:
AVLiveBody:
type: application
platform: macOS
deploymentTarget: "15.0"
sources:
- path: Sources/AVLiveBody
excludes:
- Info.plist
- Resources/scene.metal
postBuildScripts:
- script: |
cp "${SRCROOT}/Sources/AVLiveBody/Resources/scene.metal" \
"${BUILT_PRODUCTS_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/scene.metal"
name: "Copy scene.metal source to bundle"
basedOnDependencyAnalysis: false
dependencies:
- package: AVLiveWire
product: AVLiveWire
configFiles:
Debug: Config/Shared.xcconfig
Release: Config/Shared.xcconfig
settings:
base:
PRODUCT_NAME: AVLiveBody
PRODUCT_BUNDLE_IDENTIFIER: cc.saillant.AVLiveBody
INFOPLIST_FILE: Sources/AVLiveBody/Info.plist
GENERATE_INFOPLIST_FILE: NO
CODE_SIGN_STYLE: Manual
CODE_SIGN_IDENTITY: "-"
CODE_SIGNING_REQUIRED: NO
CODE_SIGNING_ALLOWED: NO
SWIFT_VERSION: "5.10"
ENABLE_HARDENED_RUNTIME: NO
AVLiveBodyTests:
type: bundle.unit-test
platform: macOS
sources:
- path: Tests/AVLiveBodyTests
dependencies:
- target: AVLiveBody
- package: AVLiveWire
product: AVLiveWire
settings:
base:
GENERATE_INFOPLIST_FILE: YES
+14
View File
@@ -0,0 +1,14 @@
.venv/
__pycache__/
*.pyc
.uv-cache/
uv.lock
# Config local : peut contenir des creds (RTE OAuth, GCN Kafka).
# Le fichier de reference est config.toml.example (tracke).
# config.toml et config.data-only.toml restent committes tant qu'ils
# ne contiennent que des champs vides ; surveiller pre-commit hook.
*.local.toml
# Modeles YOLO telecharges au runtime
*.pt
+134
View File
@@ -0,0 +1,134 @@
# data_feeds — Pont flux temps réel → OSC
Worker Python asynchrone qui aspire **20 sources publiques** (sismique,
geophysique, meteo, qualite de l'air, espace, energie, foudre, aviation,
social, blockchain, evenements monde…) et les rebalance en OSC vers
SuperCollider (`:57121`), openFrameworks (`:57123`) et le dashboard web
data-only (`:57124`). Le but : nourrir l'engine audio et le visualizer
avec des **signaux du monde reel**, sans bricoler du networking dans
`sclang`.
## Architecture
```text
┌────────────────────────────────┐
│ data_feeds/bridge.py │
│ ├─ usgs (HTTP 60 s) │
│ ├─ swpc (HTTP 60 s) │
│ ├─ netzfrequenz(WebSocket) │
│ ├─ blitzortung (WebSocket) │
│ ├─ opensky (HTTP 15 s) │
│ ├─ bluesky (WebSocket) │
│ ├─ mempool (WebSocket) │
│ ├─ rte_eco2mix (OAuth2) │
│ ├─ github (HTTP 30 s) │
│ └─ gcn (Kafka) │
└─────────────┬──────────────────┘
│ OSC broadcast
┌─────────────┴────────────┐
UDP :57121 UDP :57123
┌──▼────────────┐ ┌─────▼────────────┐
│ SuperCollider │ │ openFrameworks │
│ ~feeds dict │ │ OscClient.data()│
└───────────────┘ └──────────────────┘
```
## Démarrage
```bash
cd data_feeds
uv sync # créé .venv et installe les deps
uv run python bridge.py -v # -v = verbose
```
Côté SC :
```supercollider
"sound_algo/control/data_feeds.scd".loadRelative; // installe les OSCdef
~feedDump.value; // affiche l'état
```
Côté oF : automatique dès que `OscClient::update()` tourne (déjà appelé
chaque frame). Lecture :
```cpp
float kp = osc_.dataf("swpc", "kp", /*fallback*/ 2.0f);
std::vector<float> strike;
if (osc_.consumeDataPulse("blitzortung", "strike", strike)) {
// strike = [lat, lon, age, mult]
}
```
## Schéma OSC
Toutes les routes sont préfixées `/data/<feed>/<sub>`. Voir
[`docs/DATA_FEEDS_OSC.md`](../docs/DATA_FEEDS_OSC.md) pour le schéma
complet.
| Feed | Routes | Cadence |
|----------------|---------------------------------------------|-------------|
| `usgs` | `event`, `rate` | 60 s |
| `swpc` | `wind`, `bz`, `kp`, `xray` | 60 s |
| `netzfrequenz` | `freq`, `dev`, `time_dev` | ~200 ms |
| `blitzortung` | `strike`, `rate` | event-based |
| `opensky` | `count`, `plane` | 15 s |
| `bluesky` | `post`, `rate` | event-based |
| `mempool` | `tx`, `block` | event-based |
| `rte_eco2mix` | `mix`, `co2` | 15 min |
| `github` | `event`, `rate` | 30 s |
| `gcn` | `alert` | rare |
| `pose` | `count`, `person`, `skel`, `bone` | ~20 fps |
| `openmeteo` | `now` (temp, hum, wind, press, rain) | 10 min |
| `openaq` | `now` (PM2.5, PM10, NO2, O3) | 15 min |
| `iss` | `pos` (lat, lon, alt, vel), `pass` | 5 s |
| `volcano` | `active`, `eruption` | 1 h |
| `social_buzz` | `reddit`, `hn`, `pulse` | 1 min |
| `gdelt` | `batch`, `event` (lat, lon, tone, country) | 15 min |
| `wikimedia` | `edit`, `rate` | streaming |
| `tides` | `level` (obs/pred/residual), `moon` | 6 min |
| `atc` | `hub` (icao, listeners), `total` | 5 min |
## Configuration
Éditer `config.toml` :
- `osc.targets` : liste `{host, port}` à arroser. Profil data-only
par défaut : SC `:57121` + oF `:57123` + web data-only `:57124`.
- `feeds.<name>.enabled` : booléen.
- `feeds.<name>.poll_seconds` : période pour les feeds HTTP.
- `feeds.opensky.bbox` : `[lamin, lomin, lamax, lomax]` (Lyon par défaut).
- `feeds.bluesky.sample_rate` : 0..1, fraction des posts conservée.
Flux nécessitant des identifiants (désactivés par défaut) :
- `rte_eco2mix` : créer un client sur
<https://data.rte-france.com/> puis renseigner `client_id` /
`client_secret`.
- `gcn` : <https://gcn.nasa.gov/quickstart> + `uv add gcn-kafka`.
- `pose` : install les deps optionnelles avec `uv sync --extra pose`
(opencv-python + ultralytics). Sur Mac M5 utiliser `device = "mps"`.
Une seule app peut grabber la webcam : si oF tourne `WebcamVis` en
capture locale, mettre `feeds.pose.enabled = false` (et inversement).
## Diagnostic
```bash
# Sniffer les paquets recus cote SC
uv run python -c "from pythonosc import osc_server, dispatcher; \
d=dispatcher.Dispatcher(); d.set_default_handler(lambda a,*x: print(a,x)); \
osc_server.BlockingOSCUDPServer(('127.0.0.1',57121),d).serve_forever()"
```
Côté SC, vérifier le heartbeat :
```supercollider
~feedAlive.value // true si le pont émet depuis < 15 s
```
## Ajout d'un flux
1. Créer `data_feeds/feeds/<name>.py` exposant `async def run(ctx)`.
2. L'enregistrer dans `config.toml` avec `enabled = true`.
3. Ajouter les OSCdef correspondants dans
`sound_algo/control/data_feeds.scd`.
4. Documenter le schéma OSC dans `docs/DATA_FEEDS_OSC.md`.
View File
+165
View File
@@ -0,0 +1,165 @@
#!/usr/bin/env python3
"""Orchestrateur du pont data_feeds → OSC.
Charge `config.toml`, lance un worker async par flux activé, et diffuse
en broadcast vers tous les `osc.targets`. Chaque worker doit exposer
une coroutine `run(ctx)` qui prend un `Context` et émet via `ctx.send(...)`.
"""
from __future__ import annotations
import argparse
import asyncio
import importlib
import logging
import re
import signal
import sys
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any
# Whitelist des noms de feed : empeche l'injection de modules arbitraires
# via un config.toml malveillant (importlib resolve sur du . ou .. ferait
# remonter dans l'arborescence).
_FEED_NAME_RE = re.compile(r"^[a-z][a-z0-9_]{0,30}$")
try:
import tomllib # py311+
except ModuleNotFoundError:
import tomli as tomllib # type: ignore
from pythonosc.udp_client import SimpleUDPClient
LOG = logging.getLogger("bridge")
@dataclass
class Context:
cfg: dict[str, Any]
prefix: str
clients: list[SimpleUDPClient]
feed_name: str
def send(self, sub: str, *args: Any) -> None:
path = f"{self.prefix}/{self.feed_name}/{sub}"
for c in self.clients:
try:
c.send_message(path, list(args))
except OSError as e:
LOG.warning("OSC send failed %s: %s", path, e)
def load_config(path: Path) -> dict[str, Any]:
with path.open("rb") as f:
return tomllib.load(f)
async def run_feed(name: str, cfg: dict[str, Any], ctx: Context) -> None:
"""Charge `data_feeds.feeds.<name>` et appelle `run(ctx)`."""
if not _FEED_NAME_RE.match(name):
LOG.error("invalid feed name %r — must match %s",
name, _FEED_NAME_RE.pattern)
return
try:
mod = importlib.import_module(f"data_feeds.feeds.{name}")
except ModuleNotFoundError:
# Fallback : exécution depuis le dossier data_feeds/
mod = importlib.import_module(f"feeds.{name}")
LOG.info("starting feed: %s", name)
backoff = 1.0
while True:
try:
await mod.run(ctx)
# Retour propre : on reset le backoff et on rejoue en boucle.
backoff = 1.0
await asyncio.sleep(2.0)
except asyncio.CancelledError:
raise
except Exception as e: # noqa: BLE001
LOG.error("feed %s crashed: %s — retry in %.1fs", name, e, backoff)
await asyncio.sleep(backoff)
backoff = min(backoff * 2, 60.0)
async def heartbeat(clients: list[SimpleUDPClient], prefix: str) -> None:
t0 = time.monotonic()
while True:
for c in clients:
c.send_message(f"{prefix}/heartbeat", [time.monotonic() - t0])
await asyncio.sleep(5.0)
async def main_async(cfg: dict[str, Any]) -> None:
osc_cfg = cfg.get("osc", {})
prefix = osc_cfg.get("prefix", "/data")
clients = [
SimpleUDPClient(t["host"], t["port"])
for t in osc_cfg.get("targets", [{"host": "127.0.0.1", "port": 57121}])
]
LOG.info(
"OSC targets: %s",
", ".join(f"{c._address}:{c._port}" for c in clients), # noqa: SLF001
)
tasks: list[asyncio.Task[None]] = []
for name, fcfg in cfg.get("feeds", {}).items():
if not fcfg.get("enabled", False):
LOG.info("feed disabled: %s", name)
continue
ctx = Context(cfg=fcfg, prefix=prefix, clients=clients, feed_name=name)
tasks.append(asyncio.create_task(run_feed(name, fcfg, ctx), name=name))
if not tasks:
LOG.warning("no feed enabled — exiting")
return
tasks.append(asyncio.create_task(heartbeat(clients, prefix), name="heartbeat"))
loop = asyncio.get_running_loop()
stop = loop.create_future()
sig_count = 0
def _on_signal() -> None:
nonlocal sig_count
sig_count += 1
if sig_count > 1:
LOG.warning("second signal received — forcing exit")
sys.exit(1)
if not stop.done():
stop.cancel()
for sig in (signal.SIGINT, signal.SIGTERM):
loop.add_signal_handler(sig, _on_signal)
try:
await stop
except asyncio.CancelledError:
pass
finally:
for t in tasks:
t.cancel()
await asyncio.gather(*tasks, return_exceptions=True)
LOG.info("bridge stopped")
def main() -> int:
p = argparse.ArgumentParser()
p.add_argument("-c", "--config", type=Path, default=Path(__file__).parent / "config.toml")
p.add_argument("-v", "--verbose", action="store_true")
args = p.parse_args()
logging.basicConfig(
level=logging.DEBUG if args.verbose else logging.INFO,
format="%(asctime)s %(levelname)-7s %(name)s%(message)s",
datefmt="%H:%M:%S",
)
cfg = load_config(args.config)
try:
asyncio.run(main_async(cfg))
except KeyboardInterrupt:
pass
return 0
if __name__ == "__main__":
sys.exit(main())
+37
View File
@@ -0,0 +1,37 @@
[osc]
host = "127.0.0.1"
port = 57127
[feeds.eco2mix]
enabled = true
interval_sec = 60
[feeds.velib]
enabled = true
interval_sec = 120
station_codes = []
[feeds.hubeau]
enabled = true
interval_sec = 300
codes = ["F050001001"]
[feeds.gbfs]
enabled = false
interval_sec = 120
url = "https://velib-metropole-opendata.smoove.pro/opendata/Velib_Metropole/station_status.json"
[feeds.ais]
enabled = false
[feeds.carburants]
enabled = false
[feeds.prim]
enabled = false
[feeds.sytadin]
enabled = false
[feeds.teleray]
enabled = false
+149
View File
@@ -0,0 +1,149 @@
# Profil "data-only" : que les flux open-data + pose YOLO, vers SC + oF.
#
# Pas de sclang/oscope/web → la chaine consomatrice est libre de
# brancher ce qu'elle veut sur les ports 57121 / 57123.
#
# Lancer avec : uv run python bridge.py -c config.data-only.toml -v
[osc]
targets = [
{ host = "127.0.0.1", port = 57121 }, # SuperCollider (si lance)
{ host = "127.0.0.1", port = 57123 }, # openFrameworks
{ host = "127.0.0.1", port = 57124 }, # Web dashboard data-only
]
prefix = "/data"
# -- Pose / webcam --------------------------------------------------------
[feeds.pose]
# Active uniquement quand le pont tourne dans le bundle launcher (TCC OK).
# En CLI le prompt webcam ne peut pas apparaitre -> camera silently refused.
enabled = true
model = "yolov8n-pose.pt"
device = "mps"
camera = 0
width = 640
height = 480
target_fps = 20
conf_thresh = 0.35
max_persons = 4
emit_keypoints = true
# -- Sismique / géophysique ------------------------------------------------
[feeds.usgs]
enabled = true
url = "https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/all_hour.geojson"
poll_seconds = 60
[feeds.swpc]
enabled = true
url_plasma = "https://services.swpc.noaa.gov/products/solar-wind/plasma-1-day.json"
url_mag = "https://services.swpc.noaa.gov/products/solar-wind/mag-1-day.json"
url_kp = "https://services.swpc.noaa.gov/products/noaa-planetary-k-index.json"
url_xray = "https://services.swpc.noaa.gov/json/goes/primary/xrays-1-day.json"
poll_seconds = 60
# -- Réseau électrique -----------------------------------------------------
[feeds.netzfrequenz]
# WARN : mainsfrequenz.de a ferme son WS public (NXDOMAIN 2026-05).
# Garde a false jusqu'a trouver une source alternative (gridradar ?
# swissgrid open data ?).
enabled = true
ws_url = "wss://www.mainsfrequenz.de/frequenz.socket"
[feeds.rte_eco2mix]
enabled = true
client_id = ""
client_secret = ""
poll_seconds = 900
# -- Foudre / atmosphère ---------------------------------------------------
[feeds.blitzortung]
enabled = true
ws_url = "wss://ws1.blitzortung.org:443/"
# -- Aviation / mouvement --------------------------------------------------
[feeds.opensky]
enabled = true
url = "https://opensky-network.org/api/states/all"
# API anonyme : 10 req / 1 min credit-based. On reste a 60s pour ne
# jamais epuiser le credit en cas de fork de process / restart rapide.
# Les 429 vus en 2026-05 etaient dus au poll 15s/20s precedent.
poll_seconds = 60
bbox = [45.5, 4.6, 46.0, 5.2]
# -- Pouls numérique -------------------------------------------------------
[feeds.bluesky]
enabled = true
ws_url = "wss://jetstream2.us-east.bsky.network/subscribe?wantedCollections=app.bsky.feed.post"
sample_rate = 0.02
[feeds.mempool]
enabled = true
ws_url = "wss://mempool.space/api/v1/ws"
[feeds.github]
enabled = true
url = "https://api.github.com/events"
poll_seconds = 30
# -- Meteo locale (Open-Meteo, sans cle) ----------------------------------
[feeds.openmeteo]
enabled = true
lat = 48.8566 # Paris par defaut
lon = 2.3522
poll_seconds = 600
# -- Qualite de l'air (OpenAQ v3, sans cle) -------------------------------
[feeds.openaq]
enabled = true
lat = 48.8566
lon = 2.3522
radius_m = 25000
poll_seconds = 900
# -- Station spatiale (ISS / wheretheiss.at) ------------------------------
[feeds.iss]
enabled = true
lat = 48.8566
lon = 2.3522
pass_radius_km = 1500
poll_seconds = 5
# -- Volcans actifs (Smithsonian GVP 7-jours JSON) ------------------------
[feeds.volcano]
enabled = true
url = "https://volcano.si.edu/feeds/eruptions7days.json"
poll_seconds = 3600
# -- Pouls social (Reddit hot + HackerNews top) ---------------------------
[feeds.social_buzz]
enabled = true
poll_seconds = 60
# -- GDELT (evenements monde 15-min) --------------------------------------
[feeds.gdelt]
enabled = true
poll_seconds = 900
# -- Wikipedia recent changes ---------------------------------------------
[feeds.wikimedia]
enabled = true
sample_rate = 0.05
rate_window_s = 5
# -- NOAA tides + lune ----------------------------------------------------
[feeds.tides]
enabled = true
station = "8443970"
poll_seconds = 360
# -- LiveATC hubs ---------------------------------------------------------
[feeds.atc]
enabled = true
hubs = ["KJFK", "KLAX", "KSFO", "KORD", "EGLL", "LFPG"]
poll_seconds = 300
[feeds.gcn]
enabled = false
client_id = ""
client_secret = ""
+135
View File
@@ -0,0 +1,135 @@
# Configuration du pont data_feeds → OSC.
#
# - SC écoute par défaut sur 57121 (cf. sound_algo/web_bridge.scd).
# - oF écoute sur 57123 (cf. ofApp::setup, oscListenPort_).
# Le pont diffuse en broadcast vers TOUS les `osc_targets` listés.
#
# Activer/désactiver un flux : `enabled = true|false`.
# Régler le débit avec `poll_seconds` (HTTP) ou laisser les WS gérer.
[osc]
targets = [
{ host = "127.0.0.1", port = 57121 }, # SuperCollider
{ host = "127.0.0.1", port = 57123 }, # openFrameworks
{ host = "127.0.0.1", port = 57124 }, # web bridge (sound_algo/web + web_realart)
]
# Préfixe commun. Toutes les routes sont /data/<feed>/...
prefix = "/data"
# -- Pose / webcam --------------------------------------------------------
[feeds.pose]
enabled = true
# YOLOv8/v11-pose via ultralytics. Auto-download du .pt au premier run.
# Modeles : yolov8n-pose (fast), yolov8s-pose, yolov8m-pose, yolov8l-pose.
# Sur Mac M5 prefere `n` ou `s`, device="mps" (Metal).
model = "yolov8n-pose.pt"
device = "mps" # "cpu", "mps" (Apple Silicon), "cuda:0"
camera = 0 # index ofVideoGrabber-style (0 = camera par defaut)
width = 640
height = 480
target_fps = 20 # plafond (le serveur peut faire moins)
conf_thresh = 0.35
max_persons = 4
# Si false, n'emit que `/data/pose/count` et les bbox (pas les 17 kp).
emit_keypoints = true
# Routes :
# /data/pose/count <n>
# /data/pose/person <idx> <cx> <cy> <w> <h> <conf> (normalises 0..1)
# /data/pose/skel <idx> <conf_avg> <x0 y0 c0 ... x16 y16 c16> (17 kp COCO)
# /data/pose/bone <idx> <kp_a> <kp_b> (segments du skeleton)
# -- Sismique / géophysique ------------------------------------------------
[feeds.usgs]
enabled = true
url = "https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/all_hour.geojson"
poll_seconds = 60
# Émet /data/usgs/event <mag> <lon> <lat> <depth> <age_sec>
# /data/usgs/rate <events_per_hour>
[feeds.swpc]
enabled = true
# Vent solaire (DSCOVR plasma)
url_plasma = "https://services.swpc.noaa.gov/products/solar-wind/plasma-1-day.json"
url_mag = "https://services.swpc.noaa.gov/products/solar-wind/mag-1-day.json"
url_kp = "https://services.swpc.noaa.gov/products/noaa-planetary-k-index.json"
url_xray = "https://services.swpc.noaa.gov/json/goes/primary/xrays-1-day.json"
poll_seconds = 60
# /data/swpc/wind <speed_kms> <density_pcm3> <temp_K>
# /data/swpc/bz <Bz_nT> <Bt_nT>
# /data/swpc/kp <kp> <a_index>
# /data/swpc/xray <short_W_m2> <long_W_m2> <flare_class_norm>
# -- Réseau électrique -----------------------------------------------------
[feeds.netzfrequenz]
# WARN : mainsfrequenz.de a ferme son WS public (NXDOMAIN 2026-05).
# Voir alternatives : gridradar.net (auth requise), swissgrid open data.
enabled = false
# Mainsfrequenz.de WebSocket (résolution ~200 ms, mesure Karlsruhe)
ws_url = "wss://www.mainsfrequenz.de/frequenz.socket"
# /data/grid/freq <hz> 50.000 ± 0.200
# /data/grid/dev <delta_hz> écart vs 50 Hz
# /data/grid/time_dev <sec> dérive intégrée
[feeds.rte_eco2mix]
enabled = false # nécessite token OAuth RTE (gratuit, register)
client_id = ""
client_secret = ""
poll_seconds = 900
# /data/rte/mix <nuclear> <gas> <coal> <oil> <hydro> <wind> <solar> <bio>
# /data/rte/co2 <gCO2_per_kWh>
# -- Foudre / atmosphère ---------------------------------------------------
[feeds.blitzortung]
enabled = true
# LightningMaps relay (Blitzortung dérivé, public)
ws_url = "wss://ws1.blitzortung.org:443/"
# /data/lightning/strike <lat> <lon> <age_sec> <multiplicity>
# /data/lightning/rate <strikes_per_min>
# -- Aviation / mouvement --------------------------------------------------
[feeds.opensky]
enabled = true
url = "https://opensky-network.org/api/states/all"
poll_seconds = 15
# Bbox optionnelle (Lyon par défaut : lamin,lomin,lamax,lomax)
bbox = [45.5, 4.6, 46.0, 5.2]
# /data/aviation/count <n>
# /data/aviation/plane <icao> <lon> <lat> <alt_m> <vel_ms> <heading_deg>
# -- Pouls numérique -------------------------------------------------------
[feeds.bluesky]
enabled = true
# Jetstream firehose (posts publics WS, JSON décompressé)
ws_url = "wss://jetstream2.us-east.bsky.network/subscribe?wantedCollections=app.bsky.feed.post"
sample_rate = 0.02 # garde 2 % des événements pour pas saturer
# /data/social/post <text_len> <lang_hash>
# /data/social/rate <posts_per_sec>
[feeds.mempool]
enabled = false
ws_url = "wss://mempool.space/api/v1/ws"
# /data/btc/tx <value_btc> <fee_sat_vb>
# /data/btc/block <height> <tx_count> <reward_btc>
[feeds.github]
enabled = false
url = "https://api.github.com/events"
poll_seconds = 30
# /data/github/event <type_hash> <repo_hash>
# -- Espace / GCN ----------------------------------------------------------
[feeds.gcn]
enabled = false
# GCN Classic over Kafka : nécessite credentials.
# Voir https://gcn.nasa.gov/quickstart pour générer un token.
client_id = ""
client_secret = ""
# /data/gcn/alert <mission_hash> <ra_deg> <dec_deg> <error_arcmin>
+27
View File
@@ -0,0 +1,27 @@
"""Registry of available feed classes (auto-discovery on import)."""
from __future__ import annotations
from .base import Feed
from .eco2mix import Eco2MixFeed
from .gbfs import GBFSFeed
from .hubeau import HubeauFeed
from .velib import VelibFeed
from .ais import AISFeed
from .carburants import CarburantsFeed
from .prim import PRIMFeed
from .sytadin import SytadinFeed
from .teleray import TelerayFeed
REGISTRY: dict[str, type[Feed]] = {
"eco2mix": Eco2MixFeed,
"gbfs": GBFSFeed,
"hubeau": HubeauFeed,
"velib": VelibFeed,
"ais": AISFeed,
"carburants": CarburantsFeed,
"prim": PRIMFeed,
"sytadin": SytadinFeed,
"teleray": TelerayFeed,
}
__all__ = ["Feed", "REGISTRY"]
+49
View File
@@ -0,0 +1,49 @@
"""Helpers communs aux feeds."""
from __future__ import annotations
import collections
import time
from typing import Iterable
class RateMeter:
"""Compte les événements sur une fenêtre glissante (en secondes)."""
def __init__(self, window: float = 60.0) -> None:
self.window = window
self._events: collections.deque[float] = collections.deque()
def tick(self) -> int:
now = time.monotonic()
self._events.append(now)
while self._events and now - self._events[0] > self.window:
self._events.popleft()
return len(self._events)
@property
def rate(self) -> float:
return len(self._events) / max(self.window, 1e-6)
def djb2(s: str) -> int:
"""Hash stable 0..65535 pour transformer une string en float OSC."""
h = 5381
for c in s.encode("utf-8", errors="ignore"):
h = ((h << 5) + h + c) & 0xFFFF
return h
def fnorm(x: float, lo: float, hi: float) -> float:
if hi <= lo:
return 0.0
return max(0.0, min(1.0, (x - lo) / (hi - lo)))
def safe_get(d: dict, path: Iterable[str], default=None):
cur = d
for k in path:
if isinstance(cur, dict) and k in cur:
cur = cur[k]
else:
return default
return cur
+22
View File
@@ -0,0 +1,22 @@
"""AIS vessel positions feed — STUB.
TODO: needs aisstream.io API key + websocket subscription.
"""
from __future__ import annotations
import logging
from .base import Feed
LOG = logging.getLogger("data_feeds.ais")
class AISFeed(Feed):
name = "ais"
interval_sec = 60.0
def fetch(self):
return None
def publish(self, payload) -> None:
LOG.info("stub")
+60
View File
@@ -0,0 +1,60 @@
"""LiveATC feeds metadata — pas de pull audio, juste compteur de
streams actifs et indicateur d'activite par hub aeroport.
LiveATC.net expose un endpoint stats JSON simplifie pour quelques
aeroports majeurs (KJFK, KLAX, KSFO, EGLL, LFPG). On polle pour
chacun le nombre d'auditeurs courant (proxy pour 'activite ATC').
OSC out :
/data/atc/hub icao listeners
/data/atc/total total_listeners n_hubs
"""
from __future__ import annotations
import asyncio
import logging
import re
import httpx
LOG = logging.getLogger("feed.atc")
# LiveATC publie un page HTML par feed avec "Listeners: N" — on parse
# ca via regex au lieu d'un API officielle (pas disponible).
HUB_URL = "https://www.liveatc.net/search/?icao={icao}"
LISTENERS_RE = re.compile(r"Listeners:\s*<b>(\d+)</b>", re.I)
async def _fetch_hub(cli: httpx.AsyncClient, icao: str
) -> int:
r = await cli.get(HUB_URL.format(icao=icao),
headers={"User-Agent": "av-live-data-feeds/1.0"})
r.raise_for_status()
matches = LISTENERS_RE.findall(r.text)
if not matches:
return 0
return sum(int(m) for m in matches)
async def run(ctx) -> None:
cfg = ctx.cfg
hubs = cfg.get("hubs",
["KJFK", "KLAX", "KSFO", "KORD", "EGLL", "LFPG"])
period = float(cfg.get("poll_seconds", 300.0)) # 5 min
async with httpx.AsyncClient(timeout=20.0) as cli:
while True:
total = 0
active = 0
for icao in hubs:
try:
listeners = await _fetch_hub(cli, icao)
except Exception as e: # noqa: BLE001
LOG.warning("atc %s failed: %s", icao, e)
continue
ctx.send("hub", icao, float(listeners))
total += listeners
if listeners > 0:
active += 1
await asyncio.sleep(0.5) # polite scrape
ctx.send("total", float(total), float(active))
await asyncio.sleep(period)
+55
View File
@@ -0,0 +1,55 @@
"""Abstract base class for data feeds."""
from __future__ import annotations
import abc
import logging
import time
import threading
from typing import Any
LOG = logging.getLogger("data_feeds.base")
class Feed(abc.ABC):
name: str = "feed"
interval_sec: float = 60.0
def __init__(self, osc_send, **cfg) -> None:
self.osc_send = osc_send
self.cfg = cfg
self._stop = threading.Event()
self._thread: threading.Thread | None = None
self.last_t: float = 0.0
def configure(self, **kwargs) -> None:
self.cfg.update(kwargs)
if "interval_sec" in kwargs:
self.interval_sec = float(kwargs["interval_sec"])
@abc.abstractmethod
def fetch(self) -> Any: ...
@abc.abstractmethod
def publish(self, payload: Any) -> None: ...
def tick(self) -> None:
try:
payload = self.fetch()
self.publish(payload)
self.last_t = time.time()
self.osc_send(f"/data/{self.name}/heartbeat", [self.last_t])
except Exception as e: # noqa: BLE001
LOG.warning("%s fetch failed: %s", self.name, e)
def start(self) -> None:
self._thread = threading.Thread(
target=self._run, name=f"feed-{self.name}", daemon=True)
self._thread.start()
def stop(self) -> None:
self._stop.set()
def _run(self) -> None:
while not self._stop.is_set():
self.tick()
self._stop.wait(self.interval_sec)
+48
View File
@@ -0,0 +1,48 @@
"""Blitzortung / LightningMaps — impacts de foudre temps réel.
Protocole : à la connexion, envoyer `{"a":111}` (handshake LightningMaps).
Chaque message JSON contient { time, lat, lon, mds (multiplicity)... }.
"""
from __future__ import annotations
import asyncio
import json
import logging
import time
import websockets
from ._util import RateMeter
LOG = logging.getLogger("feed.blitzortung")
async def run(ctx) -> None:
cfg = ctx.cfg
url = cfg["ws_url"]
rate = RateMeter(window=60.0)
while True:
try:
async with websockets.connect(url, ping_interval=20, max_size=2**20) as ws:
await ws.send(json.dumps({"a": 111}))
LOG.info("connected %s", url)
async for raw in ws:
try:
d = json.loads(raw)
except (json.JSONDecodeError, TypeError):
continue
lat = float(d.get("lat", 0.0))
lon = float(d.get("lon", 0.0))
# time est en ns Unix ; on calcule un age en secondes
t_ns = d.get("time", 0)
age = 0.0
if isinstance(t_ns, (int, float)) and t_ns > 0:
age = max(0.0, time.time() - t_ns / 1e9)
mult = int(d.get("mds") or 1)
ctx.send("strike", lat, lon, age, mult)
rate.tick()
if rate._events: # noqa: SLF001
ctx.send("rate", rate.rate * 60.0)
except Exception as e: # noqa: BLE001
LOG.warning("ws disconnected: %s — reconnecting", e)
await asyncio.sleep(5.0)
+46
View File
@@ -0,0 +1,46 @@
"""Bluesky Jetstream — firehose des posts publics (WebSocket JSON)."""
from __future__ import annotations
import asyncio
import json
import logging
import random
import time
import websockets
from ._util import RateMeter, djb2
LOG = logging.getLogger("feed.bluesky")
async def run(ctx) -> None:
cfg = ctx.cfg
url = cfg["ws_url"]
sample = float(cfg.get("sample_rate", 0.02))
rate = RateMeter(window=10.0)
last_rate_emit = 0.0
while True:
try:
async with websockets.connect(url, ping_interval=20, max_size=2**20) as ws:
LOG.info("connected jetstream (sample %.0f%%)", sample * 100)
async for raw in ws:
rate.tick()
now = time.monotonic()
if now - last_rate_emit > 1.0:
ctx.send("rate", rate.rate)
last_rate_emit = now
if random.random() > sample:
continue
try:
d = json.loads(raw)
except (json.JSONDecodeError, TypeError):
continue
commit = d.get("commit") or {}
rec = commit.get("record") or {}
text = rec.get("text") or ""
lang = (rec.get("langs") or ["?"])[0]
ctx.send("post", float(len(text)), float(djb2(lang)))
except Exception as e: # noqa: BLE001
LOG.warning("ws disconnected: %s — reconnecting", e)
await asyncio.sleep(3.0)
+22
View File
@@ -0,0 +1,22 @@
"""Prix carburants feed — STUB.
TODO: needs prix-carburants.gouv.fr GeoJSON cache + station selection.
"""
from __future__ import annotations
import logging
from .base import Feed
LOG = logging.getLogger("data_feeds.carburants")
class CarburantsFeed(Feed):
name = "carburants"
interval_sec = 3600.0
def fetch(self):
return None
def publish(self, payload) -> None:
LOG.info("stub")
+58
View File
@@ -0,0 +1,58 @@
"""RTE eco2mix feed — France electricity production mix in MW.
Uses the public OpenDataSoft mirror of RTE eco2mix-national-tr (temps reel,
15-min resolution). Stdlib HTTP only.
"""
from __future__ import annotations
import json
import logging
import urllib.parse
import urllib.request
from typing import Any
from .base import Feed
LOG = logging.getLogger("data_feeds.eco2mix")
# OpenDataSoft public mirror — no key required.
URL = (
"https://odre.opendatasoft.com/api/records/1.0/search/"
"?dataset=eco2mix-national-tr&rows=1&sort=-date_heure"
)
class Eco2MixFeed(Feed):
name = "eco2mix"
interval_sec = 60.0
def fetch(self) -> Any:
req = urllib.request.Request(URL, headers={"User-Agent": "av-live/0.1"})
with urllib.request.urlopen(req, timeout=10) as r:
data = json.loads(r.read().decode("utf-8"))
records = data.get("records") or []
if not records:
return None
return records[0].get("fields") or {}
def publish(self, payload: Any) -> None:
if not isinstance(payload, dict):
return
# Pick a representative subset (MW). Keys per eco2mix-national-tr.
keys = [
"consommation", "nucleaire", "gaz", "charbon", "fioul",
"eolien", "solaire", "hydraulique", "bioenergies",
"ech_physiques",
]
count = 0
for k in keys:
v = payload.get(k)
if v is None:
continue
try:
fv = float(v)
except (TypeError, ValueError):
continue
self.osc_send(f"/data/{self.name}/sample", [k, fv])
count += 1
self.osc_send(f"/data/{self.name}/count", [count])
+53
View File
@@ -0,0 +1,53 @@
"""Generic GBFS (General Bikeshare Feed Specification) reader.
Reads a `station_status.json` URL and publishes aggregate counters.
"""
from __future__ import annotations
import json
import logging
import urllib.request
from typing import Any
from .base import Feed
LOG = logging.getLogger("data_feeds.gbfs")
class GBFSFeed(Feed):
name = "gbfs"
interval_sec = 120.0
def fetch(self) -> Any:
url = self.cfg.get("url")
if not url:
LOG.info("gbfs disabled: no url configured")
return None
req = urllib.request.Request(url, headers={"User-Agent": "av-live/0.1"})
with urllib.request.urlopen(req, timeout=10) as r:
return json.loads(r.read().decode("utf-8"))
def publish(self, payload: Any) -> None:
if not isinstance(payload, dict):
return
stations = (payload.get("data") or {}).get("stations") or []
if not stations:
return
codes = set(self.cfg.get("station_codes") or [])
bikes = 0
docks = 0
operative = 0
sampled = 0
for s in stations:
sid = str(s.get("station_id", ""))
if codes and sid not in codes:
continue
bikes += int(s.get("num_bikes_available") or 0)
docks += int(s.get("num_docks_available") or 0)
if s.get("is_renting") or s.get("is_installed"):
operative += 1
sampled += 1
self.osc_send(f"/data/{self.name}/sample", ["bikes_available", float(bikes)])
self.osc_send(f"/data/{self.name}/sample", ["docks_available", float(docks)])
self.osc_send(f"/data/{self.name}/sample", ["stations_active", float(operative)])
self.osc_send(f"/data/{self.name}/count", [sampled])
+66
View File
@@ -0,0 +1,66 @@
"""GCN Classic over Kafka — alertes astrophysiques (GRB, GW, neutrinos).
Nécessite des credentials Kafka. Voir https://gcn.nasa.gov/quickstart.
Cette implémentation est volontairement minimale : on extrait ra/dec/error.
"""
from __future__ import annotations
import asyncio
import logging
LOG = logging.getLogger("feed.gcn")
async def run(ctx) -> None:
cfg = ctx.cfg
cid, csec = cfg.get("client_id"), cfg.get("client_secret")
if not (cid and csec):
LOG.warning("GCN credentials manquants — feed inactif (voir gcn.nasa.gov/quickstart)")
await asyncio.Event().wait()
return
try:
from gcn_kafka import Consumer # type: ignore
except ModuleNotFoundError:
LOG.error("`gcn-kafka` non installé. uv add gcn-kafka")
await asyncio.Event().wait()
return
from ._util import djb2
cons = Consumer(client_id=cid, client_secret=csec)
cons.subscribe([
"gcn.classic.text.SWIFT_BAT_GRB_POS_ACK",
"gcn.classic.text.FERMI_GBM_FLT_POS",
"gcn.classic.text.LVC_INITIAL",
"gcn.classic.text.ICECUBE_ASTROTRACK_GOLD",
])
LOG.info("subscribed GCN classic streams")
loop = asyncio.get_running_loop()
def _poll():
return cons.consume(num_messages=10, timeout=1.0)
while True:
msgs = await loop.run_in_executor(None, _poll)
for m in msgs or []:
if m.error():
continue
txt = m.value().decode("utf-8", "ignore")
ra, dec, err = _parse(txt)
ctx.send("alert", float(djb2(m.topic())), ra, dec, err)
def _parse(txt: str) -> tuple[float, float, float]:
ra = dec = err = 0.0
for line in txt.splitlines():
l = line.lower()
try:
if "ra:" in l and ra == 0.0:
ra = float(line.split(":", 1)[1].split()[0])
elif "dec:" in l and dec == 0.0:
dec = float(line.split(":", 1)[1].split()[0])
elif "error" in l and "arcmin" in l and err == 0.0:
err = float(line.split(":", 1)[1].split()[0])
except (ValueError, IndexError):
continue
return ra, dec, err
+93
View File
@@ -0,0 +1,93 @@
"""GDELT Project — feed des evenements 'world' 15-min.
Source : GDELT 2.0 Events CSV ; updates toutes les 15 min.
On compte les evenements + extrait les top countries / themes du
dernier batch.
OSC out :
/data/gdelt/batch n_events n_countries avg_tone
/data/gdelt/event lat lon tone country_code root_event_id
"""
from __future__ import annotations
import asyncio
import collections
import logging
import time
import zipfile
from io import BytesIO
import httpx
LOG = logging.getLogger("feed.gdelt")
# GDELT v2 master file list ; on prend juste le dernier .export.CSV.zip
MASTER = "http://data.gdeltproject.org/gdeltv2/lastupdate.txt"
async def _fetch_latest_csv(cli: httpx.AsyncClient) -> list[list[str]]:
r = await cli.get(MASTER)
r.raise_for_status()
# 3 lignes : events, mentions, gkg ; on prend events (premiere ligne)
first = (r.text.strip().splitlines() or [""])[0].split(" ")
if len(first) < 3:
return []
url = first[2]
rz = await cli.get(url, follow_redirects=True)
rz.raise_for_status()
with zipfile.ZipFile(BytesIO(rz.content)) as zf:
name = zf.namelist()[0]
text = zf.read(name).decode("utf-8", errors="ignore")
return [line.split("\t") for line in text.splitlines() if line]
async def run(ctx) -> None:
cfg = ctx.cfg
period = float(cfg.get("poll_seconds", 900.0)) # 15 min
seen: collections.OrderedDict[str, None] = collections.OrderedDict()
SEEN_MAX = 8192
async with httpx.AsyncClient(timeout=60.0) as cli:
while True:
try:
rows = await _fetch_latest_csv(cli)
if not rows:
LOG.warning("gdelt: empty batch")
await asyncio.sleep(period)
continue
count = 0
tones = []
countries: collections.Counter[str] = collections.Counter()
# GDELT 2.0 events CSV : 61 colonnes.
# idx 0 = GLOBALEVENTID, 7 = Actor1CountryCode,
# 34 = AvgTone, 39 = ActionGeo_Lat, 40 = ActionGeo_Long
for row in rows:
if len(row) < 41:
continue
eid = row[0]
if not eid or eid in seen:
continue
seen[eid] = None
if len(seen) > SEEN_MAX:
seen.popitem(last=False)
count += 1
try:
tone = float(row[34] or "0")
except ValueError:
tone = 0.0
tones.append(tone)
cc = row[7].strip()[:3]
if cc:
countries[cc] += 1
try:
lat = float(row[39] or "0")
lon = float(row[40] or "0")
except ValueError:
lat = lon = 0.0
if lat or lon:
ctx.send("event", lat, lon, tone, cc, eid)
avg_tone = (sum(tones) / len(tones)) if tones else 0.0
ctx.send("batch", float(count),
float(len(countries)), float(avg_tone))
except Exception as e: # noqa: BLE001
LOG.warning("gdelt fetch failed: %s", e)
await asyncio.sleep(period)
+43
View File
@@ -0,0 +1,43 @@
"""GitHub public events — firehose dev mondial (polling REST anonyme)."""
from __future__ import annotations
import asyncio
import logging
import httpx
from ._util import djb2
LOG = logging.getLogger("feed.github")
async def run(ctx) -> None:
cfg = ctx.cfg
url = cfg["url"]
period = float(cfg.get("poll_seconds", 30))
# IDs GitHub sont des strings numeriques croissants : on compare en int
# (la compare string casse des que le nombre de digits change).
last_id: int = 0
async with httpx.AsyncClient(timeout=20.0,
headers={"Accept": "application/vnd.github+json"}) as cli:
while True:
try:
r = await cli.get(url)
r.raise_for_status()
for ev in reversed(r.json()):
raw = ev.get("id", "")
try:
eid = int(raw)
except (TypeError, ValueError):
continue
if eid <= last_id:
continue
ctx.send(
"event",
float(djb2(ev.get("type", "?"))),
float(djb2(((ev.get("repo") or {}).get("name") or "?"))),
)
last_id = eid
except Exception as e: # noqa: BLE001
LOG.warning("fetch failed: %s", e)
await asyncio.sleep(period)
+66
View File
@@ -0,0 +1,66 @@
"""Hub'Eau hydrometrie feed — water level and flow rate for French rivers.
API: https://hubeau.eaufrance.fr/api/v1/hydrometrie/observations_tr
Open, no API key required.
"""
from __future__ import annotations
import json
import logging
import urllib.parse
import urllib.request
from typing import Any
from .base import Feed
LOG = logging.getLogger("data_feeds.hubeau")
BASE = "https://hubeau.eaufrance.fr/api/v1/hydrometrie/observations_tr"
class HubeauFeed(Feed):
name = "hubeau"
interval_sec = 300.0
def fetch(self) -> Any:
codes = self.cfg.get("codes") or ["F050001001"]
out: dict[str, dict[str, float]] = {}
for code in codes:
params = {
"code_entite": code,
"size": 1,
"sort": "desc",
"fields": "code_station,grandeur_hydro,resultat_obs,date_obs",
}
url = BASE + "?" + urllib.parse.urlencode(params)
try:
req = urllib.request.Request(
url, headers={"User-Agent": "av-live/0.1"})
with urllib.request.urlopen(req, timeout=10) as r:
data = json.loads(r.read().decode("utf-8"))
except Exception as e: # noqa: BLE001
LOG.warning("hubeau %s failed: %s", code, e)
continue
for obs in data.get("data") or []:
station = obs.get("code_station") or code
gr = obs.get("grandeur_hydro") or "X"
v = obs.get("resultat_obs")
if v is None:
continue
try:
fv = float(v)
except (TypeError, ValueError):
continue
out.setdefault(station, {})[gr] = fv
return out
def publish(self, payload: Any) -> None:
if not isinstance(payload, dict) or not payload:
return
count = 0
for station, vals in payload.items():
for gr, v in vals.items():
key = f"{station}_{gr}"
self.osc_send(f"/data/{self.name}/sample", [key, float(v)])
count += 1
self.osc_send(f"/data/{self.name}/count", [count])
+61
View File
@@ -0,0 +1,61 @@
"""ISS position via wheretheiss.at (json).
Renvoie position lat/lon + altitude + velocity. Polling 5s par defaut.
Egalement emet l'event 'pass' (1.0) lorsque la station franchit une
zone d'observation autour de l'observateur (configurable lat/lon/radius).
OSC out :
/data/iss/pos lat lon alt_km vel_kmh
/data/iss/pass 1 (transient, quand iss enter dans le radius)
"""
from __future__ import annotations
import asyncio
import logging
import math
import httpx
LOG = logging.getLogger("feed.iss")
URL = "https://api.wheretheiss.at/v1/satellites/25544"
def _great_circle_km(lat1: float, lon1: float,
lat2: float, lon2: float) -> float:
r = 6371.0
p1 = math.radians(lat1)
p2 = math.radians(lat2)
dp = math.radians(lat2 - lat1)
dl = math.radians(lon2 - lon1)
a = (math.sin(dp / 2) ** 2
+ math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2)
return 2 * r * math.asin(math.sqrt(a))
async def run(ctx) -> None:
cfg = ctx.cfg
period = float(cfg.get("poll_seconds", 5.0))
obs_lat = float(cfg.get("lat", 48.8566))
obs_lon = float(cfg.get("lon", 2.3522))
pass_radius = float(cfg.get("pass_radius_km", 1500.0))
inside_prev = False
async with httpx.AsyncClient(timeout=10.0) as cli:
while True:
try:
r = await cli.get(URL)
r.raise_for_status()
j = r.json()
lat = float(j.get("latitude", 0.0))
lon = float(j.get("longitude", 0.0))
alt = float(j.get("altitude", 0.0))
vel = float(j.get("velocity", 0.0))
ctx.send("pos", lat, lon, alt, vel)
dist = _great_circle_km(obs_lat, obs_lon, lat, lon)
inside_now = dist < pass_radius
if inside_now and not inside_prev:
ctx.send("pass", 1.0, dist)
inside_prev = inside_now
except Exception as e: # noqa: BLE001
LOG.warning("iss fetch failed: %s", e)
await asyncio.sleep(period)
+43
View File
@@ -0,0 +1,43 @@
"""mempool.space — Bitcoin txs and blocks (WebSocket)."""
from __future__ import annotations
import asyncio
import json
import logging
import websockets
LOG = logging.getLogger("feed.mempool")
async def run(ctx) -> None:
cfg = ctx.cfg
url = cfg["ws_url"]
while True:
try:
async with websockets.connect(url, ping_interval=20, max_size=2**21) as ws:
await ws.send(json.dumps({"action": "want", "data": ["mempool-blocks", "blocks", "live-2h-chart"]}))
LOG.info("connected mempool.space")
async for raw in ws:
try:
d = json.loads(raw)
except (json.JSONDecodeError, TypeError):
continue
if "block" in d:
b = d["block"] or {}
ctx.send(
"block",
float(b.get("height", 0)),
float(b.get("tx_count", 0)),
float(b.get("extras", {}).get("reward", 0) or 0) / 1e8,
)
if "transactions" in d:
for tx in (d["transactions"] or [])[:5]:
ctx.send(
"tx",
float(tx.get("value", 0)) / 1e8,
float(tx.get("fee", 0)) / max(1.0, float(tx.get("vsize", 1))),
)
except Exception as e: # noqa: BLE001
LOG.warning("ws disconnected: %s — reconnecting", e)
await asyncio.sleep(5.0)
+60
View File
@@ -0,0 +1,60 @@
"""Fréquence du réseau électrique européen — WebSocket Mainsfrequenz.de.
Format payload (texte) : "f=49.987 t=2026-05-11T06:42:00Z" environ.
Le serveur peut changer ; on parse defensively et on extrait `f`.
"""
from __future__ import annotations
import asyncio
import logging
import re
import time
import websockets
LOG = logging.getLogger("feed.netzfrequenz")
_RE_F = re.compile(r"f\s*=\s*([0-9]+\.[0-9]+)")
async def run(ctx) -> None:
cfg = ctx.cfg
url = cfg["ws_url"]
time_dev = 0.0 # dérive intégrée (secondes)
last_t = time.monotonic()
# Backoff exponentiel cap a 5 minutes pour ne pas spammer un host
# mort (mainsfrequenz.de NXDOMAIN depuis 2026-05). On log la
# premiere et chaque dixieme reconnect uniquement.
backoff = 3.0
attempt = 0
while True:
try:
async with websockets.connect(url, ping_interval=20) as ws:
LOG.info("connected %s", url)
backoff = 3.0 # reset on success
attempt = 0
async for msg in ws:
text = msg if isinstance(msg, str) else msg.decode("utf-8", "ignore")
m = _RE_F.search(text)
if not m:
continue
try:
f = float(m.group(1))
except ValueError:
continue
now = time.monotonic()
dt = now - last_t
last_t = now
delta = f - 50.0
# Intégration : 1 s réelle à 49.5 Hz → -0.01 s d'horloge
time_dev += (delta / 50.0) * dt
ctx.send("freq", f)
ctx.send("dev", delta)
ctx.send("time_dev", time_dev)
except Exception as e: # noqa: BLE001
attempt += 1
if attempt == 1 or attempt % 10 == 0:
LOG.warning("ws disconnected (attempt %d, backoff %ds): %s",
attempt, int(backoff), e)
await asyncio.sleep(backoff)
backoff = min(backoff * 1.6, 300.0)
+57
View File
@@ -0,0 +1,57 @@
"""OpenAQ — qualite de l'air locale.
Mesures temps reel PM2.5 / PM10 / NO2 / O3 autour d'un point geo.
API v3 publique sans cle (rate-limited mais souple).
OSC out :
/data/openaq/now pm25 pm10 no2 o3
"""
from __future__ import annotations
import asyncio
import logging
import httpx
LOG = logging.getLogger("feed.openaq")
URL = ("https://api.openaq.org/v3/locations"
"?coordinates={lat},{lon}&radius={radius}&limit=20")
def _latest(values: list, param: str) -> float:
"""Cherche la mesure la plus recente pour `param` dans la liste
de locations OpenAQ v3."""
best = 0.0
for loc in values:
for sensor in loc.get("sensors", []):
p = sensor.get("parameter", {})
if p.get("name") == param:
last = sensor.get("latest", {})
v = last.get("value")
if v is not None and v > best:
best = float(v)
return best
async def run(ctx) -> None:
cfg = ctx.cfg
lat = float(cfg.get("lat", 48.8566))
lon = float(cfg.get("lon", 2.3522))
radius = int(cfg.get("radius_m", 25000)) # 25 km autour
period = float(cfg.get("poll_seconds", 900.0)) # 15 min
url = URL.format(lat=lat, lon=lon, radius=radius)
async with httpx.AsyncClient(timeout=20.0) as cli:
while True:
try:
r = await cli.get(url)
r.raise_for_status()
locs = r.json().get("results", [])
pm25 = _latest(locs, "pm25")
pm10 = _latest(locs, "pm10")
no2 = _latest(locs, "no2")
o3 = _latest(locs, "o3")
ctx.send("now", pm25, pm10, no2, o3)
except Exception as e: # noqa: BLE001
LOG.warning("openaq fetch failed: %s", e)
await asyncio.sleep(period)
+46
View File
@@ -0,0 +1,46 @@
"""Open-Meteo — meteo locale (temp / vent / humidite / pression / pluie).
Pas de cle API. Geolocalisation via lat/lon en config.toml.
Update toutes les `poll_seconds` (defaut 600s = 10 min).
OSC out :
/data/openmeteo/now temp_c humidity wind_mps wind_deg pressure_hpa rain_mmh
"""
from __future__ import annotations
import asyncio
import logging
import httpx
LOG = logging.getLogger("feed.openmeteo")
URL = ("https://api.open-meteo.com/v1/forecast"
"?latitude={lat}&longitude={lon}"
"&current=temperature_2m,relative_humidity_2m,wind_speed_10m,"
"wind_direction_10m,pressure_msl,rain"
"&wind_speed_unit=ms&timezone=UTC")
async def run(ctx) -> None:
cfg = ctx.cfg
lat = float(cfg.get("lat", 48.8566)) # Paris by default
lon = float(cfg.get("lon", 2.3522))
period = float(cfg.get("poll_seconds", 600.0))
url = URL.format(lat=lat, lon=lon)
async with httpx.AsyncClient(timeout=15.0) as cli:
while True:
try:
r = await cli.get(url)
r.raise_for_status()
cur = r.json().get("current", {})
ctx.send("now",
float(cur.get("temperature_2m", 0.0)),
float(cur.get("relative_humidity_2m", 0.0)),
float(cur.get("wind_speed_10m", 0.0)),
float(cur.get("wind_direction_10m", 0.0)),
float(cur.get("pressure_msl", 1013.0)),
float(cur.get("rain", 0.0)))
except Exception as e: # noqa: BLE001
LOG.warning("openmeteo fetch failed: %s", e)
await asyncio.sleep(period)
+48
View File
@@ -0,0 +1,48 @@
"""OpenSky Network — ADS-B aircraft states (REST polling, anon ≤ 15 s)."""
from __future__ import annotations
import asyncio
import logging
import httpx
LOG = logging.getLogger("feed.opensky")
async def run(ctx) -> None:
cfg = ctx.cfg
base = cfg["url"]
period = float(cfg.get("poll_seconds", 15))
bbox = cfg.get("bbox") # [lamin, lomin, lamax, lomax]
params = None
if bbox and len(bbox) == 4:
params = {
"lamin": bbox[0], "lomin": bbox[1],
"lamax": bbox[2], "lomax": bbox[3],
}
async with httpx.AsyncClient(timeout=20.0) as cli:
while True:
try:
r = await cli.get(base, params=params)
r.raise_for_status()
data = r.json()
except Exception as e: # noqa: BLE001
LOG.warning("fetch failed: %s", e)
await asyncio.sleep(period)
continue
states = data.get("states") or []
ctx.send("count", float(len(states)))
for s in states:
# index: 0=icao24, 5=lon, 6=lat, 7=baro_alt, 9=velocity, 10=heading
try:
icao = (s[0] or "?")[:8]
lon = float(s[5]) if s[5] is not None else 0.0
lat = float(s[6]) if s[6] is not None else 0.0
alt = float(s[7]) if s[7] is not None else 0.0
vel = float(s[9]) if s[9] is not None else 0.0
head = float(s[10]) if s[10] is not None else 0.0
except (IndexError, TypeError, ValueError):
continue
ctx.send("plane", icao, lon, lat, alt, vel, head)
await asyncio.sleep(period)
+194
View File
@@ -0,0 +1,194 @@
"""Webcam → OpenCV → pose detection (YOLOv8-pose) → OSC.
Pourquoi YOLOv8-pose plutot qu'OpenPose proper ?
- OpenPose officiel = build CUDA, douloureux sur Mac ARM.
- YOLOv8-pose : pip install, MPS/Metal accelere, 17 keypoints COCO
(proche d'OpenPose BODY_25, suffisant pour de l'AV-live).
- Pour un vrai OpenPose, swap simple : remplacer `Detector` par un
wrapper autour de pyopenpose ou cmu-openpose et conserver le format
keypoints (x_norm, y_norm, conf) emis sur OSC.
Sortie OSC :
/data/pose/count <n>
/data/pose/person <idx> <cx> <cy> <w> <h> <conf>
/data/pose/skel <idx> <conf_avg> <x0 y0 c0 ... x16 y16 c16>
/data/pose/bone <idx> <kp_a> <kp_b> (a la connexion, statique)
/data/pose/stats <avg_conf> <avg_size> <cx_bar> <cy_bar> (par batch)
Toutes les coordonnees sont normalisees 0..1 (origine top-left).
"""
from __future__ import annotations
import asyncio
import logging
import time
LOG = logging.getLogger("feed.pose")
# Squelette COCO 17 keypoints (paires d'os).
COCO_BONES: list[tuple[int, int]] = [
(0, 1), (0, 2), (1, 3), (2, 4), # tete
(5, 6), (5, 7), (7, 9), (6, 8), (8, 10),# bras
(5, 11), (6, 12), (11, 12), # torse
(11, 13), (13, 15), (12, 14), (14, 16), # jambes
]
class _Lazy:
"""Imports lourds differes pour ne pas casser le pont entier si pose
n'est pas demande."""
def __init__(self) -> None:
self.cv2 = None
self.YOLO = None
self.np = None
def load(self) -> None:
if self.cv2 is not None:
return
import cv2 # type: ignore
import numpy as np # type: ignore
from ultralytics import YOLO # type: ignore
self.cv2 = cv2
self.np = np
self.YOLO = YOLO
_LAZY = _Lazy()
async def run(ctx) -> None:
cfg = ctx.cfg
try:
_LAZY.load()
except ModuleNotFoundError as e:
LOG.error("dependances manquantes : %s — uv sync --extra pose", e)
await asyncio.Event().wait()
return
cv2, np, YOLO = _LAZY.cv2, _LAZY.np, _LAZY.YOLO
cam_idx = int(cfg.get("camera", 0))
width = int(cfg.get("width", 640))
height = int(cfg.get("height", 480))
target_fps = float(cfg.get("target_fps", 20))
conf_thresh = float(cfg.get("conf_thresh", 0.35))
max_persons = int(cfg.get("max_persons", 4))
emit_kp = bool(cfg.get("emit_keypoints", True))
model_name = cfg.get("model", "yolov8n-pose.pt")
device = cfg.get("device", "mps")
LOG.info("loading %s on %s", model_name, device)
model = YOLO(model_name)
cap = cv2.VideoCapture(cam_idx)
cap.set(cv2.CAP_PROP_FRAME_WIDTH, width)
cap.set(cv2.CAP_PROP_FRAME_HEIGHT, height)
if not cap.isOpened():
LOG.error("camera index %d indisponible", cam_idx)
await asyncio.Event().wait()
return
# Annonce du squelette (statique, une fois)
for a, b in COCO_BONES:
ctx.send("bone", float(a), float(b))
loop = asyncio.get_running_loop()
period = 1.0 / max(1.0, target_fps)
LOG.info("pose stream up: %dx%d @ %.1f fps target", width, height, target_fps)
def _grab():
ok, frame = cap.read()
return frame if ok else None
# ThreadPoolExecutor dedie : empeche l'inference longue (>20ms sur MPS)
# de monopoliser le pool partage et de bloquer les autres feeds.
import concurrent.futures
pool = concurrent.futures.ThreadPoolExecutor(max_workers=1,
thread_name_prefix="pose")
def _infer(fr):
return model.predict(fr, device=device, conf=conf_thresh,
verbose=False, max_det=max_persons)
try:
while True:
t0 = time.monotonic()
frame = await loop.run_in_executor(pool, _grab)
if frame is None:
await asyncio.sleep(period)
continue
h, w = frame.shape[:2]
try:
# Non-bloquant : l'event loop continue de servir les autres
# feeds pendant les ~20-80 ms d'inference.
results = await loop.run_in_executor(pool, _infer, frame)
except Exception as e: # noqa: BLE001
LOG.warning("inference failed: %s", e)
await asyncio.sleep(period)
continue
if not results:
ctx.send("count", 0.0)
await asyncio.sleep(period)
continue
res = results[0]
kp_xy = getattr(res.keypoints, "xy", None)
kp_conf = getattr(res.keypoints, "conf", None)
boxes = getattr(res, "boxes", None)
n = 0 if kp_xy is None else int(len(kp_xy))
ctx.send("count", float(n))
# Agregats pour le dashboard : confiance moyenne globale,
# taille moyenne (proxy distance cam), centre du barycentre
# des personnes, fraction de l'image occupee.
global_conf_sum = 0.0
global_conf_n = 0
size_sum = 0.0
cx_sum = 0.0
cy_sum = 0.0
for i in range(n):
# bbox normalisee
if boxes is not None and i < len(boxes):
b = boxes.xywhn[i].cpu().numpy().tolist() # cx, cy, w, h
conf_b = float(boxes.conf[i].item())
ctx.send("person", float(i), *b, conf_b)
cx_sum += float(b[0])
cy_sum += float(b[1])
size_sum += float(b[2]) * float(b[3])
if not emit_kp or kp_xy is None:
continue
pts = kp_xy[i].cpu().numpy() # (17, 2) px
cfs = kp_conf[i].cpu().numpy() if kp_conf is not None \
else np.ones(len(pts), dtype=float)
flat: list[float] = []
conf_sum = 0.0
for (x, y), c in zip(pts, cfs):
xn = float(x) / max(1.0, w)
yn = float(y) / max(1.0, h)
cc = float(c)
flat.extend([xn, yn, cc])
conf_sum += cc
avg = conf_sum / max(1, len(pts))
ctx.send("skel", float(i), avg, *flat)
global_conf_sum += avg
global_conf_n += 1
# /data/pose/stats avg_conf avg_size cx cy
if n > 0:
avg_conf = global_conf_sum / max(1, global_conf_n)
avg_size = size_sum / n
cx_bar = cx_sum / n
cy_bar = cy_sum / n
ctx.send("stats", float(avg_conf), float(avg_size),
float(cx_bar), float(cy_bar))
else:
ctx.send("stats", 0.0, 0.0, 0.5, 0.5)
# cadence
dt = time.monotonic() - t0
if dt < period:
await asyncio.sleep(period - dt)
finally:
cap.release()
pool.shutdown(wait=False, cancel_futures=True)
+22
View File
@@ -0,0 +1,22 @@
"""PRIM Ile-de-France Mobilites feed — STUB.
TODO: needs API key (https://prim.iledefrance-mobilites.fr/).
"""
from __future__ import annotations
import logging
from .base import Feed
LOG = logging.getLogger("data_feeds.prim")
class PRIMFeed(Feed):
name = "prim"
interval_sec = 60.0
def fetch(self):
return None
def publish(self, payload) -> None:
LOG.info("stub")
+84
View File
@@ -0,0 +1,84 @@
"""RTE éCO2mix — mix électrique France (OAuth2 client_credentials)."""
from __future__ import annotations
import asyncio
import logging
import time
import httpx
LOG = logging.getLogger("feed.rte_eco2mix")
TOKEN_URL = "https://digital.iservices.rte-france.com/token/oauth/"
API_URL = "https://digital.iservices.rte-france.com/open_api/actual_generation/v1/actual_generations_per_production_type"
# Mapping des `production_type` RTE (verbose) vers nos categories courtes.
# L'API agrege HYDRO_* et WIND_* pour le contexte musical : on additionne.
_RTE_MAP = {
"NUCLEAR": "NUCLEAR",
"FOSSIL_GAS": "GAS",
"FOSSIL_HARD_COAL": "COAL",
"FOSSIL_OIL": "OIL",
"HYDRO_WATER_RESERVOIR": "HYDRO",
"HYDRO_RUN_OF_RIVER_AND_POUNDAGE":"HYDRO",
"HYDRO_PUMPED_STORAGE": "HYDRO",
"WIND_ONSHORE": "WIND",
"WIND_OFFSHORE": "WIND",
"SOLAR": "SOLAR",
"BIOMASS": "BIOENERGY",
"WASTE": "BIOENERGY",
}
async def _get_token(cli: httpx.AsyncClient, cid: str, csec: str) -> tuple[str, float]:
r = await cli.post(TOKEN_URL, auth=(cid, csec),
data={"grant_type": "client_credentials"})
r.raise_for_status()
j = r.json()
return j["access_token"], time.monotonic() + float(j.get("expires_in", 7200)) - 60
async def run(ctx) -> None:
cfg = ctx.cfg
cid, csec = cfg.get("client_id"), cfg.get("client_secret")
period = float(cfg.get("poll_seconds", 900))
if not (cid and csec):
LOG.warning("client_id/client_secret manquants — feed inactif")
await asyncio.Event().wait()
return
token, exp = "", 0.0
async with httpx.AsyncClient(timeout=30.0) as cli:
while True:
try:
if time.monotonic() > exp:
token, exp = await _get_token(cli, cid, csec)
r = await cli.get(API_URL, headers={"Authorization": f"Bearer {token}"})
r.raise_for_status()
j = r.json()
latest: dict[str, float] = {}
for series in (j.get("actual_generations_per_production_type") or []):
raw = series.get("production_type", "?")
typ = _RTE_MAP.get(raw)
if typ is None:
continue # type non-mappe, ignore
vals = series.get("values") or []
if vals:
# Aggregation : on additionne les sous-categories
# (ex: WIND_ONSHORE + WIND_OFFSHORE -> WIND).
latest[typ] = latest.get(typ, 0.0) \
+ float(vals[-1].get("value", 0.0))
# mapping standard RTE → ordre des args
ctx.send(
"mix",
latest.get("NUCLEAR", 0.0),
latest.get("GAS", 0.0),
latest.get("COAL", 0.0),
latest.get("OIL", 0.0),
latest.get("HYDRO", 0.0),
latest.get("WIND", 0.0),
latest.get("SOLAR", 0.0),
latest.get("BIOENERGY", 0.0),
)
except Exception as e: # noqa: BLE001
LOG.warning("fetch failed: %s", e)
await asyncio.sleep(period)
+81
View File
@@ -0,0 +1,81 @@
"""Reddit /r/all + HackerNews top — pulse social pour viz 'social storm'.
Reddit : /r/all/hot.json — score, num_comments des top posts
HN : algolia API search_by_date front_page — points, comments
OSC out :
/data/social_buzz/reddit score_avg comments_avg n
/data/social_buzz/hn score_avg comments_avg n
/data/social_buzz/pulse combined_score (event tick toutes ~30s)
"""
from __future__ import annotations
import asyncio
import logging
import httpx
LOG = logging.getLogger("feed.social_buzz")
REDDIT_URL = "https://www.reddit.com/r/all/hot.json?limit=25"
HN_URL = "https://hacker-news.firebaseio.com/v0/topstories.json"
HN_ITEM = "https://hacker-news.firebaseio.com/v0/item/{}.json"
async def _fetch_reddit(cli: httpx.AsyncClient) -> tuple[float, float, int]:
r = await cli.get(REDDIT_URL,
headers={"User-Agent": "av-live-data-feeds/1.0"})
r.raise_for_status()
posts = r.json().get("data", {}).get("children", [])
if not posts:
return 0.0, 0.0, 0
scores = [int(p["data"].get("score", 0)) for p in posts]
comments = [int(p["data"].get("num_comments", 0)) for p in posts]
n = len(scores)
return sum(scores) / n, sum(comments) / n, n
async def _fetch_hn(cli: httpx.AsyncClient, top_n: int = 15
) -> tuple[float, float, int]:
r = await cli.get(HN_URL)
r.raise_for_status()
ids = r.json()[:top_n]
coros = [cli.get(HN_ITEM.format(i)) for i in ids]
resps = await asyncio.gather(*coros, return_exceptions=True)
scores, comments = [], []
for resp in resps:
if isinstance(resp, Exception):
continue
try:
it = resp.json()
except Exception:
continue
scores.append(int(it.get("score", 0)))
comments.append(int(it.get("descendants", 0)))
n = len(scores) or 1
return sum(scores) / n, sum(comments) / n, len(scores)
async def run(ctx) -> None:
cfg = ctx.cfg
period = float(cfg.get("poll_seconds", 60.0))
async with httpx.AsyncClient(timeout=20.0) as cli:
while True:
try:
r_score, r_com, r_n = await _fetch_reddit(cli)
ctx.send("reddit", r_score, r_com, float(r_n))
except Exception as e: # noqa: BLE001
LOG.warning("reddit fetch failed: %s", e)
r_score = 0.0
try:
h_score, h_com, h_n = await _fetch_hn(cli)
ctx.send("hn", h_score, h_com, float(h_n))
except Exception as e: # noqa: BLE001
LOG.warning("hn fetch failed: %s", e)
h_score = 0.0
# Combined score normalize en [0..1] ; reddit hot ~10k+ posts,
# HN front ~300 points. On scale chaque source puis on max.
combined = max(min(r_score / 10000.0, 1.0),
min(h_score / 500.0, 1.0))
ctx.send("pulse", combined)
await asyncio.sleep(period)
+96
View File
@@ -0,0 +1,96 @@
"""NOAA SWPC — vent solaire, IMF Bz, indice Kp, X-ray flux GOES."""
from __future__ import annotations
import asyncio
import logging
import math
import httpx
LOG = logging.getLogger("feed.swpc")
def _last_row(data) -> list | None:
if not data or len(data) < 2:
return None
return data[-1]
def _flare_class_norm(long_wm2: float) -> float:
"""Mappe le X-ray long band en classe normalisée 0..1.
A=1e-8, B=1e-7, C=1e-6, M=1e-5, X=1e-4. log10 → [0..1] sur A→X.
"""
if long_wm2 <= 0:
return 0.0
return max(0.0, min(1.0, (math.log10(long_wm2) + 8.0) / 4.0))
async def _fetch_json(cli: httpx.AsyncClient, url: str):
r = await cli.get(url)
r.raise_for_status()
return r.json()
async def run(ctx) -> None:
cfg = ctx.cfg
period = float(cfg.get("poll_seconds", 60))
urls = {
"plasma": cfg.get("url_plasma"),
"mag": cfg.get("url_mag"),
"kp": cfg.get("url_kp"),
"xray": cfg.get("url_xray"),
}
async with httpx.AsyncClient(timeout=20.0) as cli:
while True:
try:
if urls["plasma"]:
j = await _fetch_json(cli, urls["plasma"])
row = _last_row(j)
if row:
# ["time_tag","density","speed","temperature"]
try:
density = float(row[1])
speed = float(row[2])
temp = float(row[3])
ctx.send("wind", speed, density, temp)
except (TypeError, ValueError):
pass
if urls["mag"]:
j = await _fetch_json(cli, urls["mag"])
row = _last_row(j)
if row:
# ["time_tag","bx_gsm","by_gsm","bz_gsm","lon_gsm","lat_gsm","bt"]
try:
bz = float(row[3])
bt = float(row[6])
ctx.send("bz", bz, bt)
except (TypeError, ValueError):
pass
if urls["kp"]:
j = await _fetch_json(cli, urls["kp"])
# NOAA renvoie maintenant une liste de dicts pour Kp
# ({"time_tag":..., "Kp":..., "a_running":...}) au lieu
# de la liste-de-listes historique. On supporte les deux.
if j:
last = j[-1]
try:
if isinstance(last, dict):
kp = float(last.get("Kp", 0.0))
a = float(last.get("a_running", 0.0))
else:
kp = float(last[1])
a = float(last[2])
ctx.send("kp", kp, a)
except (TypeError, ValueError, KeyError, IndexError):
pass
if urls["xray"]:
j = await _fetch_json(cli, urls["xray"])
# split short/long bands
short = next((d for d in reversed(j) if d.get("energy") == "0.05-0.4nm"), None)
long_ = next((d for d in reversed(j) if d.get("energy") == "0.1-0.8nm"), None)
s = float(short.get("flux", 0.0)) if short else 0.0
l = float(long_.get("flux", 0.0)) if long_ else 0.0
ctx.send("xray", s, l, _flare_class_norm(l))
except Exception as e: # noqa: BLE001
LOG.warning("fetch failed: %s: %s", type(e).__name__, e)
await asyncio.sleep(period)
+22
View File
@@ -0,0 +1,22 @@
"""Sytadin Paris traffic feed — STUB.
TODO: needs sytadin.fr scraping / cumulative km of congestion.
"""
from __future__ import annotations
import logging
from .base import Feed
LOG = logging.getLogger("data_feeds.sytadin")
class SytadinFeed(Feed):
name = "sytadin"
interval_sec = 300.0
def fetch(self):
return None
def publish(self, payload) -> None:
LOG.info("stub")
+22
View File
@@ -0,0 +1,22 @@
"""IRSN Teleray radiation feed — STUB.
TODO: needs https://teleray.irsn.fr/data endpoint research.
"""
from __future__ import annotations
import logging
from .base import Feed
LOG = logging.getLogger("data_feeds.teleray")
class TelerayFeed(Feed):
name = "teleray"
interval_sec = 600.0
def fetch(self):
return None
def publish(self, payload) -> None:
LOG.info("stub")
+64
View File
@@ -0,0 +1,64 @@
"""NOAA tides (station configurable) + phase lunaire calculee.
NOAA CO-OPS API : observed water level + predicted, station codee
(defaut Boston). Phase lunaire algorithme Conway approx (sans dep).
OSC out :
/data/tides/level water_level_m predicted_m residual_m
/data/tides/moon phase_0_1 illum_0_1
"""
from __future__ import annotations
import asyncio
import logging
import math
import time
import httpx
LOG = logging.getLogger("feed.tides")
NOAA_URL = ("https://api.tidesandcurrents.noaa.gov/api/prod/datagetter"
"?product={product}&application=AV-Live&format=json"
"&time_zone=gmt&datum=MLLW&units=metric"
"&date=latest&station={station}")
def _moon_phase(t: float) -> tuple[float, float]:
"""t epoch -> (phase 0..1 ou 0 = new, 0.5 = full ; illum 0..1).
Approx Conway, +-1 jour de precision suffisant."""
# Reference : 2000-01-06 18:14 UTC ~ new moon
new = 946755300.0
cycle = 29.530588853 * 86400.0
phase = ((t - new) % cycle) / cycle
illum = (1.0 - math.cos(2 * math.pi * phase)) / 2.0
return phase, illum
async def _fetch_level(cli: httpx.AsyncClient, station: str
) -> tuple[float, float]:
obs_url = NOAA_URL.format(product="water_level", station=station)
pred_url = NOAA_URL.format(product="predictions", station=station)
obs = await cli.get(obs_url)
pred = await cli.get(pred_url)
obs.raise_for_status()
pred.raise_for_status()
o = obs.json().get("data", [{}])[0]
p = pred.json().get("predictions", [{}])[0]
return float(o.get("v") or 0.0), float(p.get("v") or 0.0)
async def run(ctx) -> None:
cfg = ctx.cfg
station = str(cfg.get("station", "8443970")) # Boston by default
period = float(cfg.get("poll_seconds", 360.0)) # 6 min
async with httpx.AsyncClient(timeout=20.0) as cli:
while True:
try:
obs, pred = await _fetch_level(cli, station)
ctx.send("level", obs, pred, obs - pred)
except Exception as e: # noqa: BLE001
LOG.warning("tides fetch failed: %s", e)
phase, illum = _moon_phase(time.time())
ctx.send("moon", float(phase), float(illum))
await asyncio.sleep(period)
+57
View File
@@ -0,0 +1,57 @@
"""USGS earthquakes — GeoJSON polling (1 min)."""
from __future__ import annotations
import asyncio
import collections
import logging
import time
import httpx
from ._util import RateMeter
LOG = logging.getLogger("feed.usgs")
async def run(ctx) -> None:
cfg = ctx.cfg
url = cfg["url"]
period = float(cfg.get("poll_seconds", 60))
# OrderedDict avec eviction LRU : conserve les 4096 derniers IDs vus
# dans l'ORDRE d'arrivee. set() perdait l'ordre au pruning, ce qui
# pouvait re-emettre un evenement deja vu.
seen: "collections.OrderedDict[str, None]" = collections.OrderedDict()
SEEN_MAX = 4096
rate = RateMeter(window=3600.0)
async with httpx.AsyncClient(timeout=20.0) as cli:
while True:
try:
r = await cli.get(url)
r.raise_for_status()
data = r.json()
except Exception as e: # noqa: BLE001
LOG.warning("fetch failed: %s", e)
await asyncio.sleep(period)
continue
now_ms = time.time() * 1000.0
for feat in data.get("features", []):
fid = feat.get("id")
if not fid or fid in seen:
continue
seen[fid] = None
props = feat.get("properties") or {}
coords = (feat.get("geometry") or {}).get("coordinates") or [0, 0, 0]
mag = float(props.get("mag") or 0.0)
t_ms = float(props.get("time") or now_ms)
age = max(0.0, (now_ms - t_ms) / 1000.0)
ctx.send("event", mag, float(coords[0]), float(coords[1]),
float(coords[2]), age)
rate.tick()
ctx.send("rate", rate.rate * 3600.0)
# garde la mémoire bornée — evict les plus anciens en preservant
# l'ordre d'insertion (LRU front, head most-recent).
while len(seen) > SEEN_MAX:
seen.popitem(last=False)
await asyncio.sleep(period)
+25
View File
@@ -0,0 +1,25 @@
"""Velib Metropole feed — specialization of GBFS against the Paris URL."""
from __future__ import annotations
import logging
from .gbfs import GBFSFeed
LOG = logging.getLogger("data_feeds.velib")
VELIB_URL = (
"https://velib-metropole-opendata.smoove.pro/opendata/"
"Velib_Metropole/station_status.json"
)
class VelibFeed(GBFSFeed):
name = "velib"
interval_sec = 120.0
def configure(self, **kwargs) -> None:
# Force the URL if caller did not provide one.
kwargs.setdefault("url", VELIB_URL)
super().configure(**kwargs)
if not self.cfg.get("url"):
self.cfg["url"] = VELIB_URL
+65
View File
@@ -0,0 +1,65 @@
"""Volcans actifs — Smithsonian GVP weekly reports + USGS volcano hazards.
Source primaire : USGS volcano feed (RSS / GeoJSON), couvre les volcans
US actifs. Pour les volcans monde, on parse les CSV publics Smithsonian
si configures. Polling 1h.
OSC out :
/data/volcano/active count
/data/volcano/eruption lat lon vei region (nouvelle eruption depuis last poll)
"""
from __future__ import annotations
import asyncio
import collections
import logging
import httpx
LOG = logging.getLogger("feed.volcano")
USGS_URL = ("https://volcanoes.usgs.gov/hans2/api/volcano/getEvents"
"?starttime={start}&endtime={end}")
async def run(ctx) -> None:
cfg = ctx.cfg
period = float(cfg.get("poll_seconds", 3600.0))
url = cfg.get("url",
"https://volcano.si.edu/feeds/eruptions7days.json")
seen: collections.OrderedDict[str, None] = collections.OrderedDict()
SEEN_MAX = 512
async with httpx.AsyncClient(timeout=30.0) as cli:
while True:
try:
r = await cli.get(url)
r.raise_for_status()
ct = r.headers.get("content-type", "")
items = []
if "json" in ct:
data = r.json()
items = data.get("features", data.get("items", []))
count = 0
for it in items:
props = it.get("properties", it)
eid = str(props.get("id") or props.get("eventid")
or props.get("volcanoNumber") or "")
if not eid:
continue
count += 1
if eid in seen:
continue
seen[eid] = None
if len(seen) > SEEN_MAX:
seen.popitem(last=False)
geom = it.get("geometry") or {}
coords = geom.get("coordinates") or [0, 0]
lon, lat = float(coords[0]), float(coords[1])
vei = float(props.get("vei", 0) or 0)
region = str(props.get("country")
or props.get("region", ""))[:32]
ctx.send("eruption", lat, lon, vei, region)
ctx.send("active", float(count))
except Exception as e: # noqa: BLE001
LOG.warning("volcano fetch failed: %s", e)
await asyncio.sleep(period)
+66
View File
@@ -0,0 +1,66 @@
"""Wikipedia recent changes — Wikimedia EventStreams SSE.
Firehose des modifications Wikipedia toutes langues confondues.
Tres bavard (~10-30 events/s) — on echantillonne et on emet une
fraction + des compteurs.
OSC out :
/data/wikimedia/edit lang title bot (sample)
/data/wikimedia/rate per_second
"""
from __future__ import annotations
import asyncio
import json
import logging
import random
import time
import httpx
LOG = logging.getLogger("feed.wikimedia")
URL = "https://stream.wikimedia.org/v2/stream/recentchange"
async def run(ctx) -> None:
cfg = ctx.cfg
sample = float(cfg.get("sample_rate", 0.05)) # emit 5% des edits
window = float(cfg.get("rate_window_s", 5.0))
# WMF EventStreams refuse l'User-Agent par defaut httpx (403). Il
# faut une string descriptive + URL/email pour les abuse reports.
headers = {
"Accept": "text/event-stream",
"User-Agent": "av-live-data-feeds/1.0 (https://github.com/electron-rare/AV-Live)",
}
while True:
try:
async with httpx.AsyncClient(timeout=None) as cli:
async with cli.stream("GET", URL, headers=headers) as r:
r.raise_for_status()
bucket = 0
bucket_start = time.monotonic()
async for line in r.aiter_lines():
if not line.startswith("data:"):
continue
try:
ev = json.loads(line[5:].strip())
except json.JSONDecodeError:
continue
bucket += 1
# Sample emit
if random.random() < sample:
lang = (ev.get("wiki") or "").replace("wiki", "")
title = str(ev.get("title", ""))[:64]
bot = 1.0 if ev.get("bot") else 0.0
ctx.send("edit", lang, title, bot)
# Periodic rate
now = time.monotonic()
if now - bucket_start >= window:
per_s = bucket / (now - bucket_start)
ctx.send("rate", float(per_s))
bucket = 0
bucket_start = now
except Exception as e: # noqa: BLE001
LOG.warning("wikimedia stream error: %s — reconnect 5s", e)
await asyncio.sleep(5.0)
+57
View File
@@ -0,0 +1,57 @@
"""Run all enabled feeds, publish OSC to AVLiveBody."""
from __future__ import annotations
import argparse
import logging
import sys
import time
import tomllib
from pathlib import Path
from .feeds import REGISTRY
from .osc_sender import OscSender
def main(argv: list[str] | None = None) -> int:
p = argparse.ArgumentParser()
p.add_argument("--config", default="data_feeds/config.avlivedata.toml")
p.add_argument("--osc-host")
p.add_argument("--osc-port", type=int)
p.add_argument("-v", "--verbose", action="store_true")
args = p.parse_args(argv)
logging.basicConfig(
level=logging.INFO if args.verbose else logging.WARNING,
format="%(asctime)s %(levelname)s %(name)s %(message)s")
cfg = tomllib.loads(Path(args.config).read_text())
osc_cfg = cfg.get("osc", {})
host = args.osc_host or osc_cfg.get("host", "127.0.0.1")
port = args.osc_port or osc_cfg.get("port", 57127)
sender = OscSender(host, port)
feeds = []
for name, kwargs in (cfg.get("feeds") or {}).items():
if not kwargs.get("enabled", False):
continue
cls = REGISTRY.get(name)
if cls is None:
logging.warning("Unknown feed: %s", name)
continue
f = cls(sender.send)
f.configure(**kwargs)
f.start()
feeds.append(f)
logging.info("started feed %s (interval %.0fs)", name, f.interval_sec)
if not feeds:
logging.warning("No feeds enabled. Exiting.")
return 1
try:
while True:
time.sleep(60)
except KeyboardInterrupt:
return 0
finally:
for f in feeds:
f.stop()
if __name__ == "__main__":
sys.exit(main())
+22
View File
@@ -0,0 +1,22 @@
"""Wrapper around python-osc SimpleUDPClient with per-route helpers."""
from __future__ import annotations
import logging
from typing import Any
from pythonosc.udp_client import SimpleUDPClient
LOG = logging.getLogger("data_feeds.osc")
class OscSender:
def __init__(self, host: str, port: int) -> None:
self.host = host
self.port = port
self._client = SimpleUDPClient(host, port)
def send(self, addr: str, args: list[Any]) -> None:
try:
self._client.send_message(addr, args)
except OSError as e:
LOG.warning("send %s failed: %s", addr, e)
+23
View File
@@ -0,0 +1,23 @@
[project]
name = "av-live-data-feeds"
version = "0.1.0"
description = "Real-world data → OSC bridge for AV-Live (SuperCollider + openFrameworks)"
requires-python = ">=3.11"
dependencies = [
"python-osc>=1.8.3",
"httpx>=0.27",
"websockets>=12.0",
"aiomqtt>=2.3",
"tomli>=2.0;python_version<'3.11'",
"skyfield>=1.49",
]
[project.optional-dependencies]
pose = [
"opencv-python>=4.10",
"ultralytics>=8.3", # YOLOv8/v11-pose (CoreML/MPS sur Apple Silicon)
"numpy>=1.26",
]
[tool.uv]
package = false
+4
View File
@@ -0,0 +1,4 @@
__pycache__/
*.pyc
.venv/
.uv-cache/
+104
View File
@@ -0,0 +1,104 @@
# data_only_viz
Visualiseur natif Metal (pyobjc) pour le mode data-only d'AV-Live : capture caméra → détection pose multi-personne → tracker → rendu Metal → OSC out vers `oscope-of`.
## Environnement
```bash
cd data_only_viz
uv sync # base
uv sync --extra pose # MediaPipe + YOLO + Ultralytics
uv sync --extra nlf # Neural Localizer Fields (SMPL body mesh)
uv sync --extra detrpose # DETRPose transformer (clone manuel — voir detrpose.py)
uv run python -m data_only_viz.main # lancement standard
```
Python **3.11+** requis. `pyproject.toml` est la source de vérité — ne jamais éditer `uv.lock` à la main.
## Backends pose disponibles
| Backend | Fichier | Statut |
|---------|---------|--------|
| MediaPipe Holistic | `holistic.py` | stable |
| MediaPipe multi (Pose+Face+Hand) | `multi.py` | stable ; `MEDIAPIPE_DELEGATE=gpu` (défaut) ou `cpu`. **GPU Metal exige SRGBA 4-ch** (3-ch SRGB crashe `gpu_buffer_storage_cv_pixel_buffer.cc`) — multi.py route auto vers `cv2.COLOR_BGR2RGBA` + `mp.ImageFormat.SRGBA` quand delegate=GPU. Bench M5 image-mode SRGBA : pose 2.9 vs 6.7 ms (GPU/CPU), face 1.0 vs 4.1, hand 3.2 vs 6.1 |
| Ultralytics YOLOv8-pose | `pose.py` | stable, modèle `yolov8n-pose.pt` à la racine repo |
| Apple Vision (Core ML) | `apple_vision_pose.py`, `coreml_pose.py` | macOS uniquement |
| DETRPose | `detrpose.py` | clone manuel + checkpoint, voir docstring |
| NLF (SMPL body mesh) | `nlf_worker.py` | TorchScript, **bloqué CPU/MPS** (NotImplementedError 2026-05-13), CUDA-only ; checkpoints via `scripts/setup_nlf.sh` |
| Multi-HMR | `multi_hmr_scaffold.md` | scaffold seulement |
| SMPLER-X / WHAM-TRAM | `*_scaffold.md` | scaffold seulement |
## Conventions
- État partagé multi-thread : `state.py` expose `State.lock()` — toujours mutationner sous lock.
- Filtrage temporel : `euro_filter.py` (One Euro Filter) sur les keypoints avant tracker.
- ARKit body source (**`--iphone-usb`, défaut concert 2026-06-30**) : le corps
vient **100% ARKit**, le pose+face MediaPipe du Mac sont **coupés** (comme les
mains, cf. `4c0794a`). L'app iPhone envoie le squelette 2D projeté (frame
AVLiveWire `skeleton2D`, tag **6**) → `state.persons_arkit_2d` ; le 3D monde
arrive via la frame `skeleton``state.persons_arkit_joints`.
`multi.py` reconstruit `bodies`/`bodies3d` (MP33) via
`arkit_joint_map.arkit_body_2d`/`arkit_body_3d` (slots mappés c=1, sinon c=0 ;
pid le plus bas, single-person). `/pose/mouth` + le mesh visage disparaissent.
`POSE_FILTER` **sans `arkit_fuse`** (ARKit EST la source). Décode :
`scripts/iphone_usb_bridge.decode_skeleton2D` (819 octets = 91×2 f32 BE + 91
valid). Mapping 14 joints + ancre tête `ARKIT_HEAD_IDX` dans `arkit_joint_map.py`
(index à confirmer live). **Orientation : PORTRAIT validé live 2026-07-02**
(meilleure détection des mains, ARKit body suit — l'ancienne contrainte
paysage de 2026-06-30 ne se reproduit plus). La vidéo HEVC arrive en repère
capteur paysage → `VIDEO_ROTATE=ccw` (défaut launcher concert) la redresse ;
les DEUX flux 2D (skeleton2D + mains Vision) arrivent déjà droits et ne sont
PAS tournés (knobs `IPHONE_SKEL_ROTATE`/`IPHONE_HANDS_ROTATE` si besoin).
- ARKit fusion (**legacy, chemin webcam + OSC**) : `iphone_osc_listener.py`
consume /body3d/kp UDP :57128 → `state.persons_arkit_joints`.
`pose_filter.py::ArkitFuse` (stage `arkit_fuse`) splices the 14 mapped body
slots into MediaPipe pose before kalman ; `multi_hmr_worker::arkit_pelvis_z_override`
locks the SMPL-X cam translation z to the ARKit pelvis. Mapping in
`arkit_joint_map.py`. (Supersédé par l'ARKit body source sous `--iphone-usb`.)
- Association multi-personne : `tracker.py` IoU-based, `scipy.optimize.linear_sum_assignment`.
- Shaders Metal dans `shaders/` (`.metal`), recompilés au runtime ; topologie mesh (SMPL faces) en binaire dans `mesh_topology.py`.
- OSC out : `osc_listener.py` / `pose_bridge.py` — destination `oscope-of` sur `:57123`.
## action-head (classifier action debout/assise/danse)
Tête de classification d'action streaming au-dessus des j3d SMPL-X (ou body3d MediaPipe en fallback). Implémentée 2026-05-13.
| Fichier | Rôle |
|---|---|
| `action_head.py` | `ActionHeadModel` (GRU 1L + MLP, 37 811 params, <2 ms/step M5), `ActionHead.step(pid, j3d) → (label, probs, kin)`, `PerPersonBuffer`, `FeatureExtractor` (201-D : j3d + vel + accel + scalaires) |
| `action_head_pub.py` | Publisher thread démarré dans `multi.py` `__init__`. Polle `state.persons_smplx` (préféré) ou `state.persons_body3d` (fallback) à 30 Hz, dédup par timestamp, extrait j3d22 via `SMPLX_JOINT_ANCHOR_VERTS` ou `MEDIAPIPE_TO_22`, émet OSC `/pose/action` + `/pose/kin` + `/pose/enter/leave` |
| `training/{dataset,autolabel,augment,train_action_head,eval,review}.py` | Pipeline complet : jsonl IO + sliding windows + by-session split / règles auto-label + glue CLI / 4 augmentations / training MPS AdamW CE-weighted / confusion matrix + latence micro-bench / TUI textuel pour review manuel |
| `scripts/capture_actions.py` | Webcam → MP4 + timestamps |
| `scripts/extract_j3d_offline.py` | MP4 → jsonl j3d22 via `MultiHMRCoreMLBackend.infer()` directement (pas de refactor worker) |
| `scripts/train_on_studio.sh` | rsync grosmac → bastion electron-server → studio M3 Ultra + uv sync `--extra multihmr` + train MPS + ckpt back |
Pipeline complet de capture à live :
```bash
uv run python -m data_only_viz.scripts.capture_actions --session sess01 --duration 600
uv run python -m data_only_viz.scripts.extract_j3d_offline --session sess01 --video ~/.cache/av-live-action/raw/sess01.mp4
uv run python -m data_only_viz.training.autolabel --frames ~/.cache/av-live-action/raw/sess01.jsonl --out ~/.cache/av-live-action/dataset/auto.jsonl
uv run python -m data_only_viz.training.review --in ~/.cache/av-live-action/dataset/auto.jsonl --out ~/.cache/av-live-action/dataset/dataset.jsonl
./data_only_viz/scripts/train_on_studio.sh --epochs 50
uv run python -m data_only_viz.training.eval --ckpt ~/.cache/av-live-action/checkpoints/action_head.pt --dataset ~/.cache/av-live-action/dataset/dataset.jsonl
# Live : publisher déjà câblé dans multi.py, aucune action requise
```
Checkpoint par défaut : `~/.cache/av-live-action/checkpoints/action_head.pt`. Absent → random init (warmup retourne `debout`).
## Tests
```bash
uv run pytest tests/ -v
```
Tests TDD-first pour `nlf_worker.py` ; valider avant chaque commit qui touche un worker.
Suite action-head (8 fichiers, 39 tests) : `tests/test_action_head_*.py`, `tests/test_{dataset,autolabel,augment,training_smoke,pose_bridge_action}.py`. Tous doivent rester verts avant chaque commit qui touche `action_head*.py` ou `training/*.py`.
## Anti-patterns
- Ne pas charger un modèle ML sans guard `try/except ImportError` — les optional-extras peuvent manquer.
- Ne pas committer `*.pt`, `*.ckpt`, `*.safetensors`, `*.mlpackage` (gitignore racine).
- Ne pas appeler `state.persons_nlf = ...` hors `with state.lock():`.
- Ne pas hardcoder le device (`cuda`/`mps`/`cpu`) : détecter via `torch.backends.mps.is_available()` puis fallback.
- Pas de `print` dans la boucle de rendu — utiliser un logger conditionnel.
+131
View File
@@ -0,0 +1,131 @@
# Multi-HMR + RealityKit — utilisation
## Setup une fois
```bash
# 1. Clone + checkpoint Multi-HMR (1.28 GB)
./data_only_viz/scripts/setup_multihmr.sh
# 2. SMPL-X NEUTRAL.npz — inscription manuelle MPII
# https://smpl-x.is.tue.mpg.de/ → SMPL-X v1.1 (NPZ+PKL)
# Extraire SMPLX_NEUTRAL.npz vers
# ~/.cache/av-live-multihmr/models/smplx/SMPLX_NEUTRAL.npz
# 3. Python deps
cd data_only_viz && uv sync --extra multihmr
# 4. Extraire les faces SMPL-X pour Swift (250 896 octets)
.venv/bin/python scripts/dump_smplx_faces.py
```
## Lancement
### Via le launcher (auto)
1. Ouvrir AVLiveLauncher.app
2. Mode **data-only**, activer le switch *Multi-HMR (mesh SMPL-X dense)*
3. Demarrer : le launcher spawn sclang + viz Python + AV-Live-Body Swift
### Manuel (debug)
```bash
# Terminal 1 — RealityKit listener
cd launcher/AV-Live-Body
swift run -c release AVLiveBody
# Terminal 2 — worker Python
cd /Users/electron/Documents/Projets/AV-Live
data_only_viz/.venv/bin/python -m data_only_viz.main \
-v --pose --multi-hmr --fullscreen
```
## Architecture
```
webcam Mac (cv2 idx 0, 672x672)
|
v
Multi-HMR ViT-S (PyTorch MPS, ~31.5M params)
|
v
humans = [{v3d (10475,3), j3d, transl, shape, expression, ...}, ...]
|
v
OneEuroFilter (shape, expression) + IoU tracker
|
v
state.persons_smplx : list[SMPLXPerson]
|
v
SMPLXTCPSender : binaire ~126 KB / frame / personne sur :57130
|
v (TCP)
AVLiveBody Swift
- OSCServer (Network.framework NWListener)
- MeshRenderer (LowLevelMesh, vertex buffer in-place)
- BodyView (ARView, perspective cam 2m back)
```
## Decisions cles vs. plan initial
- **NLF abandonne** : son detecteur YOLO TorchScript a CUDA hardcode
(`aten::empty_strided` echoue sur CPU/MPS).
- **Multi-HMR `demo.py` bypasse** : depend de `multi_hmr_anny` (package
prive `anny`) et `utils.render` (pyrender + OpenGL offscreen lourd).
On stubbe ces modules dans `sys.modules` et on construit le `Model`
directement depuis `model.py`.
- **`v3d` direct** : Multi-HMR renvoie deja les vertices SMPL-X
decodes ; on n'utilise pas `SMPLXDecoder` dans le hot path (il
reste utile pour les tests neutres).
- **macOS 15 requis** : `LowLevelMesh.parts.replaceAll` (API RealityKit
release 2024-09) permet le vertex update in-place ; sur macOS 14 il
faudrait rebuild la MeshResource a chaque frame (3-4x plus lent).
## FPS attendu
- Multi-HMR ViT-S MPS mesure 2026-05-13 :
- bench headless (dummy 672x672, 20 iter) : 199 ms median = 5.0 fps
- bench camera live (30 s real capture) : 228 ms median = 3.8 fps
(overhead capture/pre/tensor ~30 ms)
- Pas de speedup significatif vs ViT-L : le bottleneck n'est PAS le
backbone DINOv2 sur MPS — probablement la tete SMPL-X (identique
entre variantes S/B/L) et l'absence de SDPA fused / xFormers sur MPS.
La piste "ViT-S pour gagner du FPS" est invalidee par mesure.
- Fallback ViT-L (`multiHMR_896_L.pt`) : ~150-200 ms = 5-7 fps si
precision insuffisante avec ViT-S
- TCP sender throttle a 12 fps cible
- RealityKit render : 60 fps Cocoa, interpole entre frames Multi-HMR
## Assets caches
```
~/.cache/av-live-multihmr/
├── checkpoints/
│ └── multiHMR_672_S.pt (124 MB)
├── models/
│ ├── smplx/
│ │ ├── SMPLX_NEUTRAL.npz (108 MB)
│ │ ├── SMPLX_MALE.npz (109 MB)
│ │ ├── SMPLX_FEMALE.npz (109 MB)
│ │ ├── SMPLX_NEUTRAL.pkl (520 MB)
│ │ └── smplx_uv_2023.npz (UV mapping, 1 MB)
│ └── smpl_mean_params.npz (1.3 KB)
└── multi-hmr/ (repo clone)
└── models -> ../models (symlink relatif)
```
## Debug
| Symptome | Verification |
|----------|--------------|
| Pas de mesh visible | `ls ~/.cache/av-live-multihmr/checkpoints/multiHMR_672_S.pt` |
| `Multi-HMR load failed` | `~/.cache/av-live-multihmr/models/smplx/SMPLX_NEUTRAL.npz` present ? |
| `TCP refused on :57130` | Lancer l'app Swift AVANT le worker Python |
| FPS trop bas | Switcher vers ViT-B en editant `CKPT` dans `multi_hmr_worker.py` |
| MPS NotImplementedError | Variable env `PYTORCH_ENABLE_MPS_FALLBACK=1` |
## Pistes futures
- Texture webcam projetee sur le mesh via `smplx_uv_2023.npz` (62 724
UV coords deja en cache).
- Switch dynamique L/B/S selon load CPU (autotuning).
- Compression vertices (delta-encoded float16) si la bande passante TCP
devient un goulot.
+13
View File
@@ -0,0 +1,13 @@
# data_only_viz
Python + Metal visualiser for the AV-Live data-only patch.
## Hand features OSC
`/pose/hands [pid=0, lx, ly, lopen, lspeed, rx, ry, ropen, rspeed, dist]`
- Emitted once per pipeline tick by `pose_bridge.send_hands` to sclang `:57121`.
- L = leftmost hand on screen, R = rightmost; absent hand -> its 4 floats are 0.
- Ranges: positions/openness/dist in [0,1]; speed >= 0 (normalized units/frame).
- Consumed by SuperCollider `~handFeat` (data_only) and the Metal renderer uniforms.
- Multi-person is not yet carried (pid is always 0).
View File
+238
View File
@@ -0,0 +1,238 @@
"""Capture webcam via AVFoundation natif (pyobjc), sans cv2.
Resout le mismatch d'indices entre `cv2.VideoCapture(N)` et l'ordre
des devices retournes par AVCaptureDeviceDiscoverySession : on
selectionne le device par `uniqueID` ou par type
(BuiltInWideAngleCamera) au lieu d'un index opaque.
Pattern :
- AVCaptureSession + AVCaptureVideoDataOutput
- Delegate avec @objc.python_method pour la copie numpy
- Global dispatch queue (libdispatch via ctypes) — pas de main queue
pour ne pas bloquer NSApp
- Frame BGRA -> BGR HxWx3 uint8 partagee sous lock
- API .read() compatible cv2 : (ok, frame_bgr)
"""
from __future__ import annotations
import ctypes
import logging
import threading
import time
from typing import Optional
import numpy as np
import objc
import AVFoundation as AVF
import CoreMedia as CM
import Quartz
from Foundation import NSObject
LOG = logging.getLogger("av_capture")
_DEVICE_TYPES = [
"AVCaptureDeviceTypeBuiltInWideAngleCamera",
"AVCaptureDeviceTypeContinuityCamera",
"AVCaptureDeviceTypeExternal",
"AVCaptureDeviceTypeDeskViewCamera",
]
def _get_global_queue() -> object:
"""Retourne une dispatch_queue_t globale wrappee en pyobjc object."""
libdispatch = ctypes.CDLL("/usr/lib/system/libdispatch.dylib")
libdispatch.dispatch_get_global_queue.restype = ctypes.c_void_p
libdispatch.dispatch_get_global_queue.argtypes = [
ctypes.c_long, ctypes.c_ulong]
# 0 = DISPATCH_QUEUE_PRIORITY_DEFAULT, 0 = flags
q_ptr = libdispatch.dispatch_get_global_queue(0, 0)
return objc.objc_object(c_void_p=q_ptr)
def enumerate_devices() -> list[dict]:
"""Retourne la liste des devices video disponibles avec metadata
: uniqueID, localizedName, deviceType."""
session = (AVF.AVCaptureDeviceDiscoverySession
.discoverySessionWithDeviceTypes_mediaType_position_(
_DEVICE_TYPES, "vide", 0))
devices = list(session.devices() or [])
out = []
for d in devices:
out.append({
"uniqueID": str(d.uniqueID()),
"name": str(d.localizedName()),
"type": (str(d.deviceType()) if hasattr(d, "deviceType")
else "").split(".")[-1],
"_device": d,
})
return out
_BANNED_NAME_TOKENS = ("iphone", "gsm", "desk view", "continuity")
def find_builtin_device() -> Optional[dict]:
"""Selectionne la webcam Mac integree :
- deviceType doit etre BuiltInWideAngleCamera
- le nom ne doit contenir aucun de _BANNED_NAME_TOKENS
Evite les pieges Continuity/iPhone/Desk View qui peuvent matcher
BuiltInWideAngleCamera dans certaines configs."""
for info in enumerate_devices():
if "BuiltInWideAngleCamera" not in info["type"]:
continue
name_l = info["name"].lower()
if any(tok in name_l for tok in _BANNED_NAME_TOKENS):
continue
return info
return None
class _FrameDelegate(NSObject):
"""Delegate AVCaptureVideoDataOutput. Convertit BGRA -> BGR numpy
et stocke la derniere frame sous lock."""
def init(self):
self = objc.super(_FrameDelegate, self).init()
if self is None:
return None
self._buf = None
self._lock = threading.Lock()
self._frame_count = 0
return self
@objc.python_method
def get_latest(self):
with self._lock:
if self._buf is None:
return False, None, self._frame_count
return True, self._buf.copy(), self._frame_count
def captureOutput_didOutputSampleBuffer_fromConnection_(
self, output, sample_buffer, connection):
try:
self._handle_sample_buffer(sample_buffer)
except Exception as e: # noqa: BLE001
LOG.warning("frame conv failed: %s", e)
@objc.python_method
def _handle_sample_buffer(self, sample_buffer):
pixel_buffer = CM.CMSampleBufferGetImageBuffer(sample_buffer)
if pixel_buffer is None:
return
w = Quartz.CVPixelBufferGetWidth(pixel_buffer)
h = Quartz.CVPixelBufferGetHeight(pixel_buffer)
Quartz.CVPixelBufferLockBaseAddress(pixel_buffer, 1) # read-only
try:
base_addr = Quartz.CVPixelBufferGetBaseAddress(pixel_buffer)
stride = Quartz.CVPixelBufferGetBytesPerRow(pixel_buffer)
# base_addr est un objc.varlist (void* wrapper). On va via
# as_buffer(N) qui renvoie un memoryview de N octets.
if base_addr is None:
return
buf = base_addr.as_buffer(h * stride)
arr = np.frombuffer(buf, dtype=np.uint8).reshape(
(h, stride // 4, 4))[:, :w, :3] # BGRA -> BGR
with self._lock:
self._buf = np.ascontiguousarray(arr)
self._frame_count += 1
finally:
Quartz.CVPixelBufferUnlockBaseAddress(pixel_buffer, 1)
class AVCapture:
"""API minimale cv2-like sur AVFoundation. Compatible drop-in pour
les workers qui font `cap.read()` en boucle.
Usage :
cap = AVCapture(device_info) # ou AVCapture.builtin()
cap.start()
ok, frame_bgr = cap.read()
cap.stop()
"""
def __init__(self, device_info: dict) -> None:
self._info = device_info
self._session = None
self._delegate = None
self._queue = None
self._last_count = 0
@classmethod
def builtin(cls) -> Optional["AVCapture"]:
info = find_builtin_device()
return cls(info) if info is not None else None
def start(self) -> bool:
device = self._info["_device"]
session = AVF.AVCaptureSession.alloc().init()
try:
input_, err = (
AVF.AVCaptureDeviceInput
.deviceInputWithDevice_error_(device, None))
except Exception as e: # noqa: BLE001
LOG.error("AVCaptureDeviceInput failed: %s", e)
return False
if input_ is None:
LOG.error("input is None (err=%s)", err)
return False
if not session.canAddInput_(input_):
LOG.error("session refused input")
return False
session.addInput_(input_)
output = AVF.AVCaptureVideoDataOutput.alloc().init()
settings = {
Quartz.kCVPixelBufferPixelFormatTypeKey:
Quartz.kCVPixelFormatType_32BGRA,
}
output.setVideoSettings_(settings)
output.setAlwaysDiscardsLateVideoFrames_(True)
delegate = _FrameDelegate.alloc().init()
queue = _get_global_queue()
output.setSampleBufferDelegate_queue_(delegate, queue)
if not session.canAddOutput_(output):
LOG.error("session refused output")
return False
session.addOutput_(output)
session.startRunning()
self._session = session
self._delegate = delegate
self._queue = queue
LOG.info("AV session running on '%s' (%s)",
self._info["name"], self._info["type"])
return True
def read(self, timeout_s: float = 0.5) -> tuple[bool, Optional[np.ndarray]]:
"""Bloque jusqu'a recevoir une frame NOUVELLE (frame_count
different du dernier read), ou timeout. Retourne (ok, BGR
HxWx3 uint8)."""
if self._delegate is None:
return False, None
deadline = time.monotonic() + timeout_s
while time.monotonic() < deadline:
ok, frame, count = self._delegate.get_latest()
if ok and count != self._last_count:
self._last_count = count
return True, frame
time.sleep(0.01)
# Au timeout, on renvoie quand meme la derniere frame si elle
# existe (pour les cas FPS < target)
ok, frame, count = self._delegate.get_latest()
if ok:
self._last_count = count
return True, frame
return False, None
def stop(self) -> None:
if self._session is not None:
try:
self._session.stopRunning()
except Exception:
pass
self._session = None
self._delegate = None
self._queue = None
+102
View File
@@ -0,0 +1,102 @@
"""Helper de selection de camera macOS : enumere les devices via
AVFoundation et retourne l'index OpenCV qui correspond a la webcam
built-in (BuiltInWideAngleCamera), en evitant Continuity Camera
(iPhone), Desk View, et External."""
from __future__ import annotations
import logging
from typing import Iterable
LOG = logging.getLogger("camera_select")
def list_cameras() -> list[tuple[int, str, str]]:
"""Retourne [(index, localized_name, device_type_short), ...]."""
try:
import objc
from Foundation import NSBundle
b = NSBundle.bundleWithPath_(
"/System/Library/Frameworks/AVFoundation.framework")
b.load()
ns: dict = {}
objc.loadBundle("AVFoundation", ns, b.bundlePath())
DiscoverySession = ns["AVCaptureDeviceDiscoverySession"]
session = (DiscoverySession
.discoverySessionWithDeviceTypes_mediaType_position_(
["AVCaptureDeviceTypeBuiltInWideAngleCamera",
"AVCaptureDeviceTypeContinuityCamera",
"AVCaptureDeviceTypeExternal",
"AVCaptureDeviceTypeDeskViewCamera"],
"vide", 0))
devices = session.devices() or []
result = []
for i, d in enumerate(devices):
name = str(d.localizedName())
dtype = str(d.deviceType() if hasattr(d, "deviceType") else "")
result.append((i, name, dtype.split(".")[-1]))
return result
except Exception as e: # noqa: BLE001
LOG.warning("camera enum failed: %s", e)
return []
def pick_builtin_camera(fallback: int = 0) -> int:
"""Retourne l'index du BuiltInWideAngleCamera, sinon fallback."""
devices = list_cameras()
for i, name, dtype in devices:
LOG.info("camera [%d] %s (%s)", i, name, dtype)
for i, _, dtype in devices:
if "BuiltInWideAngleCamera" in dtype:
LOG.info("camera Mac built-in -> index %d", i)
return i
return fallback
def probe_cv2_indices(max_idx: int = 4) -> list[tuple[int, int, int, float]]:
"""Pour chaque index cv2 0..max_idx, retourne (idx, w, h, mean) ou
None pour les indices indisponibles. Le 'mean' est la luminance
moyenne d'une frame — un capture standby (iPhone verrouille,
Continuity inactive) a mean ~0-30 ; une cam active ~80+."""
try:
import cv2
import numpy as np
except ImportError:
return []
result = []
for idx in range(max_idx):
cap = cv2.VideoCapture(idx, cv2.CAP_AVFOUNDATION)
if not cap.isOpened():
cap.release()
continue
w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
# Drain 2 frames pour passer l'auto-exposure
for _ in range(2):
cap.read()
ok, frame = cap.read()
mean = float(np.mean(frame)) if (ok and frame is not None) else -1.0
result.append((idx, w, h, mean))
cap.release()
return result
def resolve_camera_index(requested: int, min_mean: float = 50.0) -> int:
"""`requested=-1` -> probe cv2, prefere l'index avec frame_mean
>= min_mean (rejette les flux noirs / standby). Sinon retourne
`requested`."""
if requested >= 0:
return requested
probes = probe_cv2_indices()
for idx, w, h, mean in probes:
LOG.info("cv2 probe [%d] %dx%d mean=%.1f", idx, w, h, mean)
bright = [p for p in probes if p[3] >= min_mean]
if bright:
idx = bright[0][0]
LOG.info("cv2 auto-pick index %d (mean %.1f >= %.1f)",
idx, bright[0][3], min_mean)
return idx
if probes:
LOG.warning("no bright cv2 stream — defaulting to index %d",
probes[0][0])
return probes[0][0]
return 0
+175
View File
@@ -0,0 +1,175 @@
"""Shims mmcv 1.x API surface pour SMPLer-X vendored mmpose.
mmcv-lite 2.x a migré la plupart des symboles vers mmengine ;
SMPLer-X est écrit pour mmcv-full 1.x. Cette module ré-exporte
les symboles 1.x sous leur ancien chemin avant qu'on importe la
vendored mmpose.
Appel : `from data_only_viz._smplerx_shims import install_all`
puis `install_all()` AVANT tout `import mmpose`.
"""
from __future__ import annotations
import sys
import time
import types
import warnings
def _no_op_decorator(*args, **kwargs):
"""Décorateur no-op : remplace deprecated_api_warning."""
if len(args) == 1 and callable(args[0]):
return args[0]
def wrap(fn):
return fn
return wrap
class _SimpleTimer:
"""Timer minimal compat mmcv.Timer 1.x."""
def __init__(self, start: bool = True):
self._start = time.perf_counter() if start else None
def start(self):
self._start = time.perf_counter()
def since_start(self):
return time.perf_counter() - (self._start or time.perf_counter())
def since_last_check(self):
now = time.perf_counter()
d = now - (self._start or now)
self._start = now
return d
def _is_seq_of(seq, expected_type, seq_type=None):
"""mmcv.is_seq_of -> mmengine.utils.is_seq_of (parfois absent)."""
if seq_type is None:
exp_seq_type = (list, tuple)
else:
exp_seq_type = seq_type
if not isinstance(seq, exp_seq_type):
return False
return all(isinstance(item, expected_type) for item in seq)
def install_all() -> None:
"""Installer tous les shims sur mmcv pour compat 1.x API."""
import mmcv
# --- top-level symbols ---
if not hasattr(mmcv, "Config"):
from mmengine.config import Config
mmcv.Config = Config
if not hasattr(mmcv, "deprecated_api_warning"):
mmcv.deprecated_api_warning = _no_op_decorator
if not hasattr(mmcv, "Timer"):
mmcv.Timer = _SimpleTimer
if not hasattr(mmcv, "is_seq_of"):
try:
from mmengine.utils import is_seq_of
mmcv.is_seq_of = is_seq_of
except Exception:
mmcv.is_seq_of = _is_seq_of
# --- mmcv.cnn symbols ---
import mmcv.cnn as _cnn
# Init functions migrated to mmengine.model.weight_init
try:
from mmengine.model import (
constant_init, normal_init, kaiming_init,
trunc_normal_init, xavier_init, bias_init_with_prob,
)
except ImportError:
# Fallback : implémentations minimales locales
import torch.nn as nn
def constant_init(module, val=0, bias=0):
if hasattr(module, 'weight') and module.weight is not None:
nn.init.constant_(module.weight, val)
if hasattr(module, 'bias') and module.bias is not None:
nn.init.constant_(module.bias, bias)
def normal_init(module, mean=0, std=1, bias=0):
if hasattr(module, 'weight') and module.weight is not None:
nn.init.normal_(module.weight, mean, std)
if hasattr(module, 'bias') and module.bias is not None:
nn.init.constant_(module.bias, bias)
def kaiming_init(module, a=0, mode='fan_out',
nonlinearity='relu', bias=0, distribution='normal'):
if hasattr(module, 'weight') and module.weight is not None:
if distribution == 'normal':
nn.init.kaiming_normal_(module.weight, a=a, mode=mode,
nonlinearity=nonlinearity)
else:
nn.init.kaiming_uniform_(module.weight, a=a, mode=mode,
nonlinearity=nonlinearity)
if hasattr(module, 'bias') and module.bias is not None:
nn.init.constant_(module.bias, bias)
def trunc_normal_init(module, mean=0, std=1, a=-2, b=2, bias=0):
if hasattr(module, 'weight') and module.weight is not None:
nn.init.trunc_normal_(module.weight, mean, std, a, b)
if hasattr(module, 'bias') and module.bias is not None:
nn.init.constant_(module.bias, bias)
def xavier_init(module, gain=1, bias=0, distribution='normal'):
if hasattr(module, 'weight') and module.weight is not None:
if distribution == 'normal':
nn.init.xavier_normal_(module.weight, gain)
else:
nn.init.xavier_uniform_(module.weight, gain)
if hasattr(module, 'bias') and module.bias is not None:
nn.init.constant_(module.bias, bias)
def bias_init_with_prob(prior_prob):
import math
return float(-math.log((1 - prior_prob) / prior_prob))
for name, fn in {
"constant_init": constant_init,
"normal_init": normal_init,
"kaiming_init": kaiming_init,
"trunc_normal_init": trunc_normal_init,
"xavier_init": xavier_init,
"bias_init_with_prob": bias_init_with_prob,
}.items():
if not hasattr(_cnn, name):
setattr(_cnn, name, fn)
# Linear / Conv2d / MaxPool2d : juste re-export torch.nn
import torch.nn as _nn
for name, cls in {
"Linear": _nn.Linear,
"Conv2d": _nn.Conv2d,
"MaxPool2d": _nn.MaxPool2d,
}.items():
if not hasattr(_cnn, name):
setattr(_cnn, name, cls)
# build_model_from_cfg : si mmcv.cnn ne l'expose pas, prendre de
# mmengine.registry.build_model_from_cfg
if not hasattr(_cnn, "build_model_from_cfg"):
try:
from mmengine.registry import build_model_from_cfg
_cnn.build_model_from_cfg = build_model_from_cfg
except ImportError:
pass
# MODELS export : SMPLer-X importe `from mmcv.cnn import MODELS as
# MMCV_MODELS`. mmcv 2.x a son MODELS dans mmcv.cnn.
if not hasattr(_cnn, "MODELS"):
try:
from mmengine.registry import MODELS
_cnn.MODELS = MODELS
except ImportError:
pass
__all__ = ["install_all"]
+279
View File
@@ -0,0 +1,279 @@
"""Action classifier head on top of Multi-HMR j3d.
Streaming GRU-1-layer + MLP per-person, with a 16-frame ring buffer.
Trained windowed (Studio M3 Ultra MPS), inferred streaming (M5 eager CPU).
Output per step: (label_idx, probs (3,), kin (3,)) where kin is
(speed_m_s, accel_m_s2, symmetry_in_minus1_plus1).
"""
from __future__ import annotations
from collections import deque
from pathlib import Path
import numpy as np
import torch
from torch import nn
HIDDEN_DIM: int = 48
MLP_HIDDEN: int = 32
WARMUP_FRAMES: int = 3
NAN_SKIP_BUDGET: int = 5
WINDOW_LEN: int = 16
J3D_BODY: int = 22
J3D_FINGERS_PER_HAND: int = 5
J3D_FINGERS: int = 2 * J3D_FINGERS_PER_HAND # 10
J3D_JOINTS: int = J3D_BODY + J3D_FINGERS # 32
J3D_DIMS: int = 3
NUM_CLASSES: int = 3
LABELS: tuple[str, str, str] = ("debout", "assise", "danse")
EXPR_DIM: int = 10
EXTRA_SCALARS: int = 4 # hip_y, knee_angle, sym_score, mouth_open
# NEW v3 : MediaPipe Hands keypoints block.
HANDS_KP_PER_HAND: int = 21
HANDS_KP_TOTAL: int = 2 * HANDS_KP_PER_HAND # 42
HANDS_KP_DIMS: int = 3
HANDS_KP_FLAT: int = HANDS_KP_TOTAL * HANDS_KP_DIMS # 126
# Layout per step (v3) :
# [0 : 96] j3d (32, 3)
# [96 : 192] vel (32, 3)
# [192 : 288] accel (32, 3)
# [288 : 414] hands_kp (42, 3) zero-padded if absent
# [414 : 424] expression (10,)
# [424 : 428] scalars (hip_y, knee_angle, sym, mouth_open)
FEATURE_DIM: int = J3D_JOINTS * J3D_DIMS * 3 + HANDS_KP_FLAT + EXPR_DIM + EXTRA_SCALARS # 428
# Body joint indices (unchanged from v1, indices 0..21).
HIP_LEFT: int = 1
HIP_RIGHT: int = 2
KNEE_LEFT: int = 4
KNEE_RIGHT: int = 5
ANKLE_LEFT: int = 7
ANKLE_RIGHT: int = 8
SHOULDER_LEFT: int = 16
SHOULDER_RIGHT: int = 17
WRIST_LEFT: int = 20
WRIST_RIGHT: int = 21
# Fingertip indices (new, 22..31), order: L thumb..pinky, R thumb..pinky.
FINGERTIP_LEFT_BASE: int = 22
FINGERTIP_RIGHT_BASE: int = 27
class FeatureExtractor:
"""Stateless feature builder over a list of recent j3d frames.
Vector layout (FEATURE_DIM = 428, v3):
[0 : 96] j3d current frame, flattened (32 joints x 3 dims)
[96 : 192] velocity j3d[t] - j3d[t-1] (32 x 3)
[192 : 288] acceleration vel[t] - vel[t-1] (32 x 3)
[288 : 414] hands_kp (42, 3) MediaPipe Hands, zero-padded if absent
[414 : 424] expression PCA coefficients (10,)
[424 : 428] kinetics scalars (hip_y, knee_angle, symmetry_score, mouth_open)
"""
@staticmethod
def from_buffer(frames: list[np.ndarray],
expr: np.ndarray | None = None,
mouth_open: float = 0.0,
hands_kp: np.ndarray | None = None) -> np.ndarray:
if not frames:
return np.zeros(FEATURE_DIM, dtype=np.float32)
cur = frames[-1]
prev = frames[-2] if len(frames) >= 2 else cur
prev2 = frames[-3] if len(frames) >= 3 else prev
vel = (cur - prev).astype(np.float32, copy=False)
prev_vel = (prev - prev2).astype(np.float32, copy=False)
accel = (vel - prev_vel).astype(np.float32, copy=False)
hip_y = float((cur[HIP_LEFT, 1] + cur[HIP_RIGHT, 1]) * 0.5)
knee_angle = FeatureExtractor._mean_knee_angle(cur)
sym = FeatureExtractor._symmetry_score(vel)
# hands block (42, 3) -> 126
hands_flat = np.zeros(HANDS_KP_FLAT, dtype=np.float32)
if hands_kp is not None:
hk = np.asarray(hands_kp, dtype=np.float32)
if hk.shape == (HANDS_KP_TOTAL, HANDS_KP_DIMS):
hands_flat = hk.reshape(-1).astype(np.float32, copy=False)
# expression
if expr is None:
expr_vec = np.zeros(EXPR_DIM, dtype=np.float32)
else:
expr_vec = np.zeros(EXPR_DIM, dtype=np.float32)
n = min(EXPR_DIM, len(expr))
expr_vec[:n] = expr[:n]
return np.concatenate([
cur.reshape(-1),
vel.reshape(-1),
accel.reshape(-1),
hands_flat,
expr_vec,
np.array([hip_y, knee_angle, sym, float(mouth_open)], dtype=np.float32),
]).astype(np.float32, copy=False)
@staticmethod
def kinetics(frames: list[np.ndarray]) -> np.ndarray:
"""Return (speed, accel_mag, symmetry) averaged over the buffer."""
if len(frames) < 2:
return np.zeros(3, dtype=np.float32)
arr = np.stack(frames).astype(np.float32, copy=False)
diffs = arr[1:] - arr[:-1]
speeds = np.linalg.norm(diffs, axis=-1).mean(axis=-1)
speed = float(speeds.mean())
if len(frames) >= 3:
ddiffs = diffs[1:] - diffs[:-1]
accel = float(np.linalg.norm(ddiffs, axis=-1).mean())
else:
accel = 0.0
sym = FeatureExtractor._symmetry_score(diffs[-1])
return np.array([speed, accel, sym], dtype=np.float32)
@staticmethod
def _mean_knee_angle(j3d: np.ndarray) -> float:
"""Angle (rad) at left+right knees, averaged."""
def _angle(hip: int, knee: int, ankle: int) -> float:
v1 = j3d[hip] - j3d[knee]
v2 = j3d[ankle] - j3d[knee]
n1 = np.linalg.norm(v1) + 1e-6
n2 = np.linalg.norm(v2) + 1e-6
cos = float(np.dot(v1, v2) / (n1 * n2))
return float(np.arccos(np.clip(cos, -1.0, 1.0)))
return 0.5 * (_angle(HIP_LEFT, KNEE_LEFT, ANKLE_LEFT)
+ _angle(HIP_RIGHT, KNEE_RIGHT, ANKLE_RIGHT))
@staticmethod
def _symmetry_score(vel: np.ndarray) -> float:
"""Cosine sim between left-arm and mirrored right-arm velocity."""
left = vel[WRIST_LEFT].copy()
right = vel[WRIST_RIGHT].copy()
right_mirror = right.copy()
right_mirror[0] = -right_mirror[0]
n1 = np.linalg.norm(left) + 1e-6
n2 = np.linalg.norm(right_mirror) + 1e-6
return float(np.dot(left, right_mirror) / (n1 * n2))
class PerPersonBuffer:
"""Per-pid ring buffer of j3d frames (deque maxlen=WINDOW_LEN)."""
__slots__ = ("_buffers",)
def __init__(self) -> None:
self._buffers: dict[int, deque[np.ndarray]] = {}
def append(self, pid: int, j3d: np.ndarray) -> None:
if j3d.shape != (J3D_JOINTS, J3D_DIMS):
raise ValueError(
f"j3d must be ({J3D_JOINTS}, {J3D_DIMS}), got {j3d.shape}"
)
dq = self._buffers.get(pid)
if dq is None:
dq = deque(maxlen=WINDOW_LEN)
self._buffers[pid] = dq
dq.append(j3d.astype(np.float32, copy=False))
def frames_for(self, pid: int) -> list[np.ndarray]:
dq = self._buffers.get(pid)
return list(dq) if dq is not None else []
def forget(self, pid: int) -> None:
self._buffers.pop(pid, None)
def __len__(self) -> int:
return len(self._buffers)
def pids(self) -> list[int]:
return list(self._buffers.keys())
class ActionHeadModel(nn.Module):
"""1-layer GRU + small MLP head.
Input : (B, FEATURE_DIM) -- single step
Hidden : (1, B, HIDDEN_DIM)
Output : (B, NUM_CLASSES) logits, new hidden
"""
def __init__(self) -> None:
super().__init__()
self.gru = nn.GRU(input_size=FEATURE_DIM,
hidden_size=HIDDEN_DIM,
num_layers=1,
batch_first=True)
self.mlp = nn.Sequential(
nn.Linear(HIDDEN_DIM, MLP_HIDDEN),
nn.ReLU(inplace=True),
nn.Linear(MLP_HIDDEN, NUM_CLASSES),
)
def init_hidden(self, batch: int = 1, device: str = "cpu") -> torch.Tensor:
return torch.zeros(1, batch, HIDDEN_DIM, device=device)
def forward(self, x: torch.Tensor,
h: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
out, h_new = self.gru(x.unsqueeze(1), h)
logits = self.mlp(out.squeeze(1))
return logits, h_new
class ActionHead:
"""Streaming action classifier per person.
Use:
head = ActionHead(ckpt_path=...)
label, probs, kin = head.step(pid, j3d)
head.forget(pid)
"""
def __init__(self,
ckpt_path: Path | None = None,
device: str = "cpu") -> None:
self._device = device
self._model = ActionHeadModel().to(device).eval()
if ckpt_path is not None:
payload = torch.load(ckpt_path, map_location=device,
weights_only=True)
state = payload.get("model_state_dict", payload)
self._model.load_state_dict(state)
self._buffers = PerPersonBuffer()
self._hidden: dict[int, torch.Tensor] = {}
self._nan_streak: dict[int, int] = {}
def step(self, pid: int, j3d: np.ndarray,
expr: np.ndarray | None = None,
mouth_open: float = 0.0,
hands_kp: np.ndarray | None = None) -> tuple[str, np.ndarray, np.ndarray]:
if np.isnan(j3d).any():
streak = self._nan_streak.get(pid, 0) + 1
self._nan_streak[pid] = streak
if streak > NAN_SKIP_BUDGET:
self.forget(pid)
probs = np.array([1.0, 0.0, 0.0], dtype=np.float32)
return LABELS[0], probs, np.zeros(3, dtype=np.float32)
self._nan_streak[pid] = 0
self._buffers.append(pid, j3d)
frames = self._buffers.frames_for(pid)
if len(frames) < WARMUP_FRAMES:
probs = np.array([1.0, 0.0, 0.0], dtype=np.float32)
return LABELS[0], probs, np.zeros(3, dtype=np.float32)
feat = FeatureExtractor.from_buffer(frames, expr=expr, mouth_open=mouth_open,
hands_kp=hands_kp)
kin = FeatureExtractor.kinetics(frames)
h = self._hidden.get(pid)
if h is None:
h = self._model.init_hidden(batch=1, device=self._device)
x = torch.from_numpy(feat).unsqueeze(0).to(self._device)
with torch.no_grad():
logits, h_new = self._model(x, h)
probs_t = torch.softmax(logits, dim=-1).squeeze(0)
self._hidden[pid] = h_new
probs = probs_t.cpu().numpy().astype(np.float32, copy=False)
return LABELS[int(np.argmax(probs))], probs, kin
def forget(self, pid: int) -> None:
self._buffers.forget(pid)
self._hidden.pop(pid, None)
self._nan_streak.pop(pid, None)
+563
View File
@@ -0,0 +1,563 @@
"""Action-head publisher : reads state.persons_smplx / persons_body3d,
runs ActionHead per pid, emits /pose/action and /pose/kin via pose_bridge.
Stand-alone thread to avoid touching multi_hmr_worker.py while it
iterates. Polls state at ~30 Hz, deduplicates by smplx_last_t.
"""
from __future__ import annotations
import logging
import threading
import time
from pathlib import Path
from typing import Any
import numpy as np
from data_only_viz.action_head import (
ActionHead,
EXPR_DIM,
HANDS_KP_DIMS,
HANDS_KP_PER_HAND,
HANDS_KP_TOTAL,
J3D_FINGERS,
J3D_FINGERS_PER_HAND,
LABELS,
)
from data_only_viz.hand_display import gesture_quality, hand_plausible, HandPersistenceGate
from data_only_viz.hand_slots import route_hands
LOG = logging.getLogger("action_head_pub")
DEFAULT_CKPT = (
Path.home() / ".cache" / "av-live-action" / "checkpoints" / "action_head.pt"
)
# Canonical SMPL-X fingertip vertex IDs from smplx.vertex_ids.SMPLX_VERTEX_IDS.
# Order : L thumb, L index, L middle, L ring, L pinky,
# R thumb, R index, R middle, R ring, R pinky.
SMPLX_FINGERTIP_VERTS: tuple[int, ...] = (
5361, 4933, 5058, 5169, 5286, # L : lthumb, lindex, lmiddle, lring, lpinky
8079, 7669, 7794, 7905, 8022, # R : rthumb, rindex, rmiddle, rring, rpinky
)
# 32 vertex indices on the 10475-vertex SMPL-X mesh:
# 22 body (UNCHANGED from v1) + 10 fingertips.
# NOTE: approximate vertex anchors -- real SMPL-X joints come from
# J_regressor @ v3d, but loading the regressor here is avoided for
# live OSC performance. Action-head training must use the same anchors.
SMPLX_JOINT_ANCHOR_VERTS: tuple[int, ...] = (
# 22 body (UNCHANGED indices, same vertex IDs as before)
8204, 3992, 6677, 3500, 3469, 6394, 3279, 3327, 6736, 3074,
8846, 8889, 8848, 1300, 4660, 8964, 3013, 6470, 1602, 5083,
2114, 5559,
# 10 fingertips
*SMPLX_FINGERTIP_VERTS,
)
assert len(SMPLX_JOINT_ANCHOR_VERTS) == 32
# Mouth-open: distance between two lip vertices on SMPL-X mesh.
# vert 8970 (upper outer lip), 8855 (lower outer lip) -- approximate.
SMPLX_UPPER_LIP_VERT: int = 8970
SMPLX_LOWER_LIP_VERT: int = 8855
# MediaPipe FaceMesh inner-mouth landmark indices.
# 13 = upper inner mid, 14 = lower inner mid.
MEDIAPIPE_LIP_UPPER_INNER: int = 13
MEDIAPIPE_LIP_LOWER_INNER: int = 14
# MediaPipe HAND fingertip indices (21-kp hand model).
MEDIAPIPE_HAND_FINGERTIPS: tuple[int, ...] = (4, 8, 12, 16, 20)
# MediaPipe 33-landmark indices mapped into the 22-joint slot order.
# NOTE: approximate mapping -- spine joints reuse hip/shoulder anchors.
# https://developers.google.com/mediapipe/solutions/vision/pose_landmarker
MEDIAPIPE_TO_22: tuple[int, ...] = (
24, 23, 24, 23, 25, 26, 11, 27, 28, 11,
31, 32, 0, 11, 12, 0, 11, 12, 13, 14, 15, 16,
)
class ActionHeadPublisher(threading.Thread):
"""Thread that polls state, runs ActionHead per pid, emits OSC."""
def __init__(self, state: Any, bridge: Any,
ckpt_path: Path | None = DEFAULT_CKPT,
period_s: float = 1.0 / 30.0) -> None:
super().__init__(daemon=True, name="action-head-pub")
self.state = state
self.bridge = bridge
self.period = period_s
try:
ckpt = ckpt_path if (ckpt_path and ckpt_path.exists()) else None
self.head = ActionHead(ckpt_path=ckpt, device="cpu")
LOG.info("action_head loaded ckpt=%s",
ckpt if ckpt else "<random init>")
except Exception as e:
LOG.warning("action_head init failed: %s", e)
self.head = None
self._stop = threading.Event()
self._last_smplx_t = 0.0
self._last_body_t = 0.0
self._last_pids: set[int] = set()
from data_only_viz.hand_features import HandFeatureExtractor
self._hand_ext = HandFeatureExtractor()
self._init_finger_piano()
def stop(self) -> None:
self._stop.set()
def run(self) -> None:
if self.head is None:
LOG.warning("publisher exiting: no action_head")
return
LOG.info("publisher started")
while not self._stop.is_set():
t0 = time.perf_counter()
try:
self._tick(t0)
except Exception:
LOG.exception("publisher tick failed")
dt = time.perf_counter() - t0
if dt < self.period:
time.sleep(self.period - dt)
LOG.info("publisher stopped")
def _tick(self, t_now: float) -> None:
persons32, source_t, source_tag, is_new = self._read_sources()
if not is_new:
return
if "smplx" in source_tag:
self._last_smplx_t = source_t
else:
self._last_body_t = source_t
current_pids: set[int] = set()
if persons32:
for pid, j3d, expr_np, mouth, hands_kp42 in persons32:
current_pids.add(pid)
label, probs, kin = self.head.step(pid, j3d, expr=expr_np,
mouth_open=mouth,
hands_kp=hands_kp42)
idx = LABELS.index(label)
self.bridge.send_action(pid, idx, probs, t_now, force=True)
self.bridge.send_kin(pid, kin, t_now, force=True)
self.bridge.send_mouth(pid, mouth)
if pid not in self._last_pids:
self.bridge.send_enter(pid=pid)
for gone in self._last_pids - current_pids:
self.head.forget(gone)
self.bridge.send_leave(pid=gone)
self._last_pids = current_pids
if getattr(self, "_stab", None) is not None:
self._stab_slotted = self._step_stab()
# Reset finger-strike state for slots that just transitioned from
# "held replay" back to a real hand, preventing phantom strikes from
# the compressed motion delta accumulated during the hold window.
for i, resumed in enumerate(self._stab.resumed_flags()):
if resumed:
self._finger_det.reset_slot(i)
else:
self._stab_slotted = None
self._emit_hands(t_now)
self._emit_fingers(t_now)
self._emit_pinch(t_now)
self._update_gesture_slot_status()
def _emit_hands(self, t_now: float) -> None:
"""Lock state, extract hand features, emit /pose/hands once per tick."""
if getattr(self, "_tick_hands", None) is not None:
# Fast path: validated by _step_stab this tick (audit R3/R4: single lock).
hands = self._tick_hands
chirality = self._tick_chir
mirror = self._tick_mirror
else:
# Fallback for standalone calls (unit tests bypassing _tick).
with self.state.lock():
hands = list(getattr(self.state, "persons_hands", None) or [])
chirality = list(getattr(self.state, "persons_hands_chirality", None) or [])
mirror = bool(getattr(self.state, "mirror_2d", False))
if not hands and getattr(self.state, "hands_present", False):
lkp = getattr(self.state, "left_hand_kp", None)
rkp = getattr(self.state, "right_hand_kp", None)
hands = [h for h in (lkp, rkp) if h is not None and len(h) > 0]
chirality = []
# Inline plausibility gate only (no persistence gate in this path).
conf_min = getattr(self, "_gesture_conf_min", 0.0)
def _plausible_fb(h):
try:
return hand_plausible(h, conf_min=conf_min)
except (AttributeError, TypeError):
return True
plausible_mask = [_plausible_fb(h) for h in hands]
hands = [h for h, ok in zip(hands, plausible_mask) if ok]
chirality = [c for c, ok in zip(chirality, plausible_mask) if ok]
stab = getattr(self, "_stab", None)
fist_enabled = stab.active_flags() if stab is not None else (True, True)
feats = self._hand_ext.step(hands, chirality or None,
swap=getattr(self, "_hand_swap_lr", False),
fist_enabled=fist_enabled,
mirror=mirror)
with self.state.lock():
self.state.hand_feats = feats
self.bridge.send_hands(feats, t_now)
def _init_finger_piano(self) -> None:
"""Read FINGER_* env config and build the strike + pinch detectors."""
from data_only_viz.config import VizConfig
from data_only_viz.finger_strike import (
FingerStrikeDetector, PinchDetector,
)
cfg = VizConfig.from_env()
self._finger_enabled = cfg.finger_piano
# Pinch detection is gated independently of the air-piano finger-strike:
# the matrix global actions want pinches WITHOUT the finger-strike "piano".
self._pinch_enabled = cfg.pinch_enable
self._finger_det = FingerStrikeDetector(
vel_thresh=cfg.finger_strike_vel,
refractory_ms=cfg.finger_strike_refractory_ms,
)
self._pinch_det = PinchDetector(
ratio_on=cfg.pinch_ratio_on,
ratio_off=cfg.pinch_ratio_off,
refractory_ms=cfg.pinch_refractory_ms,
margin=cfg.pinch_margin,
ext_ratio=cfg.pinch_ext_ratio,
ext_min=cfg.pinch_ext_min,
debounce_frames=cfg.pinch_debounce_frames,
)
self._finger_dbg = cfg.finger_debug
# auto = iPhone Vision hands if fresh, else MediaPipe. Or force
# "iphone" / "mediapipe".
self._finger_source = cfg.finger_source
self._fdbg_n = 0
# Safety knob: invert Vision chirality interpretation.
# Chirality validated correct live 2026-07-02; keep False unless a
# future iPhone app build flips it.
self._hand_swap_lr = cfg.hand_swap_lr
self._hand_near_min = cfg.hand_near_min
self._hand_near_off = cfg.hand_near_off
self._hand_hold_frames = cfg.hand_hold_frames
from data_only_viz.hand_slots import GestureSlotStabilizer
self._stab = GestureSlotStabilizer(
hold_frames=cfg.hand_hold_frames,
near_on=cfg.hand_near_min,
near_off=cfg.hand_near_off,
face_min=cfg.hand_face_min,
)
self._gesture_conf_min = cfg.hand_conf_min
# Gesture-path gate stepped at ~30 Hz (publisher tick) — correctly
# cadenced, unlike the renderer's 60 fps instance (audit R6).
self._gesture_gate = HandPersistenceGate(
min_frames=cfg.hand_persist_frames, grace=cfg.hand_persist_grace
)
self._tick_hands: list | None = None
self._tick_chir: list = []
self._tick_mirror: bool = False
self._tick_raw_slotted: list = [None, None]
def _pick_hands(self) -> tuple[list, list, str, bool, bool]:
"""Return (hands, chirality, source_label, ip_fresh, mirror).
mirror is read from state.mirror_2d under the lock so it is consistent
with the chirality dropout decision made in the same tick. Callers
pass it through to route_hands so the cx fallback slot assignment stays
coherent with the renderer's panel layout when chirality is absent.
"""
with self.state.lock():
mp_hands = list(getattr(self.state, "persons_hands", None) or [])
ip_hands = list(getattr(self.state, "persons_hands_iphone", None) or [])
ip_t = getattr(self.state, "persons_hands_iphone_t", 0.0)
ip_chir = list(getattr(self.state, "persons_hands_chirality", None) or [])
mirror = bool(getattr(self.state, "mirror_2d", False))
ip_fresh = bool(ip_hands) and (time.perf_counter() - ip_t) < 0.5
src = getattr(self, "_finger_source", "auto")
if src == "iphone":
return ip_hands, ip_chir, "iphone", ip_fresh, mirror
if src == "mediapipe":
return mp_hands, [], "mediapipe", ip_fresh, mirror
if ip_fresh:
return ip_hands, ip_chir, "iphone", ip_fresh, mirror
return mp_hands, [], "mediapipe", ip_fresh, mirror
def _step_stab(self) -> list:
"""Pick hands, apply quality gates, route, step gesture stabilizer.
Called once per tick from _tick() to share the result across all
three gesture emitters. Also callable directly from emitters when
_stab_slotted is not yet set (e.g. in unit tests that call emitters
without going through _tick).
"""
hands, chir, _, _, mirror = self._pick_hands()
# 1. Plausibility gate: reject out-of-frame / undersized / low-conf ghosts.
# Raw numpy-array hands (no .x/.y/.c protocol) are passed through unchanged
# so _build_hands_map / action-head model paths keep working.
def _plausible(h):
try:
return hand_plausible(h, conf_min=self._gesture_conf_min)
except (AttributeError, TypeError):
return True
plausible_mask = [_plausible(h) for h in hands]
plausible_hands = [h for h, ok in zip(hands, plausible_mask) if ok]
plausible_chir = [c for c, ok in zip(chir, plausible_mask) if ok]
# 2. Establishment gate: anti-ghost temporal filter; same chirality mask
# keeps chir[i] aligned with plausible_hands[i] through both filters.
established_mask = self._gesture_gate.step(plausible_hands)
self._tick_hands = [h for h, ok in zip(plausible_hands, established_mask) if ok]
self._tick_chir = [c for c, ok in zip(plausible_chir, established_mask) if ok]
self._tick_mirror = mirror
raw = route_hands(
self._tick_hands, self._tick_chir or None,
swap=getattr(self, "_hand_swap_lr", False),
near_min=0.0,
mirror=mirror,
)
self._tick_raw_slotted = list(raw) # save pre-stabilizer routing for status
return self._stab.step(raw)
def _emit_fingers(self, t_now: float) -> None:
"""Detect finger strikes (air-piano) and emit /pose/finger."""
if not getattr(self, "_finger_enabled", False):
return
hands, chirality, used, ip_fresh, _ = self._pick_hands()
slotted = getattr(self, "_stab_slotted", None)
if slotted is None:
slotted = self._step_stab()
events = self._finger_det.step(slotted, t_now)
if getattr(self, "_finger_dbg", False):
self._fdbg_n += 1
if events or self._fdbg_n % 30 == 0:
LOG.info("fingers dbg: src=%s hands=%d strikes=%d (ip_fresh=%s)",
used, len(hands), len(events), ip_fresh)
for ev in events:
self.bridge.send_finger(ev)
def _emit_pinch(self, t_now: float) -> None:
"""Detect thumb-to-finger pinches (matrix global actions) and emit
/pose/pinch. Gated by PINCH_ENABLE, independently of the air-piano."""
if not getattr(self, "_pinch_enabled", False):
return
hands, chirality, used, _ip, _ = self._pick_hands()
slotted = getattr(self, "_stab_slotted", None)
if slotted is None:
slotted = self._step_stab()
for ev in self._pinch_det.step(slotted, t_now):
if getattr(self, "_finger_dbg", False):
LOG.info("pinch dbg: src=%s hand=%d finger=%d",
used, ev.hand, ev.finger)
self.bridge.send_pinch(ev)
def _update_gesture_slot_status(self) -> None:
"""Compute per-slot gesture status and quality, write both to state.
Status values: 0=absent, 1=detected(plausible+established, not armed),
2=armed(near+facing), 3=pinch engaged.
Quality ∈ [0, 1]: continuous intensity for frame brightness + thickness.
Called once per tick after all emitters have run.
"""
stab = getattr(self, "_stab", None)
if stab is None:
return
pinch_det = getattr(self, "_pinch_det", None)
raw = getattr(self, "_tick_raw_slotted", [None, None])
active = stab.active_flags()
engaged = pinch_det.engaged_slots() if pinch_det is not None else (False, False)
status = [0, 0]
quality = [0.0, 0.0]
face_min = stab._face_min
near_on = stab._near_on
for slot in range(2):
if engaged[slot]:
status[slot] = 3
elif active[slot]:
status[slot] = 2
elif raw[slot] is not None:
status[slot] = 1
quality[slot] = gesture_quality(
raw[slot],
face_min=face_min,
near_on=near_on,
engaged=bool(engaged[slot]),
)
with self.state.lock():
self.state.gesture_slot_status = status
self.state.gesture_slot_quality = quality
def _read_sources(self) -> tuple[
list[tuple[int, np.ndarray, np.ndarray, float, np.ndarray]] | None,
float, str, bool,
]:
"""Return (persons32, source_t, source_tag, is_new).
Each person entry is (pid, j3d32, expr10, mouth_open, hands_kp42x3).
is_new is True when the timestamp advanced (even if person list
is empty), so _tick can still run the purge loop.
"""
with self.state.lock():
persons_smplx = getattr(self.state, "persons_smplx", None)
t_smplx = getattr(self.state, "smplx_last_t", 0.0)
persons_b3d = getattr(self.state, "persons_body3d", None)
ids_b3d = getattr(self.state, "persons_body_ids", None)
persons_face = getattr(self.state, "persons_face", None)
ids_face = getattr(self.state, "persons_face_ids", None)
persons_hands = getattr(self.state, "persons_hands", None)
ids_hands = getattr(self.state, "persons_hands_ids", None)
t_body = getattr(self.state, "pose_last_t", 0.0)
# Build pid -> hands_kp(42, 3) map from MediaPipe persons_hands.
hands_by_pid: dict[int, np.ndarray] = self._build_hands_map(
persons_hands or [], ids_hands or [],
)
# Build pid -> mouth_open scalar from MediaPipe persons_face lips.
face_mouth_by_pid: dict[int, float] = self._build_face_mouth_map(
persons_face or [], ids_face or [],
)
# SMPL-X path (preferred)
if t_smplx > self._last_smplx_t:
out: list[tuple[int, np.ndarray, np.ndarray, float, np.ndarray]] = []
for i, p in enumerate(persons_smplx or []):
# Support both SMPLXPerson dataclass (multi_hmr_worker, field
# names: pid / vertices_3d / expression) and legacy dict format
# (keys: "pid" / "v3d" / "expression").
_is_dict = isinstance(p, dict)
pid = int(p.get("pid", i) if _is_dict else p.pid)
v3d = p.get("v3d") if _is_dict else p.vertices_3d
if v3d is None:
continue
# CoreMLArray wraps a numpy array but has no __array__
# protocol; unwrap via .numpy() before np.asarray.
if hasattr(v3d, "numpy") and not isinstance(v3d, np.ndarray):
v3d = v3d.numpy()
v3d_np = np.asarray(v3d, dtype=np.float32)
if v3d_np.shape[0] < max(SMPLX_JOINT_ANCHOR_VERTS) + 1:
continue
j3d32 = v3d_np[list(SMPLX_JOINT_ANCHOR_VERTS)].astype(np.float32)
# expression — field name matches in both dict and dataclass
expr = p.get("expression") if _is_dict else p.expression
if expr is not None:
if hasattr(expr, "numpy") and not isinstance(expr, np.ndarray):
expr = expr.numpy()
expr_np = np.asarray(expr, dtype=np.float32).flatten()
else:
expr_np = np.zeros(EXPR_DIM, dtype=np.float32)
# mouth_open: prefer MediaPipe face lips, fallback SMPL-X v3d.
if pid in face_mouth_by_pid:
mouth = face_mouth_by_pid[pid]
elif v3d_np.shape[0] > max(SMPLX_UPPER_LIP_VERT, SMPLX_LOWER_LIP_VERT):
mouth = float(np.linalg.norm(
v3d_np[SMPLX_UPPER_LIP_VERT] - v3d_np[SMPLX_LOWER_LIP_VERT]
))
else:
mouth = 0.0
hands_kp42 = hands_by_pid.get(
pid, np.zeros((HANDS_KP_TOTAL, HANDS_KP_DIMS), dtype=np.float32)
)
out.append((pid, j3d32, expr_np, mouth, hands_kp42))
return out or None, t_smplx, "smplx", True
# MediaPipe body3d fallback
if t_body > self._last_body_t:
ids = ids_b3d or list(range(len(persons_b3d or [])))
out = []
for i, body in enumerate(persons_b3d or []):
pid = int(ids[i]) if i < len(ids) else i
arr = self._kp_list_to_array(body)
if arr is None or arr.shape[0] < 33:
continue
body22 = arr[list(MEDIAPIPE_TO_22)].astype(np.float32)
# fingertips from persons_hands if available
tips = np.zeros((J3D_FINGERS, 3), dtype=np.float32)
hands_kp42 = hands_by_pid.get(
pid, np.zeros((HANDS_KP_TOTAL, HANDS_KP_DIMS), dtype=np.float32)
)
# extract fingertips from hands_kp42 (idx 4,8,12,16,20 each side)
for side_idx in (0, 1):
base = side_idx * HANDS_KP_PER_HAND
for k, mp_tip in enumerate(MEDIAPIPE_HAND_FINGERTIPS):
if base + mp_tip < hands_kp42.shape[0]:
tips[side_idx * J3D_FINGERS_PER_HAND + k] = \
hands_kp42[base + mp_tip]
j3d32 = np.concatenate([body22, tips], axis=0)
mouth = face_mouth_by_pid.get(pid, 0.0)
expr_np = np.zeros(EXPR_DIM, dtype=np.float32)
out.append((pid, j3d32, expr_np, mouth, hands_kp42))
return out or None, t_body, "body3d", True
return None, 0.0, "", False
def _build_hands_map(self, persons_hands: list,
ids_hands: list) -> dict[int, np.ndarray]:
"""Combine left+right hand kp arrays per pid into a single (42, 3) array.
persons_hands is a flat list ; ids_hands maps each hand-list entry to a
pid (and odd/even index indicates which side). When the user's pipeline
keeps a different convention, this helper makes the best effort and
pads zeros for missing sides.
"""
out: dict[int, np.ndarray] = {}
for hi, hkp in enumerate(persons_hands):
if hkp is None:
continue
pid_raw = ids_hands[hi] if hi < len(ids_hands) else hi
try:
pid = int(pid_raw)
except (TypeError, ValueError):
pid = hi
side = hi % 2 # 0 = L, 1 = R
arr = self._kp_list_to_array(hkp)
if arr is None or arr.shape[0] < HANDS_KP_PER_HAND:
continue
slot = out.setdefault(
pid, np.zeros((HANDS_KP_TOTAL, HANDS_KP_DIMS), dtype=np.float32)
)
base = side * HANDS_KP_PER_HAND
slot[base:base + HANDS_KP_PER_HAND] = arr[:HANDS_KP_PER_HAND]
return out
def _build_face_mouth_map(self, persons_face: list,
ids_face: list) -> dict[int, float]:
"""Compute mouth_open = norm(upper_inner_lip - lower_inner_lip) per pid."""
out: dict[int, float] = {}
for fi, fkp in enumerate(persons_face):
if fkp is None:
continue
arr = self._kp_list_to_array(fkp)
if arr is None or arr.shape[0] <= MEDIAPIPE_LIP_LOWER_INNER:
continue
upper = arr[MEDIAPIPE_LIP_UPPER_INNER]
lower = arr[MEDIAPIPE_LIP_LOWER_INNER]
mouth = float(np.linalg.norm(upper - lower))
try:
pid = int(ids_face[fi]) if fi < len(ids_face) else fi
except (TypeError, ValueError):
pid = fi
out[pid] = mouth
return out
@staticmethod
def _kp_list_to_array(body: Any) -> np.ndarray | None:
"""Best-effort conversion of a body keypoint list to (N, 3) array.
Handles attribute objects (the Kp3D dataclass exposes .x/.y/.z) and
indexable rows. Kp3D is NOT subscriptable, so the index fallback must
not be evaluated eagerly (the old `getattr(kp, "x", kp[0])` raised
TypeError on Kp3D and silently returned None — breaking the body3d /
MediaPipe-only path that feeds action-head when no SMPL-X is present)."""
if body is None:
return None
if isinstance(body, np.ndarray):
return body
rows = []
for kp in body:
if hasattr(kp, "x"):
rows.append((kp.x, kp.y, getattr(kp, "z", 0.0)))
else:
try:
rows.append((kp[0], kp[1], kp[2] if len(kp) > 2 else 0.0))
except (TypeError, IndexError):
return None
if not rows:
return None
return np.asarray(rows, dtype=np.float32)
+628
View File
@@ -0,0 +1,628 @@
"""Apple Vision body pose — version cv2 + Vision (sans AVCaptureSession).
Le pipeline AVCaptureSession + dispatch_queue + delegate crashe silencieusement
sur Python 3.14 (probablement libdispatch via ctypes). On bypass : capture
webcam via cv2.VideoCapture (deja eprouve dans multi.py), inference Vision
via VNImageRequestHandler en passant un JPEG bytes.
Avantages :
- Pas de delegate AVF, pas de dispatch_queue ctypes
- Pattern thread daemon classique, robuste
- VNDetectHumanBodyPoseRequest ANE-accelerated meme via JPEG handler
Inconvenients vs AVCaptureSession :
- Encodage JPEG entre cv2 et Vision (~3 ms overhead)
- Pas de zero-copy CVPixelBuffer
Active : AV_LIVE_APPLE_VISION=1 uv run python -m data_only_viz.main --pose
"""
from __future__ import annotations
import logging
import threading
import time
import objc
from Foundation import NSBundle, NSData
from .euro_filter import SkeletonFilter
from .fine_analysis import FineAnalyzer
from .mesh_topology import FACE_OFFSETS
from .pose_bridge import PoseSoundBridge
from .state import PoseKp, State
from .tracker import IoUTracker
LOG = logging.getLogger("apple_vision_pose")
# Ordre des 21 joints VNHumanHandPoseObservation (standard MediaPipe).
HAND_JOINTS: tuple[str, ...] = (
"VNHLKWrist",
"VNHLKThumbCMC", "VNHLKThumbMP", "VNHLKThumbIP", "VNHLKThumbTip",
"VNHLKIndexMCP", "VNHLKIndexPIP", "VNHLKIndexDIP", "VNHLKIndexTip",
"VNHLKMiddleMCP", "VNHLKMiddlePIP", "VNHLKMiddleDIP", "VNHLKMiddleTip",
"VNHLKRingMCP", "VNHLKRingPIP", "VNHLKRingDIP", "VNHLKRingTip",
"VNHLKLittleMCP", "VNHLKLittlePIP", "VNHLKLittleDIP", "VNHLKLittleTip",
)
# Regions de VNFaceLandmarks2D dans l'ordre attendu par FACE_OFFSETS.
FACE_REGIONS: tuple[tuple[str, int], ...] = (
("faceContour", 17),
("leftEye", 8),
("rightEye", 8),
("leftEyebrow", 6),
("rightEyebrow", 6),
("outerLips", 14),
("innerLips", 10),
("nose", 6),
("medianLine", 6),
)
# ---------------------------------------------------------------------------
# Charge Vision via loadBundle (pas de pyobjc-framework-Vision sur PyPI)
# ---------------------------------------------------------------------------
_NS: dict = {}
_LOADED = False
def _load_vision() -> dict:
"""Charge Vision.framework dans le namespace _NS (lazy, idempotent)."""
global _LOADED
if _LOADED:
return _NS
bundle = NSBundle.bundleWithPath_("/System/Library/Frameworks/Vision.framework")
if bundle is None or not bundle.load():
raise RuntimeError("Impossible de charger Vision.framework")
objc.loadBundle("Vision", _NS, bundle.bundlePath())
# Enregistrer la metadata explicite : pointAtIndex_ prend un NSUInteger
# et retourne un CGPoint struct (pas une id). Sans ca pyobjc voit
# un selector 0-arg et plante avec "Need 0 arguments, got 1".
try:
objc.registerMetaDataForSelector(
b'VNFaceLandmarkRegion2D', b'pointAtIndex:',
{
'arguments': {2: {'type': objc._C_NSUInteger}},
'retval': {'type': b'{CGPoint=dd}'},
}
)
except Exception:
pass
_LOADED = True
return _NS
# Mapping joint Apple Vision -> indice MediaPipe POSE_LANDMARKS
# (cf https://developers.google.com/mediapipe/solutions/vision/pose_landmarker)
JOINT_MAP: dict[str, int] = {
"nose": 0,
"left_eye": 1,
"right_eye": 4,
"left_ear": 7,
"right_ear": 8,
"left_shoulder": 11,
"right_shoulder": 12,
"left_elbow": 13,
"right_elbow": 14,
"left_wrist": 15,
"right_wrist": 16,
"left_hip": 23,
"right_hip": 24,
"left_knee": 25,
"right_knee": 26,
"left_ankle": 27,
"right_ankle": 28,
}
class AppleVisionPoseWorker:
"""Worker thread : cv2.VideoCapture + VNDetectHumanBodyPoseRequest."""
def __init__(
self,
state: State,
camera_index: int = 0,
target_fps: float = 30.0,
num_persons: int = 4,
score_thresh: float = 0.30,
) -> None:
self.state = state
self.camera_index = camera_index
self.period = 1.0 / max(1.0, target_fps)
self.target_fps = target_fps
self.num_persons = num_persons
self.score_thresh = score_thresh
self._stop = threading.Event()
self._thread: threading.Thread | None = None
# Lissage + tracking (reutilises de multi.py)
self._tracker = IoUTracker(iou_threshold=0.20, max_miss=10)
self._smooth = SkeletonFilter(min_cutoff=1.2, beta=0.06)
# Pont OSC pose -> sclang (pour piloter du son live)
self._sound_bridge = PoseSoundBridge(throttle_hz=30.0)
# Analyse fine : crops haute resolution sur visage/mains
# (cadence 10 Hz pour ne pas saturer ANE)
self._fine_analyzer: FineAnalyzer | None = None
@staticmethod
def is_available() -> bool:
"""True si Vision.framework + cv2 + Foundation chargent OK."""
try:
_load_vision()
import cv2 # noqa: F401
return True
except Exception:
return False
def start(self) -> None:
self._thread = threading.Thread(
target=self._run, name="apple-vision-pose", daemon=True)
self._thread.start()
def stop(self) -> None:
self._stop.set()
# ------------------------------------------------------------------
def _pick_builtin_camera(self) -> int:
"""Energe les cameras via AVFoundation, retourne l'index de la
BuiltIn (MacBook Pro / FaceTime HD), evite Continuity Camera
(iPhone) et Desk View. Fallback sur self.camera_index (0)."""
try:
from Foundation import NSBundle
b = NSBundle.bundleWithPath_(
"/System/Library/Frameworks/AVFoundation.framework")
b.load()
ns = {}
objc.loadBundle("AVFoundation", ns, b.bundlePath())
DiscoverySession = ns["AVCaptureDeviceDiscoverySession"]
session = (DiscoverySession
.discoverySessionWithDeviceTypes_mediaType_position_(
["AVCaptureDeviceTypeBuiltInWideAngleCamera",
"AVCaptureDeviceTypeContinuityCamera",
"AVCaptureDeviceTypeExternal",
"AVCaptureDeviceTypeDeskViewCamera"],
"vide", 0))
devices = session.devices() or []
for i, d in enumerate(devices):
name = str(d.localizedName())
dtype = str(d.deviceType() if hasattr(d, "deviceType") else "")
LOG.info("camera [%d] %s (%s)", i, name, dtype.split(".")[-1])
# Cherche l'index BuiltInWideAngleCamera
for i, d in enumerate(devices):
dtype = str(d.deviceType() if hasattr(d, "deviceType") else "")
if "BuiltInWideAngleCamera" in dtype:
LOG.info("camera Mac built-in -> index %d", i)
return i
except Exception as e:
LOG.warning("camera enum failed: %s", e)
return self.camera_index
def _run(self) -> None:
try:
import cv2
import numpy as np # noqa: F401
except ModuleNotFoundError as e:
LOG.error("deps manquantes : %s — uv sync --extra pose", e)
return
try:
ns = _load_vision()
except Exception as e: # noqa: BLE001
LOG.error("Vision.framework KO : %s", e)
return
VNImageRequestHandler = ns["VNImageRequestHandler"]
VNDetectHumanBodyPoseRequest = ns["VNDetectHumanBodyPoseRequest"]
# Face + hands : noms exposes par Vision.framework.
VNDetectFaceLandmarksRequest = ns.get("VNDetectFaceLandmarksRequest")
VNDetectHumanHandPoseRequest = ns.get("VNDetectHumanHandPoseRequest")
if VNDetectFaceLandmarksRequest is None:
LOG.warning("VNDetectFaceLandmarksRequest absent — face mesh OFF")
if VNDetectHumanHandPoseRequest is None:
LOG.warning("VNDetectHumanHandPoseRequest absent — hand mesh OFF")
# Force cam Mac built-in (evite Continuity Camera iPhone par defaut)
cam_idx = self._pick_builtin_camera()
cap = cv2.VideoCapture(cam_idx)
cap.set(cv2.CAP_PROP_FRAME_WIDTH, 640)
cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 480)
if not cap.isOpened():
LOG.error("camera index %d indisponible (TCC ?)", cam_idx)
return
LOG.info("camera ouverte index=%d — VNDetectHumanBodyPoseRequest ANE actif", cam_idx)
n_frames = 0
sum_ms = 0.0
while not self._stop.is_set():
tA = time.monotonic()
ok, frame_bgr = cap.read()
if not ok or frame_bgr is None:
time.sleep(self.period)
continue
h, w = frame_bgr.shape[:2]
# Encode JPEG une seule fois (webcam HUD + Vision input)
ok2, jpg = cv2.imencode(".jpg", frame_bgr,
[int(cv2.IMWRITE_JPEG_QUALITY), 75])
if not ok2:
time.sleep(self.period)
continue
jpg_bytes = bytes(jpg)
# Vision : VNImageRequestHandler accepte des bytes via NSData
data = NSData.dataWithBytes_length_(jpg_bytes, len(jpg_bytes))
handler = VNImageRequestHandler.alloc().initWithData_options_(
data, {})
request = VNDetectHumanBodyPoseRequest.alloc().init()
# On execute 3 requetes (body + face + hands) sur la MEME
# frame : 1 inference ANE pour les 3 (parallelisation interne
# Vision). Si une requete est indisponible, on passe.
requests = [request]
face_request = None
hand_request = None
if VNDetectFaceLandmarksRequest is not None:
face_request = VNDetectFaceLandmarksRequest.alloc().init()
requests.append(face_request)
if VNDetectHumanHandPoseRequest is not None:
hand_request = VNDetectHumanHandPoseRequest.alloc().init()
try:
hand_request.setMaximumHandCount_(self.num_persons * 2)
except Exception:
pass
requests.append(hand_request)
try:
t_inf = time.monotonic()
# pyobjc peut retourner soit bool, soit (bool, error) selon
# la version. On normalise.
ret = handler.performRequests_error_(requests, None)
if isinstance(ret, tuple):
ok3, err = ret
else:
ok3, err = bool(ret), None
infer_ms = (time.monotonic() - t_inf) * 1000.0
sum_ms += infer_ms
n_frames += 1
except Exception as e: # noqa: BLE001
LOG.warning("Vision performRequests crash : %s", e)
time.sleep(self.period)
continue
if not ok3:
if n_frames < 5:
LOG.warning("Vision request failed: %s", err)
time.sleep(self.period)
continue
results = request.results() or []
bodies: list[list[PoseKp]] = []
n_body_raw = len(results)
for obs in results[: self.num_persons * 2]:
kps = self._parse_observation(obs)
if kps is not None:
bodies.append(kps)
# ---- Face landmarks ------------------------------------
faces: list[list[PoseKp]] = []
n_face_raw = 0
if face_request is not None:
try:
face_results = face_request.results() or []
except Exception:
face_results = []
n_face_raw = len(face_results)
for obs in face_results[: self.num_persons * 2]:
fkps = self._parse_face_observation(obs)
if fkps is not None:
faces.append(fkps)
# ---- Hand poses ----------------------------------------
hands: list[list[PoseKp]] = []
n_hand_raw = 0
if hand_request is not None:
try:
hand_results = hand_request.results() or []
except Exception:
hand_results = []
n_hand_raw = len(hand_results)
for obs in hand_results[: self.num_persons * 2]:
hkps = self._parse_hand_observation(obs)
if hkps is not None:
hands.append(hkps)
# Log debug : raw counts vs parsed counts (toutes les ~3s)
if n_frames % 90 == 0:
LOG.info("Vision raw: body=%d face=%d hand=%d "
"parsed: body=%d face=%d hand=%d",
n_body_raw, n_face_raw, n_hand_raw,
len(bodies), len(faces), len(hands))
# ---- Analyse fine : re-inference sur crops haute resolution
# (visage/mains agrandis 4x avant repasse Vision). Throttle 10 Hz.
if self._fine_analyzer is None:
self._fine_analyzer = FineAnalyzer(ns, throttle_hz=10.0)
t_now = time.monotonic()
if self._fine_analyzer.should_refine(t_now):
# Wrappers de re-projection : les kps du crop sont en
# coordonnees crop normalisees ; on les remap dans l'image
# entiere via (x_origin, y_origin) + scale.
def _wrap_face(obs, x_origin, y_origin, scale_x, scale_y):
kps = self._parse_face_observation(obs)
if kps is None:
return None
return [PoseKp(
x=x_origin + k.x * scale_x,
y=y_origin + k.y * scale_y,
z=k.z, c=k.c,
) for k in kps]
def _wrap_hand(obs, x_origin, y_origin, scale_x, scale_y):
kps = self._parse_hand_observation(obs)
if kps is None:
return None
return [PoseKp(
x=x_origin + k.x * scale_x,
y=y_origin + k.y * scale_y,
z=k.z, c=k.c,
) for k in kps]
faces = self._fine_analyzer.refine_face(
frame_bgr, faces, _wrap_face)
hands = self._fine_analyzer.refine_hands(
frame_bgr, hands, _wrap_hand)
# Tracking + lissage (t_now deja defini au bloc fine analysis)
ids = self._tracker.update(bodies)
bodies_smooth = []
for i, kps in enumerate(bodies):
pid = ids[i] if i < len(ids) else -1
if pid >= 0:
smoothed = []
for k, kp in enumerate(kps):
if kp.c > 0.0:
sx, sy, sz = self._smooth.smooth(
pid, k, kp.x, kp.y, kp.z, t_now)
smoothed.append(PoseKp(x=sx, y=sy, z=sz, c=kp.c))
else:
smoothed.append(kp)
bodies_smooth.append(smoothed)
else:
bodies_smooth.append(kps)
# Pont sonore vers sclang (OSC /pose/* sur 57121)
self._sound_bridge.send(bodies_smooth, ids, t_now)
with self.state.lock():
self.state.persons_body = bodies_smooth
self.state.persons_body_ids = ids
self.state.persons_face = faces
# Pas de tracking dedie pour les faces : IDs = index.
self.state.persons_face_ids = list(range(len(faces)))
self.state.persons_hands = hands
self.state.persons_hands_ids = list(range(len(hands)))
self.state.body_present = bool(bodies_smooth)
self.state.face_present = bool(faces)
self.state.hands_present = bool(hands)
self.state.pose_count = len(bodies_smooth)
self.state.pose_last_t = time.monotonic()
self.state.last_webcam_jpeg = jpg_bytes
# Compat single-person : copie le 1er body dans pose_kp legacy
if bodies_smooth and bodies_smooth[0]:
for k in range(min(17, len(bodies_smooth[0]))):
self.state.body_kp[k] = bodies_smooth[0][k]
# Throttle target_fps
dt = time.monotonic() - tA
if dt < self.period:
time.sleep(self.period - dt)
cap.release()
avg = sum_ms / max(1, n_frames)
LOG.info("apple-vision stop — %d frames, %.1f ms moy inference",
n_frames, avg)
# ------------------------------------------------------------------
def _parse_observation(self, obs) -> list[PoseKp] | None:
"""Recupere TOUS les joints via recognizedPointsForGroupKey:
retourne un dict {jointName: VNRecognizedPoint}. On mappe les keys
retournees (peu importe leur format string exact) sur les indices
MediaPipe via un suffixe (case insensitive).
"""
try:
conf = float(obs.confidence())
except Exception:
conf = 1.0
if conf < self.score_thresh:
return None
kps = [PoseKp() for _ in range(33)]
# Apple Vision body joints sont nommes selon la hierarchie ARKit
# skeleton, pas les COCO/MP names. Les vraies cles :
# head_joint, neck_1_joint, root,
# left_shoulder_1_joint, right_shoulder_1_joint,
# left_forearm_joint, right_forearm_joint,
# left_hand_joint, right_hand_joint,
# left_upLeg_joint, right_upLeg_joint,
# left_leg_joint, right_leg_joint,
# left_foot_joint, right_foot_joint
APPLE_TO_MP = {
"head_joint": 0, # nose (approximation)
"left_shoulder_1_joint": 11,
"right_shoulder_1_joint":12,
"left_forearm_joint": 13, # elbow gauche
"right_forearm_joint": 14,
"left_hand_joint": 15, # poignet gauche
"right_hand_joint": 16,
"left_upLeg_joint": 23, # hanche
"right_upLeg_joint": 24,
"left_leg_joint": 25, # genou
"right_leg_joint": 26,
"left_foot_joint": 27, # cheville
"right_foot_joint": 28,
}
for apple_name, mp_idx in APPLE_TO_MP.items():
try:
ret = obs.recognizedPointForJointName_error_(apple_name, None)
pt = ret[0] if isinstance(ret, tuple) else ret
if pt is None:
continue
pc = float(pt.confidence())
if pc < 0.1:
continue
loc = pt.location()
kps[mp_idx] = PoseKp(
x=float(loc.x),
y=1.0 - float(loc.y),
z=0.0,
c=pc,
)
except Exception:
continue
# Si la 1ere passe ne trouve rien, debug log les vraies keys
n_visible = sum(1 for k in kps if k.c > 0.1)
if n_visible == 0 and not hasattr(self, "_logged_keys"):
try:
ret = obs.availableJointNames_error_(None)
names = ret[0] if isinstance(ret, tuple) else ret
LOG.info("availableJointNames: %s", list(names)[:10] if names else "EMPTY")
self._logged_keys = True
except Exception as e:
LOG.info("availableJointNames KO: %s", e)
self._logged_keys = True
# Verifie qu'on a au moins quelques kp visibles
n_visible = sum(1 for k in kps if k.c > 0.1)
if n_visible < 4:
return None
return kps
# ------------------------------------------------------------------
def _parse_face_observation(self, obs) -> list[PoseKp] | None:
"""Parse les face landmarks Apple Vision via `pointAtIndex_(k)`
(API stable qui retourne un CGPoint struct, pas un UnsafePointer
problematique). Resout le blocage pyobjc PyObjCPointer.
"""
"""Extrait les landmarks face Apple Vision en liste plate de PoseKp.
Layout : offsets FACE_OFFSETS (cf mesh_topology.py). Le bbox de
l'observation (.boundingBox normalize 0..1) sert a re-projeter
les normalizedPoints (normalises DANS le bbox) vers le repere
plein cadre normalise (0..1, top-left).
"""
try:
landmarks = obs.landmarks()
if landmarks is None:
if not hasattr(self, "_face_no_lm_logged"):
LOG.info("face: obs.landmarks() == None — face mesh OFF")
self._face_no_lm_logged = True
return None
bb = obs.boundingBox()
bx, by = float(bb.origin.x), float(bb.origin.y)
bw, bh = float(bb.size.width), float(bb.size.height)
except Exception as e:
if not hasattr(self, "_face_err_logged"):
LOG.info("face parse err: %s", e)
self._face_err_logged = True
return None
# Pre-rempli a (0,0,0,0). On comble les regions disponibles.
kps: list[PoseKp] = [PoseKp() for _ in range(83)]
def fill(region_name: str, start: int, end: int) -> None:
region = None
# Essaye 3 acces : methode obj-c, attribut Python, KVC
for fetcher in (
lambda: getattr(landmarks, region_name)(),
lambda: getattr(landmarks, region_name),
lambda: landmarks.valueForKey_(region_name),
):
try:
region = fetcher()
if region is not None:
break
except Exception:
continue
if region is None:
if not hasattr(self, "_logged_face_fail_" + region_name):
LOG.info("face: region %s introuvable", region_name)
setattr(self, "_logged_face_fail_" + region_name, True)
return
try:
count = int(region.pointCount())
except Exception:
return
if not hasattr(self, "_logged_face_ok_" + region_name):
LOG.info("face: region %s count=%d", region_name, count)
setattr(self, "_logged_face_ok_" + region_name, True)
# pyobjc 11 ne sait pas que pointAtIndex_ prend 1 arg, et
# pointsInImageOfSize_ retourne un PyObjCPointer C-array sans
# API d'acces simple. Face parsing depuis Apple Vision est
# actuellement bloque ; on garde MediaPipe (CPU XNNPACK) pour
# face/hand fin tandis que Vision sert body 2D sur ANE.
# Skip pour eviter le spam ObjCPointerWarning a 30 fps.
return
# faceContour
fill("faceContour", *FACE_OFFSETS["contour"])
fill("leftEye", *FACE_OFFSETS["left_eye"])
fill("rightEye", *FACE_OFFSETS["right_eye"])
fill("leftEyebrow", *FACE_OFFSETS["left_brow"])
fill("rightEyebrow",*FACE_OFFSETS["right_brow"])
fill("outerLips", *FACE_OFFSETS["outer_lips"])
fill("innerLips", *FACE_OFFSETS["inner_lips"])
fill("nose", *FACE_OFFSETS["nose"])
fill("medianLine", *FACE_OFFSETS["median"])
# Pupilles : single-point regions ; meme workaround pyobjc.
for region_name, idx in (("leftPupil", 81), ("rightPupil", 82)):
try:
region = getattr(landmarks, region_name)()
if region is None or region.pointCount() < 1:
continue
try:
pts = region.pointsInImageOfSize_((1.0, 1.0))
except Exception:
pts = region.normalizedPoints()
if not pts:
continue
pt = pts[0]
try:
px, py = float(pt.x), float(pt.y)
except (AttributeError, TypeError):
px, py = float(pt[0]), float(pt[1])
fx = bx + px * bw
fy_bl = by + py * bh
kps[idx] = PoseKp(x=fx, y=1.0 - fy_bl, z=0.0, c=1.0)
except Exception:
continue
n_visible = sum(1 for k in kps if k.c > 0.0)
if n_visible < 8:
return None
return kps
# ------------------------------------------------------------------
def _parse_hand_observation(self, obs) -> list[PoseKp] | None:
"""Extrait les 21 joints d'une VNHumanHandPoseObservation."""
kps = [PoseKp() for _ in range(21)]
n_visible = 0
for k, joint_name in enumerate(HAND_JOINTS):
try:
ret = obs.recognizedPointForJointName_error_(joint_name, None)
pt = ret[0] if isinstance(ret, tuple) else ret
if pt is None:
continue
pc = float(pt.confidence())
if pc < 0.1:
continue
loc = pt.location()
kps[k] = PoseKp(
x=float(loc.x),
y=1.0 - float(loc.y),
z=0.0,
c=pc,
)
n_visible += 1
except Exception:
continue
if n_visible < 4:
return None
return kps
+91
View File
@@ -0,0 +1,91 @@
"""ARKit ARSkeleton3D 91-joint indices → MediaPipe Pose 33 indices.
The ARKit ARSkeleton.JointName enum (Apple SDK) orders 91 joints
starting with the root, hips, spine chain, shoulders, etc. We pick
only the joints with a clear 1:1 anatomical correspondence to the
MediaPipe Pose 33 landmark set (which is what AVLiveBody renders).
Face/hand sub-joints (fingers, eyes) are skipped — those keep their
existing data sources (MediaPipe Face/Hand + HaMeR MANO).
Reference for ARKit joint order : Apple developer docs
"ARSkeleton.JointName" — the canonical 91-joint list runs from
root_joint=0 down to right_handThumbEndJoint=90.
The selection here mirrors `multi.py::SMPLX_TO_MP33` so the same 14
body slots are overridden by ARKit when fresh. Confidence comes
from ARKit's tracking state but is not currently fanned out — we
trust ARKit body tracking when its OSC frame is present.
"""
from __future__ import annotations
# MediaPipe Pose 33 cardinality (cf. mediapipe pose_world_landmarks).
MP33_NUM_LANDMARKS = 33
# Pelvis = ARKit hips_joint, slot 1 in the canonical enum order.
# Used by multi_hmr_worker for cam-translation z lock.
ARKIT_PELVIS_IDX = 1
# ARKit head joint index in the 91-joint enum.
# Tentatively mapped to index 51 (head_joint in Apple's canonical ordering).
# VERIFY LIVE (controller will confirm the index on the rig)
ARKIT_HEAD_IDX = 51
# (arkit_joint_idx, mediapipe_pose_idx). Match the body slots used
# by the SMPL-X body fusion in multi.py.
ARKIT91_TO_MP33: tuple[tuple[int, int], ...] = (
(ARKIT_HEAD_IDX, 0), # head_joint -> NOSE (anchor; VERIFY LIVE)
(50, 11), # left_shoulder_1_joint -> L_SHOULDER
(32, 12), # right_shoulder_1_joint -> R_SHOULDER
(53, 13), # left_arm_joint -> L_ELBOW
(35, 14), # right_arm_joint -> R_ELBOW
(54, 15), # left_forearm_joint -> L_WRIST
(36, 16), # right_forearm_joint -> R_WRIST
(62, 23), # left_upLeg_joint -> L_HIP
(57, 24), # right_upLeg_joint -> R_HIP
(63, 25), # left_leg_joint -> L_KNEE
(58, 26), # right_leg_joint -> R_KNEE
(64, 27), # left_foot_joint -> L_ANKLE
(59, 28), # right_foot_joint -> R_ANKLE
(65, 31), # left_toes_joint -> L_FOOT_INDEX
(60, 32), # right_toes_joint -> R_FOOT_INDEX
)
def arkit_body_2d(arr2d) -> list:
"""Map a 91×2 normalized ARKit 2D skeleton to a MediaPipe Pose-33 list.
Mapped slots get c=1.0; all other slots default to c=0.0 (low confidence).
Args:
arr2d: array-like of shape (91, 2), image-normalized coords [0..1].
Returns:
list of 33 PoseKp instances.
"""
from .state import PoseKp # noqa: PLC0415
body: list = [PoseKp() for _ in range(MP33_NUM_LANDMARKS)]
for ai, mp in ARKIT91_TO_MP33:
if mp < MP33_NUM_LANDMARKS and ai < len(arr2d):
body[mp] = PoseKp(x=float(arr2d[ai][0]), y=float(arr2d[ai][1]),
z=0.0, c=1.0)
return body
def arkit_body_3d(arr3d) -> list:
"""Map a 91×3 world ARKit skeleton to a MediaPipe Pose-33 Kp3D list.
Mapped slots get c=1.0; all other slots default to c=0.0 (low confidence).
Args:
arr3d: array-like of shape (91, 3), metric world coords (metres).
Returns:
list of 33 Kp3D instances.
"""
from .state import Kp3D # noqa: PLC0415
body: list = [Kp3D() for _ in range(MP33_NUM_LANDMARKS)]
for ai, mp in ARKIT91_TO_MP33:
if mp < MP33_NUM_LANDMARKS and ai < len(arr3d):
body[mp] = Kp3D(x=float(arr3d[ai][0]), y=float(arr3d[ai][1]),
z=float(arr3d[ai][2]), c=1.0)
return body
+69
View File
@@ -0,0 +1,69 @@
"""Build line segments from a full ARKit skeleton + parent topology.
Pure functions, no Metal/state dependency, so they are unit-testable.
The renderer feeds the resulting (ax, ay, bx, by) tuples straight into
the GPU line buffer.
"""
from __future__ import annotations
import math
def bones_from_parents(parents: list[int]) -> list[tuple[int, int]]:
"""(child, parent) pairs for every joint with a valid parent."""
n = len(parents)
return [(i, p) for i, p in enumerate(parents) if 0 <= p < n]
def arkit_segments(arr2d, valid, parents, max_len: float = 0.5, bounds: float = 0.1):
"""Return (ax, ay, bx, by) for each bone with both endpoints valid.
arr2d: indexable of (x, y) normalized [0,1], length == len(parents).
valid: indexable of bool (length == len(parents)) or None to keep all.
parents: parent index per joint (-1 = root).
max_len: maximum bone length in normalized units; bones longer than this
are skipped (garbage coords during ARKit tracking loss).
Caveat: on an extreme close-up (subject filling the frame) a
legit bone can approach 0.5 — raise ARKIT_BONE_MAX live if
the skeleton visibly loses bones when very near the camera.
bounds: margin outside [0,1] that is still accepted; endpoints outside
[-bounds, 1+bounds] in x or y are rejected.
"""
n = len(parents)
lo = -bounds
hi = 1.0 + bounds
segs: list[tuple[float, float, float, float]] = []
for child, parent in bones_from_parents(parents):
if child >= n or parent >= n:
continue
if valid is not None and (not valid[child] or not valid[parent]):
continue
ax, ay = float(arr2d[child][0]), float(arr2d[child][1])
bx, by = float(arr2d[parent][0]), float(arr2d[parent][1])
# Reject out-of-frame endpoints
if not (lo <= ax <= hi and lo <= ay <= hi):
continue
if not (lo <= bx <= hi and lo <= by <= hi):
continue
# Reject implausibly long bones (tracking-loss garbage)
if math.hypot(bx - ax, by - ay) > max_len:
continue
segs.append((ax, ay, bx, by))
return segs
# ARKit's 91-joint body includes per-finger joints whose 2D projection is
# unreliable (the user sees them as "false hands" glued to the skeleton).
# The display uses the filtered Vision hands instead; these joints are
# masked out of the wireframe. The wrist anchors (left_hand_joint /
# right_hand_joint) are kept so the forearm bone still terminates.
_FINGER_TOKENS = ("thumb", "index", "mid", "ring", "little", "pinky")
def finger_joint_mask(names: list) -> list[bool]:
"""True for each ARKit joint that belongs to a finger (to be hidden)."""
out = []
for n in names:
ln = str(n).lower()
out.append("hand" in ln and any(t in ln for t in _FINGER_TOKENS))
return out
+43
View File
@@ -0,0 +1,43 @@
"""AVLiveWire topology payload codec.
Mirror of the Swift `TopologyPayload` (shared/AVLiveWire). Layout
(big-endian): u16 jointCount; per joint [u8 nameLen + utf8 name];
then per joint i16 parent (-1 = root).
"""
from __future__ import annotations
import struct
def encode_topology(names: list[str], parents: list[int]) -> bytes:
out = bytearray()
out += struct.pack(">H", len(names))
for nm in names:
b = nm.encode("utf-8")[:255]
out.append(len(b))
out += b
for p in parents:
out += struct.pack(">h", p)
return bytes(out)
def decode_topology(payload: bytes) -> tuple[list[str], list[int]] | None:
"""Return (joint_names, parents) or None if malformed."""
if len(payload) < 2:
return None
n = struct.unpack(">H", payload[:2])[0]
o = 2
names: list[str] = []
for _ in range(n):
if o >= len(payload):
return None
ln = payload[o]
o += 1
if o + ln > len(payload):
return None
names.append(payload[o:o + ln].decode("utf-8", "replace"))
o += ln
if o + n * 2 > len(payload):
return None
parents = list(struct.unpack(">" + "h" * n, payload[o:o + n * 2]))
return names, parents
+335
View File
@@ -0,0 +1,335 @@
"""Central configuration for data_only_viz — single source of truth for env vars.
All os.environ reads for data_only_viz belong here.
Consumers call ``VizConfig.from_env()`` inside their ``__init__`` or the
consuming method — never at module import time so that test monkeypatching
of os.environ is picked up correctly.
Env var reference table
-----------------------
Name Default Type Effect
MEDIAPIPE_DELEGATE "gpu" str "gpu" (Metal) or "cpu"
VIDEO_ROTATE "none" str none/ccw/cw/180 — rotate frame before MP+JPEG
POSE_GHOST_MIN_VISIBLE 10 int min visible kps to accept a body detection
POSE_GHOST_MIN_CONF 0.5 float min mean confidence to keep a detection
POSE_HAND_MIN_VISIBLE 15 int visibility gate for hand kps
POSE_FACE_MIN_VISIBLE 50 int visibility gate for face kps
POSE_NMS_IOU 0.7 float IoU threshold for body NMS
POSE_FILTER None str filter stage list (+/-sep); None=defaults
POSE_FILTER_FACE None str filter stages for face; None=defaults
POSE_FILTER_HAND None str filter stages for hands; None=defaults
FINGER_PIANO "0" bool enable air-piano finger-strike emission
FINGER_STRIKE_VEL 0.02 float downward relative-velocity threshold
FINGER_STRIKE_REFRACTORY_MS 120 float min ms between strikes per finger
FINGER_SOURCE "auto" str "auto"/"iphone"/"mediapipe"
FINGER_DEBUG "0" bool log each detected strike/pinch
PINCH_ENABLE "0" bool enable pinch gesture detection
PINCH_RATIO_ON 0.45 float thumb-to-finger dist/hand-size to engage
PINCH_RATIO_OFF 0.65 float dist/size above which pinch re-arms
PINCH_REFRACTORY_MS 250 float min ms between pinches per finger
PINCH_MARGIN 0.20 float extra margin added to pinch zone
PINCH_EXT_RATIO 1.35 float extended-pinch activation ratio
PINCH_EXT_MIN 2 int min extended fingers for a pinch to fire; 0 disables open-hand gate
PINCH_DEBOUNCE_FRAMES 3 int debounce frames for pinch state machine
HAND_CONF_MIN 0.45 float min hand landmark confidence to render
HAND_PERSIST_FRAMES 3 int consecutive frames before a hand track is drawn; 1=off
HAND_SWAP_LR "0" bool Safety knob: invert Vision chirality (validated correct live 2026-07-02; keep 0 unless a future iPhone app build flips it)
HAND_NEAR_MIN 0.10 float min hand size (norm) to consider "near"
HAND_FACE_MIN 0.5 float palm-spread ratio threshold: slot activates gestures only when facing >= this (side-on < 0.4, facing ~0.7-1.0)
HAND_PERSIST_GRACE 2 int grace frames before dropping a hand track on Vision miss (0 = strict)
HAND_NEAR_OFF 0.08 float hand size below which near gate deactivates (hysteresis off-threshold)
HAND_HOLD_FRAMES 2 int gesture slot hold: frames to carry last hand on Vision miss
ARKIT_BONE_MAX 0.5 float max bone length (norm) for ARKit skeleton
ARKIT_FULL_SKELETON "1" bool draw full 91-joint ARKit body (!=0 = True)
VIZ_HUD "0" bool show debug HUD overlay
VIZ_SOURCE_KEYS "0" bool wxcvbn + 0-9 source-bundle shortcuts (off: perf safety)
VIZ_AUDIO_KEYS "0" bool qsdfghjkl audio-scene shortcuts (off: perf safety; m stays)
VIZ_DEFAULT_MODE "auto" str fixed boot viz mode (e.g. voronoi); disables auto-openpos
SKEL_LINE_WIDTH "4" int body skeleton stroke width, approx px (1 = thin)
IPHONE_OSC_PORT 57128 int UDP port for iPhone ARBodyTracker /body3d/kp
CONCERT_MIRROR "1" bool mirror video horizontally (!=0 = True)
ICP_FUSION "0" bool enable ICP LiDAR fusion (only "1" = True)
ICP_LIDAR_HOST None str iPhone LiDAR stream host when ICP_FUSION=1
ICP_LIDAR_PORT 5500 int iPhone LiDAR stream port
ICP_LIDAR_EXTRINSIC None str path to extrinsic JSON for ICP
MULTIHMR_BACKEND "pytorch" str "pytorch"/"coreml"/"remote"
MULTIHMR_LOOP_FPS 30.0 float Multi-HMR worker loop target fps
MULTIHMR_AUTOCAST "0" bool MPS fp16 autocast for Multi-HMR (only "1")
MULTIHMR_REMOTE_HOST "192.168.0.175" str remote Multi-HMR server host
MULTIHMR_REMOTE_PORT 57140 int remote Multi-HMR server port
MULTIHMR_REMOTE_JPEG True bool send JPEG-compressed frames to remote
MULTIHMR_REMOTE_JPEG_QUALITY 80 int JPEG quality for remote frames
MULTIHMR_REMOTE_ASYNC True bool async pipeline for remote backend
MULTIHMR_REID "dino" str reid mode: "dino" or "iou"
MULTIHMR_REID_ALPHA 0.5 float DINOv2 weight in hybrid reid score
COREML_MLPACKAGE None str path to .mlpackage (None=default cache path)
COREML_COMPUTE_UNITS "" str "cpu_only"/"cpu_and_gpu"/"all"/"cpu_and_ne"
AVBODY_HOST "127.0.0.1" str host for AVLiveBody skeleton UDP/TCP
VDMX_OSC_HOST None str VDMX VJ OSC host (None=disabled)
VDMX_OSC_PORT 1234 int VDMX VJ OSC port
AV_LIVE_MEDIAPIPE True bool enable MediaPipe Multi worker (!=0 = True)
AV_LIVE_APPLE_VISION True bool enable Apple Vision fallback (!=0 = True)
AV_LIVE_COREML True bool enable CoreML pose fallback (!=0 = True)
AV_LIVE_DETRPOSE False bool enable DETRPose fallback (only "1" = True)
AV_LIVE_PARALLEL_POSE "both" str parallel pose backend in multi-hmr mode
MESH_RIG "1" bool enable SMPL-X mesh rigger (!=0 = True)
"""
from __future__ import annotations
import dataclasses
import os
from typing import Mapping
@dataclasses.dataclass(frozen=True)
class VizConfig:
"""Frozen snapshot of all data_only_viz environment configuration.
Construct with ``VizConfig.from_env()`` inside ``__init__`` or the
consuming method — never at module import time.
"""
# ---- MediaPipe / video -----------------------------------------------
mediapipe_delegate: str = "gpu"
video_rotate: str = "none"
# ---- Pose discrimination thresholds ----------------------------------
pose_ghost_min_visible: int = 10
pose_ghost_min_conf: float = 0.5
pose_hand_min_visible: int = 15
pose_face_min_visible: int = 50
pose_nms_iou: float = 0.7
# ---- Pose filter chain -----------------------------------------------
pose_filter: str | None = None
pose_filter_face: str | None = None
pose_filter_hand: str | None = None
# ---- Finger piano / strike -------------------------------------------
finger_piano: bool = False
finger_strike_vel: float = 0.02
finger_strike_refractory_ms: float = 120.0
finger_source: str = "auto"
finger_debug: bool = False
# ---- Pinch detection -------------------------------------------------
pinch_enable: bool = False
pinch_ratio_on: float = 0.50
pinch_ratio_off: float = 0.65
pinch_refractory_ms: float = 250.0
pinch_margin: float = 0.05
pinch_ext_ratio: float = 1.35
pinch_ext_min: int = 1
pinch_debounce_frames: int = 3
# ---- Hand display / gesture ------------------------------------------
hand_conf_min: float = 0.45
hand_persist_frames: int = 3
hand_persist_grace: int = 2
hand_swap_lr: bool = False
hand_near_min: float = 0.10
hand_near_off: float = 0.08
hand_hold_frames: int = 2
hand_face_min: float = 0.5
arkit_bone_max: float = 0.5
arkit_full_skeleton: bool = True
# ---- HUD -------------------------------------------------------------
viz_hud: bool = False
# Source-bundle keyboard shortcuts (wxcvbn): OFF by default — a stray
# keypress during a matrix performance switches the audio scene
# (user-disabled live 2026-07-02). VIZ_SOURCE_KEYS=1 re-enables.
viz_source_keys: bool = False
# Audio-scene row (qsdfghjkl): same perf-safety gate, same date. The
# special m key (matrix mode: scene stop + openpos viz) stays active.
viz_audio_keys: bool = False
# Fixed viz mode at boot (a viz_mode_names entry, e.g. "voronoi").
# "auto" = historical behavior (mode 0 + auto-openpos when persons
# appear). A fixed mode also DISABLES the auto-openpos timer so it is
# truly always on (user request live 2026-07-02: t/voronoi permanent).
viz_default_mode: str = "auto"
# Approximate body-skeleton stroke width in pixels (multi-pass offsets;
# Metal lines are 1px). 1 = thin historical look.
skel_line_width: int = 4
# ---- iPhone OSC ------------------------------------------------------
iphone_osc_port: int = 57128
# ---- Concert / ARKit -------------------------------------------------
concert_mirror: bool = True
# ---- ICP LiDAR fusion ------------------------------------------------
icp_fusion: bool = False
icp_lidar_host: str | None = None
icp_lidar_port: int = 5500
icp_lidar_extrinsic: str | None = None
# ---- Multi-HMR -------------------------------------------------------
multihmr_backend: str = "pytorch"
multihmr_loop_fps: float = 30.0
multihmr_autocast: bool = False
multihmr_remote_host: str = "192.168.0.175"
multihmr_remote_port: int = 57140
multihmr_remote_jpeg: bool = True
multihmr_remote_jpeg_quality: int = 80
multihmr_remote_async: bool = True
multihmr_reid: str = "dino"
multihmr_reid_alpha: float = 0.5
# ---- CoreML ----------------------------------------------------------
coreml_mlpackage: str | None = None
coreml_compute_units: str = ""
# ---- OSC destinations ------------------------------------------------
avbody_host: str = "127.0.0.1"
vdmx_osc_host: str | None = None
vdmx_osc_port: int = 1234
# ---- main.py worker selection ----------------------------------------
av_live_mediapipe: bool = True
av_live_apple_vision: bool = True
av_live_coreml: bool = True
av_live_detrpose: bool = False
av_live_parallel_pose: str = "both"
mesh_rig: bool = True
# ------------------------------------------------------------------
# Factory
# ------------------------------------------------------------------
@classmethod
def from_env(cls, environ: Mapping[str, str] | None = None) -> "VizConfig":
"""Build a VizConfig by reading the given mapping (default: os.environ).
Numeric conversion errors fall back to the field default silently.
Bool conventions vary by field to match existing code exactly:
- _bool_std: not in ("0","","false","False") → True (standard)
- _bool_ne0: != "0" → True (empty string is True; None uses default)
- _bool_eq1: == "1" only (all other values → False)
- _bool_flag: in ("1","true","yes","on") → True (multihmr_remote style)
"""
env: Mapping[str, str] = environ if environ is not None else os.environ
_FALSE_STD = frozenset(("0", "", "false", "False"))
def _bool_std(name: str, default: bool) -> bool:
v = env.get(name)
if v is None:
return default
return v not in _FALSE_STD
def _bool_ne0(name: str, default: bool) -> bool:
"""Matches `env.get(name, "1") != "0"` style."""
v = env.get(name)
if v is None:
return default
return v != "0"
def _bool_eq1(name: str) -> bool:
"""Matches `env.get(name, "0") == "1"` style — only "1" is True."""
return env.get(name, "") == "1"
def _bool_flag(name: str, default: bool) -> bool:
"""Matches multihmr_remote _env_flag: in ("1","true","yes","on")."""
v = env.get(name)
if v is None:
return default
return v.strip().lower() in ("1", "true", "yes", "on")
def _int(name: str, default: int) -> int:
v = env.get(name)
if v is None:
return default
try:
return int(v)
except (ValueError, TypeError):
return default
def _float(name: str, default: float) -> float:
v = env.get(name)
if v is None:
return default
try:
return float(v)
except (ValueError, TypeError):
return default
def _str(name: str, default: str | None = None) -> str | None:
v = env.get(name)
return v if v is not None else default
def _str_lower(name: str, default: str) -> str:
v = env.get(name)
return v.lower() if v is not None else default
return cls(
mediapipe_delegate=_str_lower("MEDIAPIPE_DELEGATE", "gpu"),
video_rotate=_str_lower("VIDEO_ROTATE", "none"),
pose_ghost_min_visible=_int("POSE_GHOST_MIN_VISIBLE", 10),
pose_ghost_min_conf=_float("POSE_GHOST_MIN_CONF", 0.5),
pose_hand_min_visible=_int("POSE_HAND_MIN_VISIBLE", 15),
pose_face_min_visible=_int("POSE_FACE_MIN_VISIBLE", 50),
pose_nms_iou=_float("POSE_NMS_IOU", 0.7),
pose_filter=_str("POSE_FILTER"),
pose_filter_face=_str("POSE_FILTER_FACE"),
pose_filter_hand=_str("POSE_FILTER_HAND"),
finger_piano=_bool_std("FINGER_PIANO", False),
finger_strike_vel=_float("FINGER_STRIKE_VEL", 0.02),
finger_strike_refractory_ms=_float("FINGER_STRIKE_REFRACTORY_MS", 120.0),
finger_source=_str_lower("FINGER_SOURCE", "auto"),
finger_debug=_bool_std("FINGER_DEBUG", False),
pinch_enable=_bool_std("PINCH_ENABLE", False),
pinch_ratio_on=_float("PINCH_RATIO_ON", 0.50),
pinch_ratio_off=_float("PINCH_RATIO_OFF", 0.65),
pinch_refractory_ms=_float("PINCH_REFRACTORY_MS", 250.0),
pinch_margin=_float("PINCH_MARGIN", 0.05),
pinch_ext_ratio=_float("PINCH_EXT_RATIO", 1.35),
pinch_ext_min=_int("PINCH_EXT_MIN", 1),
pinch_debounce_frames=_int("PINCH_DEBOUNCE_FRAMES", 3),
hand_conf_min=_float("HAND_CONF_MIN", 0.45),
hand_persist_frames=_int("HAND_PERSIST_FRAMES", 3),
hand_persist_grace=_int("HAND_PERSIST_GRACE", 2),
hand_swap_lr=_bool_std("HAND_SWAP_LR", False),
hand_near_min=_float("HAND_NEAR_MIN", 0.10),
hand_near_off=_float("HAND_NEAR_OFF", 0.08),
hand_hold_frames=_int("HAND_HOLD_FRAMES", 2),
hand_face_min=_float("HAND_FACE_MIN", 0.5),
arkit_bone_max=_float("ARKIT_BONE_MAX", 0.5),
# ARKIT_FULL_SKELETON uses != "0" convention (empty = True)
arkit_full_skeleton=_bool_ne0("ARKIT_FULL_SKELETON", True),
viz_hud=_bool_std("VIZ_HUD", False),
viz_source_keys=_bool_std("VIZ_SOURCE_KEYS", False),
viz_audio_keys=_bool_std("VIZ_AUDIO_KEYS", False),
viz_default_mode=_str("VIZ_DEFAULT_MODE", "auto"),
skel_line_width=_int("SKEL_LINE_WIDTH", 4),
iphone_osc_port=_int("IPHONE_OSC_PORT", 57128),
concert_mirror=_bool_ne0("CONCERT_MIRROR", True),
# ICP_FUSION / MULTIHMR_AUTOCAST use strict "1" convention
icp_fusion=_bool_eq1("ICP_FUSION"),
icp_lidar_host=_str("ICP_LIDAR_HOST"),
icp_lidar_port=_int("ICP_LIDAR_PORT", 5500),
icp_lidar_extrinsic=_str("ICP_LIDAR_EXTRINSIC"),
multihmr_backend=(_str("MULTIHMR_BACKEND", "pytorch") or "pytorch").strip().lower(),
multihmr_loop_fps=_float("MULTIHMR_LOOP_FPS", 30.0),
multihmr_autocast=_bool_eq1("MULTIHMR_AUTOCAST"),
multihmr_remote_host=_str("MULTIHMR_REMOTE_HOST", "192.168.0.175") or "192.168.0.175",
multihmr_remote_port=_int("MULTIHMR_REMOTE_PORT", 57140),
# MULTIHMR_REMOTE_JPEG/ASYNC use _env_flag convention
multihmr_remote_jpeg=_bool_flag("MULTIHMR_REMOTE_JPEG", True),
multihmr_remote_jpeg_quality=_int("MULTIHMR_REMOTE_JPEG_QUALITY", 80),
multihmr_remote_async=_bool_flag("MULTIHMR_REMOTE_ASYNC", True),
multihmr_reid=_str_lower("MULTIHMR_REID", "dino"),
multihmr_reid_alpha=_float("MULTIHMR_REID_ALPHA", 0.5),
coreml_mlpackage=_str("COREML_MLPACKAGE"),
coreml_compute_units=(_str("COREML_COMPUTE_UNITS", "") or "").strip().lower(),
avbody_host=_str("AVBODY_HOST", "127.0.0.1") or "127.0.0.1",
vdmx_osc_host=_str("VDMX_OSC_HOST"),
vdmx_osc_port=_int("VDMX_OSC_PORT", 1234),
# AV_LIVE_* use != "0" convention (enabled by default when unset)
av_live_mediapipe=_bool_ne0("AV_LIVE_MEDIAPIPE", True),
av_live_apple_vision=_bool_ne0("AV_LIVE_APPLE_VISION", True),
av_live_coreml=_bool_ne0("AV_LIVE_COREML", True),
av_live_detrpose=_bool_eq1("AV_LIVE_DETRPOSE"),
av_live_parallel_pose=_str("AV_LIVE_PARALLEL_POSE", "both") or "both",
mesh_rig=_bool_ne0("MESH_RIG", True),
)
+583
View File
@@ -0,0 +1,583 @@
"""Pipeline pose 100% natif M5 : AVFoundation + Vision + CoreML (ANE).
Architecture (zero-copy, ANE-first) :
AVCaptureSession (live webcam)
| delegate Python (Obj-C protocol via pyobjc)
v
CMSampleBuffer -> CVPixelBuffer BGRA
| VNImageRequestHandler
v
VNCoreMLRequest (YOLO11n-pose .mlpackage, ANE)
| parse keypoints
v
IoUTracker (Hungarian) + SkeletonFilter (One Euro)
|
v
state.persons_body / persons_body_ids / last_webcam_jpeg
Pourquoi cette stack vs MediaPipe / DETRPose ?
- Decodage video AVFoundation : zero-copy, hardware-accelerated.
- Vision wrappe le CVPixelBuffer en MLFeatureValue sans realloc.
- YOLO11n-pose tient sur l'Apple Neural Engine M5 (<8 ms / frame
en FP16) ; CPU/GPU sont laisses libres pour Metal/SuperCollider.
- Pas de cv2 dans le hot path : encodage JPEG via CIImage +
CGImageDestination, qui passe aussi par les codecs hardware.
API publique :
CoreMLPoseWorker(state).start() # thread daemon
CoreMLPoseWorker(state).stop()
CoreMLPoseWorker.is_available() # @staticmethod, check .mlpackage
Frictions Python 3.14 :
- `pyobjc-framework-Vision` n'est PAS publie sur PyPI au moment de
cette ecriture. On contourne via `objc.loadBundle()` qui charge
Vision.framework directement depuis /System/Library/Frameworks.
- Idem pour CoreML : on utilise `objc.loadBundle()`. Les classes
MLModel / VNCoreMLRequest deviennent disponibles dans le namespace
global du module.
- Le delegate AVCaptureVideoDataOutputSampleBufferDelegate est un
PROTOCOLE Objective-C ; pyobjc accepte qu'on l'implemente en
declarant le selector `captureOutput:didOutputSampleBuffer:fromConnection:`
sur une NSObject — il sera resolu par duck typing au runtime.
"""
from __future__ import annotations
import logging
import os
import threading
import time
from pathlib import Path
from typing import TYPE_CHECKING
import objc
from Foundation import NSObject, NSURL
from .euro_filter import SkeletonFilter
from .state import PoseKp, State
from .tracker import IoUTracker
if TYPE_CHECKING:
pass
LOG = logging.getLogger("coreml_pose")
CACHE_DIR = Path.home() / ".cache" / "av-live-coreml"
YOLO_MLPACKAGE = CACHE_DIR / "yolo11n-pose.mlpackage"
# ---------------------------------------------------------------------------
# Chargement Vision / CoreML / AVFoundation via objc.loadBundle
# ---------------------------------------------------------------------------
_FRAMEWORKS_LOADED = False
_NS: dict = {}
def _load_frameworks() -> dict:
"""Charge Vision + CoreML + AVFoundation dans un namespace partage.
On le fait une seule fois ; les classes Obj-C sont enregistrees
globalement par le runtime, mais on garde un dict pour acceder
aux symboles sans polluer le module."""
global _FRAMEWORKS_LOADED
if _FRAMEWORKS_LOADED:
return _NS
objc.loadBundle("Vision", _NS,
"/System/Library/Frameworks/Vision.framework")
objc.loadBundle("CoreML", _NS,
"/System/Library/Frameworks/CoreML.framework")
objc.loadBundle("AVFoundation", _NS,
"/System/Library/Frameworks/AVFoundation.framework")
objc.loadBundle("CoreMedia", _NS,
"/System/Library/Frameworks/CoreMedia.framework")
_FRAMEWORKS_LOADED = True
return _NS
# ---------------------------------------------------------------------------
# Helpers : encodage JPEG via Quartz (CIImage -> CGImageDestination)
# ---------------------------------------------------------------------------
def _pixelbuffer_to_jpeg(pixel_buffer, quality: float = 0.7) -> bytes | None:
"""Encode un CVPixelBuffer en JPEG via le pipeline hardware Quartz."""
try:
from Quartz import CIImage, CIContext
from Foundation import NSMutableData
from CoreFoundation import CFDictionaryCreate, kCFTypeDictionaryKeyCallBacks, kCFTypeDictionaryValueCallBacks # noqa: F401
except Exception: # noqa: BLE001
return None
try:
ci = CIImage.imageWithCVPixelBuffer_(pixel_buffer)
if ci is None:
return None
ctx = CIContext.context()
# On utilise jpegRepresentationOfImage:colorSpace:options: (macOS 10.12+)
from Quartz import CGColorSpaceCreateDeviceRGB
cs = CGColorSpaceCreateDeviceRGB()
data = ctx.JPEGRepresentationOfImage_colorSpace_options_(
ci, cs, {"kCGImageDestinationLossyCompressionQuality": quality})
if data is None:
return None
return bytes(data)
except Exception: # noqa: BLE001
return None
# ---------------------------------------------------------------------------
# Delegate AVCaptureVideoDataOutput
# ---------------------------------------------------------------------------
class _CaptureDelegate(NSObject):
"""Delegate Obj-C qui recoit chaque frame webcam.
Implementation du protocole AVCaptureVideoDataOutputSampleBufferDelegate :
pyobjc resout le selector par signature, pas besoin de declarer
formellement le protocole."""
def initWithWorker_(self, worker): # noqa: N802
self = objc.super(_CaptureDelegate, self).init()
if self is None:
return None
self._worker = worker
return self
# Signature: -(void)captureOutput:(AVCaptureOutput*)output
# didOutputSampleBuffer:(CMSampleBufferRef)sampleBuffer
# fromConnection:(AVCaptureConnection*)connection
def captureOutput_didOutputSampleBuffer_fromConnection_( # noqa: N802
self, output, sample_buffer, connection):
self._worker._on_frame(sample_buffer)
# ---------------------------------------------------------------------------
# Queue GCD serielle pour le delegate AVFoundation
# ---------------------------------------------------------------------------
def _make_serial_queue(label: str):
"""Cree une dispatch_queue_create(label, DISPATCH_QUEUE_SERIAL) via objc.
pyobjc expose les fonctions GCD via le module `objc`. La signature
Python est : dispatch_queue_create(label_bytes, None) -> dispatch_queue_t.
"""
try:
from libdispatch import dispatch_queue_create # type: ignore
return dispatch_queue_create(label.encode("utf-8"), None)
except ImportError:
pass
# Fallback : utiliser le runtime Obj-C via ctypes pour appeler
# dispatch_queue_create. Toutefois pyobjc 11+ expose ces fonctions
# via Foundation / objc directement.
try:
import Foundation # noqa: F401
# pyobjc enregistre dispatch_queue_create dans `objc` namespace
from objc import _objc # type: ignore # noqa: F401
except Exception: # noqa: BLE001
pass
# Dernier recours : ctypes wrapper sur libdispatch (toujours present sur macOS)
import ctypes
libdispatch = ctypes.CDLL("/usr/lib/system/libdispatch.dylib")
libdispatch.dispatch_queue_create.restype = ctypes.c_void_p
libdispatch.dispatch_queue_create.argtypes = [ctypes.c_char_p, ctypes.c_void_p]
q = libdispatch.dispatch_queue_create(label.encode("utf-8"), None)
return q
# ===========================================================================
# Worker principal
# ===========================================================================
class CoreMLPoseWorker:
"""Worker pose natif M5 — AVFoundation + Vision + CoreML (YOLO11n-pose).
start() peut etre appele depuis n'importe quel thread tant que la run
loop NSApplication tourne (le delegate AVFoundation est dispatche sur
une queue serielle GCD dediee, pas sur le main thread)."""
@staticmethod
def is_available() -> bool:
if not YOLO_MLPACKAGE.exists():
return False
try:
_load_frameworks()
return True
except Exception: # noqa: BLE001
return False
def __init__(
self,
state: State,
target_fps: float = 30.0,
num_persons: int = 4,
score_thresh: float = 0.45,
) -> None:
self.state = state
self.target_fps = float(target_fps)
self.num_persons = int(num_persons)
self.score_thresh = float(score_thresh)
self._frame_period = 1.0 / max(1.0, self.target_fps)
self._last_emit = 0.0
self._tracker = IoUTracker(iou_threshold=0.20, max_miss=10)
self._smooth = SkeletonFilter(min_cutoff=1.2, beta=0.06)
# Refs Obj-C
self._session = None
self._input = None
self._output = None
self._delegate = None
self._queue = None
self._vn_model = None
self._frame_size: tuple[int, int] = (640, 480)
# Metriques
self._n_frames = 0
self._n_emitted = 0
self._sum_infer_ms = 0.0
self._started = False
# ------------------------------------------------------------------
def start(self) -> None:
if self._started:
return
try:
self._setup_pipeline()
except Exception as e: # noqa: BLE001
LOG.error("setup pipeline echoue : %s", e)
return
self._started = True
# startRunning peut bloquer brievement — on le fait dans un thread
# daemon pour ne pas geler le main thread AppKit.
threading.Thread(
target=self._start_session, name="coreml-session-start",
daemon=True).start()
def stop(self) -> None:
self._started = False
try:
if self._session is not None:
self._session.stopRunning()
except Exception: # noqa: BLE001
pass
LOG.info("coreml-pose stop — %d frames, %d emises, %.1f ms moy inference",
self._n_frames, self._n_emitted,
(self._sum_infer_ms / max(1, self._n_emitted)))
# ------------------------------------------------------------------
def _setup_pipeline(self) -> None:
ns = _load_frameworks()
AVCaptureSession = ns["AVCaptureSession"]
AVCaptureDevice = ns["AVCaptureDevice"]
AVCaptureDeviceInput = ns["AVCaptureDeviceInput"]
AVCaptureVideoDataOutput = ns["AVCaptureVideoDataOutput"]
AVMediaTypeVideo = ns["AVMediaTypeVideo"]
MLModel = ns["MLModel"]
MLModelConfiguration = ns["MLModelConfiguration"]
VNCoreMLModel = ns["VNCoreMLModel"]
# 1) Charger le modele
cfg = MLModelConfiguration.alloc().init()
try:
cfg.setComputeUnits_(0) # MLComputeUnitsAll
except Exception: # noqa: BLE001
pass
url = NSURL.fileURLWithPath_(str(YOLO_MLPACKAGE))
ml_model, err = MLModel.modelWithContentsOfURL_configuration_error_(
url, cfg, None)
if ml_model is None:
raise RuntimeError(f"MLModel load: {err}")
vn_model, err = VNCoreMLModel.modelForMLModel_error_(ml_model, None)
if vn_model is None:
raise RuntimeError(f"VNCoreMLModel wrap: {err}")
self._vn_model = vn_model
LOG.info("CoreML pose worker — modele %s charge (computeUnits=all=ANE+GPU+CPU)",
YOLO_MLPACKAGE.name)
# 2) Session capture
session = AVCaptureSession.alloc().init()
try:
session.setSessionPreset_("AVCaptureSessionPreset640x480")
except Exception: # noqa: BLE001
pass
device = AVCaptureDevice.defaultDeviceWithMediaType_(AVMediaTypeVideo)
if device is None:
raise RuntimeError("aucune camera (AVCaptureDevice defaultDevice)")
input_, err = AVCaptureDeviceInput.deviceInputWithDevice_error_(
device, None)
if input_ is None:
raise RuntimeError(f"AVCaptureDeviceInput: {err}")
if not session.canAddInput_(input_):
raise RuntimeError("session.canAddInput == False")
session.addInput_(input_)
self._input = input_
# 3) Output BGRA
output = AVCaptureVideoDataOutput.alloc().init()
BGRA = 0x42475241 # kCVPixelFormatType_32BGRA
try:
# cle officielle = kCVPixelBufferPixelFormatTypeKey
from Quartz import kCVPixelBufferPixelFormatTypeKey
output.setVideoSettings_({kCVPixelBufferPixelFormatTypeKey: BGRA})
except Exception: # noqa: BLE001
# Fallback : nom de cle litteral
output.setVideoSettings_({"PixelFormatType": BGRA})
output.setAlwaysDiscardsLateVideoFrames_(True)
if not session.canAddOutput_(output):
raise RuntimeError("session.canAddOutput == False")
session.addOutput_(output)
self._output = output
# 4) Delegate + queue serielle GCD
delegate = _CaptureDelegate.alloc().initWithWorker_(self)
queue = _make_serial_queue("av-live.coreml.pose")
output.setSampleBufferDelegate_queue_(delegate, queue)
self._delegate = delegate
self._queue = queue
self._session = session
def _start_session(self) -> None:
if self._session is None:
return
LOG.info("AVCaptureSession.startRunning ...")
self._session.startRunning()
LOG.info("session running — fps_target=%.0f num_persons=%d thresh=%.2f",
self.target_fps, self.num_persons, self.score_thresh)
# ------------------------------------------------------------------
# Callback delegate — appele par GCD sur la queue serielle "coreml.pose"
# ------------------------------------------------------------------
def _on_frame(self, sample_buffer) -> None:
self._n_frames += 1
now = time.monotonic()
if now - self._last_emit < self._frame_period:
return
self._last_emit = now
t0 = time.monotonic()
try:
self._process_frame(sample_buffer)
except Exception as e: # noqa: BLE001
LOG.warning("process_frame: %s", e)
return
self._sum_infer_ms += (time.monotonic() - t0) * 1000.0
self._n_emitted += 1
def _process_frame(self, sample_buffer) -> None:
ns = _NS
VNImageRequestHandler = ns["VNImageRequestHandler"]
VNCoreMLRequest = ns["VNCoreMLRequest"]
# CMSampleBufferGetImageBuffer renvoie un CVPixelBuffer
from Quartz import (
CMSampleBufferGetImageBuffer,
CVPixelBufferGetWidth,
CVPixelBufferGetHeight,
)
pixel_buffer = CMSampleBufferGetImageBuffer(sample_buffer)
if pixel_buffer is None:
return
w = CVPixelBufferGetWidth(pixel_buffer)
h = CVPixelBufferGetHeight(pixel_buffer)
self._frame_size = (w, h)
# Construction du request synchrone : on capture les resultats
# dans une closure puis on perform().
results_box: list = []
def _handler(request, error):
r = request.results()
if r is not None:
# On copie les pointeurs Obj-C immediatement
for obs in r:
results_box.append(obs)
request = VNCoreMLRequest.alloc().initWithModel_completionHandler_(
self._vn_model, _handler)
try:
# Image crop & scale : on laisse Vision faire le scaleFit ;
# YOLO11n-pose attend 640x640.
request.setImageCropAndScaleOption_(1) # VNImageCropAndScaleOptionScaleFit
except Exception: # noqa: BLE001
pass
handler = VNImageRequestHandler.alloc().initWithCVPixelBuffer_options_(
pixel_buffer, {})
ok, err = handler.performRequests_error_([request], None)
if not ok:
LOG.debug("perform request error: %s", err)
return
# Parsing : Vision retourne soit VNRecognizedPointsObservation
# (modele "pose" classique), soit VNCoreMLFeatureValueObservation
# (modele YOLO converti par ultralytics — tenseur brut).
bodies = self._parse_results(results_box, w, h)
# Tracking + lissage
ids = self._tracker.update(bodies)
t_now = time.monotonic()
bodies_smooth = []
for i, kps in enumerate(bodies):
pid = ids[i] if i < len(ids) else -1
if pid < 0:
bodies_smooth.append(kps)
continue
out = []
for k, kp in enumerate(kps):
sx, sy, sz = self._smooth.smooth(pid, k, kp.x, kp.y, kp.z, t_now)
out.append(PoseKp(x=sx, y=sy, z=sz, c=kp.c))
bodies_smooth.append(out)
# JPEG webcam (best effort)
jpg = _pixelbuffer_to_jpeg(pixel_buffer, quality=0.65)
with self.state.lock():
self.state.persons_body = bodies_smooth
self.state.persons_body_ids = ids
# On vide face/hands : YOLO11n-pose ne les fournit pas.
self.state.persons_face = []
self.state.persons_hands = []
self.state.face_present = False
self.state.hands_present = False
if bodies_smooth:
self.state.body_present = True
# Compat single-person : 17 kp dans body_kp[0..17],
# le reste reste a zero.
first = bodies_smooth[0]
for k in range(33):
self.state.body_kp[k] = (
first[k] if k < 17 and k < len(first) else PoseKp())
for k in range(17):
self.state.pose_kp[k] = (
first[k] if k < len(first) else PoseKp())
else:
self.state.body_present = False
self.state.pose_count = len(bodies_smooth)
self.state.pose_last_t = t_now
if jpg:
self.state.last_webcam_jpeg = jpg
# ------------------------------------------------------------------
# Parsing des observations Vision -> list[list[PoseKp]]
# ------------------------------------------------------------------
def _parse_results(self, results, w: int, h: int) -> list[list[PoseKp]]:
"""Convertit les VN*Observation en keypoints normalises 0..1.
Deux formats possibles selon la conversion CoreML :
(a) VNHumanBodyPoseObservation : API Vision native, 17 kp pre-parses
avec joint names (CGPoint normalises + confidence).
(b) VNCoreMLFeatureValueObservation : tenseur brut YOLO output
(post-NMS si nms=True dans l'export ultralytics) — format
(N, 56) = [cx, cy, bw, bh, conf, kp_x1, kp_y1, kp_v1, ..., x17, y17, v17]
en pixels du modele (640x640).
"""
bodies: list[list[PoseKp]] = []
if not results:
return bodies
# Cas (a) : Vision pre-parse
first = results[0]
cls_name = first.className() if hasattr(first, "className") else ""
if "BodyPose" in cls_name or "HumanBodyPose" in cls_name:
for obs in results[: self.num_persons * 2]:
conf = float(obs.confidence()) if hasattr(obs, "confidence") else 1.0
if conf < self.score_thresh:
continue
kps = self._extract_body_pose_obs(obs)
if kps:
bodies.append(kps)
return bodies[: self.num_persons]
# Cas (b) : tenseur YOLO brut
for obs in results:
try:
fv = obs.featureValue()
except Exception: # noqa: BLE001
continue
arr = fv.multiArrayValue() if fv is not None else None
if arr is None:
continue
parsed = self._parse_yolo_tensor(arr)
bodies.extend(parsed)
# tri par conf decroissant + cap
bodies.sort(key=lambda kps: -max((k.c for k in kps), default=0.0))
return bodies[: self.num_persons]
def _extract_body_pose_obs(self, obs) -> list[PoseKp]:
"""Extrait 17 kp d'un VNHumanBodyPoseObservation (ordre COCO).
L'API Vision retourne les points via recognizedPointsForGroupKey:error:
OU recognizedPointForJointName:error:. On utilise l'ordre COCO :
nose, leye, reye, lear, rear, lsh, rsh, lel, rel, lwr, rwr,
lhi, rhi, lkn, rkn, lan, ran.
"""
joint_names = [
"nose", "left_eye", "right_eye", "left_ear", "right_ear",
"left_shoulder", "right_shoulder",
"left_elbow", "right_elbow",
"left_wrist", "right_wrist",
"left_hip", "right_hip",
"left_knee", "right_knee",
"left_ankle", "right_ankle",
]
out: list[PoseKp] = []
for name in joint_names:
try:
pt, _err = obs.recognizedPointForJointName_error_(name, None)
except Exception: # noqa: BLE001
pt = None
if pt is None:
out.append(PoseKp())
continue
try:
loc = pt.location()
cf = float(pt.confidence())
# Vision : y origin en bas, on flip pour aligner avec image y-down
out.append(PoseKp(x=float(loc.x), y=1.0 - float(loc.y),
z=0.0, c=cf))
except Exception: # noqa: BLE001
out.append(PoseKp())
return out
def _parse_yolo_tensor(self, ml_array) -> list[list[PoseKp]]:
"""Parse un MLMultiArray YOLO11n-pose post-NMS -> bodies.
Shape attendu apres export ultralytics nms=True : (1, N, 56)
avec 56 = box(4) + conf(1) + 17 * (x,y,v) = 4+1+51.
Sans NMS : (1, 56, M) transpose. On gere les deux."""
try:
shape = list(ml_array.shape)
dims = [int(s) for s in shape]
except Exception: # noqa: BLE001
return []
# Acceder aux donnees via dataPointer() est risque ; on passe par
# itemAtIndexedSubscript: ou la conversion en numpy via .float32 view.
try:
import numpy as np
# MLMultiArray expose 'dataPointer' (raw void*) — on prefere
# construire un buffer Python via getBytes:length: indirect.
count = 1
for d in dims:
count *= d
buf = ml_array.dataPointer()
# pyobjc renvoie un objc.varlist ou un voidp — on tente numpy.frombuffer
arr = np.frombuffer(buf, dtype=np.float32, count=count).reshape(dims)
except Exception: # noqa: BLE001
return []
# Squeeze batch
while arr.ndim > 2 and arr.shape[0] == 1:
arr = arr[0]
if arr.ndim != 2:
return []
# Si shape (56, M) au lieu de (M, 56) -> transpose
if arr.shape[0] == 56 and arr.shape[1] != 56:
arr = arr.T
elif arr.shape[1] != 56 and arr.shape[0] != 56:
return []
bodies: list[list[PoseKp]] = []
for row in arr:
conf = float(row[4])
if conf < self.score_thresh:
continue
kps: list[PoseKp] = []
for k in range(17):
kx = float(row[5 + k * 3 + 0])
ky = float(row[5 + k * 3 + 1])
kv = float(row[5 + k * 3 + 2])
# Coords en pixels du modele 640x640 -> normaliser
kps.append(PoseKp(x=kx / 640.0, y=ky / 640.0, z=0.0, c=kv))
bodies.append(kps)
return bodies
+356
View File
@@ -0,0 +1,356 @@
"""DETRPose multi-personne — worker alternatif a MediaPipe Multi.
DETRPose (2025, S. Janampa) : premier transformer end-to-end temps reel
pour la detection de pose multi-personne. Sortie COCO 17 keypoints,
multi-personne nativement (queries DETR), entraine 5 a 10x plus vite
que ses concurrents grace a un denoising base sur OKS.
- Paper : https://arxiv.org/abs/2506.13027
- Repo : https://github.com/SebastianJanampa/DETRPose
- Weights : https://github.com/SebastianJanampa/DETRPose/releases/tag/model_weights
- Demo HF : https://huggingface.co/spaces/SebasJanampa/DETRPose
============================================================================
INSTALLATION (manuelle — DETRPose n'est PAS pip-installable)
============================================================================
Le repo n'a pas de setup.py / pyproject.toml — il faut le cloner et
l'ajouter au PYTHONPATH. Procedure :
# 1. Cloner dans le cache utilisateur
mkdir -p ~/.cache/av-live-detrpose
cd ~/.cache/av-live-detrpose
git clone https://github.com/SebastianJanampa/DETRPose.git
# 2. Dependances Python (sans numpy<1.24 — on garde le numpy du venv)
cd ~/Documents/Projets/AV-Live/data_only_viz
uv pip install torch torchvision transformers omegaconf cloudpickle \
pycocotools xtcocotools scipy calflops iopath
# 3. Telecharger un checkpoint (N = nano, ~16 MB, le plus rapide)
cd ~/.cache/av-live-detrpose
curl -L -o detrpose_hgnetv2_n.pth \
https://github.com/SebastianJanampa/DETRPose/releases/download/model_weights/detrpose_hgnetv2_n.pth
Sinon, le worker logge une erreur claire et main.py retombe sur MediaPipe.
============================================================================
DEVICE
============================================================================
DETRPose s'appuie sur PyTorch standard — compatible MPS (Apple Silicon),
CUDA, CPU. Pas de couche custom CUDA-only. On essaie MPS en premier, on
retombe sur CPU si erreur. Inference ~30-50 ms sur M5 avec le modele N.
============================================================================
FORMAT DE SORTIE
============================================================================
COCO 17 keypoints, ordre standard :
0: nose, 1-2: eyes, 3-4: ears, 5-6: shoulders, 7-8: elbows,
9-10: wrists, 11-12: hips, 13-14: knees, 15-16: ankles.
Le state AV-Live attend `persons_body` = list[list[PoseKp]] ou chaque
PoseKp a x, y normalises 0..1. DETRPose ne fournit pas la profondeur z
(modele 2D pur) ni la visibilite par keypoint — on met z=0 et c=score
global de la personne.
"""
from __future__ import annotations
import logging
import os
import sys
import threading
import time
from pathlib import Path
from .state import PoseKp, State
LOG = logging.getLogger("detrpose")
CACHE_DIR = Path.home() / ".cache" / "av-live-detrpose"
REPO_DIR = CACHE_DIR / "DETRPose"
# Modele N (nano) par defaut : 16 MB, le plus rapide.
DEFAULT_MODEL_SIZE = "n"
_VALID_SIZES = {"n", "s", "l"}
DEFAULT_CKPT = CACHE_DIR / f"detrpose_hgnetv2_{DEFAULT_MODEL_SIZE}.pth"
DEFAULT_CONFIG_REL = f"configs/detrpose/detrpose_hgnetv2_{DEFAULT_MODEL_SIZE}.py"
def _check_install() -> tuple[bool, str]:
"""Verifie que le repo et le checkpoint sont presents. Renvoie (ok, msg)."""
if not REPO_DIR.exists():
return False, (
f"DETRPose repo absent ({REPO_DIR}). Voir docstring du module "
"pour la procedure d'install."
)
if not (REPO_DIR / DEFAULT_CONFIG_REL).exists():
return False, f"config manquante : {REPO_DIR / DEFAULT_CONFIG_REL}"
if not DEFAULT_CKPT.exists():
return False, f"checkpoint manquant : {DEFAULT_CKPT}"
return True, "ok"
def is_available() -> bool:
"""Test rapide : repo + checkpoint presents ET PyTorch importable."""
ok, _ = _check_install()
if not ok:
return False
try:
import torch # noqa: F401
except ImportError:
return False
return True
class DETRPoseWorker:
"""Worker multi-personne DETRPose (body only, 17 keypoints COCO).
Suit le meme contrat que MultiWorker : ecrit dans state.persons_body
et state.last_webcam_jpeg, thread daemon, stop() propre.
"""
def __init__(
self,
state: State,
camera_index: int = 0,
target_fps: float = 18.0,
num_persons: int = 4,
score_thresh: float = 0.5,
model_size: str = DEFAULT_MODEL_SIZE,
device: str = "auto",
) -> None:
self.state = state
self.camera_index = camera_index
self.period = 1.0 / max(1.0, target_fps)
self.num_persons = num_persons
self.score_thresh = score_thresh
self._configure_model_size(model_size)
self.device_pref = device
self._stop = threading.Event()
self._thread: threading.Thread | None = None
def start(self) -> None:
self._thread = threading.Thread(
target=self._run, name="detrpose", daemon=True)
self._thread.start()
def stop(self) -> None:
self._stop.set()
def _configure_model_size(self, size: str) -> None:
"""Validate and set model_size; raise ValueError for unknown sizes."""
if size not in _VALID_SIZES:
raise ValueError(
f"DETRPose model_size must be one of {sorted(_VALID_SIZES)}, got {size!r}"
)
self.model_size = size
# ------------------------------------------------------------------
# Chargement modele : on importe le repo DETRPose en ajoutant son
# dossier au sys.path, puis on suit le pattern de tools/inference/torch_inf.py.
# ------------------------------------------------------------------
def _load_model(self):
ok, msg = _check_install()
if not ok:
raise RuntimeError(msg)
if str(REPO_DIR) not in sys.path:
sys.path.insert(0, str(REPO_DIR))
# DETRPose utilise des chemins relatifs (configs/, src/) — il
# faut chdir dans le repo pour que les imports config marchent.
# On preserve le cwd appelant pour ne pas perturber le reste de l'app.
prev_cwd = os.getcwd()
try:
os.chdir(REPO_DIR)
import torch
from omegaconf import OmegaConf
# L'API d'instantiation hydra-like utilisee par DETRPose.
try:
from src.misc.lazy_config import instantiate # type: ignore
except ImportError:
from src.core import instantiate # type: ignore
cfg_path = f"configs/detrpose/detrpose_hgnetv2_{self.model_size}.py"
cfg = OmegaConf.load(cfg_path) if cfg_path.endswith(
".yaml") else _load_py_config(cfg_path)
ckpt_path = CACHE_DIR / f"detrpose_hgnetv2_{self.model_size}.pth"
ckpt = torch.load(ckpt_path, map_location="cpu")
state_dict = ckpt.get("model") or ckpt.get("ema", {}).get(
"module") or ckpt
model = instantiate(cfg.model)
model.load_state_dict(state_dict, strict=False)
try:
model = model.deploy()
except AttributeError:
pass
model.eval()
postprocessor = instantiate(cfg.postprocessor)
try:
postprocessor = postprocessor.deploy()
except AttributeError:
pass
device = self._pick_device(torch)
model = model.to(device)
LOG.info("DETRPose %s charge sur %s", self.model_size, device)
return model, postprocessor, device, torch
finally:
os.chdir(prev_cwd)
def _pick_device(self, torch):
pref = self.device_pref
if pref == "auto":
if torch.backends.mps.is_available():
return torch.device("mps")
if torch.cuda.is_available():
return torch.device("cuda:0")
return torch.device("cpu")
if pref == "mps" and not torch.backends.mps.is_available():
LOG.warning("MPS demande mais indisponible — fallback CPU")
return torch.device("cpu")
return torch.device(pref)
def _run(self) -> None:
try:
import cv2
import numpy as np
import torch
except ModuleNotFoundError as e:
LOG.error("deps manquantes : %s", e)
return
try:
model, postprocessor, device, _ = self._load_model()
except Exception as e: # noqa: BLE001
LOG.error("chargement DETRPose echoue : %s", e)
return
cap = cv2.VideoCapture(self.camera_index)
cap.set(cv2.CAP_PROP_FRAME_WIDTH, 640)
cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 480)
if not cap.isOpened():
LOG.error("camera index %d indisponible", self.camera_index)
return
LOG.info("camera ouverte (index %d)", self.camera_index)
# Tenseur d'input fixe 640x640 (cf torch_inf.py)
INPUT_SIZE = 640
mean = torch.tensor([0.485, 0.456, 0.406], device=device).view(1, 3, 1, 1)
std = torch.tensor([0.229, 0.224, 0.225], device=device).view(1, 3, 1, 1)
while not self._stop.is_set():
tA = time.monotonic()
ok, frame_bgr = cap.read()
if not ok or frame_bgr is None:
time.sleep(self.period)
continue
h, w = frame_bgr.shape[:2]
frame_rgb = cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2RGB)
# Preprocess : resize 640x640, [0,1], NCHW, normalisation ImageNet
img = cv2.resize(frame_rgb, (INPUT_SIZE, INPUT_SIZE))
tens = torch.from_numpy(img).to(device).float().permute(2, 0, 1)
tens = tens.unsqueeze(0) / 255.0
tens = (tens - mean) / std
orig_sizes = torch.tensor([[w, h]], device=device)
try:
with torch.no_grad():
outputs = model(tens)
scores, labels, keypoints = postprocessor(outputs, orig_sizes)
except Exception as e: # noqa: BLE001
LOG.warning("inference: %s", e)
time.sleep(self.period)
continue
# scores: (1, N), keypoints: (1, N, 17, 2) en pixels image originale
scores0 = scores[0].detach().cpu().numpy()
kps0 = keypoints[0].detach().cpu().numpy()
idx = scores0 > self.score_thresh
sel_scores = scores0[idx]
sel_kps = kps0[idx]
# Trier par score decroissant et limiter a num_persons
order = (-sel_scores).argsort()[: self.num_persons]
bodies: list[list[PoseKp]] = []
for i in order:
conf = float(sel_scores[i])
pts = sel_kps[i] # (17, 2) en pixels
kp_list = []
for kx, ky in pts:
kp_list.append(PoseKp(
x=float(kx) / max(1, w),
y=float(ky) / max(1, h),
z=0.0,
c=conf,
))
bodies.append(kp_list)
# Encode webcam JPEG pour overlay
ok2, jpg = cv2.imencode(".jpg", frame_bgr,
[int(cv2.IMWRITE_JPEG_QUALITY), 70])
jpg_bytes = bytes(jpg) if ok2 else None
with self.state.lock():
self.state.persons_body = bodies
# DETRPose ne fournit pas face/hands — on vide pour
# eviter que le renderer dessine des anciennes valeurs.
self.state.persons_face = []
self.state.persons_hands = []
self.state.face_present = False
self.state.hands_present = False
if bodies:
self.state.body_present = True
# Compat single-person : on remplit les 17 premiers
# slots du buffer body_kp (mediapipe en attend 33,
# le reste reste a zero — acceptable).
for k in range(33):
if k < 17 and k < len(bodies[0]):
self.state.body_kp[k] = bodies[0][k]
else:
self.state.body_kp[k] = PoseKp()
# On remplit aussi pose_kp[17] (legacy YOLO COCO).
for k in range(17):
self.state.pose_kp[k] = (
bodies[0][k] if k < len(bodies[0]) else PoseKp())
else:
self.state.body_present = False
self.state.pose_count = len(bodies)
self.state.pose_last_t = time.monotonic()
if jpg_bytes:
self.state.last_webcam_jpeg = jpg_bytes
dt = time.monotonic() - tA
if dt < self.period:
time.sleep(self.period - dt)
cap.release()
LOG.info("detrpose worker stopped")
def _load_py_config(path: str):
"""Charge une config DETRPose ecrite en .py (style detectron2/lazy)."""
from omegaconf import OmegaConf
# Les configs DETRPose sont des fichiers Python qui exposent un dict
# `model = LazyCall(...)`. On utilise le helper lazy_config si dispo.
# DETRPose exposes LazyConfig from src.core (see tools/inference/torch_inf.py);
# the older src.misc.lazy_config path is kept as a fallback. LazyConfig.load
# handles the config's relative imports (`from .include...`) that a raw exec
# cannot.
for _mod in ("src.core", "src.misc.lazy_config"):
try:
mod = __import__(_mod, fromlist=["LazyConfig"])
return mod.LazyConfig.load(path)
except (ImportError, AttributeError):
continue
# Fallback minimal : exec + recup des noms cles.
ns: dict = {}
with open(path) as f:
code = compile(f.read(), path, "exec")
exec(code, ns)
cfg = OmegaConf.create({
k: ns[k] for k in ("model", "postprocessor")
if k in ns
})
return cfg

Some files were not shown because too many files have changed in this diff Show More