@msb235/blits 0.7.1 → 0.8.1

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 (156) hide show
  1. package/CHANGELOG.md +193 -0
  2. package/README.md +22 -4
  3. package/dist/book.js +4 -4
  4. package/dist/chain.d.ts +1 -1
  5. package/dist/chain.js +57 -54
  6. package/dist/channels.d.ts +26 -5
  7. package/dist/channels.js +12 -8
  8. package/dist/clock.d.ts +8 -1
  9. package/dist/clock.js +14 -3
  10. package/dist/clone.d.ts +8 -0
  11. package/dist/clone.js +22 -0
  12. package/dist/closed.d.ts +1 -1
  13. package/dist/closed.js +3 -2
  14. package/dist/color.d.ts +0 -11
  15. package/dist/color.js +4 -24
  16. package/dist/columns.js +0 -1
  17. package/dist/copyout.d.ts +6 -0
  18. package/dist/copyout.js +38 -0
  19. package/dist/crowd.d.ts +7 -5
  20. package/dist/crowd.js +32 -18
  21. package/dist/cue.d.ts +3 -1
  22. package/dist/cue.js +31 -20
  23. package/dist/departed.d.ts +34 -0
  24. package/dist/departed.js +73 -0
  25. package/dist/easing.d.ts +5 -0
  26. package/dist/easing.js +31 -2
  27. package/dist/everyone.d.ts +56 -0
  28. package/dist/everyone.js +221 -0
  29. package/dist/fade.d.ts +8 -1
  30. package/dist/fade.js +34 -6
  31. package/dist/fill.d.ts +22 -13
  32. package/dist/fill.js +104 -41
  33. package/dist/fit.js +2 -1
  34. package/dist/fold.d.ts +37 -0
  35. package/dist/fold.js +216 -31
  36. package/dist/frames.d.ts +21 -0
  37. package/dist/frames.js +59 -0
  38. package/dist/gather.d.ts +15 -9
  39. package/dist/gather.js +60 -25
  40. package/dist/gone.js +10 -0
  41. package/dist/handle.d.ts +16 -5
  42. package/dist/handle.js +56 -6
  43. package/dist/held.d.ts +8 -5
  44. package/dist/held.js +56 -58
  45. package/dist/history.d.ts +17 -13
  46. package/dist/history.js +47 -28
  47. package/dist/hosts.js +55 -8
  48. package/dist/index.d.ts +9 -4
  49. package/dist/index.js +5 -2
  50. package/dist/keyfill.d.ts +7 -0
  51. package/dist/keyfill.js +124 -0
  52. package/dist/keyrows.d.ts +1 -0
  53. package/dist/keyrows.js +1 -1
  54. package/dist/lane.d.ts +32 -2
  55. package/dist/lane.js +5 -0
  56. package/dist/lanes.d.ts +32 -8
  57. package/dist/lanes.js +69 -37
  58. package/dist/leavings.d.ts +26 -0
  59. package/dist/leavings.js +70 -0
  60. package/dist/locus.js +2 -2
  61. package/dist/meet.js +14 -8
  62. package/dist/mixer.d.ts +39 -7
  63. package/dist/mixer.js +50 -10
  64. package/dist/motion.d.ts +2 -0
  65. package/dist/motion.js +4 -1
  66. package/dist/motions.d.ts +22 -15
  67. package/dist/motions.js +40 -34
  68. package/dist/move.js +23 -16
  69. package/dist/moved.d.ts +11 -0
  70. package/dist/moved.js +29 -0
  71. package/dist/origin.d.ts +19 -0
  72. package/dist/origin.js +68 -0
  73. package/dist/owed.d.ts +4 -4
  74. package/dist/owed.js +8 -8
  75. package/dist/owner.d.ts +1 -1
  76. package/dist/owner.js +2 -2
  77. package/dist/pace.d.ts +13 -5
  78. package/dist/pace.js +21 -8
  79. package/dist/pack.d.ts +3 -1
  80. package/dist/pack.js +15 -24
  81. package/dist/paging.d.ts +8 -2
  82. package/dist/paging.js +39 -4
  83. package/dist/patch.d.ts +14 -9
  84. package/dist/patch.js +83 -21
  85. package/dist/place.js +18 -3
  86. package/dist/project.d.ts +12 -1
  87. package/dist/project.js +101 -83
  88. package/dist/pull.d.ts +2 -0
  89. package/dist/pull.js +8 -0
  90. package/dist/qualify.js +10 -2
  91. package/dist/reading.d.ts +5 -0
  92. package/dist/reading.js +5 -0
  93. package/dist/record.d.ts +9 -0
  94. package/dist/record.js +42 -0
  95. package/dist/relink.d.ts +11 -5
  96. package/dist/relink.js +7 -5
  97. package/dist/restored.d.ts +41 -0
  98. package/dist/restored.js +91 -0
  99. package/dist/revive.d.ts +10 -3
  100. package/dist/revive.js +31 -23
  101. package/dist/roster.d.ts +11 -0
  102. package/dist/roster.js +46 -0
  103. package/dist/rotation.d.ts +29 -0
  104. package/dist/rotation.js +91 -0
  105. package/dist/rows.d.ts +5 -5
  106. package/dist/rows.js +5 -5
  107. package/dist/sample.d.ts +3 -2
  108. package/dist/sample.js +32 -15
  109. package/dist/seek.js +97 -114
  110. package/dist/shared.d.ts +6 -0
  111. package/dist/shared.js +15 -0
  112. package/dist/signals.d.ts +24 -3
  113. package/dist/signals.js +50 -11
  114. package/dist/spans.js +7 -0
  115. package/dist/stack.d.ts +11 -0
  116. package/dist/stack.js +47 -0
  117. package/dist/standing.d.ts +10 -0
  118. package/dist/standing.js +79 -0
  119. package/dist/store.d.ts +3 -3
  120. package/dist/store.js +3 -3
  121. package/dist/strays.js +3 -0
  122. package/dist/tape.d.ts +8 -5
  123. package/dist/tape.js +21 -13
  124. package/dist/testing.d.ts +35 -0
  125. package/dist/testing.js +116 -0
  126. package/dist/touch.d.ts +6 -0
  127. package/dist/touch.js +28 -0
  128. package/dist/transport.d.ts +45 -6
  129. package/dist/transport.js +64 -19
  130. package/dist/turns.d.ts +18 -0
  131. package/dist/turns.js +72 -0
  132. package/dist/tweened.js +1 -1
  133. package/dist/types/channel.d.ts +46 -0
  134. package/dist/types/channel.js +1 -0
  135. package/dist/types/history.d.ts +139 -0
  136. package/dist/types/history.js +1 -0
  137. package/dist/types/mix.d.ts +336 -0
  138. package/dist/types/mix.js +1 -0
  139. package/dist/types/patch.d.ts +187 -0
  140. package/dist/types/patch.js +1 -0
  141. package/dist/types/place.d.ts +157 -0
  142. package/dist/types/place.js +1 -0
  143. package/dist/types/transport.d.ts +71 -0
  144. package/dist/types/transport.js +1 -0
  145. package/dist/types/voice.d.ts +323 -0
  146. package/dist/types/voice.js +1 -0
  147. package/dist/types.d.ts +7 -1162
  148. package/dist/unpage.d.ts +2 -2
  149. package/dist/unpage.js +53 -25
  150. package/dist/voice.d.ts +90 -22
  151. package/dist/voice.js +105 -43
  152. package/dist/wave.d.ts +42 -0
  153. package/dist/wave.js +62 -0
  154. package/dist/weigh.js +1 -1
  155. package/docs/schema.html +42 -36
  156. package/package.json +8 -3
package/docs/schema.html CHANGED
@@ -161,7 +161,7 @@
161
161
  <h1>An effects schema, and the API around it</h1>
162
162
  <p class="lede">A small runtime package for concurrent effects. Each effect runs on its own clock with its own state and weight, and a mix folds them into one value per subject per frame by rules that belong to the channel, not the effect.</p>
163
163
  <p class="meta"><b>For:</b> whoever builds <code>blits</code>, and whoever ports klieg, wod, sherpa or magicsmoke onto it. <b>Answers:</b> what the vocabulary is, what an author writes, what a host calls, and where a second engine would plug in. The package implements this page; where the two differ, the code in <code>src/</code> is the one that runs.</p>
164
- <span class="status">Built · v0.7.0 · design 2026-09-15, package 2026-09-28</span>
164
+ <span class="status">Built · v0.8.1 · design 2026-09-15, package 2026-09-28</span>
165
165
  </header>
166
166
 
167
167
  <h2 id="picture">In one picture</h2>
@@ -237,7 +237,7 @@
237
237
  <dt>Contribution</dt><dd>What one voice does to one subject in one frame, once its weight is applied. Not a share of the whole: two voices each contributing <code>gain: 0.1</code> leave the subject at 0.01.</dd>
238
238
  <dt>Duration</dt><dd>How long one pass of a patch lasts, in milliseconds, whether or not the voice loops. 0 means the patch has no passes.</dd>
239
239
  <dt>Pass</dt><dd>One run through a patch, phase 0 to 1.</dd>
240
- <dt>Period</dt><dd>The duration a looping voice repeats. <code>Patch.period</code> is a deprecated alias for <code>duration</code>.</dd>
240
+ <dt>Period</dt><dd>The duration a looping voice repeats.</dd>
241
241
  <dt>Phase</dt><dd>Where a voice is within its current pass, 0 to 1, wrapping. The first thing a patch is handed, and what <code>stagger</code> spreads per subject.</dd>
242
242
  <dt>Setting</dt><dd>What a patch may read and did not compute, for one subject this frame: the clock and <code>dt</code> the whole frame shares, the <code>elapsed</code>, <code>pass</code> and <code>weight</code> particular to the voice, its state, and whatever the host added.</dd>
243
243
  <dt>System</dt><dd>One complete area of driven behavior: a kit, the subject type it drives, and the mix over them. klieg's motion, effects and lighting are three systems in one host. Two systems never share a mix, so a voice belongs to exactly one.</dd>
