@msb235/blits 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/schema.html CHANGED
@@ -160,7 +160,7 @@
160
160
  <h1>An effects schema, and the API around it</h1>
161
161
  <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>
162
162
  <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>
163
- <span class="status">Built · v0.3.0 · design 2026-09-15, package 2026-09-28</span>
163
+ <span class="status">Built · v0.4.0 · design 2026-09-15, package 2026-09-28</span>
164
164
  </header>
165
165
 
166
166
  <h2 id="picture">In one picture</h2>
@@ -381,12 +381,15 @@ interface Setting&lt;S = void&gt; {
381
381
  start?: number;
382
382
  /** Playback rate. 1 is real time; 0 freezes. */
383
383
  rate?: number;
384
- <span class="cm">/** true loops for good, false plays one pass, n plays n passes. A finite loop leaves when its passes are done — over <code>fade.out</code> where one is set, at once where none is. */</span>
384
+ <span class="cm">/** true loops for good, false plays one pass, n plays n passes. A finite loop leaves when its passes are done — over <code>fade.out</code> where one is set, at once where none is — unless it holds after. */</span>
385
385
  loop?: boolean | number;
386
386
  <span class="cm">/** Per-subject delay in voice ms. klieg's stagger grammar stays klieg's and passes in the one
387
387
  thing the mix needs: how long this subject waits. Asked once per subject on first sight and
388
388
  kept, like <code>target</code>; the subject's fade in starts when the voice clock reaches it. */</span>
389
389
  stagger?: (subject: I) =&gt; number;
390
+ <span class="cm">/** Show the first frame before a subject starts, the last after its passes run out, or both. Default: neither,
391
+ so the voice shows nothing outside its passes. See Holding. */</span>
392
+ hold?: 'before' | 'after' | 'both';
390
393
 
391
394
  <span class="cm">// weight</span>
392
395
  <span class="cm">/** Steady weight, or a signal read per subject per frame. Default 1. */</span>
@@ -416,7 +419,8 @@ interface Setting&lt;S = void&gt; {
416
419
 
417
420
  interface Handle&lt;I&gt; {
418
421
  readonly id: number;
419
- readonly state: 'pending' | 'live' | 'fading' | 'done';
422
+ <span class="cm">/** <code>held</code>: a voice holding after has played every pass and shows its last frame until faded. */</span>
423
+ readonly state: 'pending' | 'live' | 'held' | 'fading' | 'done';
420
424
  <span class="cm">/** Live. Writes land on the next sync. */</span>
421
425
  weight: number;
422
426
  rate: number;
@@ -426,15 +430,31 @@ interface Handle&lt;I&gt; {
426
430
  seek(elapsed: number): void;
427
431
  <span class="cm">/** Moves the rate to `rate` linearly over `over` mix ms. The voice clock integrates it, so speed eases too. */</span>
428
432
  ramp(rate: number, over: number): void;
429
- <span class="cm">/** Ramps out over `fade.out` unless given a fade here; `at: 'rest'` waits per subject for a resting pose first. */</span>
430
- fade(opts?: { over?: number; at?: 'rest'; deadline?: number }): void;
433
+ <span class="cm">/** Ramps out over `fade.out` unless given a fade here; `at: 'rest'` waits per subject for a resting pose first.
434
+ Given a `subject`, fades that subject alone out of this voice, which plays on for the rest. */</span>
435
+ fade(opts?: { over?: number; at?: 'rest'; deadline?: number } | { subject: I; over?: number }): void;
431
436
  <span class="cm">/** The weight this voice gave `subject` the last frame it was probed: after fades and its signal, before a
432
437
  locus folds it. 0 where it gave none. One number on a record the mix keeps anyway, so free unasked. */</span>
433
438
  weightOf(subject: I): number;
434
439
  <span class="cm">/** Resolves when the voice has been removed from the mix, however that happened. */</span>
435
440
  readonly done: Promise&lt;void&gt;;
441
+ <span class="cm">/** Resolves true when a finite loop's last pass ends, for the latest-staggered subject it has seen, and false
442
+ if the voice leaves before that. Never rejects. */</span>
443
+ readonly played: Promise&lt;boolean&gt;;
436
444
  }</code></pre>
437
445
 
446
+ <p><b>Fading one subject.</b> <code>fade({ subject, over })</code> ramps one subject out of a voice and leaves the voice playing for every other subject; <code>over: 0</code> takes it out at once. When the ramp ends the voice forgets the subject &mdash; its record, its lane position and a motion patch&rsquo;s state for it &mdash; and no longer reaches it, so nothing it held stays stacked under whatever takes the subject over. A motion patch&rsquo;s <code>to</code> brings it back, met afresh: <code>from</code>, <code>target</code> and <code>stagger</code> are asked again. Any other voice reaches it again only once <code>drop</code> forgets the subject. This is what lets a host that starts many short animations, one per node, run them as one voice on one lane instead of a voice per call, while still cancelling a node or handing it to a different patch.</p>
447
+
448
+ <p><b>Holding.</b> A voice shows nothing outside its passes unless it holds. <code>hold: 'before'</code> shows a subject the voice&rsquo;s first frame from the cue until the subject starts: while the voice is pending, and while the subject waits out its <code>stagger</code>. <code>hold: 'after'</code> shows the last frame of the last pass once a finite loop has played out, and the voice stays until <code>fade()</code> takes it out, ramping from that frame. <code>'both'</code> does both. wod asked for it to replace WAAPI&rsquo;s <code>fill</code>: a spin lands and holds its angle, and staggered wedges wait on their first frame rather than popping in.</p>
449
+ <ul>
450
+ <li><b>A held subject&rsquo;s clock stands still.</b> It reads phase 0 of pass 0 before, and phase 1 of the last pass after, with <code>elapsed</code> pinned to that edge. <code>at</code> is asked for that frame. <code>step</code> does not run while a subject waits, so a stateful patch starts playing from the state it was cued with; after, it runs once more to where the last pass ended and then no more, so the held frame does not depend on when the last frame before it fell. A voice holding before stays <code>pending</code> until it starts, and <code>weightOf</code> reports the weight it gives a waiting subject.</li>
451
+ <li><b><code>fade.in</code> counts from the first frame the voice shows</b>, so with <code>'before'</code> it ramps in on the held frame rather than at full weight, and does not dip when play begins.</li>
452
+ <li><b>A voice holding after reaches <code>held</code>, not <code>fading</code>,</b> at the moment a voice without a hold would begin its fade out. <code>played</code> resolves true then. Its <code>out</code> and <code>end</code> marks are unfixed until it is faded, like those of a voice that loops for good, so <code>{ after: 'spin' }</code> waits for the fade. A host that wants to start something when the passes run out awaits <code>played</code>.</li>
453
+ <li><b>The host owns every held voice.</b> One cued per spin and never faded stays in the mix for good, one more voice folded every frame. The next voice&rsquo;s cue is the time to fade the last, at once or <code>at: 'rest'</code>.</li>
454
+ <li><b>Where it changes nothing.</b> A voice that loops for good never runs out of passes, so only <code>'before'</code> applies to it. A motion patch (<code>spring</code>, <code>glide</code>, <code>tween</code>) already holds its target and has no passes, so <code>hold</code> does nothing to it. Lanes give the same pose with it as without them, as for everything else.</li>
455
+ <li><b>What it costs.</b> A voice holding before runs on the general path while it is pending, which takes the channels it writes off their lanes until it starts; one that is already live, waiting only on a subject&rsquo;s stagger, stays on its lane. A mix with no hold reads no slower (<code>keys</code> and <code>fn</code> rows of <code>bench/frame.mjs</code>, three passes alternated against <code>main</code> on a laptop, 2026-10-03).</li>
456
+ </ul>
457
+
438
458
  <p>Weights are the only knob the mix owns. Fade, crossfade, handover, alternatives, and hinge's continuous mode are all weights over time; the section on blending shows each one as such. A patch never sees a weight applied to its output, it only sees the number in <code>setting.weight</code> if it cares.</p>
439
459
 
440
460
  <h2 id="mix">Mix</h2>
@@ -492,6 +512,9 @@ interface Mix&lt;I, O&gt; {
492
512
  `blend` make it true again at once; the mix never calls back into the host, so restarting the loop is
493
513
  the caller's to do on the same path that cued. */</span>
494
514
  readonly live: boolean;
515
+ <span class="cm">/** Another frame would change no pose: every voice is done, held at a plain weight, or a motion whose every
516
+ subject has landed. A host may sleep its loop while this is true and `live` still is. */</span>
517
+ readonly inert: boolean;
495
518
  <span class="cm">/** Fades every voice out: over `over` when given, over each voice's own `fade.out` otherwise. */</span>
496
519
  mute(opts?: { over?: number }): void;
497
520
  <span class="cm">/** Forgets per-subject state, a motion patch's included. Call when a subject is retired, or let the WeakMap do it. */</span>
@@ -530,7 +553,7 @@ function gate&lt;I&gt;(of: Signal&lt;I&gt;, at: number): Signal&lt;I&gt;;
530
553
  <li><b>Time away is the host's to call.</b> A hidden tab gets no frames, so its next <code>sync</code> can be an hour past the last. By default that hour is a gap like any other: fades and finite loops finish, and every subject catches up by all of it. A host that calls <code>mix.rebase()</code> on <code>visibilitychange</code> says the gap does not count, and the mix clock &mdash; what <code>setting.timestamp</code> reads, and what voices, fades and subjects run on &mdash; resumes where it was left. It is then the host's timestamp less the time taken out, and a <code>start</code> the host gives is read on the host's clock and converted. <code>maxDt</code> caps the <code>dt</code> a <code>step</code> or signal is handed for any gap, the net an integrator wants; it is off by default because a patch with a closed form wants the whole gap.</li>
531
554
  <li><b>There is no frame rate, only an optional step interval.</b> A frame is a reading, so a stateless patch reads the same at any rate. A <code>step</code> handed the frame's gap does not: an integrator gives a different answer at 30 and 144&nbsp;fps. <code>stepMs</code> runs <code>step</code> once per whole interval instead, on a grid counted from when the subject's delay ran out, carrying the remainder, so the answer and every event's time are the same at any frame rate. <code>maxDt</code> then caps how many intervals one sample runs. Signals and <code>at</code> still read the frame. The host's clock is whatever it passes <code>sync</code>: a rAF timestamp and <code>performance.now()</code> share an origin, and <code>Date.now()</code> does not.</li>
532
555
  <li><b>Events come out by draining.</b> A patch calls <code>setting.send(event)</code>, usually from <code>step</code>, and the host calls <code>mix.drain()</code> after probing to take everything sent, earliest first. Events are made while a subject catches up, so a subject nobody probes has sent nothing yet, and promptness is the host's to buy by probing. Under <code>stepMs</code> each is stamped with the end of the interval it happened in, so the events and their times do not depend on how the probes are spaced. Time taken out by <code>rebase</code> sends nothing. The exception is a stateless patch sending from <code>at</code> on a lane: a lane fills every subject the mix has met, so it sends for each of them every frame, probed or not. This is the small half of weasel's events: no booking ahead against an outside clock, and no retraction.</li>
533
- <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, 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>
556
+ <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>
534
557
  <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>
535
558
  </ul>
536
559
 
@@ -564,13 +587,13 @@ const g = glide&lt;Node, Pose&gt;('x', { from: 0, velocity: 1200, ms: 325 }); <
564
587
 
565
588
  const t = tween&lt;Node, Pose&gt;('x', { from: (n) =&gt; n.x, to: (n) =&gt; n.goal, ms: 400, ease: 'ease-out' });
566
589
  t.to(node, 40); <span class="cm">// from wherever it is, over a full 400 ms again</span></code></pre>
567
- <p>A <b>tween</b> has no momentum: it is an easing over <code>ms</code> from one value to another. It is a motion patch rather than a <code>keys</code> one because its endpoints are each subject's own data, read once when the mix first meets the subject, and it retargets one subject at a time like a spring. A retarget starts from where the subject is and runs the easing again from 0, so its velocity jumps where a spring's would not. A voice's <code>loop</code> and <code>period</code> do not repeat it. It takes the easings <code>keys</code> takes; its velocity, which <code>read</code> reports, is the easing's slope, which a lane never computes, since a mix only wants the value.</p>
590
+ <p>A <b>tween</b> has no momentum: it is an easing over <code>ms</code> from one value to another. It is a motion patch rather than a <code>keys</code> one because its endpoints are each subject's own data, read once when the mix first meets the subject, and it retargets one subject at a time like a spring. A retarget starts from where the subject is and runs the easing again from 0, so its velocity jumps where a spring's would not. <code>ms</code> may be a function of the subject, asked each time one of its stretches starts, so a retarget can take its own duration. A voice's <code>loop</code> and <code>period</code> do not repeat it. It takes the easings <code>keys</code> takes; its velocity, which <code>read</code> reports, is the easing's slope, which a lane never computes, since a mix only wants the value.</p>
568
591
  <ul>
569
592
  <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>
570
593
  <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>
571
594
  <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>
572
595
  <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.</li>
573
- <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>, 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>
596
+ <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>
574
597
  <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>
575
598
  </ul>
576
599
 
@@ -643,17 +666,19 @@ export const mixer: Engine; <span class="cm">// the one that ships</span></cod
643
666
  <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>
644
667
  <tr><td>voice</td><td>a number weight, with fades and <code>mute</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, <code>locus</code>, <code>from: 'current'</code>, <code>fade({ at: 'rest' })</code></td></tr>
645
668
  </table></div>
646
- <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 projection always reads through the general path.</p>
669
+ <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 one laned channel is the exception: it joins its channel&rsquo;s crowd, 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>
647
670
  <ul>
648
671
  <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>
649
672
  <li><b>The first probe after a <code>sync</code> fills every lane</b>, for every numbered subject each laned voice reaches, then copies the subject's values into the pose; later probes that frame only copy. 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>
650
673
  <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>
674
+ <li><b>Single-subject voices share a crowd.</b> A laned voice whose <code>subjects</code> names one subject and that writes one channel does not get a lane of its own: every such voice on a channel is a row in one crowd, in voice order, holding 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 qualifies again and rebuilds it. 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. Reading the stops as flat numbers instead of through <code>readKeys</code> was tried and 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 voice naming several subjects, or one naming one and writing several channels, still has a lane of its own, which a stateless <code>fn</code> or <code>keys</code> one leaves idle: its subject takes the general path.</li>
675
+ <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>
651
676
  <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>
652
677
  <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, probed or not. A patch that first calls <code>setting.keep</code> partway through playing, from a call for a subject the host did not probe 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>
653
678
  <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>
654
679
  </ul>
655
680
  <p>What they cost, measured on the fleet on 2026-10-02 against the build before them (the table is in <code>spikes/gpu-engine/README.md</code>, under Lanes, as built): three <code>keys</code> voices over 10,000 subjects fell from 6.1 to 2.6&nbsp;ms a frame, and a stateless <code>fn</code> gained up to 37%. A spring frame first rose from 4.3 to 5.4&nbsp;ms; once the spring solver stopped allocating per sample, a rerun put a spring frame at 0.53&ndash;0.72 of the earlier build's, <code>keys</code> at 0.38&ndash;0.57 and a stateless <code>fn</code> at 0.70&ndash;0.88. A host that probed 5% of the subjects it had met paid for all of them, 1.3&nbsp;ms against 0.1 at 10,000, until resting lanes brought that to about 0.15&nbsp;ms (one local machine). And a probe costs about 110&ndash;140&nbsp;ns at 10,000 subjects even when a lane did all the work, so a host probing every subject pays 1.1&ndash;1.4&nbsp;ms before any arithmetic.</p>
656
- <p><b>Reading in bulk.</b> <code>mix.pull(subjects, { position, gain })</code> writes each subject's values into the host's own arrays instead of a pose object. It does everything a probe does for each subject in turn, so it gives the same values, to the bit; <code>test/lanes.test.ts</code> runs every case through it too. While every voice is on a lane, a subject the lanes filled is copied straight from them; any other subject is folded through the general path into one scratch pose and copied from that. Measured on the fleet on 2026-10-02 (msb-uai, three passes of <code>bench/frame.mjs</code>), a frame read through <code>pull</code> costs 0.84 of one read through <code>probe(subject, out)</code> for three <code>keys</code> voices over 10,000 subjects, 0.89 at 1,000, 0.91 for a spring and 0.93 for a stateless <code>fn</code>. What is left of a probe&rsquo;s cost is per subject: finding its record, and the lane bookkeeping that decides which subjects a fill skips. Taking that out means keeping subject numbers between calls and doing the bookkeeping for a whole call at once; it is not built.</p>
681
+ <p><b>Reading in bulk.</b> <code>mix.pull(subjects, { position, gain })</code> writes each subject's values into the host's own arrays instead of a pose object. It does everything a probe does for each subject in turn, so it gives the same values, to the bit; <code>test/lanes.test.ts</code> runs every case through it too. While every voice is on a lane, a subject the lanes filled is copied straight from them; any other subject is folded through the general path into one scratch pose and copied from that. Measured on the fleet on 2026-10-02 (msb-uai, three passes of <code>bench/frame.mjs</code>), a frame read through <code>pull</code> costs 0.84 of one read through <code>probe(subject, out)</code> for three <code>keys</code> voices over 10,000 subjects, 0.89 at 1,000, 0.91 for a spring and 0.93 for a stateless <code>fn</code>. An array read again in the same order skips finding each record: the mix remembers the last array by position, with each subject&rsquo;s record chain, and takes a remembered chain while the subject in that place is the same one and the chain is current, so an array edited in place stays right. That took a frame read through <code>pull</code> to 0.77&ndash;0.89 of what it was for a tween voice over 10,000 subjects, 0.83&ndash;0.88 for a spring and 0.83&ndash;0.85 for three <code>keys</code> voices (teitou, three alternated passes, 2026-10-02). Queued lane values are then copied column by column at the end of the call, as one block where the host lists subjects in the order the mix numbered them; and a motion voice&rsquo;s lane fills in one loop that samples each subject straight from the patch&rsquo;s buffer, falling back to the per-subject step only for a subject with a change pending, an earlier stretch kept, or a probe already this frame. Against <code>main</code> before those (teitou, three alternated passes, 2026-10-02), a weasel-shaped tween voice over 10,000 subjects read by <code>pull</code> costs 0.61&ndash;0.66 of what it did, a tween 0.59&ndash;0.65, a spring 0.68&ndash;0.74; read by <code>probe</code>, a tween 0.74&ndash;0.82 and a spring per subject 0.74&ndash;0.78; <code>keys</code> and <code>fn</code> rows are unchanged. The lane bookkeeping that decides which subjects a fill skips is still per subject. A remembered array is now read from the lanes in one run inside them, the frame&rsquo;s fill and qualify checked once, not per subject, stopping at the first subject that has to take a probe&rsquo;s own path (one that moved, was relinked, has a lane still to meet, or went to the general path this frame) and resuming after it, so subjects are still read in the host&rsquo;s order. Against the build before (teitou, five alternated passes, 2026-10-03, ms a frame at 10,000 subjects): a tween voice read by <code>pull</code> 0.37&ndash;0.41 from 0.45&ndash;0.48, weasel&rsquo;s shape 0.38&ndash;0.45 from 0.46&ndash;0.51, one tween voice per subject with one replaced each frame 0.44&ndash;0.51 from 0.55&ndash;0.58; reads by <code>probe</code> unchanged.</p>
657
682
 
658
683
  <h2 id="kits">Channel kits</h2>
659
684
  <p>What each consumer declares. Each row is one system &mdash; a kit, the subject type it drives, and the mix over them &mdash; and the rules are the ones each repo already applies; the kit makes them one line each.</p>
@@ -700,6 +725,7 @@ export const mixer: Engine; <span class="cm">// the one that ships</span></cod
700
725
  <li><b>Subject registration.</b> A <code>mix.subjects(list)</code> call would let the mix step every state eagerly. It puts a second source of truth about the pool in the host. Lazy state keyed by identity, stepped by the gap in the timestamp, needs nothing from the host and is the rule klieg already proved.</li>
701
726
  <li><b>A slew in place of the band.</b> Smoothing a gate's input instead of giving it two edges. It delays the crossing without deciding it, so an input that rests on the threshold chatters anyway, and the rate needed to help at all costs visible lag.</li>
702
727
  <li><b>Normalizing a locus's weights per channel.</b> The other way to make a crossfade hold its value on <code>mul</code>: keep the scale-and-merge fold and pick a per-channel divisor. It costs the property the mixer's fold order rests on &mdash; that weights are scalars resolved for the subject before any channel is touched &mdash; and it has no answer for <code>last</code>, which has no arithmetic to normalize. The <code>lerp</code> fold gets the same result from the rule the package already has.</li>
728
+ <li><b>Removing a held voice once another covers it.</b> WAAPI drops a finished, filling animation when a later one replaces every property it sets. In a mix, &ldquo;covers&rdquo; depends on weights, loci and each channel&rsquo;s fold, and a voice leaving on its own would change the pose with no call to point at. The host fades it.</li>
703
729
  <li><b>A mix option for the lurch guard.</b> A global "normalize when over 1" would apply to effects that legitimately stack. The cap belongs to the voices that crossfade, so it is a <code>locus</code>.</li>
704
730
  </ul>
705
731
 
@@ -728,6 +754,7 @@ export const mixer: Engine; <span class="cm">// the one that ships</span></cod
728
754
  <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>
729
755
  <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>
730
756
  <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>
757
+ <li><b>Reading back across a subject faded out of a voice.</b> Once its ramp ends the voice forgets the subject, as <code>drop</code> forgets one, so a <code>project</code> back to before that keeps the subject reached by a stateless voice but has lost a motion patch's stretches for it; and a subject brought back reads as never having left. Keeping the parted records under <code>history</code> would fix both. Found 2026-10-02 while building subject fades.</li>
731
758
  <li><b>The stock band's width.</b> Whether <code>gate</code>'s default band is symmetric around <code>at</code> and how wide it is. It has to clear the frame noise in a real signal, so it is a measurement from klieg's <code>near</code> and <code>dwell</code>, not a guess.</li>
732
759
  </ul>
733
760
  </main>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@msb235/blits",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
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",