@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.
@@ -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