@@ -281,6 +281,8 @@ type Kit&lt;O&gt; = { readonly [K in keyof O]-?: Channel&lt;NonNullable&lt;O[K]&
281
281
  <tr><td><code>max()</code></td><td class="num">0</td><td>max(a, b)</td><td>v · w</td><td>linear</td><td>dark, aperture</td></tr>
282
282
  <tr><td><code>last()</code></td><td>none</td><td>b</td><td>—</td><td>caller's</td><td>offsetAngle</td></tr>
283
283
  <tr><td><code>vec(n, ch)</code></td><td>per axis</td><td>per axis</td><td>per axis</td><td>per axis</td><td>Vec3 position, premultiplied light</td></tr>
284
+ <tr><td><code>angle({ turn })</code></td><td class="num">0</td><td>a + b</td><td>v the short way round · w</td><td>the short way round</td><td>a rotation about one axis that wraps; <code>turn</code> is one full turn, default 360</td></tr>
285
+ <tr><td><code>quat()</code></td><td>[0, 0, 0, 1]</td><td>a then b, in cue order</td><td>the short arc from rest, by w</td><td>the short arc</td><td>a rotation in space, as a unit quaternion <code>[x, y, z, w]</code></td></tr>
284
286
  <tr><td><code>color()</code></td><td>[0, 0, 0, 0]</td><td>per axis</td><td>[L, a, b, 1] · w</td><td>OKLab or OKLCH</td><td>tints, glows, lighting color</td></tr>
285
287
  <tr><td><code>color(last())</code></td><td>none</td><td>b</td><td>—</td><td>OKLab or OKLCH</td><td>a color one voice owns at a time</td></tr>
286
288
  </table></div>
@@ -308,8 +310,6 @@ type Kit&lt;O&gt; = { readonly [K in keyof O]-?: Channel&lt;NonNullable&lt;O[K]&
308
310
  <span class="cm">/** Milliseconds one pass lasts, whether or not the voice loops. 0 has no passes: `phase` and `pass`
309
311
  stay 0, `loop` is ignored, and the patch reads `setting.elapsed` instead. */</span>
310
312
  duration: number;
311
- <span class="cm">/** @deprecated Use `duration`. A patch that sets only `period` still plays. */</span>
312
- readonly period?: number;
313
313
  <span class="cm">/** The channels this patch contributes to. Every key `at` sets, and no others. */</span>
314
314
  readonly writes: readonly (keyof O)[];
315
315
  <span class="cm">/** The channels it was written against. `cue` refuses a kit whose channel of the same name is another kind. */</span>
@@ -365,13 +365,15 @@ interface Setting&lt;S = void, H = unknown&gt; {
365
365
  <div class="tbl"><table>
366
366
  <tr><th>form</th><th>signature</th><th>what it is</th></tr>
367
367
  <tr><td><code>fn</code></td><td><code>patch(duration, at, { writes, state?, step? })</code></td><td>Procedural. Flicker, roving, a hash walk, a starter strike. Only a JS engine can run one.</td></tr>
368
- <tr><td><code>keys</code></td><td><code>keys(duration, stops, { ease?, easeBy?, delayBy? })</code></td><td>Declarative stops, klieg's transition sugar and wod's keyframes. Data preserved on <code>patch.keys</code> for an engine that reads data instead of calling functions.</td></tr>
368
+ <tr><td><code>keys</code></td><td><code>keys(duration, stops, { ease?, easeBy?, delayBy?, lerpBy? })</code></td><td>Declarative stops, klieg's transition sugar and wod's keyframes. Data preserved on <code>patch.keys</code>, and the options on the patch as fields of their own names, for an engine that reads data instead of calling functions.</td></tr>
369
369
  <tr><td><code>motion</code></td><td><code>spring(writes, opts)</code>, <code>glide(writes, opts)</code>, <code>tween(writes, opts)</code></td><td>Momentum, or an eased curve between two values, solved in closed form per subject; <a href="#motion">below</a>. The kind and its constants are preserved on <code>patch.motion</code>, a <code>MotionSpec</code>.</td></tr>
370
370
  </table></div>
371
371
 
372
+ <p><code>wave(duration, { shape?, cycles?, phase?, depth })</code> is an <code>fn</code> patch for periodic motion: each channel <code>depth</code> names swings up to <code>depth</code> either side of its rest in the <code>kit</code> the wave is given (0 without one, so a <code>mul</code> channel needs the kit to pulse around 1) as a <code>'sine'</code>, <code>'triangle'</code>, <code>'saw'</code>, or <code>'square'</code>, <code>cycles</code> times a pass, starting <code>phase</code> into its cycle. Its writes are <code>depth</code>&rsquo;s keys, and only a channel holding a number can be one. The patch keeps those options on <code>patch.wave</code>, so an engine can read the wave as data rather than calling it, and <code>waveAt(shape, x)</code> is the unit wave at <code>x</code> cycles.</p>
373
+
372
374
  <p><code>writes</code> is the patch's half of the contract the rest of the package already states: a channel kit declares what a consumer has, an engine declares its <code>runs</code> set, and both are checked at <code>cue</code>. Without it the only way to learn what a patch touches is to call it, per subject, per frame. With it the mix refuses a patch contributing a channel the kit lacks, resolves each voice's channels once at <code>cue</code> instead of walking the whole kit per probe, and can answer what a voice affects without probing it. A <code>keys</code> patch derives it from its stops.</p>
373
375
 
374
- <p>Two small names the page leans on: <code>Easing</code> is a curve on 0..1, given as data where it can be &mdash; CSS&rsquo;s names (<code>'ease-in-out'</code>), <code>{ bezier: [x1, y1, x2, y2] }</code> or <code>{ steps, jump? }</code>, which a WAAPI or GPU engine can read &mdash; or as a <code>(u: number) =&gt; number</code>, which only the CPU engine runs, and <code>Keyframe&lt;O&gt;</code> is one stop, <code>{ at: number; delta: Partial&lt;O&gt;; ease?: Easing }</code>, kept on <code>patch.keys</code> so an engine can read what a <code>keys</code> patch is made of. <code>hex</code>, deprecated for <code>color(last(), { lerp: 'oklch' })</code>, holds 0xrrggbb, and <code>mixHex</code> is its <code>lerp</code>, which blends in OKLCH with hue taken the short way round, so a crossfade between hues keeps its lightness and saturation; a mix outside sRGB&rsquo;s gamut is clipped per channel. <code>hex({ space: 'srgb' })</code> blends red, green and blue each on its own instead, as klieg&rsquo;s light channel does.</p>
376
+ <p>Two small names the page leans on: <code>Easing</code> is a curve on 0..1, given as data where it can be &mdash; CSS&rsquo;s names (<code>'ease-in-out'</code>), <code>{ bezier: [x1, y1, x2, y2] }</code> or <code>{ steps, jump? }</code>, which a WAAPI or GPU engine can read &mdash; or as a <code>(u: number) =&gt; number</code>, which only the CPU engine runs, and <code>Keyframe&lt;O&gt;</code> is one stop, <code>{ at: number; delta: Partial&lt;O&gt;; ease?: Easing }</code>, kept on <code>patch.keys</code> so an engine can read what a <code>keys</code> patch is made of. <code>mixHex</code> is a <code>lerp</code> for a color kept as 0xrrggbb, which blends in OKLCH with hue taken the short way round, so a crossfade between hues keeps its lightness and saturation; a mix outside sRGB&rsquo;s gamut is clipped per channel.</p>
375
377
 
376
378
  <p>Wrappers stay wrappers. klieg's <code>turns</code>, <code>roving</code>, <code>intermittent</code>, <code>hinge</code> and <code>partition</code> take a patch and return a patch, and none of them changes except to pass <code>writes</code> through &mdash; a wrapper reshapes one contribution and never adds a channel to it. Composition at the patch level is for reshaping one contribution; composition at the mix level is for running several. The two do not merge.</p>
377
379
 
@@ -400,7 +402,7 @@ interface Setting&lt;S = void, H = unknown&gt; {
400
402
  kept, like <code>target</code>; the subject's fade in starts when the voice clock reaches it. */</span>
401
403
  stagger?: (subject: I) =&gt; number;
402
404
  <span class="cm">/** Show the first frame before a subject starts, the last after its passes run out, or both. Default: neither,
403
- so the voice shows nothing outside its passes. See Freezing. <code>hold</code>, its name before 0.6.0, still works and is deprecated. */</span>
405
+ so the voice shows nothing outside its passes. See Freezing. */</span>
404
406
  freeze?: 'before' | 'after' | 'both';
405
407
 
406
408
  <span class="cm">// weight</span>
@@ -442,15 +444,16 @@ interface Handle&lt;I&gt; {
442
444
  <span class="cm">/** Live. Writes land on the next sync. */</span>
443
445
  weight: number;
444
446
  rate: number;
445
- <span class="cm">/** Moves this voice's clock, forward or back. Phase is computed from the reading rather than accumulated
446
- into, so nothing has to unwind: state is left where it is and is never run forward to meet the new
447
- position. Mix time is not addressable this way, and nothing outside this voice moves. */</span>
448
- seek(elapsed: number): void;
447
+ <span class="cm">/** Moves this voice's clock, forward or back, and an owner's children with it. Each subject's state is made
448
+ fresh and stepped from the voice's start to `elapsed` at its next probe, unless `state: 'keep'` leaves it
449
+ where it was. Answers how sure that leaves it, as `assess` does. Mix time is not addressable this way,
450
+ and nothing outside this voice moves. */</span>
451
+ seek(elapsed: number, opts?: { state?: 'rebuild' | 'keep' }): 'exact' | 'stepped' | 'held';
449
452
  <span class="cm">/** Moves the rate to `rate` linearly over `over` mix ms. The voice clock integrates it, so speed eases too. */</span>
450
453
  ramp(rate: number, over: number): void;
451
454
  <span class="cm">/** Ramps out over `fade.out` unless given a fade here; `at: 'rest'` waits per subject for a resting pose first.
452
455
  Given a `subject`, fades that subject alone out of this voice, which plays on for the rest. */</span>
453
- fade(opts?: { over?: number; at?: 'rest'; deadline?: number } | { subject: I; over?: number }): void;
456
+ fade(opts?: { over?: number; at?: number | 'rest'; deadline?: number } | { subject: I; over?: number }): void;
454
457
  <span class="cm">/** The weight this voice gave `subject` the last frame it was probed: after fades and its signal, before a
455
458
  locus folds it. 0 where it gave none. One number on a record the mix keeps anyway, so free unasked. */</span>
456
459
  weightOf(subject: I): number;
@@ -510,7 +513,7 @@ interface Mix&lt;I, O, H = unknown&gt; {
510
513
  <span class="cm">/** N voices whose weights split one signal, k in 0..1, cued into one locus so they fold as
511
514
  alternatives. Two patches crossfade; more make stops. The signal is read once per subject per
512
515
  frame, and every member takes its share of that reading. */</span>
513
- blend(patches: readonly Patch&lt;I, O, any, H&gt;[], by: Signal&lt;I, H&gt;, spec?: Omit&lt;VoiceSpec&lt;I, O, H&gt;, 'patch' | 'weight'&gt;): Handle&lt;I&gt;[];
516
+ blend(patches: readonly Patch&lt;I, O, any, H&gt;[], by: Signal&lt;I, H&gt;, spec?: Omit&lt;VoiceSpec&lt;I, O, H&gt;, 'patch' | 'weight' | 'locus'&gt;): Handle&lt;I&gt;[];
514
517
  <span class="cm">/** An owner: a voice with no patch that plays the voices cued with `owner: handle` on its own clock. See Score. */</span>
515
518
  owns(spec: OwnerSpec&lt;I, H&gt;): Handle&lt;I&gt;;
516
519
 
@@ -533,7 +536,8 @@ interface Mix&lt;I, O, H = unknown&gt; {
533
536
  atRest(subject: I): boolean;
534
537
  <span class="cm">/** The mix clock at the last sync or seek, which `seek` and `project` take; NaN before the first sync. */</span>
535
538
  readonly now: number;
536
- <span class="cm">/** The mix read at another mix time without moving it: ahead plays what is cued forward, behind needs `history`. */</span>
539
+ <span class="cm">/** The mix read at another mix time without moving it: ahead plays what is cued forward, behind needs `history` unless
540
+ every voice still plays as it was cued. */</span>
537
541
  project(time: number): Projection&lt;I, O&gt;;
538
542
  <span class="cm">/** Moves the mix to a mix time under `history` with a `tape`, and plays on from there, making the host's
539
543
  recorded calls again as it passes them. See Score. */</span>
@@ -574,7 +578,7 @@ interface Mix&lt;I, O, H = unknown&gt; {
574
578
 
575
579
  <p>A probe walks the voices that may reach its subject, not every voice. With no <code>target</code> in the mix that is all of them, so nothing is kept; once a voice targets, each subject keeps the list of voices whose <code>target</code> took it, rebuilt when a voice is cued, starts or leaves. One voice per subject on a shared mix then costs one voice per probe: 1000 subjects, each with its own voice, took 95&nbsp;ms a frame before the list and 0.45&nbsp;ms after (<code>npm run bench</code>, rows <code>own</code>). Building the list still asks every <code>target</code> of every new subject, so the first frame of that scene took 500&ndash;580&nbsp;ms. A voice that names its subjects with <code>subjects</code> is filed under each of them at <code>cue</code>, and a subject's list is built from the voices filed under it and those that name none: the same scene's first frame took 1.0&nbsp;ms, and 10.6&nbsp;ms at 10,000 subjects (rows <code>named</code>).</p>
576
580
 
577
- <p>Subjects are not registered. State lives in a <code>WeakMap</code> keyed by the subject object, created on first probe and stepped by the gap in the timestamp since that subject was last stepped. That is the rule klieg's dwell arrived at, and it means a part a wrapper leaves unasked for a while catches up rather than resuming where it paused.</p>
581
+ <p>Subjects are not registered. State lives in a <code>WeakMap</code> keyed by the subject object, created on first probe and stepped by the gap in the timestamp since that subject was last stepped. That is the rule klieg's dwell arrived at, and it means a part a wrapper leaves unasked for a while catches up rather than resuming where it paused. A subject first asked for late catches up the same way, from when its delay ran out by the voice's clock as it ran then, so its fade-in and its state read the same however often the host probed it before.</p>
578
582
 
579
583
  <h2 id="signals">Signals</h2>
580
584
  <p>A signal is a 0..1 scalar resolved per subject per frame. It is the package's one input type from outside the clock, and it plugs in at exactly two places: a voice's <code>weight</code>, and <code>blend</code>'s selector.</p>
@@ -586,10 +590,11 @@ function peak&lt;I&gt;(...signals: Signal&lt;I&gt;[]): Signal&lt;I&gt;;
586
590
  function slew&lt;I&gt;(of: Signal&lt;I&gt;, { riseMs, fallMs, from }): Signal&lt;I&gt;; <span class="cm">// stateful: rate-limited follow toward its input, from `from` or its input</span>
587
591
  function lag&lt;I&gt;(of: Signal&lt;I&gt;, { riseMs, fallMs, floor, from }): Signal&lt;I&gt;; <span class="cm">// stateful: exponential follow, time constants</span>
588
592
  function level&lt;I&gt;(initial?: number): Signal&lt;I&gt; &amp; { set(v: number): void }; <span class="cm">// host-written</span>
593
+ function input&lt;I&gt;(read: Signal&lt;I&gt;, of?: Signal&lt;I&gt;[]): Signal&lt;I&gt; &amp; { touch(): void }; <span class="cm">// the host's own input: call touch() after each change</span>
589
594
  function gate&lt;I&gt;(of: Signal&lt;I&gt;, band: { on: number; off: number }): Signal&lt;I&gt;; <span class="cm">// stateful: holds inside the band</span>
590
595
  function gate&lt;I&gt;(of: Signal&lt;I&gt;, at: number): Signal&lt;I&gt;; <span class="cm">// sugar for on = off = at</span></code></pre>
591
596
 
592
- <p>A stateful signal keeps its state in the mix, through <code>setting.keep(owner, init)</code>: per voice and per subject, keyed by the signal itself, made on first ask. So two voices handed the same <code>slew</code> each follow on their own, except a blend&rsquo;s members, which read their signal once and keep its state once between them; and a read at another time can copy what the mix holds instead of moving it. A signal measures its own gap, from the timestamp it last saw, rather than the gap its voice caught up by. <code>input</code> marks a signal that reads something the clock does not drive: <code>level</code> sets it, and <code>peak</code>, <code>slew</code>, <code>gate</code> and <code>blend</code>&rsquo;s shares inherit it from what they read. <code>slew</code> is klieg's <code>dwell</code> with the cursor factored out; <code>gate</code> keeps one bit, which way it last switched.</p>
597
+ <p>A stateful signal keeps its state in the mix, through <code>setting.keep(owner, init)</code>: per voice and per subject, keyed by the signal itself, made on first ask. So two voices handed the same <code>slew</code> each follow on their own, except a blend&rsquo;s members, which read their signal once and keep its state once between them; and a read at another time can copy what the mix holds instead of moving it. A signal measures its own gap, from the timestamp it last saw, rather than the gap its voice caught up by. <code>input</code> marks a signal that reads something the clock does not drive: <code>level</code> and <code>input(read)</code> set it, and <code>input(read, of)</code>, <code>peak</code>, <code>slew</code>, <code>lag</code>, <code>gate</code> and <code>blend</code>&rsquo;s shares inherit it from what they read. <code>slew</code> is klieg's <code>dwell</code> with the cursor factored out; <code>gate</code> keeps one bit, which way it last switched.</p>
593
598
 
594
599
  <p><code>slew</code> and <code>lag</code> are the package's two decay primitives. <code>slew</code> follows its input at a rate limit, no faster than <code>riseMs</code> per unit climbing or <code>fallMs</code> draining; it stays linear, because klieg's three decays are and the port's baselines depend on it. <code>lag</code> closes <code>1 − exp(−gap / ms)</code> of the distance per read, so it reads the same at any frame spacing while its input holds still; it lands on its input within <code>floor</code> (1e-6), because an exponential never reaches rest and <code>atRest</code> needs it to, and <code>floor: 0</code> keeps the curve exact. magicsmoke's intensity ease is a <code>lag</code>. It is not <code>Easing</code>, which is a curve over a known duration, and it is not called <code>ease</code> for that reason &mdash; <code>fade.ease</code> and <code>keys({ ease })</code> already mean the curve. A decay tail is a <code>slew</code> to 0 with no rise; a fade is a <code>slew</code> on a weight; klieg's <code>kicks</code> is a <code>slew</code> to 0 that <code>peak</code> pokes back up. Nothing else in the package stores a value in order to follow it. <code>gate</code> is the other half of that pair and not a substitute for it: a slew sets how fast a value may move, a band sets whether it may move at all. A slew cannot quiet a gate, because a rate limit delays a crossing without deciding it, and an input resting on the threshold drags the slewed value onto the threshold with it.</p>
595
600
 
@@ -634,7 +639,7 @@ mix.book({
634
639
  },
635
640
  });</code></pre>
636
641
  A hit plays once per pass, for the voice and never per subject, so <code>stagger</code> does not spread it; it sits on the voice clock exactly where a mark does, through the voice's rate, ramp and seeks, its owners', and <code>mix.rate</code>. Booking runs at each <code>sync</code> and takes whatever falls within <code>ahead</code> host ms, which has to cover the longest gap between syncs. <code>when</code> is on the outside clock, by an offset that folds in 5% of each frame's difference and starts over from the clock's own reading on a jump past 50&nbsp;ms (weasel's filter). Nothing is booked twice: a mark is known by its voice and which mark, a hit by its voice, index and pass. A booking whose time moves by more than 1&nbsp;ms, or that goes away, has its <code>stop</code> called and is booked again at the same sync: a seek, a rate or ramp on the voice, an owner or the mix, a fade or <code>mute</code>, a <code>rebase</code>, an anchor's target moving, the voice leaving. A fade takes away the hits after the voice is gone, and the hits within the fade still play. An item first seen already past, from a voice cued partway through or a stalled frame, is taken at the outside clock's now with <code>lateBy</code> in host ms, unless that is more than <code>late</code>. What a seek jumps over is not late; it is skipped. Hits come as the voice clock runs forward, so a voice run backwards books none. <code>tag</code> and <code>score</code> split one mix between bookers, and <code>stop()</code> on what <code>book</code> returns stops every booking still ahead. <code>project</code> books nothing, and under <code>history</code> nothing about booking is kept or replayed: a read back says what was planned, not what was booked. A mix with no booker pays nothing for it.</li>
637
- <li><b>A finite loop ends when its last pass does,</b> not at the frame that notices: its fade out counts from that moment, or it turns <code>held</code> then if it holds after, so it plays the same at any frame rate and a read at another time can find it. A fade's end and a handover's deadline are kept the same way.</li>
642
+ <li><b>A finite loop ends when its last pass does,</b> not at the frame that notices: its fade out counts from that moment, or it turns <code>frozen</code> then if it freezes after, so it plays the same at any frame rate and a read at another time can find it. A fade's end and a handover's deadline are kept the same way.</li>
638
643
  <li><b>Calls land on the next sync.</b> A host writes <code>handle.weight</code> or calls <code>fade</code> on its own clock; the mix applies it at the first timestamp that sees it. Nothing changes mid-frame.</li>
639
644
  </ul>
640
645
 
@@ -649,7 +654,7 @@ mix.book({
649
654
  <tr><td>Retarget on interruption</td><td><code>from: 'current'</code> on a <code>keys</code> voice takes stop 0 from the subject's pose in the frame before it first contributes &mdash; every other voice folded in, itself excluded &mdash; and lerps from there through the channel's <code>lerp</code>. It also leaves at the velocity the subject had, taken from its last two probed poses: the first segment gains <code>c·u(1−u)²</code>, which is zero at both ends and flat at the end, so the segment's own curve still lands on its stop while the start matches the subject's speed. A subject that was still leaves at rest. With fewer than two poses on record, as on a mix whose host probes into its own object before any retarget is cued, it starts on the segment's own curve.</td><td>wod's base-sampling. New to klieg: a regroup could tween from mid-float instead of carrying it away.</td></tr>
650
655
  </table></div>
651
656
 
652
- <p><b>A locus is folded by <code>lerp</code>, not by scaling and merging.</b> Voices sharing a <code>locus</code> are alternatives in time, so the mix combines them into one contribution first: each member weighted by its share of the locus's summed weight, through the channel's own <code>lerp</code>, in cue order. That one contribution then contributes at weight <code>min(1, &Sigma;w)</code> and is scaled toward rest like any other. On a <code>sum</code> channel this is exactly <code>w&#8321;a + w&#8322;b</code>, the arithmetic klieg's timeline already runs, so the cap it was written as is a special case of the fold rather than a rule of its own. On <code>mul</code>, <code>max</code> and <code>last</code> it is the difference between an alternative and a pile-up: two voices halfway through a handover, both holding a gain of 0.06, fold to 0.06 &mdash; which is what one alternative means &mdash; where scaling and merging reads 0.28, the product of two half-scaled copies. A grouped voice may write only channels that have a <code>lerp</code>, which every stock channel does. Cue order is visible only to a <code>lerp</code> that is not linear, such as a hue taking the short way round.</p>
657
+ <p><b>A locus is folded by <code>lerp</code>, not by scaling and merging.</b> Voices sharing a <code>locus</code> are alternatives in time, so the mix combines them into one contribution first: each member weighted by its share of the locus's summed weight, through the channel's own <code>lerp</code>, in cue order. Each channel of that contribution then contributes at <code>min(1, &Sigma;w)</code> over the members that wrote it, and is scaled toward rest like any other: a channel only one member writes fades with that member, rather than holding at the whole locus&rsquo;s weight while it leaves. On a <code>sum</code> channel this is exactly <code>w&#8321;a + w&#8322;b</code>, the arithmetic klieg's timeline already runs, so the cap it was written as is a special case of the fold rather than a rule of its own. On <code>mul</code>, <code>max</code> and <code>last</code> it is the difference between an alternative and a pile-up: two voices halfway through a handover, both holding a gain of 0.06, fold to 0.06 &mdash; which is what one alternative means &mdash; where scaling and merging reads 0.28, the product of two half-scaled copies. A grouped voice may write only channels that have a <code>lerp</code>, which every stock channel does. Cue order is visible only to a <code>lerp</code> that is not linear, such as a hue taking the short way round.</p>
653
658
 
654
659
  <div class="callout">
655
660
  <div class="eyebrow">the one rule</div>
@@ -673,7 +678,7 @@ t.to(node, 40); <span class="cm">// from wherever it is, over a ful
673
678
  <li><b>Time is the voice's</b>, so <code>rate</code> and <code>seek</code> apply. A change takes effect at the voice time it names, or, named none, at the subject's own voice time at the mix's latest frame, whether or not the host probed it then; naming one is what makes a retarget land the same at any frame rate. A subject no frame of its voice has met yet, or still inside its stagger, takes an unnamed change at its first read, after any named change due by then. <code>read</code> with no time answers at that same latest frame, counting every change due by then, and answers nothing once the voice is gone.</li>
674
679
  <li><b>History is kept to the mix's horizon.</b> Each subject keeps every stretch a read may still reach, so a read earlier than a change finds the stretch that was playing then. A mix without <code>history</code> keeps only the stretch playing now.</li>
675
680
  <li><b>A spring or a glide settles.</b> Within <code>settle</code> (1e-4) of where it heads, and that slow, it lands there exactly, so <code>atRest</code> sees it. <code>settle: 0</code> keeps the curve exact. A tween needs none: it lands on its target exactly at <code>ms</code>.</li>
676
- <li><b>The state is the patch's own</b>, per subject, rather than the mix's, because the host retargets through the patch. Cue each one on one voice. A patch moves every subject on the same number of axes, and throws on a subject, a <code>to</code> or a <code>push</code> with another, leaving the subject as it was. Its values are the kind its channel holds, a number for a number and an array for an array: one every subject shares is refused at <code>cue</code>, one given per subject on that subject's first sample.</li>
681
+ <li><b>The state is the patch's own</b>, per subject, rather than the mix's, because the host retargets through the patch, or through <code>handle.to</code>, <code>push</code>, and <code>read</code>, which reach it. Cue each one on one voice. A patch moves every subject on the same number of axes, and throws on a subject, a <code>to</code> or a <code>push</code> with another, leaving the subject as it was. Its values are the kind its channel holds, a number for a number and an array for an array: one every subject shares is refused at <code>cue</code>, one given per subject on that subject's first sample.</li>
677
682
  <li><b>The constants are data.</b> <code>patch.motion</code>, a <code>MotionSpec</code>, is <code>{ kind: 'spring', stiffness, damping, mass, settle }</code> or <code>{ kind: 'glide', ms, settle }</code> or <code>{ kind: 'tween', ms, ease }</code>, with <code>ms</code> undefined where it varies by subject, so an engine can run one without calling <code>at</code>. Where each subject starts and heads stays on the patch, since either may be a function of the subject.</li>
678
683
  <li><b>Handing motion between patches</b> is <code>read</code> on the old one and <code>from</code> plus <code>velocity</code> on the new: a fling that ends in a spring starts the spring at the glide's velocity.</li>
679
684
  </ul>
@@ -699,15 +704,15 @@ interface Projection&lt;I, O&gt; {
699
704
  <ul>
700
705
  <li><b>Placing.</b> A cue anchors at most one of <code>start</code> and <code>in</code>, and one of <code>out</code> and <code>end</code>, to a timestamp or to another voice's mark: <code>anchor: { in: 12_400 }</code> is fully in by 12.4&nbsp;s, <code>anchor: { start: { after: 'intro' } }</code> starts once <code>intro</code> is gone. Anchoring <code>in</code> or <code>end</code> counts the voice's own fade. A query selects by <code>name</code>, <code>tag</code> or a channel the voice <code>writes</code>, and its resolver picks among several, <code>last</code> cued by default.</li>
701
706
  <li><b>Several scores.</b> Each source keeps its own plan: a voice's <code>score</code> names it, and the mix plays every score it holds at once, the way a DAW plays every track. Names are namespaced by score, so a bare name in an anchor means a voice in the asker's score and a query names <code>score</code> to reach into another. A voice with none is in the mix's one unnamed score.</li>
702
- <li><b>Waiting and following.</b> An anchor with no answer &mdash; a name not cued, the <code>out</code> of a voice that loops for good &mdash; leaves the voice <code>pending</code>. While it waits it follows its target, so a target faded early brings it forward; once it starts, its start is fixed. An <code>out</code> or <code>end</code> follows until the fade begins. A target that has left keeps the time it last gave, and can be anchored to fresh only while the mix's history reaches it. An anchored mark already past starts the voice partway through. A placement that waits on itself is refused at <code>cue</code>.</li>
707
+ <li><b>Waiting and following.</b> An anchor with no answer &mdash; a name not cued, the <code>out</code> of a voice that loops for good &mdash; leaves the voice <code>pending</code>. While it waits it follows its target, so a target faded early brings it forward; once it starts, its start is fixed. An <code>out</code> or <code>end</code> follows until the fade begins. A target that has left keeps the time it last gave, and can be anchored to fresh after it left: within history's reach the mix keeps every voice that left, and past it, or with no history, it keeps the marks of the first and last voice to leave under each name, tag and channel, which answer a query only where no voice the mix still knows does. An anchored mark already past starts the voice partway through. A placement that waits on itself is refused at <code>cue</code>.</li>
703
708
  <li id="joins"><b>Joins.</b> <code>{ all: [...] }</code> waits on several anchors and answers the latest of them, once every one has answered: <code>{ start: { all: [{ after: 'change-3' }, { of: 'orb-7', mark: 'end' }] } }</code> starts once both are done. <code>{ any: [...] }</code> answers the earliest, once one of them has passed or every one has answered, since a member still unknown might yet answer earlier. A join holds only the anchors listed in it, each with its own <code>by</code>, and nests. A member whose target has left keeps the time it last gave, as a lone anchor does, so one target finishing early no longer leaves the whole join unanswered. Added 2026-10-07, named after <code>Promise.all</code> and <code>Promise.any</code>, which behave the same way.</li>
704
- <li><b>Announced marks.</b> The host puts a named mark on a score with <code>mix.announce('reply')</code>, or <code>announce('beat', { at })</code> for one it knows ahead, and anchors target it as they target a voice's marks: a voice placed <code>{ with: 'reply' }</code> waits pending until the reply is announced. A mark announced ahead is seen by a read ahead; one not yet announced makes a read ahead say <code>held</code> for whatever waits on it, and a read back sees only what had been announced by then. An announced mark stays while it is ahead or while history reaches it. Decided 2026-10-01: a <b>mark</b> is any named time an anchor can hang from, a voice's own four or one the host announced, and an <b>event</b> is only what a patch <code>send</code>s to the host. What fires a mark on a condition, such as a channel crossing a value, stays the host's: blits does not read values.</li>
709
+ <li><b>Announced marks.</b> The host puts a named mark on a score with <code>mix.announce('reply')</code>, or <code>announce('beat', { at })</code> for one it knows ahead, and anchors target it as they target a voice's marks: a voice placed <code>{ with: 'reply' }</code> waits pending until the reply is announced. A mark announced ahead is seen by a read ahead; one not yet announced makes a read ahead say <code>held</code> for whatever waits on it, and a read back sees only what had been announced by then. An announced mark stays while it is ahead or while history reaches it. Decided 2026-10-01: a <b>mark</b> is any named time an anchor can hang from, a voice's own five or one the host announced, and an <b>event</b> is only what a patch <code>send</code>s to the host. What fires a mark on a condition, such as a channel crossing a value, stays the host's: blits does not read values.</li>
705
710
  <li id="owners"><b>Owners.</b> <code>mix.owns(spec)</code> cues an <b>owner</b>, a voice with no patch, and a voice cued with <code>owner: handle</code> plays under it, so a group can be placed, timed and faded as one. The mix's own clock is the outermost owner, and an owner is a link in the same chain: owners nest.
706
711
  <pre><code>const intro = mix.owns({ anchor: { start: { after: 'title' } }, fade: { out: 300 }, name: 'intro' });
707
712
  mix.cue({ patch: slide, owner: intro, start: 0, name: 'slide' });
708
713
  mix.cue({ patch: glow, owner: intro, anchor: { start: { after: 'slide' } } });
709
714
  intro.rate = 0.5; <span class="cm">// both run at half speed</span>
710
- intro.seek(800); <span class="cm">// both move; their state stays where it is</span>
715
+ intro.seek(800); <span class="cm">// both move, their state made again for where they now are</span>
711
716
  intro.fade(); <span class="cm">// one envelope over both</span></code></pre>
712
717
  <div class="tbl"><table>
713
718
  <tr><th>On the owner</th><th>What it does to the voices it holds</th></tr>
@@ -715,7 +720,7 @@ intro.fade(); <span class="cm">// one envelope over both</span></code></pr
715
720
  <tr><td>a child's <code>start</code> and <code>anchor</code></td><td>Counted on the owner's clock, in ms from when the owner starts. With neither, a child starts where that clock is when it is cued.</td></tr>
716
721
  <tr><td>bare names in a child's anchors</td><td>Resolve among its siblings, the voices the same owner holds. A query naming a <code>score</code> looks at any depth.</td></tr>
717
722
  <tr><td><code>rate</code>, <code>ramp</code></td><td>Multiply into each child's own rate: a child's clock reads its owner's clock where a voice's reads the mix's.</td></tr>
718
- <tr><td><code>seek</code></td><td>Moves the owner's clock, and every child's phase with it. State stays where it is, as for a voice's own seek.</td></tr>
723
+ <tr><td><code>seek</code></td><td>Moves the owner's clock, and every child's phase with it. Each child's state is rebuilt, or kept, as for a voice's own seek.</td></tr>
719
724
  <tr><td><code>weight</code> (a number or a signal), <code>fade</code></td><td>Multiply into each child's weight per subject, as the child's own fade does.</td></tr>
720
725
  <tr><td><code>freeze</code></td><td>Taken by every child with no freeze of its own.</td></tr>
721
726
  <tr><td><code>played</code>, <code>done</code></td><td><code>played</code> once every child has finished its passes. The owner leaves once it has held a voice and has none left; a fade that ends takes its children with it.</td></tr>
@@ -753,18 +758,19 @@ phase.result; <span class="cm">// { budget, length, over, skipped, fell }</spa
753
758
  </table></div>
754
759
  A strategy of the host's own takes the same arguments; <code>layout</code> says where a plan puts each child and how long they take, and <code>plain</code> is the plan before any fit. A child's start and rate are the span's: it takes no <code>start</code> or anchored start, a rate the host writes on its handle lasts until the next fit, and a voice that loops for good or a plain owner is refused. The span fits again when a child joins and when one leaves, and after a seek. Only what is still to come moves: a playing child keeps its place and changes rate without a jump, as a handle's <code>rate</code> write does, and a pending one is placed. A fit sees only the children that have arrived, so late ones are squeezed into what is left. A skipped child lands on its final state at that moment and its <code>end</code> mark is there, but the events its patch would have sent on the way are not sent. A span with a budget stays until the budget has passed, though its children finish sooner, so a late child can still join, and its <code>end</code> mark is wherever its children end or its budget does, whichever is later. A span held by a span is fitted through its own rate. A fit is made on the live mix and logged as the children's control changes, so a read back, a seek and its replay all see it, and the tape records nothing for it. Designed and built 2026-10-07.</li>
755
760
  <li><b>What comes next.</b> <code>mix.marks(from, to)</code> lists every mark the plan has fixed in a range, with each voice's id, name and tags. One nothing has fixed is left out.</li>
756
- <li><b>Reading another time.</b> <code>mix.project(t)</code> copies what each subject holds and reads it at <code>t</code>, sending nothing and committing nothing, so the live mix plays on exactly as if it had not been read. Ahead, it plays what is cued forward in one step across the gap. Behind, it needs <code>history</code>: the mix keeps every voice's control changes with when they landed, the voices that have left, and a copy of each stateful record per subject every <code>every</code> ms, and a read back restores the nearest copy at or before <code>t</code> and steps forward. A subject it kept nothing for starts from its voice's start with fresh state. A patch's own <code>clone</code> copies state that <code>structuredClone</code> cannot. With <code>inputs</code>, history also keeps what each input signal on a voice's weight read per subject, and a copy of the host fields patches <code>reads</code>, each time they changed, and a read back uses those instead of asking the signal or the host. Where anchors fixed a voice's start or out after the time read, it reads back as it stood then.</li>
757
- <li id="seek"><b>Seeking.</b> <code>mix.seek(t)</code> moves the live mix to mix time <code>t</code>, as it stood at the end of that frame, and it plays on from there. It needs <code>history</code> with a <code>tape</code>, and reaches back as far as a read back does. <code>history.tape</code> takes weasel-history's <code>createHistory</code> from <code>@weasel-js/history</code>, or anything of the <code>Tape</code> shape, and the mix records on it every call the host makes on the mix, its handles and its spring and glide patches: cues, weight, rate, seeks and fades, retargets and pushes, the mix's rate, marks, <code>mute</code> and <code>drop</code>, each at the mix time it was made. The tape lives as long as the mix and is never saved.
761
+ <li><b>Reading another time.</b> <code>mix.project(t)</code> copies what each subject holds and reads it at <code>t</code>, sending nothing and committing nothing, so the live mix plays on exactly as if it had not been read. Ahead, it plays what is cued forward in one step across the gap. Behind, it reads from <code>history</code>: the mix keeps every voice's control changes with when they landed, the voices that have left, and a copy of each stateful record per subject every <code>every</code> ms, and a read back restores the nearest copy at or before <code>t</code> and steps forward. A subject it kept nothing for starts from its voice's start with fresh state. Without <code>history</code> a read back works only where how the mix stands now says what it showed then: every voice plays as it was cued and reads by its clock alone, the mix's rate was never set, and <code>t</code> is no earlier than the last cue, <code>drop</code>, <code>touch</code>, <code>announce</code>, or <code>rebase</code>, or the last frame a voice left in; anywhere else it throws and says why. A patch's own <code>clone</code> copies state that <code>structuredClone</code> cannot. With <code>inputs</code>, history also keeps what each input signal on a voice's weight read per subject, and a copy of the host fields patches <code>reads</code>, each time they changed, and a read back uses those instead of asking the signal or the host. Where anchors fixed a voice's start or out after the time read, it reads back as it stood then.</li>
762
+ <li id="seek"><b>Seeking.</b> <code>mix.seek(t)</code> moves the live mix to mix time <code>t</code>, as it stood at the end of that frame, and it plays on from there. It needs <code>history</code> with a <code>tape</code>, and reaches back as far as a read back does. <code>history.tape</code> takes weasel-history's <code>createHistory</code> from <code>@weasel-js/history</code>, or anything of the <code>Tape</code> shape, and the mix records on it every call the host makes on the mix, its handles and its motion patches: cues, weight, rate, seeks and fades, retargets and pushes, the mix's rate, marks, <code>mute</code> and <code>drop</code>, each at the host time it was made, so the frames rate 0 holds at one mix time stay apart. Where it held the clock at <code>t</code> for several frames, a seek or read back to <code>t</code> lands on the last of them. What history keeps is filed by the frame a change was decided in, so a fade given a time already passed, or a handle seek past a voice's end, comes back with a seek to before the call even though its time is earlier. The tape lives as long as the mix and is never saved.
758
763
  <ul>
759
764
  <li><b>Back.</b> The mix restores itself from what history kept: each voice's controls, the voices and marks, and the mix's rate, as they stood at <code>t</code>. Voices that left after it are back on the handles the host holds, with <code>done</code> and <code>played</code> starting over where they had settled since. Voices cued after it wait as <code>pending</code> on theirs until the mix plays their cue again; on a branch that never does, they wait for good. Each stateful record is put back from the copy kept nearest before <code>t</code> the first time it is touched, and steps once to where it is next probed, exact under <code>stepMs</code>. A subject faded out of a voice or dropped after <code>t</code> comes back with the record it had, a motion patch&rsquo;s state for it included, which history keeps for it from when it left; the lanes qualify afresh. Recorded input and host fields after <code>t</code> are let go, so signals and host fields read live again, and <code>assess</code> says <code>held</code> for what they feed.</li>
760
- <li><b>Forward.</b> Through a sync or a later seek, the mix makes the recorded calls again, each with the mix moved to the time it was made at, so the mix plays on as it did, frame for frame under <code>stepMs</code>. Ahead of anything recorded, a seek forward is a sync to there without the host's clock moving.</li>
765
+ <li><b>Forward.</b> Through a sync or a later seek, the mix makes the recorded calls again, each in a frame of its own with the mix moved to where it stood when the call was made, so the mix plays on as it did, frame for frame under <code>stepMs</code>. Ahead of anything recorded, a seek forward is a sync to there without the host's clock moving.</li>
761
766
  <li><b>Branches.</b> A call the host makes while recorded calls lie ahead starts a new branch, and the tape keeps the old future beside it rather than dropping it: scrubbing never loses a recording, and neither does a new call. <code>mix.tape.branches()</code> lists the futures at the tape's current entry and <code>switchBranch</code> picks one. To reach one, seek to just before the call that started the other; a call made at the same mix time as a recorded call it follows cannot be reached apart from that call.</li>
762
767
  <li><b>The host's clock.</b> The host goes on passing its own clock: the next <code>sync</code> reads <code>t</code> plus the host's time since its last sync, and <code>rebase</code> composes with it. <code>sync</code> refuses an earlier timestamp short of a <code>rebase</code>. <code>seek</code>, <code>project</code> and <code>mix.now</code> are mix time, since once the mix replays a recording one host time can name two moments.</li>
763
768
  <li><b>Events.</b> Playing past a time again sends its events and books its marks and hits again, since the host knows it went back; what was sent or booked at or before <code>t</code> is not sent again, and undrained events after <code>t</code> are dropped.</li>
764
769
  <li><b>Reading ahead past the tape.</b> A read ahead of the mix after a seek back throws for a time past a call the tape will make again: a recorded call is bound to the live mix and its voices, and a projection is a copy, so it would leave the call out. A seek there makes it.</li>
765
770
  </ul>
766
771
  Decided 2026-10-06 and 2026-10-07, reversing the 2026-10-01 decision that mix time is only ever a reading. The mix goes back by itself rather than through snapshots the host saves and loads, since a host-kept log of its own calls would be a second record of what the mix did. What happened after <code>t</code> plays again rather than vanishing, because scrubbing must never cut off a recorded future. A call made while rewound branches rather than dropping the old future, and the mix drops its own copies of state after the fork rather than keeping them per branch, so memory stays inside the horizon. History lives only for the session. The mix's own logs hold what happened up to the playhead and the tape holds the calls after it, so nothing is recorded twice and the logs never branch. A recorded call's <code>invert</code> does nothing, since the mix restores rather than undoing; so the mix moves its tape back only with <code>goto</code>, forward one <code>redo</code> at a time, and opens no journals.</li>
767
- <li><b>How sure.</b> <code>assess</code> says per channel <code>held</code> where a voice feeding it reads input from outside the clock with no recording of it (a signal marked <code>input</code>, a patch that <code>reads</code> host fields, or a <code>from: 'current'</code> start read back with nothing kept), <code>stepped</code> where state crossed a gap in one go without <code>stepMs</code>, or a stateful signal did, and <code>exact</code> otherwise. The least sure voice decides. Under <code>stepMs</code> a read back lands on the pose the mix showed, so a scrub back and forth gives the same picture.</li>
772
+ <li><b>How sure.</b> <code>assess</code> says per channel <code>held</code> where a voice feeding it reads input from outside the clock with no recording of it (a signal marked <code>input</code>, a patch that <code>reads</code> host fields, or a <code>from: 'current'</code> start read back with nothing kept), <code>stepped</code> where state crossed a gap in one go without <code>stepMs</code>, or a stateful signal did, and <code>exact</code> otherwise. The least sure voice decides. Under <code>stepMs</code> a read back lands on the pose the mix showed, so a scrub back and forth gives the same picture. A control change the host makes between a sync and the frame's first probe or pull showed in that frame, so a read back shows it there; one made after shows from the next frame.</li>
773
+ <li><b>A voice's own seek.</b> <code>handle.seek(elapsed)</code> moves one voice's clock, which leaves the state its patch steps and its signals keep belonging to the old position. By default each subject's state is rebuilt: made fresh and stepped from the voice's start to the new position at its next probe, on the <code>stepMs</code> grid where there is one, so under <code>stepMs</code> it lands where a voice cued fresh and played there would. <code>state: 'keep'</code> leaves it as it was. The seek answers how sure the state now is: <code>exact</code> for a voice with no state, or rebuilt under <code>stepMs</code> with no <code>maxDt</code>; <code>stepped</code> where it was caught up in one step, or a signal's kept state was started over; <code>held</code> where it was kept, or depends on host fields or an input weight a rebuild cannot know. A rebuild does not read <code>history</code> yet: starting from a copy kept near the new position would save the stepping, and could make state fed by recorded input exact. Decided and built 2026-10-08.</li>
768
774
  <li><b>Cost.</b> A projection copies each subject's record on first probe: about 3&nbsp;ms for 1000 subjects under three voices, three times a live frame (<code>npm run bench</code>, rows <code>ahead</code> and <code>back</code>). That is cheap enough to make one per frame for a continuous scrub. History costs one control entry per handle change and one copy per stateful subject per <code>every</code>, all dropped past the horizon.</li>
769
775
  </ul>
770
776
 
@@ -787,7 +793,7 @@ t.sync(now); <span class="cm">// once a frame, after the frame's cues</span></co
787
793
  <li><b>Seeking.</b> One tape records every mix&rsquo;s calls. Back, each mix restores itself; forward, the tape makes the calls again in the order they were made, every mix moved to each call&rsquo;s time first, so an anchor across mixes answers as it did live. A call after a seek back branches the whole transport. A mix&rsquo;s history is the transport&rsquo;s, and a mix given its own throws.</li>
788
794
  <li><b>Joining and leaving.</b> A mix joins at the transport&rsquo;s <code>now</code> and plays a voice cued with an earlier <code>start</code> partway, as a mix alone does. <code>t.drop(mix)</code> takes one off; under history a seek back before the drop puts it back as it was and playing on drops it again, and without history it is let go.</li>
789
795
  <li><b>What history keeps.</b> Everything within its reach comes back exactly, or the read throws: a seek or read back older than history, and a read ahead past a call the tape will make again. <code>ms: Infinity</code> keeps everything. Recorded input after a seek back is let go and read live, and <code>assess</code> says so. A seek back moves the reach with it only as far as what was kept: a second seek further back than the first one's memory throws.</li>
790
- <li id="store"><b>History behind a store.</b> With <code>history.store</code>, what <code>ms</code> would drop is handed to <code>store.page</code> as plain data instead, and nothing is ever just gone: <code>ms</code> bounds memory, not reach. A seek or read past memory needs <code>await t.prepare(time)</code> first, which calls <code>store.load(time)</code>; without it, it throws <code>HistoryMiss</code>, naming the mix, before anything moves. A seek back calls <code>store.cut(time)</code>, since what was paged after it is from a future the tape makes again. <code>load</code> returns everything a restore needs for every mix; a store that returns all it holds is correct. Records are keyed by the mix&rsquo;s <code>name</code>, which every mix on a transport with a store needs, the voice&rsquo;s id and the subject&rsquo;s key: a string or number subject is its own, and an object subject needs <code>keyOf</code>, or the mix throws the first time it meets one. A patch with <code>state</code> or <code>step</code> gives <code>pack</code> and <code>unpack</code>, or <code>cue</code> refuses it; spring, glide and tween runs are paged by blits itself. What a stateful signal keeps goes by the order its owners first kept it on the record, since a signal is code no store can hold, and the same code keeps in the same order. A voice cued with <code>as: { kind, data }</code> leaves memory once history no longer reaches its end, and <code>history.revive</code> builds it again when a seek needs it, behind the handle the host holds; one cued without <code>as</code>, an owner, a span, a blend member or a voice still holding a join&rsquo;s answers stays in memory. With a store blits never prunes the tape, nor dropped mixes, rate changes or announced marks: the host bounds the tape. Built 2026-10-07.</li>
796
+ <li id="store"><b>History behind a store.</b> With <code>history.store</code>, what <code>ms</code> would drop is handed to <code>store.page</code> as plain data instead, and nothing is ever just gone: <code>ms</code> bounds memory, not reach. A seek or read past memory needs <code>await t.prepare(time)</code> first, which calls <code>store.load(time)</code>; without it, it throws <code>HistoryMiss</code>, naming the mix, before anything moves. Each record carries <code>seq</code>, the transport's number for the frame it was made in, and a seek back calls <code>store.cut(seq)</code> with the frame it lands on, since what was paged after it is from a future the tape makes again. <code>load</code> returns everything a restore needs for every mix; a store that returns all it holds is correct. Records are keyed by the mix&rsquo;s <code>name</code>, which every mix on a transport with a store needs, the voice&rsquo;s id and the subject&rsquo;s key: a string or number subject is its own, and an object subject needs <code>keyOf</code>, or the mix throws the first time it meets one. A patch with <code>state</code> or <code>step</code> gives <code>pack</code> and <code>unpack</code>, or <code>cue</code> refuses it; spring, glide and tween runs are paged by blits itself. What a stateful signal keeps goes by the order its owners first kept it on the record, since a signal is code no store can hold, and the same code keeps in the same order. A voice cued with <code>as: { kind, data }</code> leaves memory once history no longer reaches its end, and <code>history.revive</code> builds it again when a seek needs it, behind the handle the host holds; one cued without <code>as</code>, an owner, a span, a blend member or a voice still holding a join&rsquo;s answers stays in memory. With a store the transport pages its own records too, announced marks, rate changes and the frames it played, so memory stays bounded; it never prunes the tape or dropped mixes: the host bounds the tape. A store keeps one record per key and <code>seq</code>. Built 2026-10-07.</li>
791
797
  </ul>
792
798
  <p>Designed and built 2026-10-07 (<code>docs/superpowers/specs/2026-10-07-transport-design.md</code>). Not built: owners or spans holding voices in another mix.</p>
793
799
 
@@ -829,18 +835,18 @@ export const mixer: Engine; <span class="cm">// the one that ships</span></cod
829
835
  <p>Inside <code>mixer</code>, a channel can run as a <b>lane</b>: computed for every subject at once, in <code>Float64Array</code>s indexed by a number the mix gives each subject, instead of subject by subject. A host never asks for one. Lanes are on by default, and <code>MixOptions.lanes: false</code> turns them off, to rule one out or to compare against.</p>
830
836
  <div class="tbl"><table>
831
837
  <tr><th></th><th>runs on a lane</th><th>takes the general path</th></tr>
832
- <tr><td>channel</td><td><code>sum</code>, <code>mul</code>, <code>max</code>, and <code>vec</code> over one of them, as the stock functions make them, bounds or not</td><td>anything else, including a custom channel claiming a stock kind</td></tr>
833
- <tr><td>patch</td><td><code>keys</code>; <code>fn</code> with no <code>state</code> and no <code>step</code>; <code>motion</code></td><td>a stateful <code>fn</code>, or one once it calls <code>setting.keep</code>; <code>keys</code> whose <code>lerpBy</code> is not the channel's own <code>lerp</code>; under <code>history.inputs</code>, a patch that <code>reads</code> host fields</td></tr>
834
- <tr><td>voice</td><td>a number weight, with fades and <code>mute</code>; a <code>Signal</code> weight that keeps no state, weighed for each subject by the general path&rsquo;s own <code>weigh</code>; every subject, <code>target</code> or <code>subjects</code>; <code>stagger</code>, <code>loop</code>, <code>rate</code>, <code>ramp</code>, <code>seek</code>, anchors</td><td>a <code>Signal</code> weight once it calls <code>setting.keep</code>, which the stock stateful signals do from their first call, made by the general path; under <code>history.inputs</code>, a signal reading host input; a <code>locus</code> with a motion member, or any member that cannot run on a lane, which takes every member with it; <code>from: 'current'</code>, <code>fade({ at: 'rest' })</code></td></tr>
838
+ <tr><td>channel</td><td><code>sum</code>, <code>mul</code>, <code>max</code>, <code>vec</code> over one of them, <code>angle</code>, <code>quat</code>, and a <code>color</code> that interpolates in OKLab, as the stock functions make them, bounds or not</td><td>anything else, including a custom channel claiming a stock kind</td></tr>
839
+ <tr><td>patch</td><td><code>keys</code>; <code>fn</code> with no <code>state</code> and no <code>step</code>; <code>motion</code></td><td>a stateful <code>fn</code>, or one once it calls <code>setting.keep</code>; <code>keys</code> whose <code>lerpBy</code> is not the channel's own <code>lerp</code>; a patch that <code>reads</code> host fields</td></tr>
840
+ <tr><td>voice</td><td>a number weight, with fades and <code>mute</code>; a <code>Signal</code> weight that keeps no state, weighed for each subject by the general path&rsquo;s own <code>weigh</code>; every subject, <code>target</code> or <code>subjects</code>; <code>stagger</code>, <code>loop</code>, <code>rate</code>, <code>ramp</code>, <code>seek</code>, anchors</td><td>a <code>Signal</code> weight once it calls <code>setting.keep</code>, which the stock stateful signals do from their first call, made by the general path; an input signal <code>input</code> did not make, or any input signal under <code>history.inputs</code>; a <code>locus</code> with a motion member, or any member that cannot run on a lane, which takes every member with it; <code>from: 'current'</code>, <code>fade({ at: 'rest' })</code></td></tr>
835
841
  </table></div>
836
842
  <p>A channel is a lane only while every voice writing it qualifies, and a voice runs on lanes only while every channel it writes is one, so no patch is called twice in a frame. The mix works this out again when a voice is cued or retires, starts a fade to rest, or first keeps state; never per probe. A voice that names one subject and writes only laned channels is the exception: it joins the crowd of voices writing those channels, below, or leaves it, without the mix qualifying anything again, since a voice that fits joining laned channels and a laned voice leaving cannot move the fixed point. Such a voice also relinks only its own subject&rsquo;s chain, and only a voice over every subject makes every subject meet the lanes again. A projection always reads through the general path.</p>
837
843
  <ul>
838
844
  <li><b>Subjects are numbered on first sight.</b> A subject's first probe takes the general path, where <code>target</code>, <code>stagger</code> and the fade-in origin are worked out, and gives it a number; so does each voice's first meeting with it later. <code>drop</code> frees the number, and for an object subject so does the host letting it go. A subject the host never probes is never numbered and costs a lane nothing.</li>
839
845
  <li><b>The first probe after a <code>sync</code> fills every lane</b>, for every numbered subject each laned voice reaches that was probed this frame or in the last frame with a probe, then copies the subject's values into the pose; later probes that frame only copy. A subject probed in neither is left out of the fill and takes the general path if it is probed before the next one, so a subject that scrolled out of view costs a lane nothing. A cue, retirement or handle change between two probes refills at the next one. So a frame costs the fill plus a probe per subject the host reads.</li>
840
846
  <li><b>A lane rests while its subjects go unprobed.</b> When fewer than 15% of a <code>keys</code> lane's subjects were probed in the last frame with a probe, or fewer than 60% of a <code>fn</code> or <code>motion</code> lane's, the lane stops filling and probes of its subjects take the general path; it fills again once that share reaches 25%, or 80%. Those lines sit either side of where filling and probing break even, measured at 1,000 and 10,000 subjects: about 19% for <code>keys</code>, 71&ndash;77% for a spring, and 63&ndash;100% for a stateless <code>fn</code>. Both paths give the same pose, so the switch only changes the cost.</li>
841
- <li><b>Single-subject voices share a crowd.</b> A laned voice whose <code>subjects</code> names one subject does not get a lane of its own, since it shares nothing across subjects for a lane to save: every such voice writing the same channels is a row in one crowd, in voice order. A motion voice writes one channel; a <code>keys</code> or stateless <code>fn</code> voice may write several. Crowds that share a channel take turns in voice order as a fill runs them, so a subject&rsquo;s rows fold in the order the general path folds them. A row holds the per-subject numbers a lane keeps and a copy of what a fill reads of the voice (clock, rate, weight) and of its current stretch. The voice&rsquo;s copy is rewritten when a handle or the mix changes the voice; the stretch&rsquo;s when the patch replaces it (<code>Motions.touched</code>). A fill reads the crowd&rsquo;s flat arrays and calls the same <code>closed</code> forms, and folds each row between the lanes on either side of its voice, so the order of the fold, and so its bits, are the general path&rsquo;s. A row with a fade, a ramp, a change pending or a probe already this frame takes the per-subject step instead. Measured on teitou against the build before (2026-10-03, three alternated passes of <code>bench/frame.mjs</code>): 10k tween voices of one subject each cost 0.54&ndash;0.59 of what they did, as much as one tween voice over the same 10k subjects; 100k cost 0.31&ndash;0.33; 10k springs 0.64&ndash;0.70. Before it, the extra cost of a voice over a shared voice grew with the count, 18&nbsp;ns at 1k, 48 at 10k, 226 at 100k (<code>bench/split.mjs</code>): the fill was bound by memory, a <code>Voice</code>, a <code>Lane</code> and a patch buffer per voice. A <code>keys</code> or stateless <code>fn</code> row reads its voice the same way and then does what a lane does for one subject: reads the stops, or calls the patch through the same function a lane calls it through. Rows join at the end as voices are cued, so they stay in voice order; a retired voice&rsquo;s row is left empty until the crowd is more than half empty rows, when the next probe slides the rows left down over the empty ones, in order. Rebuilding the crowds through a full qualify instead made that one frame about 9&nbsp;ms at 10k rows; sliding them keeps the worst of 12,000 <code>churn</code> frames near 3&nbsp;ms, which is collection (teitou, 2026-10-04). Measured on teitou against the build before (2026-10-03, five alternated passes, ms a frame): 10k <code>keys</code> voices of one subject each by probe 1.30&ndash;1.89 from 1.60&ndash;1.77, and by <code>pull</code> 1.25&ndash;2.01 from 1.71&ndash;1.84, both bimodal from run to run; 10k <code>fn</code> voices by <code>pull</code> 0.93&ndash;1.18 from 1.36&ndash;1.52, and by probe 1.20&ndash;1.43 against 1.09&ndash;1.21, which a profiled run reversed. Folding each row's segment straight into the lanes, through the same search <code>readKeys</code> makes, rather than through a delta, took 10k such rows read by <code>pull</code> to 0.74 of their time and by probe to 0.85 (teitou, 2026-10-04). A row's stops are now copied as it joins into one array the crowd keeps for all its rows, phase, then value per axis, so a fill reads them in place rather than through the row's voice, its tracks and each stop's own array; the search is still the one <code>segment</code> makes. A row whose stops are not plain numbers on the channel's axes reads them through its voice as before. That took 10k such rows read by <code>pull</code> from 0.66&ndash;1.02&nbsp;ms a frame to 0.34&ndash;0.36, 34&ndash;36&nbsp;ns a subject against 25 a subject and voice for keys voices over every subject, and by probe from 1.00&ndash;1.37 to 0.81&ndash;0.83 (teitou, 2026-10-05). Copying the stops into each voice's own flat arrays, with the row still reaching them through its voice, had measured no different. With one of 10k tween voices stopped, its subject dropped and another cued every frame (<code>churn</code>), a frame costs 0.91&ndash;1.01&nbsp;ms by probe and 0.54&ndash;0.62 by <code>pull</code>, from 9.82&ndash;9.94 and 8.73&ndash;9.07. A <code>fn</code> voice of one subject writing two channels, which took the general path before crowds took several channels, reads at 0.65&times; at 1k voices and 0.79&times; at 10k, and 1.05&ndash;1.11&times; at 100, where a fill&rsquo;s bookkeeping costs more than the general path it saves (rows <code>named</code>, teitou, 2026-10-04). A voice naming several subjects has a lane of its own.</li>
847
+ <li><b>Single-subject voices share a crowd.</b> A laned voice whose <code>subjects</code> names one subject does not get a lane of its own, since it shares nothing across subjects for a lane to save: every such voice writing the same channels is a row in one crowd, in voice order. A motion voice writes one channel; a <code>keys</code> or stateless <code>fn</code> voice may write several. Crowds that share a channel take turns in voice order as a fill runs them, so a subject&rsquo;s rows fold in the order the general path folds them. A row holds the per-subject numbers a lane keeps and a copy of what a fill reads of the voice (clock, rate, weight) and of its current stretch. The voice&rsquo;s copy is rewritten when a handle or the mix changes the voice; the stretch&rsquo;s when the patch replaces it (<code>Motions.touched</code>). A fill reads the crowd&rsquo;s flat arrays and calls the same <code>closed</code> forms, and folds each row between the lanes on either side of its voice, so the order of the fold, and so its bits, are the general path&rsquo;s. A row with a fade, a ramp, a change pending or a probe already this frame takes the per-subject step instead. Measured on teitou against the build before (2026-10-03, three alternated passes of <code>bench/frame.mjs</code>): 10k tween voices of one subject each cost 0.54&ndash;0.59 of what they did, as much as one tween voice over the same 10k subjects; 100k cost 0.31&ndash;0.33; 10k springs 0.64&ndash;0.70. Before it, the extra cost of a voice over a shared voice grew with the count, 18&nbsp;ns at 1k, 48 at 10k, 226 at 100k (<code>bench/split.mjs</code>): the fill was bound by memory, a <code>Voice</code>, a <code>Lane</code> and a patch buffer per voice. A <code>keys</code> or stateless <code>fn</code> row reads its voice the same way and then does what a lane does for one subject: reads the stops, or calls the patch through the same function a lane calls it through. Rows join at the end as voices are cued, so they stay in voice order; a retired voice&rsquo;s row is left empty until a quarter of the crowd&rsquo;s rows are empty, past 64 of them, when the next probe slides the rows left down over the empty ones, in order. Rebuilding the crowds through a full qualify instead made that one frame about 9&nbsp;ms at 10k rows; sliding them keeps the worst of 12,000 <code>churn</code> frames near 3&nbsp;ms, which is collection (teitou, 2026-10-04). Measured on teitou against the build before (2026-10-03, five alternated passes, ms a frame): 10k <code>keys</code> voices of one subject each by probe 1.30&ndash;1.89 from 1.60&ndash;1.77, and by <code>pull</code> 1.25&ndash;2.01 from 1.71&ndash;1.84, both bimodal from run to run; 10k <code>fn</code> voices by <code>pull</code> 0.93&ndash;1.18 from 1.36&ndash;1.52, and by probe 1.20&ndash;1.43 against 1.09&ndash;1.21, which a profiled run reversed. Folding each row's segment straight into the lanes, through the same search <code>readKeys</code> makes, rather than through a delta, took 10k such rows read by <code>pull</code> to 0.74 of their time and by probe to 0.85 (teitou, 2026-10-04). A row's stops are now copied as it joins into one array the crowd keeps for all its rows, phase, then value per axis, so a fill reads them in place rather than through the row's voice, its tracks and each stop's own array; the search is still the one <code>segment</code> makes. A row whose stops are not plain numbers on the channel's axes reads them through its voice as before. That took 10k such rows read by <code>pull</code> from 0.66&ndash;1.02&nbsp;ms a frame to 0.34&ndash;0.36, 34&ndash;36&nbsp;ns a subject against 25 a subject and voice for keys voices over every subject, and by probe from 1.00&ndash;1.37 to 0.81&ndash;0.83 (teitou, 2026-10-05). Copying the stops into each voice's own flat arrays, with the row still reaching them through its voice, had measured no different. With one of 10k tween voices stopped, its subject dropped and another cued every frame (<code>churn</code>), a frame costs 0.91&ndash;1.01&nbsp;ms by probe and 0.54&ndash;0.62 by <code>pull</code>, from 9.82&ndash;9.94 and 8.73&ndash;9.07. A <code>fn</code> voice of one subject writing two channels, which took the general path before crowds took several channels, reads at 0.65&times; at 1k voices and 0.79&times; at 10k, and 1.05&ndash;1.11&times; at 100, where a fill&rsquo;s bookkeeping costs more than the general path it saves (rows <code>named</code>, teitou, 2026-10-04). A voice naming several subjects has a lane of its own.</li>
842
848
  <li><b>A locus runs on lanes in two passes.</b> The general path folds a locus at its first member&rsquo;s place in the voice order, and which voice that is differs by subject. So a fill first takes each locus&rsquo;s members in voice order and, for each subject, sums their weights and lerps their values by share of the weight taken, as <code>foldLocus</code> does, with the stock channels&rsquo; own lerp; then, folding the voices in order, it folds each subject&rsquo;s locus in at whichever member met that subject first. A locus&rsquo;s members are on lanes all together or not at all, since a member left on the general path would count in its sum there but not on a lane; a motion member keeps the whole locus off. A blend&rsquo;s signal is read by whichever member fills a subject first, and kept by subject number for the others. On teitou (2026-10-04) three voices in a locus over 10k subjects take 0.72 of the time they did on the general path, and <code>mix.blend</code> of three by a stateless signal 0.84.</li>
843
- <li><b><code>sync</code> visits only the voices with something due.</b> Each live voice sits in a heap by the earliest mix time a sync would change it: its start, its anchored out, the end of its last pass, the end of a fade or of a subject&rsquo;s ramp out. A handle change that could bring that earlier files it again. A voice whose time cannot be put ahead, one fading to rest or held and running backwards, is visited every frame. <code>test/due.test.ts</code> plays random mixes both ways, against a full walk, and compares states, poses and marks after every sync.</li>
849
+ <li><b><code>sync</code> visits only the voices with something due.</b> Each live voice sits in a heap by the earliest mix time a sync would change it: its start, its anchored out, the end of its last pass, the end of a fade or of a subject&rsquo;s ramp out. A handle change that could bring that earlier files it again. A voice whose time cannot be put ahead, one fading to rest or frozen and running backwards, is visited every frame. <code>test/due.test.ts</code> plays random mixes both ways, against a full walk, and compares states, poses and marks after every sync.</li>
844
850
  <li><b>Lanes give the same pose, to the bit.</b> Both paths call the same functions for phase, envelope, weight clamp, each stock channel's fold, the easing curves and the spring solver, and clamp <code>bounds</code> the same way after. <code>test/lanes.test.ts</code> runs every case both ways and compares each probe with <code>Object.is</code>. <code>weightOf</code>, <code>atRest</code> and a finite loop's end read what the fill keeps.</li>
845
851
  <li><b>Two things differ, both documented on the API.</b> A stateless patch calling <code>setting.send</code> from <code>at</code> sends every frame for every subject a lane fills, which is each subject probed that frame or the last, whether the host reads it this frame or not. A patch that first calls <code>setting.keep</code> partway through playing, from a call for a subject the host probed the frame before and not that frame, advances that state once more for that subject than the general path would; a patch that keeps state from its first call, or declares <code>state</code>, never does.</li>
846
852
  <li><b>Motion runs on lanes as data.</b> <code>spring</code>, <code>glide</code> and <code>tween</code> are their own form so a lane can sample each subject's stretch from the patch's arrays rather than calling <code>at</code>. A fill samples only where sampling changes nothing; a subject with a change to apply is left to the general path, so a retarget lands where it would with lanes off.</li>
@@ -906,7 +912,7 @@ export const mixer: Engine; <span class="cm">// the one that ships</span></cod
906
912
  <li><b>Fade continuity.</b> A voice stopped mid-pass with a fade produces no frame-to-frame jump larger than its per-frame ramp, on every channel with rest.</li>
907
913
  <li><b>Retarget continuity.</b> A <code>from: 'current'</code> voice cued on a moving subject has a first pose equal to the subject's previous pose.</li>
908
914
  <li><b>Band quiet.</b> A signal held inside a <code>gate</code>'s band for a whole pass produces no switch, whatever it does inside it. Same for a rest-less channel's contribution under a weight that wanders between <code>off</code> and <code>on</code>.</li>
909
- <li><b>Locus fold.</b> Two voices in a locus driving the same value produce that value, on every stock channel &mdash; the invariant the old cap met on <code>sum</code> alone. At unequal values they produce the channel's <code>lerp</code> at their weight ratio, which on <code>sum</code> is the number scaling and merging already gives and is what keeps klieg's baselines green. A locus whose weights sum below 1 scales the folded value toward rest by the sum; above 1 it caps at 1.</li>
915
+ <li><b>Locus fold.</b> Two voices in a locus driving the same value produce that value, on every stock channel &mdash; the invariant the old cap met on <code>sum</code> alone. At unequal values they produce the channel's <code>lerp</code> at their weight ratio, which on <code>sum</code> is the number scaling and merging already gives and is what keeps klieg's baselines green. A locus whose weights sum below 1 scales the folded value toward rest by the sum, channel by channel over the members writing it; above 1 it caps at 1.</li>
910
916
  <li><b>Handover.</b> <code>fade({ at: 'rest' })</code> removes per subject at the first resting frame, and at the deadline for a patch that never rests.</li>
911
917
  <li><b>Declared writes.</b> A patch's <code>at</code> sets no key outside its <code>writes</code>, on every subject and every pass. A voice whose patch writes a channel the kit lacks throws at <code>cue</code> naming both.</li>
912
918
  <li><b>Reading another time.</b> A projection ahead gives what the mix probes when it gets there, and moves nothing: a mix read ahead every frame plays exactly as one never read. A read back under <code>stepMs</code> equals the pose the mix showed at every frame, through fades, rate and weight changes, seeks and voices that have left, and a round trip back and forth repeats it. History stays inside its horizon.</li>
@@ -919,8 +925,8 @@ export const mixer: Engine; <span class="cm">// the one that ships</span></cod
919
925
 
920
926
  <h2 id="open">Open</h2>
921
927
  <ul>
922
- <li><b>The shared channel list.</b> Channels a second package might write onto another's subject, fixed with a kind, a unit and a rest so that hosts and packages agree past what <code>kind</code> checks. Candidates, names unpicked: <code>opacity</code> mul 0..1; <code>scale</code> mul factor; <code>position</code> vec(3, sum) world units; <code>rotation</code> vec(3, sum) radians; <code>bump</code> vec(2, sum) CSS px, picked on 2026-09-30 over <code>offset</code>, which blits already split into delta and pose; <code>turn</code> sum radians, the flat rotation, counting past 2π so a wheel can spin many times, which is why it blends in a straight line &mdash; an orientation that wraps, where 350° and 10° must meet at 0°, needs its own channel with a <code>lerp</code> the short way round, and weasel's rig plans one; <code>color</code> as <code>color()</code>, OKLab with coverage. Undecided too: whether wod's radial push takes a shared name, and whether sherpa's <code>clip</code> and wod's <code>aperture</code> are one shared <code>reveal</code>.</li>
923
- <li><b>What the score still lacks.</b> The score is built (see Score). Decided 2026-09-29 and not built: <b>marks placed inside a voice</b>, such as a hit 340&nbsp;ms in, as named marks beside its four. <b>Tags absorb loci</b>: a tag declared to fold its voices as alternatives does what <code>locus</code> does now; where the declaration lives is not decided. <b>Splitting a read ahead at known events</b>, one step per interval between a voice starting, a fade ending and a loop running out; today a read ahead crosses the gap in one step and says <code>stepped</code> where that matters.</li>
928
+ <li><b>The shared channel list.</b> Channels a second package might write onto another's subject, fixed with a kind, a unit and a rest so that hosts and packages agree past what <code>kind</code> checks. Candidates, names unpicked: <code>opacity</code> mul 0..1; <code>scale</code> mul factor; <code>position</code> vec(3, sum) world units; <code>rotation</code> vec(3, sum) radians; <code>bump</code> vec(2, sum) CSS px, picked on 2026-09-30 over <code>offset</code>, which blits already split into delta and pose; <code>turn</code> sum radians, the flat rotation, counting past 2π so a wheel can spin many times, which is why it blends in a straight line &mdash; an orientation that wraps, where 350° and 10° must meet at 0°, is <code>angle</code>, whose <code>lerp</code> goes the short way round; <code>color</code> as <code>color()</code>, OKLab with coverage. Undecided too: whether wod's radial push takes a shared name, and whether sherpa's <code>clip</code> and wod's <code>aperture</code> are one shared <code>reveal</code>.</li>
929
+ <li><b>What the score still lacks.</b> The score is built (see Score). Decided 2026-09-29 and not built: <b>marks placed inside a voice</b>, such as a hit 340&nbsp;ms in, as named marks beside its five. <b>Tags absorb loci</b>: a tag declared to fold its voices as alternatives does what <code>locus</code> does now; where the declaration lives is not decided. <b>Splitting a read ahead at known events</b>, one step per interval between a voice starting, a fade ending and a loop running out; today a read ahead crosses the gap in one step and says <code>stepped</code> where that matters.</li>
924
930
  <li><b>What spans do not do yet.</b> Hold back part of a budget for children the host knows are coming, short of a strategy of its own; skip a span held by a span; send the events a skipped child would have sent on the way. Found while building them, 2026-10-07.</li>
925
931
  <li><b>The package name.</b> Published as <code>@msb235/blits</code> for now, decided 2026-09-30, while npm answers a request for the bare <code>blits</code>, which the registry 404s. Moving to <code>blits</code> later changes every import. <code>@lightningjs/blits</code> is a live TV app framework with its own animation plugin, so searches for the name land there first.</li>
926
932
  <li><b>Whether <code>at</code> writes into a buffer: no, it returns.</b> A patch allocates one partial delta per subject per frame, and <code>writes</code> plus the fold order would let the mix hand it one scratch object instead. The port measured what that would buy: a frame of klieg&rsquo;s motion — 12 letters through four voices — costs 0.017&nbsp;ms, and a frame of its effects — 60 parts under three effects — costs 0.134&nbsp;ms, against a 16.7&nbsp;ms budget. Under 1% of a frame is not worth the nicest property <code>at</code> has, which is being a pure function a test calls once. The p99 of the effects frame is 1.3&nbsp;ms, which is the collector catching up on exactly these allocations, so a subject count an order of magnitude past klieg&rsquo;s would reopen it. <code>packages/core/test/bench/frame-cost.bench.ts</code> in klieg is the measurement</li>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@msb235/blits",
3
- "version": "0.7.1",
3
+ "version": "0.8.1",
4
4
  "description": "Concurrent effects: each on its own clock with its own state and weight, folded into one value per subject per frame",
5
5
  "keywords": [
6
6
  "animation",
@@ -26,6 +26,10 @@
26
26
  ".": {
27
27
  "types": "./dist/index.d.ts",
28
28
  "default": "./dist/index.js"
29
+ },
30
+ "./testing": {
31
+ "types": "./dist/testing.d.ts",
32
+ "default": "./dist/testing.js"
29
33
  }
30
34
  },
31
35
  "files": [
@@ -37,10 +41,11 @@
37
41
  "build": "tsc -b",
38
42
  "test": "vitest run",
39
43
  "test:watch": "vitest",
44
+ "test:general": "BLITS_LANES=off vitest run",
40
45
  "typecheck": "tsc -b && tsc -p tsconfig.test.json && tsc -b packages/quarks && tsc -p packages/quarks/tsconfig.test.json",
41
46
  "lint": "biome check .",
42
47
  "check": "npm run lint && npm run typecheck && npm run test",
43
- "prepack": "npm run build",
48
+ "prepack": "rm -rf dist .tsbuildinfo && npm run build",
44
49
  "prepublishOnly": "npm run check",
45
50
  "bench": "npm run build && node bench/frame.mjs",
46
51
  "site": "npm run dev -w site",
@@ -53,7 +58,7 @@
53
58
  "devDependencies": {
54
59
  "@biomejs/biome": "^2.5.8",
55
60
  "@types/node": "^26.2.0",
56
- "@weasel-js/history": "^1.9.1",
61
+ "@weasel-js/history": "1.9.2",
57
62
  "typescript": "^7.0.2",
58
63
  "vitest": "^4.1.10"
59
64
  }