@miadi/ava8-measure 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +174 -0
- package/dist/audio.d.ts +343 -0
- package/dist/audio.d.ts.map +1 -0
- package/dist/audio.js +821 -0
- package/dist/audio.js.map +1 -0
- package/dist/fft.d.ts +51 -0
- package/dist/fft.d.ts.map +1 -0
- package/dist/fft.js +246 -0
- package/dist/fft.js.map +1 -0
- package/dist/index.d.ts +112 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +183 -0
- package/dist/index.js.map +1 -0
- package/dist/midi.d.ts +333 -0
- package/dist/midi.d.ts.map +1 -0
- package/dist/midi.js +651 -0
- package/dist/midi.js.map +1 -0
- package/dist/movement.d.ts +431 -0
- package/dist/movement.d.ts.map +1 -0
- package/dist/movement.js +701 -0
- package/dist/movement.js.map +1 -0
- package/package.json +70 -0
package/dist/movement.js
ADDED
|
@@ -0,0 +1,701 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* movement.ts — read an OSC movement capture and say what the body did.
|
|
3
|
+
*
|
|
4
|
+
* A port of `atelier_movement.py` (standard library only, numpy never
|
|
5
|
+
* imported). This one has zero dependencies for the same reason: a movement
|
|
6
|
+
* take is a few thousand packets, and a module that runs everywhere is worth
|
|
7
|
+
* more here than one that runs fast. `median`, `mean` and the population
|
|
8
|
+
* standard deviation are three small functions at the bottom of this file.
|
|
9
|
+
* Nothing degrades, because there is nothing to degrade to.
|
|
10
|
+
*
|
|
11
|
+
* ## THE STREAM
|
|
12
|
+
*
|
|
13
|
+
* One JSON object per line — `{"t": <seconds>, "values": [f x 9]}`, OSC on the
|
|
14
|
+
* address `/wek/inputs`, nine float channels:
|
|
15
|
+
*
|
|
16
|
+
* | channels | content |
|
|
17
|
+
* |---|---|
|
|
18
|
+
* | 0–2 | linear acceleration |
|
|
19
|
+
* | 3–5 | gyroscope |
|
|
20
|
+
* | 6–8 | attitude — channel 8 is a HEADING in radians, and it wraps |
|
|
21
|
+
*
|
|
22
|
+
* ## UNITS ARE NOT DECLARED
|
|
23
|
+
*
|
|
24
|
+
* The studio's own field ledger says the channel semantic map is **absent**,
|
|
25
|
+
* and states only what the map *would* read. So every number this module
|
|
26
|
+
* returns is bare, with no unit, and no function here accepts or emits one.
|
|
27
|
+
* The music never depended on units — everything the atelier derives from
|
|
28
|
+
* these channels is a **ratio**: a value over that take's own peak, or one
|
|
29
|
+
* duration against another. Writing "2.96 m/s²" once, as if the unit were
|
|
30
|
+
* known, is a mistake already paid for.
|
|
31
|
+
*
|
|
32
|
+
* The single exception is {@link Heading.turnDegrees}: a difference of an
|
|
33
|
+
* *unwrapped* heading is a ratio of angles, not a conversion out of an unknown
|
|
34
|
+
* unit, so it may honestly be read in degrees.
|
|
35
|
+
*
|
|
36
|
+
* ## DEDUPE COMES FIRST, ALWAYS
|
|
37
|
+
*
|
|
38
|
+
* A packet whose `values` array equals its predecessor's is a **held value,
|
|
39
|
+
* not a measurement**. Holding each channel's last value between beats is the
|
|
40
|
+
* OSC literature's standard mitigation for UDP's non-assured delivery, and the
|
|
41
|
+
* conductor does it on purpose — but it is the transport speaking, not a body.
|
|
42
|
+
*
|
|
43
|
+
* Measured, on the capture of 2026-08-16 17:14:
|
|
44
|
+
*
|
|
45
|
+
* ```
|
|
46
|
+
* 1627 packets over 16.6 s 98 Hz of packets — the requested rate is real
|
|
47
|
+
* 1232 repeat the previous 76 %
|
|
48
|
+
* 395 new values 23.8 Hz actual, median gap 41 ms
|
|
49
|
+
* ```
|
|
50
|
+
*
|
|
51
|
+
* Multiplying the requested rate by ten multiplied the information by 2.4. At
|
|
52
|
+
* 10 Hz the same device produced **zero** repeats.
|
|
53
|
+
*
|
|
54
|
+
* Onsets found on the raw stream: **28 attacks spaced 120–133 ms** — regular,
|
|
55
|
+
* credible, and entirely false. That is the staircase of the held values, the
|
|
56
|
+
* conductor's repeat interval, and not his body. On the deduped stream:
|
|
57
|
+
* **15 attacks, spaced 153–1481 ms** — two bursts around a silence, which is a
|
|
58
|
+
* musical form, and which is his.
|
|
59
|
+
*
|
|
60
|
+
* So: {@link dedupe} runs first, every other function operates on the deduped
|
|
61
|
+
* stream, {@link rates} reports the held ratio prominently, and
|
|
62
|
+
* {@link onsets} takes `dedupeFirst` defaulting to `true` and says in its
|
|
63
|
+
* result whether it deduped. Turning it off is a deliberate act.
|
|
64
|
+
*
|
|
65
|
+
* @packageDocumentation
|
|
66
|
+
*/
|
|
67
|
+
// ── the nine channels ─────────────────────────────────────────────────────
|
|
68
|
+
/** Linear acceleration. Drives density and dynamics. */
|
|
69
|
+
export const ACCEL = [0, 1, 2];
|
|
70
|
+
/** Gyroscope. Drives density, and — where it stops — the sections. */
|
|
71
|
+
export const ROTATION = [3, 4, 5];
|
|
72
|
+
/** Attitude. Channel 8 is an absolute heading and wraps at 2π. */
|
|
73
|
+
export const ATTITUDE = [6, 7, 8];
|
|
74
|
+
/** The channel that is a compass bearing in radians, not an intensity. */
|
|
75
|
+
export const HEADING_CHANNEL = 8;
|
|
76
|
+
/** One full turn, in radians. */
|
|
77
|
+
export const TWO_PI = 2 * Math.PI;
|
|
78
|
+
/** Nine, by convention — and only by convention. See {@link ParseResult.wrongLength}. */
|
|
79
|
+
export const EXPECTED_CHANNELS = 9;
|
|
80
|
+
// ── reading, and the dedupe that must come first ──────────────────────────
|
|
81
|
+
/**
|
|
82
|
+
* Read one capture from JSONL **text**. One JSON object per line.
|
|
83
|
+
*
|
|
84
|
+
* Text in, never a path: this library never touches the filesystem, so it runs
|
|
85
|
+
* in a browser, in a worker, or on the capture device itself, and the caller
|
|
86
|
+
* keeps the decision of where a take comes from.
|
|
87
|
+
*
|
|
88
|
+
* Malformed lines are **counted and reported**, never guessed at and never
|
|
89
|
+
* repaired. A line that is not JSON, or a packet missing `values` or `t`, is
|
|
90
|
+
* not a packet — its line number comes back in
|
|
91
|
+
* {@link ParseResult.malformedLines}. Nothing is padded, nothing is
|
|
92
|
+
* interpolated, and no channel order is assumed that the file does not show.
|
|
93
|
+
*
|
|
94
|
+
* Returns the RAW packets. **Nothing downstream should use them directly —
|
|
95
|
+
* {@link dedupe} first.** They are returned raw so that the held-value ratio
|
|
96
|
+
* can be stated at all, which is the number that tells a human whether the
|
|
97
|
+
* requested rate was real.
|
|
98
|
+
*/
|
|
99
|
+
export function parse(text) {
|
|
100
|
+
const packets = [];
|
|
101
|
+
const malformedLines = [];
|
|
102
|
+
const wrongLength = [];
|
|
103
|
+
let lines = 0;
|
|
104
|
+
let lineNo = 0;
|
|
105
|
+
for (const rawLine of text.split("\n")) {
|
|
106
|
+
lineNo += 1;
|
|
107
|
+
const line = rawLine.trim();
|
|
108
|
+
if (!line)
|
|
109
|
+
continue;
|
|
110
|
+
lines += 1;
|
|
111
|
+
let parsed;
|
|
112
|
+
try {
|
|
113
|
+
parsed = JSON.parse(line);
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
malformedLines.push(lineNo);
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
120
|
+
malformedLines.push(lineNo);
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
const record = parsed;
|
|
124
|
+
if (!("values" in record) || !("t" in record)) {
|
|
125
|
+
malformedLines.push(lineNo);
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
const values = record["values"];
|
|
129
|
+
if (!Array.isArray(values) || values.length !== EXPECTED_CHANNELS) {
|
|
130
|
+
wrongLength.push(lineNo);
|
|
131
|
+
}
|
|
132
|
+
packets.push(record);
|
|
133
|
+
}
|
|
134
|
+
return {
|
|
135
|
+
packets,
|
|
136
|
+
lines,
|
|
137
|
+
malformed: malformedLines.length,
|
|
138
|
+
malformedLines,
|
|
139
|
+
wrongLength,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Drop every packet whose `values` equal the previous packet's.
|
|
144
|
+
*
|
|
145
|
+
* **MANDATORY BEFORE ANY OTHER MOVEMENT MEASURE.** A held value is the
|
|
146
|
+
* conductor's UDP mitigation, not a sample of his body; counting it as one
|
|
147
|
+
* turns the hold interval into a rhythm and hands back a staircase. See the
|
|
148
|
+
* module header for the 28 ghost onsets this prevents.
|
|
149
|
+
*
|
|
150
|
+
* Equality is element-wise over the array, as Python's `!=` on two lists is:
|
|
151
|
+
* a different length is a different value.
|
|
152
|
+
*/
|
|
153
|
+
export function dedupe(packets) {
|
|
154
|
+
const out = [];
|
|
155
|
+
let prev = null;
|
|
156
|
+
for (const p of packets) {
|
|
157
|
+
const v = p.values ?? null;
|
|
158
|
+
if (prev === null || !valuesEqual(v, prev))
|
|
159
|
+
out.push(p);
|
|
160
|
+
prev = v;
|
|
161
|
+
}
|
|
162
|
+
return out;
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Share of RAW packets that repeated the previous value. Report this first, always.
|
|
166
|
+
*
|
|
167
|
+
* 0 means the sensor kept up with the requested rate. 0.76 means it did not,
|
|
168
|
+
* and that the real resolution is four times coarser than the number on the
|
|
169
|
+
* box. Both are honest; only one is what was asked for.
|
|
170
|
+
*/
|
|
171
|
+
export function heldRatio(packets) {
|
|
172
|
+
const n = packets.length;
|
|
173
|
+
if (n <= 1)
|
|
174
|
+
return 0;
|
|
175
|
+
return 1 - dedupe(packets).length / n;
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Declared packet rate against the real rate of NEW values.
|
|
179
|
+
*
|
|
180
|
+
* Decides what resolution everything below is entitled to claim. A tempo read
|
|
181
|
+
* at 23 Hz carries roughly ±7 BPM of instrument error around 140 BPM; calling
|
|
182
|
+
* 150 and 136 different tempi without saying that would be dishonest.
|
|
183
|
+
*
|
|
184
|
+
* Takes the RAW packets — it is the one function that must, because the held
|
|
185
|
+
* count is its subject.
|
|
186
|
+
*/
|
|
187
|
+
export function rates(packets) {
|
|
188
|
+
const raw = [...packets];
|
|
189
|
+
const fresh = dedupe(raw);
|
|
190
|
+
const span = timeSpan(raw);
|
|
191
|
+
const gaps = [];
|
|
192
|
+
for (let i = 1; i < fresh.length; i += 1) {
|
|
193
|
+
gaps.push(toFloatOrThrow(fresh[i]["t"]) - toFloatOrThrow(fresh[i - 1]["t"]));
|
|
194
|
+
}
|
|
195
|
+
const gap = gaps.length > 0 ? median(gaps) : null;
|
|
196
|
+
return {
|
|
197
|
+
packets: raw.length,
|
|
198
|
+
newValues: fresh.length,
|
|
199
|
+
held: raw.length - fresh.length,
|
|
200
|
+
heldRatio: raw.length > 0 ? 1 - fresh.length / raw.length : 0,
|
|
201
|
+
duration: span,
|
|
202
|
+
packetHz: span > 0 ? raw.length / span : 0,
|
|
203
|
+
newValueHz: span > 0 ? fresh.length / span : 0,
|
|
204
|
+
medianGapNew: gap,
|
|
205
|
+
resolutionMs: gap === null ? null : 1000 * gap,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
// ── magnitudes ────────────────────────────────────────────────────────────
|
|
209
|
+
/**
|
|
210
|
+
* `(t, |v|)` per packet for a channel group. Euclidean, because direction is a
|
|
211
|
+
* separate question answered by {@link unwrapHeading}.
|
|
212
|
+
*
|
|
213
|
+
* **Deduped input assumed.** The magnitude of a group is what drives density,
|
|
214
|
+
* dynamics and onsets; the sign of an individual axis depends on how the phone
|
|
215
|
+
* sat in his pocket and decides nothing.
|
|
216
|
+
*
|
|
217
|
+
* A packet whose array is too short for a requested channel, or whose entries
|
|
218
|
+
* are not numbers, is skipped — never padded with a zero, which would be a
|
|
219
|
+
* measurement of a hole. A packet whose `t` cannot be read is skipped for the
|
|
220
|
+
* same reason; this is the one place the port is gentler than the Python,
|
|
221
|
+
* which raises there.
|
|
222
|
+
*/
|
|
223
|
+
export function magnitudes(packets, channels = ACCEL) {
|
|
224
|
+
const out = [];
|
|
225
|
+
for (const p of packets) {
|
|
226
|
+
const values = p.values;
|
|
227
|
+
let sum = 0;
|
|
228
|
+
let ok = true;
|
|
229
|
+
for (const c of channels) {
|
|
230
|
+
const x = toFloat(indexOf(values, c));
|
|
231
|
+
if (x === null) {
|
|
232
|
+
ok = false;
|
|
233
|
+
break;
|
|
234
|
+
}
|
|
235
|
+
sum += x * x;
|
|
236
|
+
}
|
|
237
|
+
if (!ok)
|
|
238
|
+
continue;
|
|
239
|
+
const t = toFloat(p.t);
|
|
240
|
+
if (t === null)
|
|
241
|
+
continue;
|
|
242
|
+
out.push({ t, m: Math.sqrt(sum) });
|
|
243
|
+
}
|
|
244
|
+
return out;
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Mean magnitude per whole second, for acceleration (0–2) and rotation (3–5).
|
|
248
|
+
*
|
|
249
|
+
* **ONE SECOND OF HIS BODY = ONE BAR.** That is the whole reason this function
|
|
250
|
+
* exists: it is the bridge from a stream to a score, and it is the shape of
|
|
251
|
+
* every piece the atelier built from movement. Reading it back is how the form
|
|
252
|
+
* is checked against him — plateau, peak, dissolution — rather than against a
|
|
253
|
+
* story about him. It is a choice, and it is undone with a word.
|
|
254
|
+
*
|
|
255
|
+
* Pass a bare channel list to measure one group; it comes back under the key
|
|
256
|
+
* `channels`. Seconds are floored toward zero, as Python's `int()` is, and
|
|
257
|
+
* each group carries its own second list because a group can lose a packet the
|
|
258
|
+
* other keeps.
|
|
259
|
+
*/
|
|
260
|
+
export function perSecond(packets, groups) {
|
|
261
|
+
let named;
|
|
262
|
+
if (groups === undefined) {
|
|
263
|
+
named = { acceleration: ACCEL, rotation: ROTATION };
|
|
264
|
+
}
|
|
265
|
+
else if (Array.isArray(groups)) {
|
|
266
|
+
named = { channels: groups };
|
|
267
|
+
}
|
|
268
|
+
else {
|
|
269
|
+
named = groups;
|
|
270
|
+
}
|
|
271
|
+
const out = {};
|
|
272
|
+
for (const [name, chans] of Object.entries(named)) {
|
|
273
|
+
const buckets = new Map();
|
|
274
|
+
for (const { t, m } of magnitudes(packets, chans)) {
|
|
275
|
+
const second = Math.trunc(t);
|
|
276
|
+
const bucket = buckets.get(second);
|
|
277
|
+
if (bucket)
|
|
278
|
+
bucket.push(m);
|
|
279
|
+
else
|
|
280
|
+
buckets.set(second, [m]);
|
|
281
|
+
}
|
|
282
|
+
const seconds = [...buckets.keys()].sort((a, b) => a - b);
|
|
283
|
+
out[name] = { seconds, means: seconds.map((s) => mean(buckets.get(s))) };
|
|
284
|
+
}
|
|
285
|
+
return out;
|
|
286
|
+
}
|
|
287
|
+
// ── onsets ────────────────────────────────────────────────────────────────
|
|
288
|
+
/**
|
|
289
|
+
* Local maxima above median + kσ, with a minimum separation. Rhythm from the body.
|
|
290
|
+
*
|
|
291
|
+
* Decides where a note is struck when his movement plays the piece. Run on the
|
|
292
|
+
* raw stream this returns the hold interval — 28 evenly spaced ghosts, spaced
|
|
293
|
+
* 120–133 ms — so `dedupeFirst` defaults to `true`, the result carries
|
|
294
|
+
* {@link Onsets.deduped} so a reader can tell which stream a number came from,
|
|
295
|
+
* and turning it off is a deliberate act.
|
|
296
|
+
*
|
|
297
|
+
* `minSepS` exists because a single gesture crosses the threshold on several
|
|
298
|
+
* consecutive new values; without it one attack is reported as three. The
|
|
299
|
+
* 0.15 s default is calibrated, not guessed: on the take of 2026-08-16 at
|
|
300
|
+
* 17:14 it returns 15 attacks in two bursts, which is what the atelier used.
|
|
301
|
+
*
|
|
302
|
+
* **Verified against that take, and the disagreement is named rather than
|
|
303
|
+
* hidden:** the count and the two-burst shape reproduce; **11 of the 15
|
|
304
|
+
* timestamps land within 30 ms of the session's own published list, and the
|
|
305
|
+
* remaining four differ, because the session's peak-picking was never written
|
|
306
|
+
* down.** Re-measure; do not quote its list.
|
|
307
|
+
*/
|
|
308
|
+
export function onsets(packets, options = {}) {
|
|
309
|
+
const channels = options.channels ?? ACCEL;
|
|
310
|
+
const k = options.k ?? 1.2;
|
|
311
|
+
const minSepS = options.minSepS ?? 0.15;
|
|
312
|
+
const dedupeFirst = options.dedupeFirst ?? true;
|
|
313
|
+
const pk = dedupeFirst ? dedupe(packets) : [...packets];
|
|
314
|
+
const mag = magnitudes(pk, channels);
|
|
315
|
+
if (mag.length < 3) {
|
|
316
|
+
return {
|
|
317
|
+
times: [],
|
|
318
|
+
forces: [],
|
|
319
|
+
threshold: null,
|
|
320
|
+
median: null,
|
|
321
|
+
sigma: null,
|
|
322
|
+
k,
|
|
323
|
+
minSepS,
|
|
324
|
+
n: 0,
|
|
325
|
+
medianGap: null,
|
|
326
|
+
gapRange: null,
|
|
327
|
+
deduped: dedupeFirst,
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
const vals = mag.map((x) => x.m);
|
|
331
|
+
const med = median(vals);
|
|
332
|
+
const sd = vals.length > 1 ? pstdev(vals) : 0;
|
|
333
|
+
const thr = med + k * sd;
|
|
334
|
+
const times = [];
|
|
335
|
+
const forces = [];
|
|
336
|
+
for (let i = 1; i < mag.length - 1; i += 1) {
|
|
337
|
+
const { t, m } = mag[i];
|
|
338
|
+
if (m < thr)
|
|
339
|
+
continue;
|
|
340
|
+
if (!(m >= mag[i - 1].m && m >= mag[i + 1].m))
|
|
341
|
+
continue;
|
|
342
|
+
if (times.length > 0 && t - times[times.length - 1] < minSepS) {
|
|
343
|
+
// One gesture, several new values above the threshold: keep the strongest.
|
|
344
|
+
if (m > forces[forces.length - 1]) {
|
|
345
|
+
times[times.length - 1] = t;
|
|
346
|
+
forces[forces.length - 1] = m;
|
|
347
|
+
}
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
times.push(t);
|
|
351
|
+
forces.push(m);
|
|
352
|
+
}
|
|
353
|
+
const gaps = [];
|
|
354
|
+
for (let i = 1; i < times.length; i += 1)
|
|
355
|
+
gaps.push(times[i] - times[i - 1]);
|
|
356
|
+
return {
|
|
357
|
+
times: times.map(round4),
|
|
358
|
+
forces: forces.map(round4),
|
|
359
|
+
threshold: thr,
|
|
360
|
+
median: med,
|
|
361
|
+
sigma: sd,
|
|
362
|
+
k,
|
|
363
|
+
minSepS,
|
|
364
|
+
n: times.length,
|
|
365
|
+
medianGap: gaps.length > 0 ? median(gaps) : null,
|
|
366
|
+
gapRange: gaps.length > 0 ? [Math.min(...gaps), Math.max(...gaps)] : null,
|
|
367
|
+
deduped: dedupeFirst,
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
// ── heading ───────────────────────────────────────────────────────────────
|
|
371
|
+
/**
|
|
372
|
+
* Cumulative heading, and how many 2π wraps were removed to get it.
|
|
373
|
+
*
|
|
374
|
+
* Decides the harmony in every piece where the chord follows where he is
|
|
375
|
+
* facing. Channel 8 never goes below 0 and reaches 6.28: it is a compass
|
|
376
|
+
* bearing in radians and it wraps at north.
|
|
377
|
+
*
|
|
378
|
+
* **Raw use is not a subtle error, it is a catastrophic one.** Used raw, the
|
|
379
|
+
* music leaps a whole turn every time he passes north — a leap that
|
|
380
|
+
* corresponds to **no movement of his at all**. One capture wraps thirteen
|
|
381
|
+
* times, another four.
|
|
382
|
+
*
|
|
383
|
+
* Deltas are folded into (−π, π] and accumulated. {@link Heading.wraps} is the
|
|
384
|
+
* count removed; **if it is 0, either he never crossed north or this channel
|
|
385
|
+
* is not a heading, and this reading should be doubted before a note is
|
|
386
|
+
* written on it.**
|
|
387
|
+
*
|
|
388
|
+
* Dedupes internally — always — so it is safe to hand this the raw stream.
|
|
389
|
+
*/
|
|
390
|
+
export function unwrapHeading(packets, channel = HEADING_CHANNEL) {
|
|
391
|
+
const pk = dedupe(packets);
|
|
392
|
+
const vals = [];
|
|
393
|
+
const times = [];
|
|
394
|
+
for (const p of pk) {
|
|
395
|
+
const v = toFloat(indexOf(p.values, channel));
|
|
396
|
+
if (v === null)
|
|
397
|
+
continue;
|
|
398
|
+
vals.push(v);
|
|
399
|
+
const t = toFloat(p.t);
|
|
400
|
+
if (t === null)
|
|
401
|
+
continue;
|
|
402
|
+
times.push(t);
|
|
403
|
+
}
|
|
404
|
+
if (vals.length === 0) {
|
|
405
|
+
return {
|
|
406
|
+
times: [],
|
|
407
|
+
heading: [],
|
|
408
|
+
raw: [],
|
|
409
|
+
wraps: 0,
|
|
410
|
+
turn: 0,
|
|
411
|
+
turnDegrees: 0,
|
|
412
|
+
range: null,
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
const u = [vals[0]];
|
|
416
|
+
let wraps = 0;
|
|
417
|
+
for (let i = 1; i < vals.length; i += 1) {
|
|
418
|
+
let d = vals[i] - vals[i - 1];
|
|
419
|
+
// A jump of very nearly a full turn is north being crossed, not a body.
|
|
420
|
+
if (Math.abs(Math.abs(d) - TWO_PI) < 0.8)
|
|
421
|
+
wraps += 1;
|
|
422
|
+
while (d > Math.PI)
|
|
423
|
+
d -= TWO_PI;
|
|
424
|
+
while (d < -Math.PI)
|
|
425
|
+
d += TWO_PI;
|
|
426
|
+
u.push(u[u.length - 1] + d);
|
|
427
|
+
}
|
|
428
|
+
const turn = u[u.length - 1] - u[0];
|
|
429
|
+
return {
|
|
430
|
+
times,
|
|
431
|
+
heading: u,
|
|
432
|
+
raw: vals,
|
|
433
|
+
wraps,
|
|
434
|
+
turn,
|
|
435
|
+
turnDegrees: (turn * 180) / Math.PI,
|
|
436
|
+
range: [Math.min(...u), Math.max(...u)],
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
/**
|
|
440
|
+
* Which nth of the compass at each moment, and where it changes.
|
|
441
|
+
*
|
|
442
|
+
* Six harmonic stations map onto six sixths of the compass: the chord stops
|
|
443
|
+
* being chosen by a clock or a threshold and starts being chosen by the
|
|
444
|
+
* direction he is facing. He turns, the harmony turns. **The change points are
|
|
445
|
+
* the deliverable** — they are where the chord moves, and they do not fall on
|
|
446
|
+
* bar lines, which is the point.
|
|
447
|
+
*
|
|
448
|
+
* Accepts the {@link Heading} from {@link unwrapHeading} or a bare sequence of
|
|
449
|
+
* unwrapped radians; a bare sequence gets its **indices** as timestamps, which
|
|
450
|
+
* is the one reason to prefer handing over the whole {@link Heading} — the
|
|
451
|
+
* change points are wanted in seconds, not in sample numbers.
|
|
452
|
+
*
|
|
453
|
+
* **A claim this port measured and had to correct.** The skill this comes from
|
|
454
|
+
* says "feeding a raw heading here puts a chord change on every pass of
|
|
455
|
+
* north". It does not: this function takes the heading modulo a turn, so the
|
|
456
|
+
* wrapped channel and the unwrapped path produce the *identical* sextant
|
|
457
|
+
* sequence — verified on the take of 2026-08-16 17:14 and on a synthetic turn
|
|
458
|
+
* through 2π in both directions. The chord choice survives raw input. What
|
|
459
|
+
* does not survive is everything that reads the heading as a **path**: on that
|
|
460
|
+
* synthetic turn the raw channel reports +331° where he turned −29°. So
|
|
461
|
+
* {@link unwrapHeading} stays mandatory — for the turn, the range, and any
|
|
462
|
+
* pitch — and this function is simply not where the damage lands.
|
|
463
|
+
*
|
|
464
|
+
* The sanity bar the atelier used before writing a note: across five usable
|
|
465
|
+
* takes this produced 6 to 32 changes per take, always touching 2 to 4 of the
|
|
466
|
+
* six stations. One chord for a whole take, or a change every eighth, is a
|
|
467
|
+
* mapping to reject at this stage rather than after listening.
|
|
468
|
+
*/
|
|
469
|
+
export function sextants(heading, n = 6) {
|
|
470
|
+
let series;
|
|
471
|
+
let times;
|
|
472
|
+
if (Array.isArray(heading)) {
|
|
473
|
+
series = [...heading];
|
|
474
|
+
times = series.map((_, i) => i);
|
|
475
|
+
}
|
|
476
|
+
else {
|
|
477
|
+
const h = heading;
|
|
478
|
+
series = [...h.heading];
|
|
479
|
+
times = [...(h.times ?? [])];
|
|
480
|
+
}
|
|
481
|
+
const idx = series.map((h) => Math.trunc((pyMod(h, TWO_PI) / TWO_PI) * n) % n);
|
|
482
|
+
const changes = [];
|
|
483
|
+
for (let i = 1; i < idx.length; i += 1) {
|
|
484
|
+
if (idx[i] !== idx[i - 1]) {
|
|
485
|
+
changes.push({
|
|
486
|
+
index: i,
|
|
487
|
+
t: i < times.length ? times[i] : null,
|
|
488
|
+
from: idx[i - 1],
|
|
489
|
+
to: idx[i],
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
const counts = new Map();
|
|
494
|
+
for (const i of idx)
|
|
495
|
+
counts.set(i, (counts.get(i) ?? 0) + 1);
|
|
496
|
+
const total = idx.length || 1;
|
|
497
|
+
const visited = [...counts.keys()].sort((a, b) => a - b);
|
|
498
|
+
const occupancy = {};
|
|
499
|
+
for (const key of visited)
|
|
500
|
+
occupancy[key] = counts.get(key) / total;
|
|
501
|
+
return { n, sextant: idx, changes, nChanges: changes.length, occupancy, visited };
|
|
502
|
+
}
|
|
503
|
+
// ── stillness ─────────────────────────────────────────────────────────────
|
|
504
|
+
/**
|
|
505
|
+
* The still spans that cut a piece into sections.
|
|
506
|
+
*
|
|
507
|
+
* **Where his body stops, the harmony changes.** Sections come from him and
|
|
508
|
+
* not from a bar count, and this is the function that says where. Nothing else
|
|
509
|
+
* in these nine channels segments a take as convincingly, and nothing else
|
|
510
|
+
* needs so little interpretation — a still body is a still body.
|
|
511
|
+
*
|
|
512
|
+
* Read per whole second, on the same grid as {@link perSecond}, so a section
|
|
513
|
+
* boundary and a bar boundary are the same object.
|
|
514
|
+
*
|
|
515
|
+
* `threshold` has **no unit**, because the stream declares none. 0.5 on
|
|
516
|
+
* rotation is what the atelier used; it is a choice, and it is undone by one
|
|
517
|
+
* word. The number of stations his body offers is whatever it offers — when
|
|
518
|
+
* six were wanted and the body gave four, the piece took two passes and said so.
|
|
519
|
+
*
|
|
520
|
+
* Dedupes internally — always.
|
|
521
|
+
*/
|
|
522
|
+
export function stillness(packets, options = {}) {
|
|
523
|
+
const threshold = options.threshold ?? 0.5;
|
|
524
|
+
const channels = options.channels ?? ROTATION;
|
|
525
|
+
const minSpanS = options.minSpanS ?? 1;
|
|
526
|
+
const pk = dedupe(packets);
|
|
527
|
+
const buckets = new Map();
|
|
528
|
+
for (const { t, m } of magnitudes(pk, channels)) {
|
|
529
|
+
const second = Math.trunc(t);
|
|
530
|
+
const bucket = buckets.get(second);
|
|
531
|
+
if (bucket)
|
|
532
|
+
bucket.push(m);
|
|
533
|
+
else
|
|
534
|
+
buckets.set(second, [m]);
|
|
535
|
+
}
|
|
536
|
+
const seconds = [...buckets.keys()].sort((a, b) => a - b);
|
|
537
|
+
const series = seconds.map((s) => mean(buckets.get(s)));
|
|
538
|
+
const still = series.map((v) => v < threshold);
|
|
539
|
+
const spans = [];
|
|
540
|
+
const cuts = [];
|
|
541
|
+
let i = 0;
|
|
542
|
+
while (i < still.length) {
|
|
543
|
+
if (still[i]) {
|
|
544
|
+
let j = i;
|
|
545
|
+
while (j + 1 < still.length && still[j + 1])
|
|
546
|
+
j += 1;
|
|
547
|
+
const duration = seconds[j] - seconds[i] + 1;
|
|
548
|
+
if (duration >= minSpanS) {
|
|
549
|
+
spans.push({ startSecond: seconds[i], endSecond: seconds[j], duration });
|
|
550
|
+
cuts.push(seconds[i]);
|
|
551
|
+
}
|
|
552
|
+
i = j + 1;
|
|
553
|
+
}
|
|
554
|
+
else {
|
|
555
|
+
i += 1;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
return {
|
|
559
|
+
threshold,
|
|
560
|
+
channels: [...channels],
|
|
561
|
+
seconds,
|
|
562
|
+
series,
|
|
563
|
+
still,
|
|
564
|
+
spans,
|
|
565
|
+
cuts,
|
|
566
|
+
nSections: cuts.length > 0 ? cuts.length : 1,
|
|
567
|
+
stillShare: still.length > 0 ? still.filter(Boolean).length / still.length : 0,
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
// ── the three small helpers, written rather than depended on ──────────────
|
|
571
|
+
/**
|
|
572
|
+
* Middle value; the mean of the two middle values when the count is even.
|
|
573
|
+
*
|
|
574
|
+
* Preferred over the mean wherever a held-value staircase or a single lurch
|
|
575
|
+
* would drag an average somewhere no second of the take ever was.
|
|
576
|
+
*/
|
|
577
|
+
export function median(values) {
|
|
578
|
+
if (values.length === 0)
|
|
579
|
+
throw new RangeError("median of no values");
|
|
580
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
581
|
+
const mid = sorted.length >> 1;
|
|
582
|
+
if (sorted.length % 2 === 1)
|
|
583
|
+
return sorted[mid];
|
|
584
|
+
return (sorted[mid - 1] + sorted[mid]) / 2;
|
|
585
|
+
}
|
|
586
|
+
/**
|
|
587
|
+
* Arithmetic mean, summed with Neumaier compensation.
|
|
588
|
+
*
|
|
589
|
+
* The compensation is not decoration: a 93 s take is thousands of small
|
|
590
|
+
* magnitudes, and naive summation drifts exactly where the per-second curve is
|
|
591
|
+
* flattest — which is the part of the take that decides where a section opens.
|
|
592
|
+
*
|
|
593
|
+
* Throws on an empty list. An empty mean has no value, and returning 0 would
|
|
594
|
+
* put a number that was never measured into a score.
|
|
595
|
+
*/
|
|
596
|
+
export function mean(values) {
|
|
597
|
+
if (values.length === 0)
|
|
598
|
+
throw new RangeError("mean of no values");
|
|
599
|
+
return neumaierSum(values) / values.length;
|
|
600
|
+
}
|
|
601
|
+
/**
|
|
602
|
+
* Population standard deviation — divided by n, not n−1.
|
|
603
|
+
*
|
|
604
|
+
* Population, because a take is not a sample of some larger take: it is all of
|
|
605
|
+
* the body there was. Used by {@link onsets} for the median + kσ threshold.
|
|
606
|
+
*/
|
|
607
|
+
export function pstdev(values) {
|
|
608
|
+
if (values.length === 0)
|
|
609
|
+
throw new RangeError("pstdev of no values");
|
|
610
|
+
if (values.length === 1)
|
|
611
|
+
return 0;
|
|
612
|
+
const mu = mean(values);
|
|
613
|
+
const ss = neumaierSum(values.map((v) => (v - mu) * (v - mu)));
|
|
614
|
+
return Math.sqrt(ss / values.length);
|
|
615
|
+
}
|
|
616
|
+
// ── internals ─────────────────────────────────────────────────────────────
|
|
617
|
+
/** Kahan–Babuška–Neumaier summation. */
|
|
618
|
+
function neumaierSum(values) {
|
|
619
|
+
let sum = 0;
|
|
620
|
+
let c = 0;
|
|
621
|
+
for (const v of values) {
|
|
622
|
+
const t = sum + v;
|
|
623
|
+
if (Math.abs(sum) >= Math.abs(v))
|
|
624
|
+
c += sum - t + v;
|
|
625
|
+
else
|
|
626
|
+
c += v - t + sum;
|
|
627
|
+
sum = t;
|
|
628
|
+
}
|
|
629
|
+
return sum + c;
|
|
630
|
+
}
|
|
631
|
+
/** Element-wise, as Python's `!=` on two lists is. A different length is a different value. */
|
|
632
|
+
function valuesEqual(a, b) {
|
|
633
|
+
if (a === b)
|
|
634
|
+
return true;
|
|
635
|
+
if (!Array.isArray(a) || !Array.isArray(b))
|
|
636
|
+
return false;
|
|
637
|
+
if (a.length !== b.length)
|
|
638
|
+
return false;
|
|
639
|
+
for (let i = 0; i < a.length; i += 1) {
|
|
640
|
+
const x = a[i];
|
|
641
|
+
const y = b[i];
|
|
642
|
+
if (x === y)
|
|
643
|
+
continue;
|
|
644
|
+
if (Array.isArray(x) || Array.isArray(y)) {
|
|
645
|
+
if (!valuesEqual(x, y))
|
|
646
|
+
return false;
|
|
647
|
+
continue;
|
|
648
|
+
}
|
|
649
|
+
return false;
|
|
650
|
+
}
|
|
651
|
+
return true;
|
|
652
|
+
}
|
|
653
|
+
/** `seq[i]`, for anything indexable, without pretending a miss is a zero. */
|
|
654
|
+
function indexOf(seq, i) {
|
|
655
|
+
if (Array.isArray(seq))
|
|
656
|
+
return i >= 0 && i < seq.length ? seq[i] : undefined;
|
|
657
|
+
if (typeof seq === "string")
|
|
658
|
+
return i >= 0 && i < seq.length ? seq[i] : undefined;
|
|
659
|
+
return undefined;
|
|
660
|
+
}
|
|
661
|
+
/**
|
|
662
|
+
* Python's `float()`, near enough: numbers, booleans and numeric strings pass;
|
|
663
|
+
* everything else — including `undefined` from a short `values` array —
|
|
664
|
+
* returns `null`, and the caller skips the packet.
|
|
665
|
+
*/
|
|
666
|
+
function toFloat(x) {
|
|
667
|
+
if (typeof x === "number")
|
|
668
|
+
return Number.isFinite(x) ? x : null;
|
|
669
|
+
if (typeof x === "boolean")
|
|
670
|
+
return x ? 1 : 0;
|
|
671
|
+
if (typeof x === "string") {
|
|
672
|
+
const s = x.trim();
|
|
673
|
+
if (!/^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$/.test(s))
|
|
674
|
+
return null;
|
|
675
|
+
const n = Number(s);
|
|
676
|
+
return Number.isFinite(n) ? n : null;
|
|
677
|
+
}
|
|
678
|
+
return null;
|
|
679
|
+
}
|
|
680
|
+
function toFloatOrThrow(x) {
|
|
681
|
+
const n = toFloat(x);
|
|
682
|
+
if (n === null)
|
|
683
|
+
throw new TypeError(`not a number: ${JSON.stringify(x) ?? String(x)}`);
|
|
684
|
+
return n;
|
|
685
|
+
}
|
|
686
|
+
/** Span of the RAW stream, first timestamp to last. */
|
|
687
|
+
function timeSpan(packets) {
|
|
688
|
+
if (packets.length < 2)
|
|
689
|
+
return 0;
|
|
690
|
+
return toFloatOrThrow(packets[packets.length - 1]["t"]) - toFloatOrThrow(packets[0]["t"]);
|
|
691
|
+
}
|
|
692
|
+
/** Python's `%`: the result takes the sign of the divisor, so it is never negative here. */
|
|
693
|
+
function pyMod(a, b) {
|
|
694
|
+
const r = a % b;
|
|
695
|
+
return r !== 0 && r < 0 !== b < 0 ? r + b : r;
|
|
696
|
+
}
|
|
697
|
+
/** Python's `round(x, 4)`. No exact tie is representable at four decimals, so these agree. */
|
|
698
|
+
function round4(x) {
|
|
699
|
+
return Number(x.toFixed(4));
|
|
700
|
+
}
|
|
701
|
+
//# sourceMappingURL=movement.js.map
|