@msb235/blits 0.11.0 → 0.12.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.
Files changed (78) hide show
  1. package/CHANGELOG.md +97 -0
  2. package/dist/arcs.d.ts +40 -0
  3. package/dist/arcs.js +239 -0
  4. package/dist/chain.d.ts +6 -1
  5. package/dist/chain.js +33 -13
  6. package/dist/channels.d.ts +2 -0
  7. package/dist/channels.js +12 -1
  8. package/dist/clone.js +16 -4
  9. package/dist/cue.js +11 -2
  10. package/dist/departed.d.ts +5 -1
  11. package/dist/departed.js +71 -27
  12. package/dist/easing.d.ts +12 -1
  13. package/dist/easing.js +7 -1
  14. package/dist/everyone.d.ts +14 -1
  15. package/dist/everyone.js +27 -2
  16. package/dist/fade.js +3 -3
  17. package/dist/fill.js +16 -9
  18. package/dist/fit.d.ts +8 -1
  19. package/dist/fit.js +7 -3
  20. package/dist/fold.js +69 -13
  21. package/dist/gather.js +38 -7
  22. package/dist/handle.d.ts +2 -0
  23. package/dist/handle.js +8 -0
  24. package/dist/held.d.ts +5 -2
  25. package/dist/held.js +6 -3
  26. package/dist/history.d.ts +19 -2
  27. package/dist/history.js +149 -31
  28. package/dist/hosts.js +27 -11
  29. package/dist/index.d.ts +4 -1
  30. package/dist/index.js +2 -0
  31. package/dist/lanes.d.ts +23 -7
  32. package/dist/lanes.js +36 -2
  33. package/dist/lean.js +2 -2
  34. package/dist/marks.js +3 -0
  35. package/dist/mixer.d.ts +15 -1
  36. package/dist/mixer.js +57 -30
  37. package/dist/move.js +7 -2
  38. package/dist/numbers.d.ts +5 -1
  39. package/dist/numbers.js +4 -0
  40. package/dist/owner.js +6 -1
  41. package/dist/place.d.ts +8 -0
  42. package/dist/place.js +16 -8
  43. package/dist/project.js +3 -3
  44. package/dist/qualify.d.ts +10 -0
  45. package/dist/qualify.js +52 -1
  46. package/dist/reading.d.ts +2 -1
  47. package/dist/reading.js +2 -1
  48. package/dist/record.d.ts +6 -0
  49. package/dist/record.js +1 -0
  50. package/dist/restored.d.ts +67 -18
  51. package/dist/restored.js +159 -49
  52. package/dist/revive.d.ts +1 -0
  53. package/dist/revive.js +1 -0
  54. package/dist/roster.js +2 -1
  55. package/dist/seek.js +72 -18
  56. package/dist/shared.d.ts +1 -1
  57. package/dist/shared.js +1 -1
  58. package/dist/spans.d.ts +5 -0
  59. package/dist/spans.js +75 -5
  60. package/dist/store.d.ts +2 -0
  61. package/dist/store.js +4 -0
  62. package/dist/touch.js +3 -2
  63. package/dist/transport.js +6 -1
  64. package/dist/types/arc.d.ts +55 -0
  65. package/dist/types/arc.js +1 -0
  66. package/dist/types/mix.d.ts +18 -0
  67. package/dist/types/voice.d.ts +10 -1
  68. package/dist/types/watch.d.ts +126 -0
  69. package/dist/types/watch.js +1 -0
  70. package/dist/types.d.ts +2 -0
  71. package/dist/voice.d.ts +37 -6
  72. package/dist/voice.js +17 -0
  73. package/dist/watch.d.ts +26 -0
  74. package/dist/watch.js +441 -0
  75. package/dist/weigh.d.ts +13 -2
  76. package/dist/weigh.js +10 -3
  77. package/docs/schema.html +39 -3
  78. package/package.json +5 -4
package/dist/departed.js CHANGED
@@ -1,19 +1,30 @@
1
1
  import { markOf, voiceMarks } from './marks.js';
2
2
  import { none } from './voice.js';
3
+ /** A departed voice's marks: its five as fields, so it stays in fast mode, on no prototype. */
4
+ class Marks {
5
+ start = undefined;
6
+ in = undefined;
7
+ coast = undefined;
8
+ out = undefined;
9
+ end = undefined;
10
+ }
11
+ Object.setPrototypeOf(Marks.prototype, null);
3
12
  /**
4
13
  * The departed voices a query could still pick: for each name, tag and channel within a score and
5
14
  * owner, the first and the last to leave. It grows with those, never with how many voices left.
6
15
  */
