@miadi/movement-conductor
v0.3.1
Published
Movement conducted into musical expression — one source, many listeners. Sensor streams become nine-channel frames; each conducted frame is heard simultaneously by Wekinator (/wek/inputs), by the score being recorded (a TLID-named take, corpus-compatible
Readme
@miadi/movement-conductor
Movement conducted into musical expression — one source, many listeners.
The phone's sensors are sections of an orchestra: each reports at its own
cadence. The conductor holds the latest frame and, at each beat of the chosen
tempo, gives it to every listener at once — the machine-listener (Wekinator,
/wek/inputs, nine float channels), the score being recorded, and live
meters. Recording and performing are one gesture heard by different
listeners.
Carries forward Miadi Chronicle Episode 083 (landbase movement capture to
Wekinator): the score format is line-per-packet JSONL with a summary sidecar,
field-compatible with the Episode 083 captures — old and new scores are one
corpus, and every capture in the chronicle can perform again through
ScoreReplaySource.
The frame contract
| Channels | Meaning | Unit | |----------|---------|------| | 1–3 | user acceleration (gravity removed), x y z | g | | 4–6 | rotation rate, x y z | rad/s | | 7–9 | attitude — roll, pitch, azimuth/yaw | rad |
Pieces
Conductor— oneMovementSource→ manyMovementSinks, with an optional tempo (fps) that holds each section's last note between beats.- Sources:
OscListenerSource(the wire — what the Episode 083 listening host was),ScoreReplaySource(the corpus as an instrument); the Android body lives in@miadi/termux-motion. - Sinks:
WekinatorSink,ScoreRecorder(JSONL +*.summary.json),LiveTap. MovementCaptureDriver— implements@miadi/capture'sCaptureDriver, so a movement score is a take: TLID-named, in the take library, with provenance that survives a restart (extension: "jsonl").SummaryAccumulator— the honest reading: per-channel stats, acceleration and rotation magnitudes, and rate fields that keep partial performances truthful.
Reading a score back — the analysis subpath
@miadi/movement-conductor/analysis turns recorded frames into the musical
answers the atelier needs: the real new-value rate, per-second magnitudes,
onsets, an unwrapped heading, compass sextants, and stillness spans.
Every measure is @miadi/ava8-measure/movement's, ported from the Python
and verified against it on nine real captures. This module implements none of
them; it is an adapter, re-exports the whole measure set, and owns exactly one
thing — the timebase.
import { readFile } from "node:fs/promises"
import { analyzeScoreText } from "@miadi/movement-conductor/analysis"
const a = analyzeScoreText(await readFile(score, "utf8"))
a.timebase.recordedTakeTiming // true — the score's own t
a.dedupe.heldRatio // 0.76: the transport, not the body
a.onsets.times // where a note is struck
a.stillness.cuts // where the sections openThe subpath is separate on purpose: importing the package root never loads the measure set.
Dedupe is the first act
A packet whose values equal its predecessor's is a held value, not a measurement — the OSC literature's standard mitigation for UDP's non-assured delivery, which the conductor's tempo mode performs on purpose. Measured on the capture of 2026-08-16 17:14: 1627 packets over 16.6 s, 98 Hz of packets, of which 1232 repeat the previous value. The 395 new values are 23.8 Hz with a 41 ms median gap.
Onsets on the raw stream: 28 attacks spaced 120–133 ms — regular, credible,
entirely false. That is the staircase, not a body. Deduped: 15 attacks, 153 to
1481 ms — two bursts around a silence, which is a musical form, and which is
his. So analyze dedupes before every measure except rates, which must see
the raw stream because the held count is its subject, and reports that it did in
analysis.dedupe.
The timebase, which is the whole of the adaptation
MovementFrame carries at: Date and no t. ScoreRecorder.hear()
synthesises t as monotonic seconds-since-open; ScoreReplaySource reads that
t back and discards it, emitting a fresh at. So t is reconstructed —
from the JSONL field when reading a score, from at deltas when handed frames:
| given | t from | the take that was recorded? |
|---|---|---|
| score JSONL text | the score's own t | yes |
| frames declared live | at deltas — arrival is the clock | yes, at ms resolution |
| frames declared replay | the replay's arrival times | no |
| frames, origin undeclared | at deltas | cannot tell, and it says so |
timebase.recordedTakeTiming is true, false, or null — and null means
nobody could tell, never no.
Two facts it carries rather than rediscovers
- The capture declares no units. Its own field ledger says the channel
semantic map is absent, so every number comes back bare. The music is built
from ratios, so nothing is lost — a unit invented here would be a claim
nobody can support.
heading.turnDegreesis the one honest exception: a difference of an unwrapped heading is a ratio of angles. sextantsdoes not need an unwrapped heading. It takes the heading modulo a turn, so wrapped and unwrapped input give the identical sequence — measured, against a widely-repeated claim to the contrary.unwrapHeadingstays mandatory for reading the heading as a path: on a synthetic turn the raw channel reports +331° where the body turned −29°.
Specification of record: rispecs/movement-conductor.spec.md in
miadisabelle/gmtermux.