7
16
  export class DepartedIndex {
8
- ends = new Map();
17
+ scopes = new Map();
9
18
  all = new Set();
10
19
  add(mix, voice) {
11
20
  // Nothing can be cued beside a voice whose owner has gone, so nothing will ask for it.
12
21
  if (voice.owner?.state === 'done')
13
22
  return;
14
- const marks = Object.create(null);
15
- for (const mark of voiceMarks)
23
+ const marks = new Marks();
24
+ for (let i = 0; i < voiceMarks.length; i++) {
25
+ const mark = voiceMarks[i];
16
26
  marks[mark] = markOf(mix, voice, mark);
27
+ }
17
28
  const inner = voice.spec.marks;
18
29
  if (inner !== undefined)
19
30
  for (const mark of Object.keys(inner))
@@ -28,25 +39,19 @@ export class DepartedIndex {
28
39
  marks,
29
40
  held: 0,
30
41
  };
31
- const base = `${d.score ?? ''}\u0000${d.owner}\u0000`;
32
- const keys = [`${base}w`];
33
- if (d.name !== undefined)
34
- keys.push(`${base}n\u0000${d.name}`);
35
- for (const tag of d.tags)
36
- keys.push(`${base}t\u0000${tag}`);
37
- for (const ch of d.writes)
38
- keys.push(`${base}c\u0000${String(ch)}`);
39
- for (const key of keys) {
40
- const end = this.ends.get(key);
41
- if (end === undefined) {
42
- this.ends.set(key, { first: d, last: d });
43
- d.held += 2;
44
- continue;
45
- }
46
- this.release(end.last);
47
- end.last = d;
48
- d.held++;
42
+ const scope = this.scopeOf(d.score, d.owner);
43
+ if (scope.whole === undefined) {
44
+ scope.whole = { first: d, last: d };
45
+ d.held += 2;
49
46
  }
47
+ else
48
+ this.hold(scope.whole, d);
49
+ if (d.name !== undefined)
50
+ this.keep(scope.names, d.name, d);
51
+ for (let i = 0; i < d.tags.length; i++)
52
+ this.keep(scope.tags, d.tags[i], d);
53
+ for (let i = 0; i < d.writes.length; i++)
54
+ this.keep(scope.writes, d.writes[i], d);
50
55
  if (d.held > 0)
51
56
  this.all.add(d);
52
57
  if (voice.holding !== null)
@@ -54,12 +59,51 @@ export class DepartedIndex {
54
59
  }
55
60
  /** Lets go of the voices an owner that has left held: nothing can be cued beside them again. */
56
61
  dropOwner(owner) {
57
- for (const [key, end] of this.ends)
58
- if (end.first.owner === owner) {
59
- this.ends.delete(key);
60
- this.all.delete(end.first);
61
- this.all.delete(end.last);
62
- }
62
+ for (const owners of this.scopes.values()) {
63
+ const scope = owners.get(owner);
64
+ if (scope === undefined)
65
+ continue;
66
+ owners.delete(owner);
67
+ if (scope.whole !== undefined)
68
+ this.forget(scope.whole);
69
+ for (const end of scope.names.values())
70
+ this.forget(end);
71
+ for (const end of scope.tags.values())
72
+ this.forget(end);
73
+ for (const end of scope.writes.values())
74
+ this.forget(end);
75
+ }
76
+ }
77
+ scopeOf(score, owner) {
78
+ let owners = this.scopes.get(score);
79
+ if (owners === undefined) {
80
+ owners = new Map();
81
+ this.scopes.set(score, owners);
82
+ }
83
+ let scope = owners.get(owner);
84
+ if (scope === undefined) {
85
+ scope = { whole: undefined, names: new Map(), tags: new Map(), writes: new Map() };
86
+ owners.set(owner, scope);
87
+ }
88
+ return scope;
89
+ }
90
+ keep(ends, key, d) {
91
+ const end = ends.get(key);
92
+ if (end === undefined) {
93
+ ends.set(key, { first: d, last: d });
94
+ d.held += 2;
95
+ }
96
+ else
97
+ this.hold(end, d);
98
+ }
99
+ hold(end, d) {
100
+ this.release(end.last);
101
+ end.last = d;
102
+ d.held++;
103
+ }
104
+ forget(end) {
105
+ this.all.delete(end.first);
106
+ this.all.delete(end.last);
63
107
  }
64
108
  release(d) {
65
109
  if (--d.held === 0)
package/dist/easing.d.ts CHANGED
@@ -1,9 +1,20 @@
1
1
  import type { Easing } from './types.js';
2
+ /**
3
+ * A curve on 0..1: the function an easing stands for.
4
+ *
5
+ * @category patch
6
+ */
2
7
  export type Curve = (u: number) => number;
3
8
  /**
4
9
  * How fast a curve leaves 0, for bending a retarget to the speed the subject had: Infinity where
5
10
  * the curve jumps or leaves vertically, so no finite bend can match it.
6
11
  */
7
12
  export declare function startSlope(c: Curve): number;
8
- /** The function an easing names. Resolved once where a patch or voice is built, not per frame. */
13
+ /**
14
+ * The function an easing names: a CSS name, a `bezier` or `steps`, or a function, which comes back
15
+ * as it is. Call it once where a curve is built, not per frame; the same name or easing object
16
+ * gives the same function each time.
17
+ *
18
+ * @category patch
19
+ */
9
20
  export declare function curve(easing: Easing): Curve;
package/dist/easing.js CHANGED
@@ -71,7 +71,13 @@ export function startSlope(c) {
71
71
  // Past a thousandth of the way within a millionth of the time: a jump, not a slope.
72
72
  return v > 1e-3 ? Number.POSITIVE_INFINITY : v / h;
73
73
  }
74
- /** The function an easing names. Resolved once where a patch or voice is built, not per frame. */
74
+ /**
75
+ * The function an easing names: a CSS name, a `bezier` or `steps`, or a function, which comes back
76
+ * as it is. Call it once where a curve is built, not per frame; the same name or easing object
77
+ * gives the same function each time.
78
+ *
79
+ * @category patch
80
+ */
75
81
  export function curve(easing) {
76
82
  if (typeof easing === 'function')
77
83
  return easing;
@@ -10,9 +10,22 @@ import type { Subject, Voice } from './voice.js';
10
10
  * and `shut` takes back after. About 32 B a subject where a record and its place in the voice's
11
11
  * store took about 370 (bench/allocs.mjs on the `swap` row, 2026-10-09). It needs lanes for the
12
12
  * numbers. What only shows later (a subject faded out of it alone, a fade at rest, state kept
13
- * through `setting.keep`) turns it back with `unshare`.
13
+ * through `setting.keep`) turns it back with `unshare`, and so does playing `shareFrames` frames.
14
14
  */
15
15
  export declare function shares<I, O>(mix: Mixer<I, O>, voice: Voice<I, O>): boolean;
16
+ /**
17
+ * How many frames a voice plays sharing one record before it takes one a subject. Sharing saves a
18
+ * voice cued and gone within a few frames a record per subject, but costs a read more than a record
19
+ * of the subject's own: 0.13 ms a frame for one voice over 10,000 subjects (`glowbase`, teitou,
20
+ * 2026-10-21), against about 2 MB allocated once to stop. Settable for bench/same.mjs.
21
+ */
22
+ export declare let shareFrames: number;
23
+ export declare function setShareFrames(n: number): void;
24
+ /**
25
+ * Turns back each voice that has played `shareFrames` frames sharing one record. Not a keys voice,
26
+ * whose record `open` never points at a subject, so it costs no read more.
27
+ */
28
+ export declare function outgrow<I, O>(mix: Mixer<I, O>): void;
16
29
  /**
17
30
  * A sharing voice's one record, made when a chain first asks; null where its origin is still
18
31
  * ahead, which a record of its own per subject follows as the clock moves, so it shares no more.
package/dist/everyone.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { headOf } from './chain.js';
1
2
  import { numericOf } from './channels.js';
2
3
  import { record } from './record.js';
3
4
  /**
@@ -10,7 +11,7 @@ import { record } from './record.js';
10
11
  * and `shut` takes back after. About 32 B a subject where a record and its place in the voice's
11
12
  * store took about 370 (bench/allocs.mjs on the `swap` row, 2026-10-09). It needs lanes for the
12
13
  * numbers. What only shows later (a subject faded out of it alone, a fade at rest, state kept
13
- * through `setting.keep`) turns it back with `unshare`.
14
+ * through `setting.keep`) turns it back with `unshare`, and so does playing `shareFrames` frames.
14
15
  */
15
16
  export function shares(mix, voice) {
16
17
  const spec = voice.spec;
@@ -38,6 +39,30 @@ export function shares(mix, voice) {
38
39
  return channel.rest !== undefined && numericOf(channel)?.op !== 'last';
39
40
  }));
40
41
  }
42
+ /**
43
+ * How many frames a voice plays sharing one record before it takes one a subject. Sharing saves a
44
+ * voice cued and gone within a few frames a record per subject, but costs a read more than a record
45
+ * of the subject's own: 0.13 ms a frame for one voice over 10,000 subjects (`glowbase`, teitou,
46
+ * 2026-10-21), against about 2 MB allocated once to stop. Settable for bench/same.mjs.
47
+ */
48
+ export let shareFrames = 60;
49
+ export function setShareFrames(n) {
50
+ shareFrames = n;
51
+ }
52
+ /**
53
+ * Turns back each voice that has played `shareFrames` frames sharing one record. Not a keys voice,
54
+ * whose record `open` never points at a subject, so it costs no read more.
55
+ */
56
+ export function outgrow(mix) {
57
+ const sharers = mix.sharers;
58
+ for (let i = sharers.length - 1; i >= 0; i--) {
59
+ const voice = sharers[i];
60
+ if (voice.built !== null || voice.state === 'pending' || voice.state === 'done')
61
+ continue;
62
+ if (++voice.shared >= shareFrames)
63
+ unshare(mix, voice);
64
+ }
65
+ }
41
66
  /**
42
67
  * A sharing voice's one record, made when a chain first asks; null where its origin is still
43
68
  * ahead, which a record of its own per subject follows as the clock moves, so it shares no more.
@@ -191,7 +216,7 @@ export function unshare(mix, voice, kept = false, keeper) {
191
216
  const at = mix.sharers.indexOf(voice);
192
217
  if (at >= 0)
193
218
  mix.sharers.splice(at, 1);
194
- const slot = kept ? (mix.chains.get(keeper)?.slot ?? -1) : -1;
219
+ const slot = kept ? (headOf(mix, keeper)?.slot ?? -1) : -1;
195
220
  // A projection numbers no subject, so its copy's record is the keeper's without being asked.
196
221
  if (all !== null && kept && (mix.projecting || sighted(voice, slot))) {
197
222
  if (slot >= 0)
package/dist/fade.js CHANGED
@@ -1,4 +1,4 @@
1
- import { changed, unlink } from './chain.js';
1
+ import { changed, headOf, unlink } from './chain.js';
2
2
  import { envelope } from './clock.js';
3
3
  import { schedule } from './due.js';
4
4
  import { unshare, unsight } from './everyone.js';
@@ -63,7 +63,7 @@ export function unpart(mix, voice, subject) {
63
63
  voice.parted.delete(subject);
64
64
  if (voice.parted.size === 0)
65
65
  voice.parted = null;
66
- const slot = mix.chains.get(subject)?.slot ?? -1;
66
+ const slot = headOf(mix, subject)?.slot ?? -1;
67
67
  if (slot >= 0)
68
68
  mix.lanes?.rejoin(slot);
69
69
  }
@@ -99,7 +99,7 @@ export function fadesAt(fades, left, seq) {
99
99
  }
100
100
  /** Drops a voice's record of a subject and relinks the subject's chain without it. */
101
101
  function forgetIn(mix, voice, subject) {
102
- const head = mix.chains.get(subject);
102
+ const head = headOf(mix, subject);
103
103
  if (head !== undefined && head.slot >= 0) {
104
104
  unreach(voice, head.slot, false);
105
105
  // What the voice kept for the subject while it shared one record, which `weightOf` reads.
package/dist/fill.js CHANGED
@@ -6,7 +6,6 @@ import { open as openRecord, shut as shutRecord } from './everyone.js';
6
6
  import { unband } from './fold.js';
7
7
  import { foldLocus, gatherLocus } from './gather.js';
8
8
  import { runKeys } from './keyfill.js';
9
- import { frozenAt } from './lane.js';
10
9
  import { reach } from './meet.js';
11
10
  import { absent } from './numbers.js';
12
11
  import { AT, NOTHING, readKeys, seg, segment, shifted } from './patch.js';
@@ -184,8 +183,6 @@ export function wake(lanes, lane) {
184
183
  */
185
184
  export function signalled(voice, slot, rec) {
186
185
  const arg = this.arg;
187
- const elapsed = arg[1 /* Arg.ELAPSED */];
188
- const pass = arg[3 /* Arg.PASS */];
189
186
  const fade = arg[5 /* Arg.FADE */];
190
187
  if (Number.isNaN(rec.probed)) {
191
188
  this.late.push(slot);
@@ -196,8 +193,8 @@ export function signalled(voice, slot, rec) {
196
193
  arg[0 /* Arg.WEIGHT */] = 0;
197
194
  return true;
198
195
  }
199
- const base = this.host.signal(voice, subject, rec, elapsed, pass, slot);
200
- arg[0 /* Arg.WEIGHT */] = weighed(base, fade, voice.parts === null ? 1 : this.host.parting(voice, subject));
196
+ this.host.signal(voice, subject, rec, slot);
197
+ arg[0 /* Arg.WEIGHT */] = weighed(arg[0 /* Arg.WEIGHT */], fade, voice.parts === null ? 1 : this.host.parting(voice, subject));
201
198
  return true;
202
199
  }
203
200
  /** What a subject's own ramp out of a voice leaves of its weight: 1 with none. */
@@ -299,7 +296,19 @@ export function one(lane, p, slot) {
299
296
  if (this.lately >= 0 && this.unread(slot))
300
297
  return false;
301
298
  const delay = data[o + 0 /* Row.DELAY */];
302
- const elapsed = frozenAt(voice, elapsedNow - delay);
299
+ const raw = elapsedNow - delay;
300
+ // `frozenAt` written out: V8 leaves it out of line here, a call a subject.
301
+ const elapsed = !(raw >= 0)
302
+ ? voice.freezesBefore
303
+ ? 0
304
+ : Number.NaN
305
+ : raw > voice.span
306
+ ? voice.hidesPast
307
+ ? Number.NaN
308
+ : voice.freezesAfter
309
+ ? voice.span
310
+ : raw
311
+ : raw;
303
312
  if (!(elapsed >= 0)) {
304
313
  data[o + 2 /* Row.WEIGHT */] = 0;
305
314
  unband(lane.records[p]);
@@ -418,9 +427,7 @@ export function readKeyed(voice, phase, delta, scratch) {
418
427
  */
419
428
  export function call(voice, chans, rec, slot) {
420
429
  const arg = this.arg;
421
- const elapsed = arg[1 /* Arg.ELAPSED */];
422
430
  const phase = arg[2 /* Arg.PHASE */];
423
- const pass = arg[3 /* Arg.PASS */];
424
431
  const delay = arg[4 /* Arg.DELAY */];
425
432
  const w = arg[0 /* Arg.WEIGHT */];
426
433
  const host = this.host;
@@ -436,7 +443,7 @@ export function call(voice, chans, rec, slot) {
436
443
  if (silent(voice, w))
437
444
  return false;
438
445
  const kept = reading.kept;
439
- host.ready(voice, subject, rec, elapsed, pass, w);
446
+ host.ready(voice, subject, rec);
440
447
  if (this.keeps)
441
448
  host.horizon(voice, delay);
442
449
  else
package/dist/fit.d.ts CHANGED
@@ -30,6 +30,11 @@ export interface Claim {
30
30
  /** Whether it may jump to its end instead of playing. */
31
31
  ballast: boolean;
32
32
  priority: Strength;
33
+ /**
34
+ * Span ms from now before which it may not start, from its `earliest`; absent for none. Below 0
35
+ * where it has passed, which places the child there.
36
+ */
37
+ floor?: number;
33
38
  }
34
39
  /**
35
40
  * The span as a fit sees it.
@@ -75,7 +80,9 @@ export declare function shareOf(order: Order, stagger: number): number;
75
80
  export declare function plain(span: SpanClaim, kids: readonly Claim[]): FitPlan;
76
81
  /**
77
82
  * Where a plan puts each child, span ms from now, and how long they take together. A playing child
78
- * is where it is, at 0; each pending one starts its share through the one before it.
83
+ * is where it is, at 0; each pending one starts its share through the one before it, or at its
84
+ * floor where that is later. A floor already past places a child before now, so it starts partway
85
+ * through; one without a floor starts no sooner than now.
79
86
  *
80
87
  * @category score
81
88
  */
package/dist/fit.js CHANGED
@@ -17,7 +17,9 @@ export function plain(span, kids) {
17
17
  }
18
18
  /**
19
19
  * Where a plan puts each child, span ms from now, and how long they take together. A playing child
20
- * is where it is, at 0; each pending one starts its share through the one before it.
20
+ * is where it is, at 0; each pending one starts its share through the one before it, or at its
21
+ * floor where that is later. A floor already past places a child before now, so it starts partway
22
+ * through; one without a floor starts no sooner than now.
21
23
  *
22
24
  * @category score
23
25
  */
@@ -29,9 +31,11 @@ export function layout(span, kids, plan) {
29
31
  let prevLen = 0;
30
32
  kids.forEach((k, i) => {
31
33
  const len = plan.skip[i] ? 0 : k.natural / plan.rate[i];
32
- const t = k.state === 'playing' || i === 0
34
+ const t = k.state === 'playing'
33
35
  ? 0
34
- : prevAt + (k.overlap ? Math.min(plan.share, base) : base) * prevLen;
36
+ : Math.max(i === 0
37
+ ? Number.NEGATIVE_INFINITY
38
+ : prevAt + (k.overlap ? Math.min(plan.share, base) : base) * prevLen, k.floor ?? 0);
35
39
  at.push(t);
36
40
  length = Math.max(length, t + len);
37
41
  prevAt = t;
package/dist/fold.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { foldNumber } from './channels.js';
1
2
  import { passAt, phaseAt, phaseBefore, silent, stepOf } from './clock.js';
2
3
  import { copy as copyValue, first } from './clone.js';
3
4
  import { open as openRecord, shut as shutRecord, weighAt } from './everyone.js';
@@ -82,7 +83,11 @@ export function contribution(voice, subject, now, held) {
82
83
  const duration = voice.duration;
83
84
  const phase = elapsed < 0 ? phaseBefore(elapsed, duration) : phaseAt(elapsed, duration, voice.passes);
84
85
  const pass = passAt(elapsed, duration, voice.passes);
85
- this.prime(voice, held, now, elapsed, pass);
86
+ const primed = this.primed;
87
+ primed[0 /* Primed.NOW */] = now;
88
+ primed[1 /* Primed.ELAPSED */] = elapsed;
89
+ primed[2 /* Primed.PASS */] = pass;
90
+ this.prime(voice, held);
86
91
  const setting = voice.setting;
87
92
  // A signal that keeps state makes its voice stateful, as a patch that does: a lane would call
88
93
  // it for subjects no probe asked about.
@@ -102,7 +107,9 @@ export function contribution(voice, subject, now, held) {
102
107
  const history = this.opts.history;
103
108
  if (history?.inputs && voice.patch.reads !== undefined && !this.projecting)
104
109
  recordHost(this, now);
105
- reading.horizon = this.horizonFor(voice, held.delay, now);
110
+ // Only a motion reads it.
111
+ if (voice.motion !== undefined)
112
+ reading.horizon = this.horizonFor(voice, held.delay, now);
106
113
  if (voice.patch.step && held.probed !== now) {
107
114
  // Frozen after, it steps once more to where its last pass ended, and no further; run back
108
115
  // past its start, to where it reached it. Before a start it has yet to reach, it does not step.
@@ -111,18 +118,21 @@ export function contribution(voice, subject, now, held) {
111
118
  : still === 1
112
119
  ? voice.timeAt(held.delay + voice.span)
113
120
  : voice.ranBackTo(held.delay, now);
121
+ // Never probed, it may be a record a seek put back, sharing its copy's state until it steps.
122
+ const snaps = held.snaps;
123
+ const sought = Number.isNaN(held.probed) && snaps !== undefined;
114
124
  if (!Number.isNaN(to)) {
115
125
  if (held.stepped < held.since) {
116
126
  held.stepped = held.since;
117
127
  setting.dt = this.capped(now - held.since);
118
128
  }
119
129
  const tick = stepOf(this, voice);
120
- if (tick !== undefined) {
121
- // Never probed, it may be a record a seek put back.
122
- if (Number.isNaN(held.probed) && held.snaps !== undefined)
123
- ahead(this, voice, subject, held, held.snaps, tick, to);
130
+ if (tick !== undefined && sought)
131
+ ahead(this, voice, subject, held, snaps, tick, to);
132
+ if (sought)
133
+ own(voice, held);
134
+ if (tick !== undefined)
124
135
  this.tick(voice, subject, held, tick, to);
125
- }
126
136
  else if (still === 0 ? now !== held.stepped : to > held.stepped) {
127
137
  if (still !== 0)
128
138
  setting.dt = this.capped(to - held.stepped);
@@ -130,6 +140,8 @@ export function contribution(voice, subject, now, held) {
130
140
  held.stepped = to;
131
141
  }
132
142
  }
143
+ else if (sought)
144
+ own(voice, held);
133
145
  }
134
146
  let delta = null;
135
147
  // A keys voice in a locus reads at weight 0 too: having a delta makes it a member, and the
@@ -193,10 +205,12 @@ function dueBy(held, tick, to) {
193
205
  return Math.floor((to - held.since) / tick + 1e-9);
194
206
  }
195
207
  /**
196
- * The first step of a record a seek put back from a copy: takes up the state that copy was last
197
- * stepped ahead to, where the steps due now run past it, and keeps the copy's stepped ahead to the
198
- * moment sought, so a mix sought back time after time does not step from the copy each time. Only
199
- * where the same steps give the same state: a fixed interval, no `maxDt`, no host field read.
208
+ * The first step of a record a seek put back from a copy: takes up the furthest state stepped on
209
+ * from that copy that the steps due now run past, the one the record the seek replaced reached or
210
+ * the one the copy was last stepped ahead to, and otherwise keeps the copy's stepped ahead to the
211
+ * moment sought, so a mix sought back time after time does not step from the copy each time, nor a
212
+ * mix sought back and returned step again to where it was. Only where the same steps give the same
213
+ * state: a fixed interval, no `maxDt`, no host field read.
200
214
  */
201
215
  function ahead(mix, voice, subject, held, snaps, tick, to) {
202
216
  const from = snaps[snaps.length - 1];
@@ -212,7 +226,16 @@ function ahead(mix, voice, subject, held, snaps, tick, to) {
212
226
  if (held.ticks !== was.ticks || held.since !== got.since || held.rebuilt !== was.rebuilt)
213
227
  return;
214
228
  const at = got.sought;
215
- const usable = got.state !== undefined && got.ticks <= dueBy(held, tick, to);
229
+ const due = dueBy(held, tick, to);
230
+ const usable = got.state !== undefined && got.ticks <= due;
231
+ if (got.left !== undefined && got.leftTicks <= due && !(usable && got.ticks > got.leftTicks)) {
232
+ // Taken, not copied: the next seek back keeps the state this record steps on to in its place.
233
+ held.state = got.left;
234
+ got.left = undefined;
235
+ voice.setting.state = held.state;
236
+ held.ticks = got.leftTicks;
237
+ return;
238
+ }
216
239
  if (usable) {
217
240
  held.state = copyState(voice, got.state);
218
241
  voice.setting.state = held.state;
@@ -221,11 +244,24 @@ function ahead(mix, voice, subject, held, snaps, tick, to) {
221
244
  const last = usable ? got.at : from.at;
222
245
  if (!(at < to) || at - last < apart(mix.opts.history))
223
246
  return;
247
+ own(voice, held);
224
248
  mix.tick(voice, subject, held, tick, at);
225
249
  got.state = copyState(voice, held.state);
226
250
  got.ticks = held.ticks;
227
251
  got.at = at;
228
252
  }
253
+ /**
254
+ * A record a seek put back shares the state of the copy it was made from, which no read changes,
255
+ * until its first probe: before it steps, it takes a copy of its own.
256
+ */
257
+ function own(voice, held) {
258
+ const snaps = held.snaps;
259
+ const from = snaps[snaps.length - 1];
260
+ if (from !== undefined && held.state !== undefined && held.state === from.held.state) {
261
+ held.state = copyState(voice, held.state);
262
+ voice.setting.state = held.state;
263
+ }
264
+ }
229
265
  /**
230
266
  * Runs `step` once per whole interval between where this subject last stopped and now, on a grid
231
267
  * counted from when its delay ran out, and leaves the remainder for the next sample. Counting
@@ -338,6 +374,12 @@ export function apply(pose, voice, held, delta, weight) {
338
374
  const value = delta[key];
339
375
  if (value === undefined)
340
376
  continue;
377
+ const op = this.scalars[slot];
378
+ if (op !== undefined && typeof value === 'number') {
379
+ // Called through the channel, `scale` boxes the double it hands `merge`.
380
+ pose[key] = foldNumber(op, pose[key], value, weight);
381
+ continue;
382
+ }
341
383
  const channel = this.channels[slot];
342
384
  if (channel.rest !== undefined && channel.scale) {
343
385
  // pose[key] is the copy of rest this fold made, so it is ours to write into.
@@ -421,8 +463,22 @@ export function folded(subject, out, dry = false, except) {
421
463
  */
422
464
  export function linked(subject) {
423
465
  const head = Number.isNaN(this.now) ? null : this.chain(subject);
466
+ const lanes = this.lanes;
467
+ // What `prepare` answers once this frame's qualify left nothing laned, without calling it.
468
+ if (head !== null &&
469
+ lanes !== null &&
470
+ lanes.laned.length === 0 &&
471
+ lanes.frameAt === this.now &&
472
+ lanes.qualifiedVersion === this.version &&
473
+ lanes.touched.length === 0 &&
474
+ !lanes.filling) {
475
+ if (head.slot >= 0)
476
+ lanes.per[head.slot * 6 /* Per.SLOT */ + 4 /* Per.GENERAL_PROBE */] = ++lanes.probes;
477
+ this.linkedLaned = false;
478
+ return head;
479
+ }
424
480
  this.linkedLaned =
425
- head !== null && this.lanes?.prepare(head.slot, subject, this.version, head) === true;
481
+ head !== null && lanes?.prepare(head.slot, subject, this.version, head) === true;
426
482
  return head;
427
483
  }
428
484
  /** The fold after a subject's chain is linked and its lanes asked whether it reads from them. */
package/dist/gather.js CHANGED
@@ -154,18 +154,49 @@ export function fold(lane, slot) {
154
154
  */
155
155
  export function foldDelta(chans, slot, delta) {
156
156
  const n = chans.length;
157
+ const arg = this.arg;
158
+ // A plain number is folded where it is read: passed to a `foldInto` V8 did not inline, it is
159
+ // boxed, 16 bytes a channel a subject, and whether V8 inlines it turns on the order its compiles
160
+ // ran (bench/allocs.mjs, 2026-10-20).
157
161
  let ch = chans[0];
158
- if (n > 0)
159
- this.foldInto(ch, slot, delta[ch.name]);
162
+ if (n > 0) {
163
+ const v = delta[ch.name];
164
+ if (typeof v === 'number' && ch.scalar && ch.plain && this.into === null) {
165
+ const values = ch.values;
166
+ values[slot] = foldNumber(ch.op, values[slot], v, arg[0 /* Arg.WEIGHT */]);
167
+ }
168
+ else
169
+ this.foldInto(ch, slot, v);
170
+ }
160
171
  ch = chans[1];
161
- if (n > 1)
162
- this.foldInto(ch, slot, delta[ch.name]);
172
+ if (n > 1) {
173
+ const v = delta[ch.name];
174
+ if (typeof v === 'number' && ch.scalar && ch.plain && this.into === null) {
175
+ const values = ch.values;
176
+ values[slot] = foldNumber(ch.op, values[slot], v, arg[0 /* Arg.WEIGHT */]);
177
+ }
178
+ else
179
+ this.foldInto(ch, slot, v);
180
+ }
163
181
  ch = chans[2];
164
- if (n > 2)
165
- this.foldInto(ch, slot, delta[ch.name]);
182
+ if (n > 2) {
183
+ const v = delta[ch.name];
184
+ if (typeof v === 'number' && ch.scalar && ch.plain && this.into === null) {
185
+ const values = ch.values;
186
+ values[slot] = foldNumber(ch.op, values[slot], v, arg[0 /* Arg.WEIGHT */]);
187
+ }
188
+ else
189
+ this.foldInto(ch, slot, v);
190
+ }
166
191
  for (let i = 3; i < n; i++) {
167
192
  ch = chans[i];
168
- this.foldInto(ch, slot, delta[ch.name]);
193
+ const v = delta[ch.name];
194
+ if (typeof v === 'number' && ch.scalar && ch.plain && this.into === null) {
195
+ const values = ch.values;
196
+ values[slot] = foldNumber(ch.op, values[slot], v, arg[0 /* Arg.WEIGHT */]);
197
+ }
198
+ else
199
+ this.foldInto(ch, slot, v);
169
200
  }
170
201
  }
171
202
  /** Folds a motion sample's axes, kept apart from `foldInto` so neither reads two array kinds. */
package/dist/handle.d.ts CHANGED
@@ -16,6 +16,7 @@ export interface HandleHost<I, O> {
16
16
  rise(voice: Voice<I, O>, opts: {
17
17
  over?: number;
18
18
  } | undefined): void;
19
+ release(voice: Voice<I, O>): void;
19
20
  weightOf(voice: Voice<I, O>, subject: I): number;
20
21
  /**
21
22
  * Records a write the host made through `h`, which `again` makes once more where a seek replays
@@ -63,6 +64,7 @@ export declare class VoiceHandle<I, O> implements Handle<I> {
63
64
  rise(opts?: {
64
65
  over?: number;
65
66
  }): void;
67
+ release(): void;
66
68
  weightOf(subject: I): number;
67
69
  to(subject: I, target: Value, at?: number): void;
68
70
  push(subject: I, velocity: Value, at?: number): void;
package/dist/handle.js CHANGED
@@ -141,6 +141,14 @@ export class VoiceHandle {
141
141
  this.#host.rise(voice, opts);
142
142
  this.#host.record(this, 'rise', (h) => h.rise(opts));
143
143
  }
144
+ release() {
145
+ const voice = this.#cued;
146
+ if (voice === null)
147
+ return;
148
+ voice.written = true;
149
+ this.#host.release(voice);
150
+ this.#host.record(this, 'release', (h) => h.release());
151
+ }
144
152
  weightOf(subject) {
145
153
  const voice = this.#voice;
146
154
  return voice === null ? 0 : this.#host.weightOf(voice, subject);
package/dist/held.d.ts CHANGED
@@ -2,8 +2,11 @@ import type { Mixer } from './mixer.js';
2
2
  import type { Subject, Voice } from './voice.js';
3
3
  /** The record a voice gives every subject it does not reach. */
4
4
  export declare function unreachedOf<I, O>(this: Mixer<I, O>, voice: Voice<I, O>, now: number): Subject<unknown>;
5
- /** What this voice holds for this subject, made on first sight with `target` and `stagger` asked once. */
6
- export declare function held<I, O>(this: Mixer<I, O>, voice: Voice<I, O>, subject: I, slot: number): Subject<unknown>;
5
+ /**
6
+ * What this voice holds for this subject, made on first sight with `target` and `stagger` asked
7
+ * once; `guess` is a record it may be, from the subject's chain as last linked.
8
+ */
9
+ export declare function held<I, O>(this: Mixer<I, O>, voice: Voice<I, O>, subject: I, slot: number, guess?: Subject<unknown> | null): Subject<unknown>;
7
10
  /**
8
11
  * Where a new record met at `now` starts stepping: from its `since`, or from `now` for a voice that
9
12
  * catches up `fresh`, on the `stepMs` grid. True where that skipped steps, so the state depends on