@msb235/blits 0.13.0 → 0.15.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/CHANGELOG.md +183 -0
- package/README.md +20 -3
- package/dist/ahead.d.ts +26 -0
- package/dist/ahead.js +264 -0
- package/dist/around.d.ts +11 -0
- package/dist/around.js +32 -0
- package/dist/bare.js +15 -7
- package/dist/blank.d.ts +10 -0
- package/dist/blank.js +26 -0
- package/dist/blend.d.ts +2 -1
- package/dist/blend.js +1 -6
- package/dist/bound.d.ts +70 -0
- package/dist/bound.js +351 -0
- package/dist/chain.d.ts +2 -1
- package/dist/chain.js +7 -5
- package/dist/changes.d.ts +24 -0
- package/dist/changes.js +189 -0
- package/dist/channels.d.ts +39 -13
- package/dist/channels.js +14 -5
- package/dist/closed.d.ts +1 -0
- package/dist/closed.js +45 -1
- package/dist/color.d.ts +12 -10
- package/dist/color.js +25 -25
- package/dist/columns.js +7 -3
- package/dist/contribution.d.ts +30 -0
- package/dist/contribution.js +374 -0
- package/dist/copyout.d.ts +10 -0
- package/dist/copyout.js +69 -24
- package/dist/crowd.d.ts +8 -2
- package/dist/crowd.js +4 -5
- package/dist/cue.d.ts +8 -1
- package/dist/cue.js +47 -14
- package/dist/cut.d.ts +8 -0
- package/dist/cut.js +4 -0
- package/dist/drop.d.ts +9 -0
- package/dist/drop.js +84 -0
- package/dist/due.d.ts +24 -5
- package/dist/due.js +113 -33
- package/dist/easing.d.ts +4 -4
- package/dist/easing.js +3 -3
- package/dist/everyone.d.ts +3 -2
- package/dist/everyone.js +3 -3
- package/dist/fade.d.ts +2 -1
- package/dist/fade.js +2 -1
- package/dist/fill.d.ts +3 -2
- package/dist/fill.js +51 -19
- package/dist/fit.d.ts +44 -26
- package/dist/fit.js +10 -9
- package/dist/fling.d.ts +66 -0
- package/dist/fling.js +162 -0
- package/dist/fold.d.ts +4 -37
- package/dist/fold.js +5 -512
- package/dist/gather.d.ts +12 -7
- package/dist/gather.js +179 -44
- package/dist/gone.js +2 -2
- package/dist/grid.d.ts +47 -0
- package/dist/grid.js +247 -0
- package/dist/handle.d.ts +3 -0
- package/dist/handle.js +19 -8
- package/dist/held.d.ts +2 -1
- package/dist/history.d.ts +6 -5
- package/dist/history.js +9 -9
- package/dist/hosts.js +9 -15
- package/dist/index.d.ts +5 -4
- package/dist/keyfill.d.ts +1 -1
- package/dist/keyfill.js +5 -3
- package/dist/keyrows.d.ts +12 -0
- package/dist/keyrows.js +99 -12
- package/dist/lane.d.ts +36 -6
- package/dist/lane.js +32 -1
- package/dist/lanes.d.ts +23 -8
- package/dist/lanes.js +29 -8
- package/dist/leavings.d.ts +2 -2
- package/dist/leavings.js +1 -1
- package/dist/lives.d.ts +47 -0
- package/dist/lives.js +180 -0
- package/dist/meet.d.ts +2 -2
- package/dist/meet.js +90 -4
- package/dist/mixer.d.ts +61 -35
- package/dist/mixer.js +64 -169
- package/dist/motion.d.ts +19 -106
- package/dist/motion.js +60 -13
- package/dist/motions.d.ts +62 -150
- package/dist/motions.js +57 -369
- package/dist/move.js +24 -1
- package/dist/moved.d.ts +1 -1
- package/dist/moved.js +16 -7
- package/dist/named.d.ts +0 -1
- package/dist/named.js +20 -2
- package/dist/numbers.d.ts +2 -0
- package/dist/numbers.js +4 -0
- package/dist/owed.d.ts +2 -0
- package/dist/owed.js +4 -0
- package/dist/pack.d.ts +3 -1
- package/dist/pack.js +4 -3
- package/dist/patch.d.ts +7 -3
- package/dist/patch.js +6 -2
- package/dist/places.d.ts +53 -0
- package/dist/places.js +171 -0
- package/dist/plot.d.ts +12 -0
- package/dist/plot.js +52 -0
- package/dist/project.d.ts +4 -4
- package/dist/project.js +9 -211
- package/dist/pull.js +11 -8
- package/dist/qualify.js +84 -21
- package/dist/reading.d.ts +6 -0
- package/dist/reading.js +5 -0
- package/dist/record.d.ts +5 -1
- package/dist/record.js +4 -2
- package/dist/relink.d.ts +1 -1
- package/dist/restored.d.ts +2 -2
- package/dist/restored.js +1 -1
- package/dist/rests.d.ts +9 -0
- package/dist/rests.js +76 -0
- package/dist/revive.d.ts +3 -2
- package/dist/revive.js +2 -2
- package/dist/{locus.d.ts → role.d.ts} +10 -9
- package/dist/{locus.js → role.js} +3 -3
- package/dist/roles.d.ts +10 -0
- package/dist/roles.js +158 -0
- package/dist/roster.js +3 -3
- package/dist/rotation.d.ts +1 -1
- package/dist/rotation.js +1 -1
- package/dist/rows.js +1 -1
- package/dist/sample.d.ts +2 -2
- package/dist/sample.js +33 -7
- package/dist/seek.js +13 -5
- package/dist/setting.d.ts +27 -0
- package/dist/setting.js +55 -0
- package/dist/shared.d.ts +1 -1
- package/dist/shared.js +1 -1
- package/dist/signals.d.ts +64 -32
- package/dist/signals.js +20 -18
- package/dist/stack.d.ts +5 -5
- package/dist/stack.js +5 -5
- package/dist/standing.d.ts +2 -1
- package/dist/standing.js +3 -2
- package/dist/store.d.ts +9 -0
- package/dist/store.js +24 -0
- package/dist/strays.d.ts +2 -1
- package/dist/strays.js +21 -14
- package/dist/stretch.d.ts +112 -0
- package/dist/stretch.js +9 -0
- package/dist/subject.d.ts +142 -0
- package/dist/subject.js +1 -0
- package/dist/tape.d.ts +9 -5
- package/dist/tape.js +5 -1
- package/dist/then.d.ts +9 -0
- package/dist/then.js +57 -0
- package/dist/ticker.d.ts +4 -4
- package/dist/transport.d.ts +0 -8
- package/dist/transport.js +0 -4
- package/dist/turns.d.ts +4 -3
- package/dist/turns.js +4 -3
- package/dist/types/arc.d.ts +12 -11
- package/dist/types/channel.d.ts +12 -8
- package/dist/types/history.d.ts +35 -19
- package/dist/types/mix.d.ts +222 -161
- package/dist/types/motion.d.ts +151 -0
- package/dist/types/motion.js +1 -0
- package/dist/types/patch.d.ts +69 -34
- package/dist/types/place.d.ts +51 -36
- package/dist/types/transport.d.ts +20 -18
- package/dist/types/voice.d.ts +145 -72
- package/dist/types/watch.d.ts +88 -40
- package/dist/types.d.ts +1 -0
- package/dist/voice.d.ts +9 -141
- package/dist/voice.js +30 -79
- package/dist/warn.d.ts +2 -0
- package/dist/warn.js +1 -1
- package/dist/watch.d.ts +2 -2
- package/dist/watch.js +5 -3
- package/dist/wave.d.ts +4 -3
- package/dist/wave.js +3 -2
- package/dist/weigh.d.ts +7 -1
- package/dist/weigh.js +27 -0
- package/docs/schema.html +223 -37
- package/package.json +6 -2
package/docs/schema.html
CHANGED
|
@@ -150,6 +150,7 @@
|
|
|
150
150
|
<li><a href="#klieg">The klieg port</a></li>
|
|
151
151
|
<li class="grp">Record</li>
|
|
152
152
|
<li><a href="#against">Decided against</a></li>
|
|
153
|
+
<li><a href="#chosen">Chosen while building</a></li>
|
|
153
154
|
<li><a href="#tests">Tests</a></li>
|
|
154
155
|
<li><a href="#open">Open</a></li>
|
|
155
156
|
</ol>
|
|
@@ -161,7 +162,7 @@
|
|
|
161
162
|
<h1>An effects schema, and the API around it</h1>
|
|
162
163
|
<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
164
|
<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.
|
|
165
|
+
<span class="status">Built · v0.14.0 · design 2026-09-15, package 2026-09-28</span>
|
|
165
166
|
</header>
|
|
166
167
|
|
|
167
168
|
<h2 id="picture">In one picture</h2>
|
|
@@ -267,7 +268,7 @@
|
|
|
267
268
|
merge(a: V, b: V): V;
|
|
268
269
|
<span class="cm">/** Fade toward `rest` by weight 0..1. Required when `rest` is set; absent otherwise. */</span>
|
|
269
270
|
scale?(v: V, w: number): V;
|
|
270
|
-
<span class="cm">/** Interpolate, for retargeting, for blending alternatives, and for folding a
|
|
271
|
+
<span class="cm">/** Interpolate, for retargeting, for blending alternatives, and for folding a role. */</span>
|
|
271
272
|
lerp(a: V, b: V, u: number): V;
|
|
272
273
|
<span class="cm">/** `merge(into, scale(v, w))`, written into `into` and returned, so a channel whose values are objects need not
|
|
273
274
|
allocate per contribution. The mix only hands it a value it made. */</span>
|
|
@@ -293,7 +294,7 @@ type Kit<O> = { readonly [K in keyof O]-?: Channel<NonNullable<O[K]&
|
|
|
293
294
|
<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>
|
|
294
295
|
</table></div>
|
|
295
296
|
|
|
296
|
-
<p><b>Color is a value; how it merges is a parameter.</b> A <code>color</code> value is OKLab <code>[L, a, b, coverage]</code>, and <code>oklab(0xrrggbb)</code> makes one at full coverage. <code>color()</code> averages: voices stack as a premultiplied sum, so the pose is <code>[Σw·L, Σw·a, Σw·b, Σw]</code>, which lanes and <code>pull</code> fold as any <code>vec(4, sum())</code>. blits knows no subject's own color, so the host lays the result over its base at write: <code>toHex(pose, under)</code> divides the coverage out, caps it at 1, mixes over <code>under</code> in OKLab and clips to sRGB per channel, and <code>css(pose)</code> writes an <code>oklab()</code> with the coverage as alpha. <code>color(last())</code> replaces, the last voice to pass winning behind the band. <code>mul</code> and <code>max</code> per OKLab axis mean nothing anyone asks for, so <code>color</code> refuses them; a tint, a hue shift, chroma and lightness are channels of their own. OKLab is the store because voices average per axis there, and OKLCH's hue wraps. How two colors interpolate is the channel's choice, <code>{ lerp: 'oklab' | 'oklch' }</code>: OKLab, the default, crosses opposite hues through a pale gray, and OKLCH goes round the hue as <code>mixHex</code> does
|
|
297
|
+
<p><b>Color is a value; how it merges is a parameter.</b> A <code>color</code> value is OKLab <code>[L, a, b, coverage]</code>, and <code>oklab(0xrrggbb)</code> makes one at full coverage. <code>color()</code> averages: voices stack as a premultiplied sum, so the pose is <code>[Σw·L, Σw·a, Σw·b, Σw]</code>, which lanes and <code>pull</code> fold as any <code>vec(4, sum())</code>. blits knows no subject's own color, so the host lays the result over its base at write: <code>toHex(pose, under)</code> divides the coverage out, caps it at 1, mixes over <code>under</code> in OKLab and clips to sRGB per channel, and <code>css(pose)</code> writes an <code>oklab()</code> with the coverage as alpha. <code>color(last())</code> replaces, the last voice to pass winning behind the band. <code>mul</code> and <code>max</code> per OKLab axis mean nothing anyone asks for, so <code>color</code> refuses them; a tint, a hue shift, chroma and lightness are channels of their own. OKLab is the store because voices average per axis there, and OKLCH's hue wraps. How two colors interpolate is the channel's choice, <code>{ lerp: 'oklab' | 'oklch' }</code>: OKLab, the default, crosses opposite hues through a pale gray, and OKLCH goes round the hue as <code>mixHex</code> does. Both run on lanes.</p>
|
|
297
298
|
|
|
298
299
|
<p>A channel with no rest cannot fade. The mix handles that in one way: below a weight threshold the contribution is dropped, above it the value passes through whole. That is the rule hinge's blend mode already applies to color, and it is stated here once so no consumer rediscovers it. <b>The threshold is a band, not an edge</b> — it drops at <code>off</code> and passes at <code>on</code>, and between the two it holds whatever it last did. One edge chatters: a weight resting on it is carried back and forth by frame noise, and every crossing drops or restores the whole contribution. Retargeting is the way a rest-less channel does move smoothly, since a lerp from a sampled value needs no identity. <b>The stock band is no band</b>: on at any weight above 0 and off at 0, so a fade in shows the value at once and a fade out holds it to its end. The band was meant to be sized from frame noise in klieg's <code>near</code> and <code>dwell</code>, but klieg, its one measured user, set its mix to exactly this to match <code>hinge</code>, so it is the default (2026-10-23); a host whose weight rests near a threshold passes a band of its own.</p>
|
|
299
300
|
|
|
@@ -329,7 +330,7 @@ type Kit<O> = { readonly [K in keyof O]-?: Channel<NonNullable<O[K]&
|
|
|
329
330
|
readonly reads?: readonly string[];
|
|
330
331
|
<span class="cm">/** `phase` is 0..1 across one pass, wrapping. Not asked for a subject its voice gives no weight, unless the
|
|
331
332
|
voice is fading to rest; `step` still runs. A `keys` patch's stops are not read there either, unless its
|
|
332
|
-
voice is in a
|
|
333
|
+
voice is in a role. */</span>
|
|
333
334
|
at(phase: number, subject: I, setting: Setting<S, H>): Partial<O>;
|
|
334
335
|
|
|
335
336
|
<span class="cm">/** Per-subject state, created on the first frame this patch sees a subject. */</span>
|
|
@@ -359,24 +360,25 @@ type Kit<O> = { readonly [K in keyof O]-?: Channel<NonNullable<O[K]&
|
|
|
359
360
|
}
|
|
360
361
|
|
|
361
362
|
interface Setting<S = void, H = unknown> {
|
|
363
|
+
<span class="cm">/** Read-only to a patch or signal: the mix fills it and reuses it, so a write would reach whatever it calls next. */</span>
|
|
362
364
|
<span class="cm">/** The mix clock at this frame. Identical for every sample in the frame. */</span>
|
|
363
|
-
timestamp: number;
|
|
365
|
+
readonly timestamp: number;
|
|
364
366
|
<span class="cm">/** The gap this subject is catching up by: milliseconds since it last advanced, which is the gap since
|
|
365
367
|
the previous sync only for a subject probed every frame. Infinity under reduced motion: snap, never
|
|
366
368
|
integrate. */</span>
|
|
367
|
-
dt: number;
|
|
369
|
+
readonly dt: number;
|
|
368
370
|
<span class="cm">/** Milliseconds this voice has been playing, rate applied. What lets a patch see across passes. Below 0 only
|
|
369
371
|
for a voice with no end cued backward, which plays into the passes before its first. */</span>
|
|
370
|
-
elapsed: number;
|
|
372
|
+
readonly elapsed: number;
|
|
371
373
|
<span class="cm">/** Which pass this is, 0 first; below 0 where `elapsed` is. */</span>
|
|
372
|
-
pass: number;
|
|
373
|
-
<span class="cm">/** This voice's weight for this subject this frame, after its fade envelope, its signal and its share of any
|
|
374
|
+
readonly pass: number;
|
|
375
|
+
<span class="cm">/** This voice's weight for this subject this frame, after its fade envelope, its signal and its share of any role:
|
|
374
376
|
the same number the mix is about to scale by. */</span>
|
|
375
|
-
weight: number;
|
|
377
|
+
readonly weight: number;
|
|
376
378
|
<span class="cm">/** This subject's state, when the patch declares one. */</span>
|
|
377
|
-
state: S;
|
|
379
|
+
readonly state: S;
|
|
378
380
|
<span class="cm">/** Anything the host adds for its own patches: klieg's pointer and pointerInWord go here. Typed as the mix's `H`. */</span>
|
|
379
|
-
host: H;
|
|
381
|
+
readonly host: H;
|
|
380
382
|
<span class="cm">/** The state `owner` keeps for this voice and subject, made by `init` on first ask. Held by the mix, so a read at
|
|
381
383
|
another time can copy it; a stateful signal keeps its state here and nowhere else. */</span>
|
|
382
384
|
keep<K>(owner: object, init: () => K): K;
|
|
@@ -445,12 +447,15 @@ interface VoiceSpec<I, O, H = unknown> extends SpanHints {
|
|
|
445
447
|
<span class="cm">// weight</span>
|
|
446
448
|
<span class="cm">/** Steady weight, or a signal read per subject per frame. Default 1. */</span>
|
|
447
449
|
weight?: number | Signal<I, H>;
|
|
450
|
+
<span class="cm">/** A circle the host moves, or the host's finder by index, outside which the `weight` signal is declared 0, so
|
|
451
|
+
the mix neither gives those subjects anything nor reads the signal for them. Needs `place` or `plot` first. See Signals. */</span>
|
|
452
|
+
within?: Circle | Finder;
|
|
448
453
|
<span class="cm">/** Ramp in and out, ms. Out applies on `fade()` and when a finite loop ends. */</span>
|
|
449
454
|
fade?: { in?: number; out?: number; ease?: Easing };
|
|
450
|
-
<span class="cm">/** Voices sharing a
|
|
455
|
+
<span class="cm">/** Voices sharing a role are alternatives: the mix folds them into one contribution through each
|
|
451
456
|
channel's `lerp` before anything else sees them. A crossfade between two behaviors, not what
|
|
452
457
|
klieg's phases are: those layer. */</span>
|
|
453
|
-
|
|
458
|
+
role?: string;
|
|
454
459
|
|
|
455
460
|
<span class="cm">// blend</span>
|
|
456
461
|
<span class="cm">/** A `keys` patch whose first stop reads "wherever this subject is now", lerped over the pass. Read from
|
|
@@ -510,14 +515,15 @@ interface Handle<I = unknown> {
|
|
|
510
515
|
goes back to `pending` and starts on time. */</span>
|
|
511
516
|
rise(opts?: { over?: number }): void;
|
|
512
517
|
<span class="cm">/** The weight this voice gave `subject` the last frame it was probed: after fades and its signal, before a
|
|
513
|
-
|
|
518
|
+
role folds it. 0 where it gave none. One number on a record the mix keeps anyway, so free unasked. */</span>
|
|
514
519
|
weightOf(subject: I): number;
|
|
515
|
-
<span class="cm">/** A motion patch's own `to`, `push`, and `read`, reached without holding the patch; `at` is a voice time. `to`
|
|
516
|
-
throws for a voice not playing a `spring` or a `tween`,
|
|
517
|
-
`read` gives undefined for a voice playing no motion patch. `Value` is a number or an array of them, and
|
|
520
|
+
<span class="cm">/** A motion patch's own `to`, `push`, `set`, and `read`, reached without holding the patch; `at` is a voice time. `to`
|
|
521
|
+
throws for a voice not playing a `spring` or a `tween`, `push` for one not playing a `spring` or a `glide`, and
|
|
522
|
+
`set` for one playing no motion patch; `read` gives undefined for a voice playing no motion patch. `Value` is a number or an array of them, and
|
|
518
523
|
`Motion` is `{ value, velocity }`. See Springs, glides and tweens. */</span>
|
|
519
524
|
to(subject: I, target: Value, at?: number): void;
|
|
520
525
|
push(subject: I, velocity: Value, at?: number): void;
|
|
526
|
+
set(subject: I, value: Value, at?: number): void;
|
|
521
527
|
read(subject: I, at?: number): Motion<Value> | undefined;
|
|
522
528
|
<span class="cm">/** Resolves when the voice has been removed from the mix, however that happened. */</span>
|
|
523
529
|
readonly done: Promise<void>;
|
|
@@ -578,11 +584,11 @@ interface MixOptions<H = unknown> {
|
|
|
578
584
|
interface Mix<I, O, H = unknown> {
|
|
579
585
|
<span class="cm">/** Cues a voice. Throws when the engine cannot run the patch's form, or the kit lacks a channel it `writes`. */</span>
|
|
580
586
|
cue(spec: VoiceSpec<I, O, H>): Handle<I>;
|
|
581
|
-
<span class="cm">/** N voices whose weights split one signal, k in 0..1, cued into one
|
|
587
|
+
<span class="cm">/** N voices whose weights split one signal, k in 0..1, cued into one role so they fold as
|
|
582
588
|
alternatives. Two patches crossfade; more make stops. The signal is read once per subject per
|
|
583
589
|
frame, and every member takes its share of that reading. One spec cues them all; a member given as
|
|
584
590
|
`{ patch, name?, tags?, hits? }` carries those labels in place of the spec's. */</span>
|
|
585
|
-
blend(members: readonly (Patch<I, O, any, H> | BlendMember<I, O, H>)[], by: Signal<I, H>, spec?: Omit<VoiceSpec<I, O, H>, 'patch' | 'weight' | '
|
|
591
|
+
blend(members: readonly (Patch<I, O, any, H> | BlendMember<I, O, H>)[], by: Signal<I, H>, spec?: Omit<VoiceSpec<I, O, H>, 'patch' | 'weight' | 'role'>): Handle<I>[];
|
|
586
592
|
<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>
|
|
587
593
|
owns(spec: OwnerSpec<I, H>): Handle<I>;
|
|
588
594
|
<span class="cm">/** A span: an owner that lays out the voices it holds in `order` and fits them into its `duration`. Its handle
|
|
@@ -605,6 +611,10 @@ interface Mix<I, O, H = unknown> {
|
|
|
605
611
|
that kept one from an earlier probe into the same `out` sees it change, so it copies what has to outlast the
|
|
606
612
|
next probe. */</span>
|
|
607
613
|
probe(subject: I, out?: O): O;
|
|
614
|
+
<span class="cm">/** A new empty pose, each call its own object, holding every channel with a rest in kit order. Made once and
|
|
615
|
+
passed as probe's `out` every frame, it takes numbers without boxing them, where an `out` made as `{}` can box
|
|
616
|
+
each one. */</span>
|
|
617
|
+
pose(): O;
|
|
608
618
|
<span class="cm">/** Each subject's pose written into one Float64Array per channel, in the order given: what probe(subject, out)
|
|
609
619
|
gives, without a pose object per subject. A channel of n numbers takes n places a subject. */</span>
|
|
610
620
|
pull(subjects: Iterable<I>, into: { [K in numeric channels of O]?: Float64Array }): void;
|
|
@@ -647,11 +657,12 @@ interface Mix<I, O, H = unknown> {
|
|
|
647
657
|
onWake(fn: () => void): () => void;
|
|
648
658
|
<span class="cm">/** Fades every voice out: over `over` when given, over each voice's own `fade.out` otherwise. */</span>
|
|
649
659
|
mute(opts?: { over?: number }): void;
|
|
650
|
-
<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>
|
|
660
|
+
<span class="cm">/** Forgets per-subject state, a motion patch's included, and the subject's plotted position. Call when a subject is retired, or let the WeakMap do it. */</span>
|
|
651
661
|
drop(subject: I): void;
|
|
652
662
|
<span class="cm">/** Says that something a patch reads from outside the mix has changed since the last sync, so the next probe
|
|
653
663
|
calls the patch again: for one subject, or with none for every subject. */</span>
|
|
654
664
|
touch(subject?: I): void;
|
|
665
|
+
<span class="cm">// place, plot, moved, put and around: subjects' indices and positions, and the signal over them. See Signals.</span>
|
|
655
666
|
<span class="cm">/** Every event sent since the last drain, earliest first. Drain after probing. */</span>
|
|
656
667
|
drain<E>(tag?: string): { timestamp: number; subject: I; voice: number; tags: readonly string[]; event: E }[];
|
|
657
668
|
<span class="cm">/** Hands the host each mark and hit `ahead` ms before it comes, on a clock of its own, and cancels what moves. See Time. */</span>
|
|
@@ -685,6 +696,27 @@ function share<I>(by: Signal<I>, i: number, n: number): Signal<I&
|
|
|
685
696
|
|
|
686
697
|
<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 — <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>
|
|
687
698
|
|
|
699
|
+
<p>A signal that is 0 outside a bound can say so, and the mix then reads it only inside. The host gives each subject an <b>index</b>, a small number it keeps until it leaves, and declares, per voice, which indices the signal can be above 0 for: either a circle the host moves, over positions the mix holds by index, or a finder of the host’s own, which writes indices into an array the mix lends it. A voice cued <code>within</code> either gives every subject outside nothing and is not called for them, so a lane fill visits only the indices the bound finds (<code>src/bound.ts</code>, <code>src/places.ts</code>). The circle and the finder share that one path; the circle is the stock finder, answered by a uniform grid. Indices hold their subjects as the rest of the mix does: an object weakly, anything else until <code>drop</code>; an index a subject leaves is the next one <code>place</code> hands out. The bound is a declaration, not a shape: outside production, every 8 frames, the mix reads the signal for a few subjects just outside, as at first sight (beyond a circle’s edge, or indices a finder did not find, in turn), and warns once per voice if one is above 0.</p>
|
|
700
|
+
|
|
701
|
+
<pre><code>interface Circle { x: number; y: number; r: number } <span class="cm">// read once a frame; the host moves it by writing to it</span>
|
|
702
|
+
interface Finder { find(out: Int32Array): number } <span class="cm">// writes the indices found into out, from 0; returns how many</span>
|
|
703
|
+
mix.place(subject: I): number; <span class="cm">// its index: the one it has, or a new one, a freed one first</span>
|
|
704
|
+
mix.plot(subjects: readonly I[], xy: Float64Array, cell?: number): void; <span class="cm">// indices in list order, and positions read in place; cell: the grid's, best near r</span>
|
|
705
|
+
mix.moved(): void; <span class="cm">// the host wrote new positions into xy</span>
|
|
706
|
+
mix.put(subject: I, x: number, y: number): void; <span class="cm">// moves one subject, writing into xy, or with no plot, the mix's own</span>
|
|
707
|
+
mix.cue({ patch, weight: close, within: pointer }); <span class="cm">// a circle or a finder; refused with no index handed out, or no signal weight</span>
|
|
708
|
+
mix.around(circle: Circle, opts?: { ease?: Easing }): Signal<I>; <span class="cm">// 1 at the center to 0 at r over the positions; carries its circle</span></code></pre>
|
|
709
|
+
|
|
710
|
+
<p><b>A finder</b> answers over any axes or none: a scene graph’s own spatial index, a timeline, what a culling pass found visible. The array it is lent has room for every index handed out and allocates nothing a frame; a repeat is read once, an index no subject holds now is skipped, and one never handed out is skipped with a warning outside production. A count it could not have written throws. On the general path a finder is asked once a frame, and a subject is within where its index was found; on a lane, once a fill. It reads to the bit as the same signal unbounded, and as a circle over the same positions, lanes on and off (<code>test/bound.test.ts</code>, <code>test/place.test.ts</code>; 2026-10-28).</p>
|
|
711
|
+
|
|
712
|
+
<p><b>Indices</b> come from <code>place</code>, one subject at a time, or from <code>plot</code>, which numbers its list in order and hands over the host’s positions with it. Another <code>plot</code> replaces every index. A subject placed after <code>plot</code> takes an index <code>drop</code> freed, which reads whatever the host’s array holds there, or one past the list, which has no position until <code>plot</code> is given room for it. With no <code>plot</code>, positions are the mix’s own, grown as indices are handed out, and a subject has none until <code>put</code> gives it one.</p>
|
|
713
|
+
|
|
714
|
+
<p><code>put</code> is for a host where few subjects move at once. It writes into the array <code>plot</code> was given, so the host’s array holds the move and the two ways mix freely, and it takes only a subject with an index that array has room for: one with no index, or one past the array, is refused rather than kept somewhere of its own. A subject that stays in its grid cell changes nothing but its two numbers; one that leaves is tested on its own by every query until the grid is built again, which happens once a sixteenth of the subjects have left (<code>src/grid.ts</code>). It reads to the bit as writing the array and calling <code>moved</code> (<code>test/put.test.ts</code>).</p>
|
|
715
|
+
|
|
716
|
+
<p><code>mix.around</code> is the stock signal of that shape: 1 at the circle’s center, 0 at <code>r</code> and beyond, by distance over the plotted positions, along an <code>Easing</code> read from the edge (0) to the center (1), linear by default. A voice it weighs is bounded to its circle as if cued <code>within</code> it, with no check, because the circle is the signal’s own edge; one cued before the first <code>place</code> or <code>plot</code> reads 0 everywhere until then and is bounded once either is called. It is a mix method, not a free function like <code>level</code>, because it reads the positions that mix was given. It reads to the bit as the signal written by hand over the same positions and cued <code>within</code> the same circle, and as that signal unbounded, on its own, naming its <code>subjects</code>, and in a role, whether the fill runs the role a subject at a time or gathers it in a pass of its own (<code>test/around.test.ts</code>); a bounded role member outside its circle is skipped in both (2026-10-27). Outside the circle a voice still does what a read at weight 0 does: a patch that steps steps, and a <code>from: 'current'</code> voice takes its base, so an owner’s child, a motion, a stepping patch and a <code>from: 'current'</code> voice read to the bit as unbounded too (<code>test/bound.test.ts</code>, 2026-10-28).</p>
|
|
717
|
+
|
|
718
|
+
<p><b>Under history</b> a bound reads as its signal does. Without <code>inputs</code>, a read back calls the signal live, and the bound with it. With <code>inputs</code>, an input signal’s reads are recorded per subject: a bounded one records 0 where it reads a subject outside, as the signal unbounded would, and a read back reads the recording rather than the bound as it stands now (2026-10-28). <code>around</code> is not an input, so a read back reads it with the circle where it is now.</p>
|
|
719
|
+
|
|
688
720
|
<h2 id="time">Time</h2>
|
|
689
721
|
<ul>
|
|
690
722
|
<li><b>The mix is pull-based on a host clock.</b> The host owns the frame loop and calls <code>sync(timestamp)</code> with milliseconds on any monotonic clock it likes. klieg passes its <code>Clock.now()</code>; wod passes <code>performance.now()</code>; a test passes integers. A host with no loop of its own can take <code>ticker()</code>'s: it syncs every mix it was given on each animation frame, or on a timer where there are none, as in a worker, sleeps while every mix is <code>inert</code>, wakes on <code>onWake</code>, and caps the rate at <code>fps</code> when asked.</li>
|
|
@@ -735,14 +767,14 @@ mix.book({
|
|
|
735
767
|
<div class="tbl"><table>
|
|
736
768
|
<tr><th>behavior</th><th>how</th><th>what it replaces</th></tr>
|
|
737
769
|
<tr><td>Fade in and out</td><td><code>fade: { in, out }</code> ramps the voice's weight; each channel's <code>scale</code> takes it toward rest.</td><td>klieg's <code>Timeline.weight</code>, the <code>blendMs</code> ramps, and <code>carry</code>.</td></tr>
|
|
738
|
-
<tr><td>Crossfade at a phase seam</td><td>Two voices in one <code>
|
|
770
|
+
<tr><td>Crossfade at a phase seam</td><td>Two voices in one <code>role</code>, one fading out as the other fades in. The role folds them by the channel's <code>lerp</code> at their weight ratio, which on <code>sum</code> is the lurch guard klieg's timeline applies when a hold is shorter than the blend, and on every other channel is the crossfade that guard was standing in for.</td><td>enter → active → exit as three voices.</td></tr>
|
|
739
771
|
<tr><td>Handover at rest</td><td><code>fade({ at: 'rest', deadline })</code> removes the voice per subject at the first frame its pose equals rest, or at the deadline. The next voice was cued already and simply becomes the only contributor.</td><td><code>turns</code>' handover rule, made available to any pair.</td></tr>
|
|
740
772
|
<tr><td>Blend between alternatives</td><td><code>mix.blend([a, b, c], by: k)</code> cues three voices whose weights are the hat functions of k at 0, ½, 1. Continuous, no rebuild, and hinge's quantization goes away for patches whose channels have rest. <code>by</code> is read once per subject per frame, with the setting of the first member to reach the subject, and the three shares come from that one reading, however often the host probes the subject that frame. <code>share(by, i, n)</code> is member <code>i</code> of <code>n</code>’s share as a signal of its own, to weight a voice outside the blend as that member, such as one member played alone. A member given as <code>{ patch, name, tags, hits }</code> answers to its own name in an anchor and carries its own tags and hits; the spec’s start, rate, loop, fade, anchor and the rest time every member alike, so they stay one blend.</td><td><code>hinge</code>'s stops mode. Its blend mode is just <code>weight: signal</code>.</td></tr>
|
|
741
773
|
<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 — every other voice folded in, itself excluded — 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>
|
|
742
774
|
</table></div>
|
|
743
775
|
|
|
744
|
-
<p><b>A tag can be a
|
|
745
|
-
<p><b>A
|
|
776
|
+
<p><b>A tag can be a role.</b> A mix made with <code>roles: ['phase']</code> folds every voice carrying the tag <code>phase</code> as one role, as if each had <code>role: 'phase'</code>, so a source that already tags its voices need not name a role too. A voice whose tags and <code>role</code> name two roles is refused. Decided 2026-09-29 that tags absorb roles; the declaration was put on the mix (2026-10-23), and <code>role</code> stays, since klieg, wod, magicsmoke and astv cue with it. Renamed from <i>locus</i> (and the mix option from <code>loci</code>) on 2026-10-26; the old names are taken for one release, with a warning outside production.</p>
|
|
777
|
+
<p><b>A role is folded by <code>lerp</code>, not by scaling and merging.</b> Voices sharing a <code>role</code> are alternatives in time, so the mix combines them into one contribution first: each member weighted by its share of the role'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, Σ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 role’s weight while it leaves. On a <code>sum</code> channel this is exactly <code>w₁a + w₂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 — which is what one alternative means — 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. A stock <code>last()</code> channel has nothing to interpolate, so the role takes its heaviest member’s value, and the later cued of two as heavy. Cue order is otherwise visible only to a <code>lerp</code> that is not linear: a <code>color({ lerp: 'oklch' })</code> or a <code>hex</code> takes the hue the short way round at each step, so three or more of them in a role can read differently by the order they were cued in.</p>
|
|
746
778
|
|
|
747
779
|
<div class="callout">
|
|
748
780
|
<div class="eyebrow">the one rule</div>
|
|
@@ -755,9 +787,14 @@ mix.book({
|
|
|
755
787
|
mix.cue({ patch: s });
|
|
756
788
|
s.to(node, 40); <span class="cm">// heads elsewhere from wherever it is, keeping its velocity</span>
|
|
757
789
|
s.push(node, 600); <span class="cm">// a kick: same target, new velocity</span>
|
|
790
|
+
s.set(node, 20); <span class="cm">// moved there: same target, same velocity</span>
|
|
758
791
|
s.read(node); <span class="cm">// { value, velocity }, for handing the motion to another patch</span>
|
|
759
792
|
|
|
760
793
|
const g = glide<Node, Pose>('x', { from: 0, velocity: 1200, ms: 325 }); <span class="cm">// a fling: rests 390 past the start</span>
|
|
794
|
+
g.set(node, 80); <span class="cm">// under a dragging pointer</span>
|
|
795
|
+
g.push(node, -900); <span class="cm">// let go: coasts on from there</span>
|
|
796
|
+
|
|
797
|
+
const list = glide<Node, Pose>('y', { from: 0, bounds: [-2400, 0] }); <span class="cm">// springs back from an edge it coasts past</span>
|
|
761
798
|
|
|
762
799
|
const t = tween<Node, Pose>('x', { from: (n) => n.x, to: (n) => n.goal, ms: 400, ease: 'ease-out' });
|
|
763
800
|
t.to(node, 40); <span class="cm">// from wherever it is, over a full 400 ms again</span></code></pre>
|
|
@@ -765,6 +802,8 @@ t.to(node, 40); <span class="cm">// from wherever it is, over a ful
|
|
|
765
802
|
<ul>
|
|
766
803
|
<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>
|
|
767
804
|
<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>
|
|
805
|
+
<li id="fling"><b>Dragging and flinging.</b> <code>@msb235/blits/fling</code> wires pointer events to a motion patch: <code>fling(element, glide, subject, { axis, scale })</code> stops the subject where it is on a press, <code>set</code>s it under the pointer on each move, and <code>push</code>es it on release at the pointer’s velocity, which <code>track</code> reads as the least-squares slope of the last 100 ms of samples, or zero if the pointer sat still for 40 ms before letting go. <code>track</code> has no listeners, so anything with a time and a position can feed it. It is a subpath because it is the package’s only code that knows a page exists, and it asks only for <code>addEventListener</code>, <code>removeEventListener</code> and <code>setPointerCapture</code>, so the package builds without the DOM’s types. Since the coast is a voice like any other, other voices read it: a signal over <code>glide.read(subject)</code> drives parallax off its value or a blur off its velocity. Past an edge of a glide's <code>bounds</code>, a drag carries the subject less and less of the way, never more than <code>give</code> (default 120 units) past: at the edge it follows 0.55 of the drag, as iOS's scroll views do, and <code>give: 0</code> holds it at the edge. A press on a subject springing back past an edge holds it where it is. Added 2026-10-24.</li>
|
|
806
|
+
<li id="bounds"><b>Bounds.</b> <code>glide(…, { bounds: [min, max], stiffness })</code> gives each axis the least and greatest value a coast comes to rest at, the same for every subject or one pair per subject; <code>g.bounds(subject)</code> reads them back. A coast that would stop inside them is the glide without bounds, to the bit. One that would stop past an edge coasts until it crosses it, then springs back on a critically damped spring of <code>stiffness</code> (default 170, a spring’s), starting at the speed it crossed at, so neither where it is nor how fast it moves jumps. One let go already past an edge springs back from there, unless its coast carries it back inside, when it coasts with no spring at all. A <code>set</code> past an edge leaves the subject there, coasting on, until the next <code>push</code>: that is how a drag holds it out, and why a <code>push(subject, 0)</code> lets it go. It needed no new state: a stretch still holds where it starts, how fast, and where it heads, which is now the edge when the coast stops past one, and the closed form tells the three cases apart from those numbers alone, so lanes, seeks and history carry it as they carry any glide. Added 2026-10-24.</li>
|
|
768
807
|
<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>
|
|
769
808
|
<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>
|
|
770
809
|
<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>
|
|
@@ -955,7 +994,7 @@ t.sync(now); <span class="cm">// once a frame, after the frame's cues</span></co
|
|
|
955
994
|
|
|
956
995
|
export const mixer: Engine; <span class="cm">// the one that ships</span></code></pre>
|
|
957
996
|
|
|
958
|
-
<p><b>mixer</b> runs every form on the CPU, samples per subject on demand except where a channel runs as a lane (<a href="#lanes">below</a>), and is the extraction of what klieg does today. It folds each contribution into the subject's accumulator as it probes, rather than collecting every layer and merging at the end as klieg's <code>EffectFrame</code> does: weights are scalars that no pose depends on, so they resolve for the subject first, and a
|
|
997
|
+
<p><b>mixer</b> runs every form on the CPU, samples per subject on demand except where a channel runs as a lane (<a href="#lanes">below</a>), and is the extraction of what klieg does today. It folds each contribution into the subject's accumulator as it probes, rather than collecting every layer and merging at the end as klieg's <code>EffectFrame</code> does: weights are scalars that no pose depends on, so they resolve for the subject first, and a role folds to one contribution before that contribution is merged. That order is what would let a later revision hand patches one scratch object to write into instead of returning a fresh partial per patch per subject per frame, which <code>writes</code> makes safe to hand out without clearing. What later engines would look like, so the seam is judged against something real and none of them is being built:</p>
|
|
959
998
|
<ul>
|
|
960
999
|
<li><b>A baked engine</b> runs <code>keys</code> voices only and emits Web Animations or CSS keyframes, so a DOM consumer like wod gets compositor-thread playback. It refuses <code>fn</code> and <code>motion</code> patches at <code>cue</code>.</li>
|
|
961
1000
|
<li><b>A recording engine</b> wraps another engine and captures every pose into keyframes, turning a procedural patch into data for the baked one.</li>
|
|
@@ -965,11 +1004,13 @@ export const mixer: Engine; <span class="cm">// the one that ships</span></cod
|
|
|
965
1004
|
|
|
966
1005
|
<h2 id="lanes">Lanes</h2>
|
|
967
1006
|
<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. A host too small to need them takes <code>mix</code> from <code>@msb235/blits/lean</code>, whose bundle leaves their code out; it refuses <code>lanes: true</code>.</p>
|
|
1007
|
+
|
|
1008
|
+
<p>The copy of laned values into a pose is a function written with <code>Function</code> once per set of laned channels, each channel stored under its own name (<code>src/copyout.ts</code>). Under a Content Security Policy without <code>'unsafe-eval'</code> the browser refuses it, and every refusal, caught or not, is a violation: a <code>securitypolicyviolation</code> event, a DevTools issue, and a report to any <code>report-uri</code>. So the first refusal is the last ask: the mix copies with a loop from then on, to the same values, and a page sees one violation at most. <code>lanes: false</code> or <code>@msb235/blits/lean</code> sees none. Observed in headless Chromium (2026-10-26): one event for each <code>new Function</code> refused.</p>
|
|
968
1009
|
<div class="tbl"><table>
|
|
969
1010
|
<tr><th></th><th>runs on a lane</th><th>takes the general path</th></tr>
|
|
970
|
-
<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
|
|
1011
|
+
<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>, either lerp, as the stock functions make them, bounds or not</td><td>anything else, including a custom channel claiming a stock kind; a <code>color(last())</code> while a voice writing it plays a motion patch, is in a <code>role</code>, or names one subject with <code>subjects</code></td></tr>
|
|
971
1012
|
<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>
|
|
972
|
-
<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’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>
|
|
1013
|
+
<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’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>role</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>; a voice with no end cued backward, and one fading out from where it ran back to its start</td></tr>
|
|
973
1014
|
</table></div>
|
|
974
1015
|
<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’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>
|
|
975
1016
|
<ul>
|
|
@@ -977,11 +1018,12 @@ export const mixer: Engine; <span class="cm">// the one that ships</span></cod
|
|
|
977
1018
|
<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>
|
|
978
1019
|
<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–77% for a spring, and 63–100% for a stateless <code>fn</code>. Both paths give the same pose, so the switch only changes the cost.</li>
|
|
979
1020
|
<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’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’s copy is rewritten when a handle or the mix changes the voice; the stretch’s when the patch replaces it (<code>Motions.touched</code>). A fill reads the crowd’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’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–0.59 of what they did, as much as one tween voice over the same 10k subjects; 100k cost 0.31–0.33; 10k springs 0.64–0.70. Before it, the extra cost of a voice over a shared voice grew with the count, 18 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’s row is left empty until a quarter of the crowd’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 ms at 10k rows; sliding them keeps the worst of 12,000 <code>churn</code> frames near 3 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–1.89 from 1.60–1.77, and by <code>pull</code> 1.25–2.01 from 1.71–1.84, both bimodal from run to run; 10k <code>fn</code> voices by <code>pull</code> 0.93–1.18 from 1.36–1.52, and by probe 1.20–1.43 against 1.09–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–1.02 ms a frame to 0.34–0.36, 34–36 ns a subject against 25 a subject and voice for keys voices over every subject, and by probe from 1.00–1.37 to 0.81–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–1.01 ms by probe and 0.54–0.62 by <code>pull</code>, from 9.82–9.94 and 8.73–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× at 1k voices and 0.79× at 10k, and 1.05–1.11× at 100, where a fill’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>
|
|
980
|
-
<li><b>A
|
|
1021
|
+
<li><b>A role runs on lanes in two passes.</b> The general path folds a role at its first member’s place in the voice order, and which voice that is differs by subject. So a fill first takes each role’s members in voice order and, for each subject, sums their weights and lerps their values by share of the weight taken, as <code>foldRole</code> does, with the stock channels’ own lerp; then, folding the voices in order, it folds each subject’s role in at whichever member met that subject first. A role’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 role off. A blend’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 role 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>
|
|
981
1022
|
<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’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>
|
|
982
1023
|
<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>
|
|
983
1024
|
<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>
|
|
984
1025
|
<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>
|
|
1026
|
+
<li><b>A <code>color({ lerp: 'oklch' })</code> folds on lanes as the sum it is.</b> A lane lerps a stock channel straight across each axis, which a lerp round the hue is not, so this channel is laned with only its lerp its own: it folds as <code>vec(4, sum())</code>, and wherever a lane interpolates it (a <code>keys</code> segment, a crowd’s stops, a role) it calls the channel’s <code>lerp</code>, the one the general path calls. <code>color(last(), { lerp: 'oklch' })</code> needed nothing more, since a <code>last</code> lane already lerps through the channel. Measured on keiei (2026-10-25, alone on the node, seven rounds, ms a frame at 10,000 subjects, against the build where it took the general path): two <code>keys</code> voices sweeping the hue over every subject (<code>hues</code>) 2.90 to 1.38 by probe and 3.29 to 0.67 by <code>pull</code>, against 5.10 with lanes off; a glow writing the color (<code>glowo</code>) 3.26 to 2.63, against 3.24 off. Folding it through the channel’s own <code>fold</code>, as a <code>quat</code> folds, read <code>hues</code> 1.59 and 0.87 (<code>bench/runs/2026-10-25-oklch-lanes.txt</code>).</li>
|
|
985
1027
|
</ul>
|
|
986
1028
|
<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 ms a frame, and a stateless <code>fn</code> gained up to 37%. A spring frame first rose from 4.3 to 5.4 ms; once the spring solver stopped allocating per sample, a rerun put a spring frame at 0.53–0.72 of the earlier build's, <code>keys</code> at 0.38–0.57 and a stateless <code>fn</code> at 0.70–0.88. A host that probed 5% of the subjects it had met paid for all of them, 1.3 ms against 0.1 at 10,000, until resting lanes brought that to about 0.15 ms (one local machine). And a probe costs about 110–140 ns at 10,000 subjects even when a lane did all the work, so a host probing every subject pays 1.1–1.4 ms before any arithmetic.</p>
|
|
987
1029
|
<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’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–0.89 of what it was for a tween voice over 10,000 subjects, 0.83–0.88 for a spring and 0.83–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’s lane fills in one loop that samples each subject straight from the patch’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–0.66 of what it did, a tween 0.59–0.65, a spring 0.68–0.74; read by <code>probe</code>, a tween 0.74–0.82 and a spring per subject 0.74–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’s fill and qualify checked once, not per subject, stopping at the first subject that has to take a probe’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’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–0.41 from 0.45–0.48, weasel’s shape 0.38–0.45 from 0.46–0.51, one tween voice per subject with one replaced each frame 0.44–0.51 from 0.55–0.58; reads by <code>probe</code> unchanged.</p>
|
|
@@ -990,7 +1032,7 @@ export const mixer: Engine; <span class="cm">// the one that ships</span></cod
|
|
|
990
1032
|
<p>What each consumer declares. Each row is one system — a kit, the subject type it drives, and the mix over them — and the rules are the ones each repo already applies; the kit makes them one line each.</p>
|
|
991
1033
|
<div class="tbl"><table>
|
|
992
1034
|
<tr><th>consumer</th><th>subject</th><th>kit</th><th>notes</th></tr>
|
|
993
|
-
<tr><td>klieg motion</td><td><code>LetterInfo</code></td><td>position vec3 sum · rotation vec3 sum · scale mul · opacity mul</td><td>Three voices in
|
|
1035
|
+
<tr><td>klieg motion</td><td><code>LetterInfo</code></td><td>position vec3 sum · rotation vec3 sum · scale mul · opacity mul</td><td>Three voices in role <code>phase</code>, fades of <code>blendMs</code>.</td></tr>
|
|
994
1036
|
<tr><td>klieg effects</td><td><code>PartInfo</code></td><td>gain mul · scale mul · dark max · crawl sum · position vec3 sum · rotation vec3 sum · light vec3 sum · color last</td><td>Light is premultiplied by amount before it reaches the channel, as <code>mergeOffsets</code> does.</td></tr>
|
|
995
1037
|
<tr><td>klieg lighting</td><td>unit</td><td>yaw sum · pitch sum</td><td>One subject, the frame. A mix with no subject dimension is a mix over one subject.</td></tr>
|
|
996
1038
|
<tr><td>wod transitions</td><td>wedge <code>Segment</code></td><td>hold mul[0, 1] · opacity mul[0, 1] · scale mul · offset sum · rotate sum · aperture max[0, 1] · offsetAngle last</td><td>Every voice is <code>keys</code> with <code>from: 'current'</code>; the voices map goes away.</td></tr>
|
|
@@ -1015,7 +1057,7 @@ export const mixer: Engine; <span class="cm">// the one that ships</span></cod
|
|
|
1015
1057
|
<ul>
|
|
1016
1058
|
<li><b>Step one reproduced the arithmetic exactly, and is done.</b> <code>Timeline.poseAt</code> is a voice per layer of each phase, weighted <code>min(inW, outW)</code>; <code>EffectFrame.resolve</code> is a mix over parts with the same modulo pass and the same stagger call; the sign’s environment is a mix with one subject. <code>mergeOffsets</code>, <code>addScaled</code>, <code>accumulate</code>, <code>scaleOffset</code> and <code>mergeEnv</code> are all the channel kits now. klieg’s vitest suites and the Playwright baselines hold unchanged.</li>
|
|
1017
1059
|
<li><b>Stagger stayed klieg's</b>, and the two are not the same operation. A voice's <code>stagger</code> delays a subject by milliseconds; klieg's warps the pass into <code>(t − start) / span</code> and clamps, so a part reaches the end of its pass with the word rather than after it. The port computes it in the patch, from <code>setting.elapsed</code>, and <code>orderKey</code> stays klieg's with it.</li>
|
|
1018
|
-
<li><b>A phase is a layer, not an alternative.</b> The three phases cue as separate voices, not as one
|
|
1060
|
+
<li><b>A phase is a layer, not an alternative.</b> The three phases cue as separate voices, not as one role: a role folds through <code>lerp</code>, which is identically scaling and merging on <code>sum</code> but not on <code>mul</code> — and <code>scale</code> and <code>opacity</code> are <code>mul</code> channels that two phases both write inside a blend window. klieg’s own guard against three phases overlapping at once, scaling every weight by <code>1/Σw</code> when the sum runs past 1, stays klieg’s for the same reason: it is a property of its phase table, not of the fold. A role is still the right shape for alternatives that replace one another, which is what <code>mix.blend</code> cues.</li>
|
|
1019
1061
|
<li><b>Step two turns on what the extraction bought.</b> <code>power</code>, <code>kicks</code> and <code>dwell</code> lose their hand-rolled frame keying, and <code>slew</code> takes the decay math with it: <code>dwell</code>'s <code>toward</code> and <code>kicks</code>' drain are one function, leaving <code>kicks</code> its falloff and its event queue. <code>power</code> keeps its state machine, which is not a decay. <code>hinge</code>'s blend mode becomes <code>weight: signal</code> and its stops mode becomes <code>mix.blend</code>; the old names stay as sugar. A host can cue an effect to a running fire and fade it in, which has no equivalent today.</li>
|
|
1020
1062
|
<li><b>Wrappers do not move.</b> <code>turns</code>, <code>roving</code>, <code>intermittent</code>, <code>partition</code> and the <code>Sequence</code>'s <code>carry</code> and <code>within</code> stay patch-level and unchanged, except that <code>carry</code> can be deleted once a regroup uses <code>from: 'current'</code>.</li>
|
|
1021
1063
|
<li><b><code>FrameCtx</code> becomes <code>Setting</code> with klieg's fields on <code>host</code>.</b> <code>pointer</code>, <code>pointerInWord</code> and <code>dt</code> keep their meanings. Every klieg patch signature changes by one property path, and a codemod does it.</li>
|
|
@@ -1024,16 +1066,161 @@ export const mixer: Engine; <span class="cm">// the one that ships</span></cod
|
|
|
1024
1066
|
|
|
1025
1067
|
<h2 id="against">Decided against</h2>
|
|
1026
1068
|
<ul>
|
|
1027
|
-
<li><b>Position and rotation as one channel.</b> A channel is the smallest thing a patch writes, so a rotation-only patch would write a zero position, and in a
|
|
1069
|
+
<li><b>Position and rotation as one channel.</b> A channel is the smallest thing a patch writes, so a rotation-only patch would write a zero position, and in a role that zero pulls the other members' positions toward it by its share; apart, a role blends each channel only among the voices that write it. Rotation could also not change its arithmetic, to quaternions say, without position. The name it was offered under, <code>pose</code>, is already the whole of what a mix resolves for a subject. Decided 2026-09-30.</li>
|
|
1028
1070
|
<li><b>A signal graph.</b> Everything as nodes evaluated once per frame. Most general, and every existing patch would be re-expressed as nodes; a misbehaving sign would be read as a graph rather than a list. Signals stay scalars and plug in at two places.</li>
|
|
1029
1071
|
<li><b>Keyframes as the only form.</b> The platform's shape and wod's. It cannot carry a hash-driven flicker or a permutation walk without an escape hatch that is the procedural form under another name. Both forms are first-class instead.</li>
|
|
1030
1072
|
<li><b>Adopting a tweening library.</b> GSAP, anime.js and Motion own a property and write absolute values, so concurrent writers conflict. anime.js v4's additive mode is the closest and still has no rest rule per channel. three's <code>AnimationMixer</code> has the right model and is keyframe-only over object property paths.</li>
|
|
1031
1073
|
<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>
|
|
1032
1074
|
<li><b>Subject aliases.</b> A <code>subjectOf(obj)</code> option mapping several host objects to one subject, so the fold runs once for all of them: every third bulb of a chase, LEDs sharing one value. What a fold makes depends on the subject wherever a voice reads it — <code>target</code>, <code>subjects</code>, <code>stagger</code>, a weight signal, <code>at</code> and <code>step</code> — so only the host knows two objects are interchangeable, and once it knows, it already has the saving: the subject is the logical cell, not the lamp. Probe one subject per cell and paint every lamp in it. In the mix the option would cost a lookup on every probe, need answers for <code>drop</code>, history and a voice's <code>subjects</code> naming one lamp of a group, and silently collapse a voice that staggers by lamp. Decided 2026-10-22.</li>
|
|
1033
1075
|
<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>
|
|
1034
|
-
<li><b>Normalizing a
|
|
1035
|
-
<li><b>Removing a frozen voice once another covers it.</b> WAAPI drops a finished, filling animation when a later one replaces every property it sets. In a mix, “covers” depends on weights,
|
|
1036
|
-
<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>
|
|
1076
|
+
<li><b>Normalizing a role'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 — that weights are scalars resolved for the subject before any channel is touched — 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>
|
|
1077
|
+
<li><b>Removing a frozen voice once another covers it.</b> WAAPI drops a finished, filling animation when a later one replaces every property it sets. In a mix, “covers” depends on weights, roles and each channel’s fold, and a voice leaving on its own would change the pose with no call to point at. The host fades it.</li>
|
|
1078
|
+
<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>role</code>.</li>
|
|
1079
|
+
<li><b>Showing a voice too brief to see.</b> A voice whose whole span falls between two syncs is never sampled mid-way, and shows only by freezing after: no jump, and no stretch to a minimum number of frames. The owner's call (2026-10-07).</li>
|
|
1080
|
+
<li><b>Flicker protection in the mix, for now.</b> Flicker, or protection from it, is something a host adds to its own pipeline; blits marks no brightness channels and counts nothing on its own. An opt-in limiter would be a signal or channel wrapper, and could warn rather than clamp. The owner's call (2026-10-07).</li>
|
|
1081
|
+
<li><b>Writing the lanes' arithmetic out again</b> where the general path had it inline, to win back what lanes cost on rows they don't serve. The exactness guarantee rests on there being one copy (2026-10-02).</li>
|
|
1082
|
+
<li><b><code>pass</code> for the span hint now called <code>ballast</code></b>: it is already public as a number, <code>setting.pass</code>, the loop pass a voice is in (2026-10-07).</li>
|
|
1083
|
+
</ul>
|
|
1084
|
+
|
|
1085
|
+
<h2 id="chosen">Chosen while building</h2>
|
|
1086
|
+
<p>Calls made while building where the design left a choice open. Each is the owner's to overturn, and says what reversing it touches where that is not obvious. A call the owner made himself is marked as his. Each carries the date it was made.</p>
|
|
1087
|
+
|
|
1088
|
+
<h3>Signal bounds</h3>
|
|
1089
|
+
<ul>
|
|
1090
|
+
<li><b>Names are the owner’s</b>: <code>place</code> for the call that hands a subject its index, “index” for the number, <code>find</code> for a finder’s method, and <code>within</code> kept for both kinds of bound (2026-10-28). <code>Finder</code>, the type’s name, was picked while building.</li>
|
|
1091
|
+
<li><b>A finder is an object with <code>find</code></b>, not a bare function, so it can take a second member later without breaking hosts; one such is a way to say its answer has not changed (the owner’s, 2026-10-28).</li>
|
|
1092
|
+
<li><b>A finder writes indices, never subjects</b>: taking subjects too would be a second, slower path, through the mix’s subject lookup (2026-10-28).</li>
|
|
1093
|
+
<li><b>An index is stable while its subject lives</b>, and a freed one is reused, not compacted: nothing has to tell the host an index moved (2026-10-28).</li>
|
|
1094
|
+
<li><b>A subject placed past the array <code>plot</code> was given has no position</b>, and <code>put</code> refuses it, rather than the mix copying the host’s array into one of its own and quietly no longer reading it in place (2026-10-28).</li>
|
|
1095
|
+
<li><b>History needs nothing recorded for a bound</b>: it reads as the signal it bounds does, live without <code>inputs</code> and recorded with them (2026-10-28).</li>
|
|
1096
|
+
<li><b>Not yet</b>: positions on other than two axes, with a ball for a circle; the owner wants it later (2026-10-28).</li>
|
|
1097
|
+
</ul>
|
|
1098
|
+
|
|
1099
|
+
<h3>Dragging, flinging and bounds</h3>
|
|
1100
|
+
<p>The split is the owner's: the motion law in the engine and the pointer wiring in a subpath, with a spring back from an edge and resistance while dragging past one (2026-10-24). See <a href="#fling">Dragging and flinging</a> and <a href="#bounds">Bounds</a>.</p>
|
|
1101
|
+
<ul>
|
|
1102
|
+
<li><b>Names</b>: <code>set</code> (a motion's jump to a value, keeping velocity); the subpath and function <code>fling</code>; the sampler <code>track</code> with <code>ms</code> (its window, default 100) and <code>stop</code> (how long still before release reads as zero, default 40); <code>axis</code> (<code>'x' | 'y' | 'xy'</code>, default <code>'y'</code>), <code>scale</code>, and <code>give</code> (how far past an edge a drag carries, default 120); on <code>glide</code>, <code>bounds</code> and <code>stiffness</code> (2026-10-24).</li>
|
|
1103
|
+
<li><b><code>set</code> is on every motion patch and on the handle</b>, not on <code>glide</code> alone, so no motion lacks it (2026-10-24).</li>
|
|
1104
|
+
<li><b><code>fling</code> types its target structurally</b> (<code>PointerTarget</code>), so the core keeps <code>lib: ES2022</code> with no DOM; an <code>HTMLElement</code> type-checks as one (2026-10-24).</li>
|
|
1105
|
+
<li><b>A <code>set</code> past an edge stays there until the next <code>push</code></b>, or a drag held past an edge would spring back between pointer moves. <code>aim</code> in <code>GlideShape</code> (<code>src/motion.ts</code>) holds the rule; a press does <code>push(0)</code> then <code>set</code>, so a subject caught mid-bounce stays put (2026-10-24).</li>
|
|
1106
|
+
<li><b>The spring back is critically damped</b>, with one stiffness, so the law keeps its five numbers (<code>[BOUND, settle, τ, ω₀, 0]</code>); any damping would need a longer law head (2026-10-24).</li>
|
|
1107
|
+
<li><b>Released past an edge with enough inward speed, it coasts back inside with no spring</b> (2026-10-24).</li>
|
|
1108
|
+
<li><b>Resistance is iOS's curve</b>: 0.55 of the drag at the edge, approaching <code>give</code> (2026-10-24).</li>
|
|
1109
|
+
</ul>
|
|
1110
|
+
|
|
1111
|
+
<h3>Arcs</h3>
|
|
1112
|
+
<p>Built on a brief with the owner's go (2026-10-22). See <a href="#arcs">Arcs</a>.</p>
|
|
1113
|
+
<ul>
|
|
1114
|
+
<li><b>Names</b>: <code>arc</code>, <code>release()</code>, <code>hold: 'open'</code> (klieg's <code>'click'</code> and <code>'forever'</code> both mean “until released” here), <code>blend</code> (klieg's <code>blendMs</code>), and a part's <code>duration</code> (2026-10-22).</li>
|
|
1115
|
+
<li><b>No <code>abort()</code></b>: the handle's own <code>fade</code> already leaves with no exit, and can ramp (2026-10-22).</li>
|
|
1116
|
+
<li><b><code>release()</code> works on a finite hold too</b>, cutting it short, where klieg's ignores one; once the exit has begun it does nothing (2026-10-22).</li>
|
|
1117
|
+
<li><b>The default hold is <code>active</code>'s own length</b>: open where it loops for good, 0 with no <code>active</code> (2026-10-22).</li>
|
|
1118
|
+
<li><b>A part left out gives its time to the one before</b>, which holds its last frame (2026-10-22).</li>
|
|
1119
|
+
<li><b><code>in</code> is the later of the enter's end and the arc's own <code>fade.in</code></b> (2026-10-22).</li>
|
|
1120
|
+
<li><b>An open arc cannot sit in a span</b>, which has to know its length. klieg's <code>stages</code> are left for later, not designed out (2026-10-22).</li>
|
|
1121
|
+
<li><b>Where it lives</b>: the crossfade is a factor in <code>ownersWeight</code> (<code>src/owner.ts</code>), read from <code>arcShare</code> in <code>src/arcs.ts</code>; the hold a release fixes is a voice control (<code>Voice.hold</code>), so a seek back restores it (2026-10-22).</li>
|
|
1122
|
+
</ul>
|
|
1123
|
+
|
|
1124
|
+
<h3>Watching a mix, and blits-panel</h3>
|
|
1125
|
+
<p><code>watched()</code> is the engine side of a debugger; the site's mix page, “Watching a mix”, says what it does. The DevTools extension's own calls are in <code>apps/devtools/README.md</code>.</p>
|
|
1126
|
+
<ul>
|
|
1127
|
+
<li><b>Names</b>: <code>watched()</code>, <code>watch</code>, <code>hold</code> and <code>free</code> (2026-10-19).</li>
|
|
1128
|
+
<li><b>A hold's rate scales the host's frame gaps</b> on a host timeline of its own, so the mix plays at the hold's rate times its own. It never writes the transport's rate: an earlier cut that did broke seeking back while paused, and left the hold's rate behind after <code>free</code>. <code>step(ms)</code> is in mix time (2026-10-19).</li>
|
|
1129
|
+
<li><b><code>free</code> takes the held time out of the clock</b>, as <code>rebase</code> does, so the mix resumes where the hold left it, not where the host's clock says (2026-10-19).</li>
|
|
1130
|
+
<li><b>No branches</b>: while held behind the tape's last call, the host's own calls act on the mix but go unrecorded, so the calls ahead still play. The spec had <code>free</code> switch branches back, which comes to the same thing with more machinery (2026-10-19).</li>
|
|
1131
|
+
<li><b>Mixes register from the public factories</b> (<code>mixer.create</code>, <code>lean.create</code>, and <code>transport()</code> for its history default), never from the <code>Mixer</code> constructor, which projections call. Custom engines don't register (2026-10-19).</li>
|
|
1132
|
+
<li><b>The exports are filed under <code>mix</code></b> on the site, not on a page of their own (2026-10-19).</li>
|
|
1133
|
+
<li><b>The panel is split into <code>agent()</code> (the page side) and the UI</b>, talking only through the plain-data <code>Source</code> protocol in <code>packages/panel/src/protocol.ts</code>, so the extension reuses both halves. Subjects cross as numbers the agent hands out (2026-10-19).</li>
|
|
1134
|
+
<li><b>The panel's behavior</b>: it plots mix time; paused frames replace one another; a seek back drops the plot ahead of it; the window is 10 s, capped at 1,200 frames; a subject the host didn't read that frame is read through <code>project</code> and marked “read, not shown”; a newly picked subject is filled backward from history, back to <code>reach</code>; switching to a mix the hold doesn't cover frees the hold; and <code>mount()</code> adds 10 s of history with a tape unless told <code>history: false</code> (2026-10-19).</li>
|
|
1135
|
+
<li><b>The panel is built with Vite</b>, not <code>tsc</code> alone, because weasel's stylesheet has to reach the shadow root as a string; <code>tsc -b</code> writes only the declarations (2026-10-19).</li>
|
|
1136
|
+
<li><b><code>ChannelPlot</code> moved from the playground into the panel</b>, which exports it; the playground imports it from there, with <code>hueColor</code> (2026-10-19).</li>
|
|
1137
|
+
</ul>
|
|
1138
|
+
|
|
1139
|
+
<h3>blits-quarks and blits-tone</h3>
|
|
1140
|
+
<ul>
|
|
1141
|
+
<li><b><code>drop()</code> on a handle also ends the voices cued through it</b> (<code>fade({ over: 0 })</code>), since a voice naming a dropped subject would otherwise stay cued, holding it (2026-10-18).</li>
|
|
1142
|
+
<li><b>Both packages export <code>drive</code></b>, so a file using both aliases them. Kept (2026-10-11).</li>
|
|
1143
|
+
<li><b>The owner's calls on blits-tone</b>: its names (<code>drive</code>, <code>attach</code>, <code>detach</code>, <code>write</code>, <code>cancel</code>, and the options <code>ahead</code>, <code>grid</code>, <code>late</code>, <code>clock</code>, <code>take</code>, <code>tag</code>, <code>score</code>); its tests taking <code>node-web-audio-api</code>, a prebuilt native binary of about 40 MB, as a dev dependency so Tone's offline render runs in Node with no audio device; and a subject binding one <code>Target</code> per channel, so a host that wants every subject heard builds a synth per subject and attaches it (all 2026-10-11).</li>
|
|
1144
|
+
</ul>
|
|
1145
|
+
|
|
1146
|
+
<h3>Shipped as is</h3>
|
|
1147
|
+
<p>Two behaviors shipped without a view from the owner, to reopen if a consumer trips on one (2026-10-18):</p>
|
|
1148
|
+
<ul>
|
|
1149
|
+
<li><b>A <code>slew</code> starts where a probe first meets its subject</b>, so the slewed fuzz variant stays out of <code>again</code>: a patch met late steps from its voice's start instead.</li>
|
|
1150
|
+
<li><b>A pending voice faded before its start keeps its start where it was until it is risen</b>, not following its anchor. Straight play, seek and read ahead agree on it.</li>
|
|
1151
|
+
</ul>
|
|
1152
|
+
|
|
1153
|
+
<h3>Shares and blend members</h3>
|
|
1154
|
+
<ul>
|
|
1155
|
+
<li><b>The public read of a member's share is a signal</b>, <code>share(by, i, n)</code>, not a read on <code>Handle</code> or <code>Mix</code>: a solo plot needs the share as the weight of a voice in another mix, and <code>Handle.weightOf</code> already reports a member's weight in its own. To reverse, drop <code>share</code> from <code>signals.ts</code> and <code>index.ts</code>; <code>cueBlend.ts</code> in the playground is its one caller (2026-10-17).</li>
|
|
1156
|
+
<li><b><code>n</code> counts the members</b>, as <code>mix.blend</code>'s array does, not the stops, and an <code>i</code> outside <code>0..n-1</code> throws (2026-10-17).</li>
|
|
1157
|
+
<li><b>A member is an entry in <code>mix.blend</code>'s array</b>, <code>{ patch, name?, tags?, hits? }</code>, beside bare patches, rather than a second array of specs. It carries only labels: not <code>score</code>, since a member in another score would resolve the shared anchor to another voice and start apart (2026-10-17).</li>
|
|
1158
|
+
<li><b>A member's <code>tags</code> replace the spec's</b> rather than adding to them, as <code>name</code> and <code>hits</code> do. To merge instead, concatenate in <code>Mixer.blend</code> (<code>src/mixer.ts</code>) (2026-10-17).</li>
|
|
1159
|
+
</ul>
|
|
1160
|
+
|
|
1161
|
+
<h3>Marks inside a voice</h3>
|
|
1162
|
+
<ul>
|
|
1163
|
+
<li><b>A spec field of its own, not a name on a hit.</b> A hit is an event handed over every pass; a mark is a name an anchor takes, reached once, so the two differ in both payload and count. They share the range rule (<code>inPass</code> in <code>src/book.ts</code>) and the clock mapping (<code>timeAt</code>). To name hits instead, give <code>Hit</code> a <code>name</code>, read it in <code>insideOf</code> (<code>src/marks.ts</code>) and drop <code>marks</code> from the specs and <code>checkMarks</code> (2026-10-17).</li>
|
|
1164
|
+
<li><b>Reached once</b>, on the first pass that reaches it from where the voice starts, so a voice looping for good has one <code>drop</code> and <code>marks(-Infinity, Infinity)</code> stays finite. Per pass would need a pass index on <code>Marked</code> and an anchor rule for which pass; <code>insideOf</code> holds the rule (2026-10-17).</li>
|
|
1165
|
+
<li><b>Not reached while the clock runs back, nor at or past the voice's <code>end</code></b>, as a hit is not. Drop the <code>voice.backs</code> and <code>end</code> tests in <code>insideOf</code> to change either (2026-10-17).</li>
|
|
1166
|
+
<li><b><code>MarkName</code> (<code>Mark | (string & {})</code>) is exported</b>, and <code>Marked.mark</code> and an anchor's <code>mark</code> widen to it, a break the changelog lists. An anchor naming a mark its target does not place waits pending, as one naming a voice nobody cued does; nothing warns (2026-10-17).</li>
|
|
1167
|
+
<li><b>A blend's members share the spec's marks</b>; <code>BlendMember</code> takes no <code>marks</code> of its own, since marks are timing and the spec times every member alike (2026-10-17).</li>
|
|
1168
|
+
<li><b>The playground draws a mark inside a clip as a labeled line across it</b>, only where the compiled mix placed it, and a link to one lands on that line (<code>Link.to.mark</code> in <code>widgets/ScoreLanes</code>). The score's Alt-drag still links only to edges (2026-10-17).</li>
|
|
1169
|
+
</ul>
|
|
1170
|
+
|
|
1171
|
+
<h3>Reading ahead</h3>
|
|
1172
|
+
<p>See “Reading ahead past the tape” and “Reading ahead at known events” in the <a href="#score">Score</a> section.</p>
|
|
1173
|
+
<ul>
|
|
1174
|
+
<li><b>The name <code>stepMs</code> on a voice's spec</b>, with 0 meaning the frame's gap; <b>a read ahead moves the tape forward and back</b>, which the tape's subscribers hear; and <b>which two calls it still refuses</b> (2026-10-11).</li>
|
|
1175
|
+
<li><b>Which events a read ahead stops at</b>: each call the tape holds, and each mark <code>marks</code> would list (a voice's five and the announced ones). Pass boundaries of a loop that goes on are not events. To narrow or widen, edit <code>nextEvent</code> in <code>src/project.ts</code> (2026-10-17).</li>
|
|
1176
|
+
<li><b>It stops only while a voice could carry state</b>: a patch with <code>step</code>, or a weight that is a function. Otherwise the read crosses in one go, so stateless mixes pay one pass over the voices. To stop always, drop the <code>carries</code> checks in <code>playAhead</code> and <code>nextEvent</code> (2026-10-17).</li>
|
|
1177
|
+
<li><b><code>assess</code> says <code>exact</code> for a stateful signal on a read ahead</b>, by the 2026-09-29 rule that one step across a stretch with no event in it is as good as any number; a signal following a clock-driven input is then exact only to the stretch. A patch <code>step</code> without <code>stepMs</code> stays <code>stepped</code>, and a read back still says <code>stepped</code> for signal state stepped from a copy. To go back, drop <code>mix.backward</code> from the <code>kept</code> test in <code>doubtOf</code> (2026-10-17).</li>
|
|
1178
|
+
<li><b>A subject the live mix never probed is read at every stop</b>, as if probed all along (2026-10-17).</li>
|
|
1179
|
+
<li><b>The record's <code>copied</code> flag</b>, which lets <code>assess</code> say <code>stepped</code> after a seek back, timed level: teitou, 7 rounds, <code>AB_EACH=1 bench/ab.sh 0c5527f 97b1aa3</code> over <code>fn</code>, <code>named</code>, <code>keys</code>, <code>probed</code>, <code>tweens^</code>, <code>weasel^</code> and <code>blend</code>, geometric mean 0.984 (2026-10-17).</li>
|
|
1180
|
+
</ul>
|
|
1181
|
+
|
|
1182
|
+
<h3>Groups</h3>
|
|
1183
|
+
<p>Owners take <code>tags</code>, <code>score</code>, hits, <code>seek</code> and a rate below 0 (2026-10-17).</p>
|
|
1184
|
+
<ul>
|
|
1185
|
+
<li><b>A group's <code>tags</code> and <code>score</code> select the group alone.</b> A booker or <code>drain</code> by tag takes its marks and hits, not its members'; a member carries only its own. To reverse, have <code>fits</code> in <code>src/book.ts</code>, <code>drain</code> in <code>src/mixer.ts</code> and the anchor query in <code>src/place.ts</code> read tags up the owner chain (2026-10-17).</li>
|
|
1186
|
+
<li><b>An owner's hits are at ms on its own clock, booked once</b>, since an owner does not loop; one behind its <code>seek</code> is taken late, as a voice's is. Per pass would need passes, which owners refuse (2026-10-17).</li>
|
|
1187
|
+
<li><b>An owner cued backward with no <code>seek</code> starts at 0</b>, so a child placed after 0 leaves at once: an owner has no end of its own at its cue to start from. To reverse, pick a default (the last child's end once it is cued) and set it in <code>Voice</code>'s constructor (2026-10-17).</li>
|
|
1188
|
+
<li><b><code>mix.span</code> refuses a rate below 0.</b> Cued backward, a span placed none of its children, which waited for good. Building it means <code>refit</code> in <code>src/spans.ts</code> laying children out from the span's seek down to 0, placing each by its end, with <code>clockOf</code>, <code>lingers</code> and <code>lapse</code> reading a clock that runs back: a rework of the fit, not a guard (2026-10-17).</li>
|
|
1189
|
+
<li><b>An owner run back to its start stays while it holds a child and leaves with its last</b>, where it used to leave at its own start and take them all (2026-10-11).</li>
|
|
1190
|
+
</ul>
|
|
1191
|
+
|
|
1192
|
+
<h3>Spans in astv's change order</h3>
|
|
1193
|
+
<ul>
|
|
1194
|
+
<li><b>A key on the child, not a comparator on the span</b>, since it is data a tape replays (2026-10-22).</li>
|
|
1195
|
+
<li><b>An anchor still unanswered holds every later child</b> under <code>queue</code> or <code>stagger</code> (2026-10-22).</li>
|
|
1196
|
+
<li><b>An anchor on a voice that has left never answers</b>, so astv passes the landing time as a number (2026-10-22).</li>
|
|
1197
|
+
<li><b>A floor already past when the child is cued places it there</b>, partway through, so a span made afresh after a seek lays out as a live one did. The owner's pick, over astv keeping <code>scheduleMarks</code> (2026-10-22).</li>
|
|
1198
|
+
</ul>
|
|
1199
|
+
|
|
1200
|
+
<h3>From the code review, and playing backward</h3>
|
|
1201
|
+
<ul>
|
|
1202
|
+
<li><b>A probe into <code>out</code> reuses its arrays</b>, which reverses a guarantee a test held: <code>test/solo.test.ts</code> said a reused <code>out</code> “never has an array the host kept from it written into”, and now says the array is the same one, and that a probe with no <code>out</code> is never written into. To undo it, drop the two reuse branches (<code>foldWith</code> in <code>fold.ts</code>, <code>copy</code> in <code>copyout.ts</code>): the <code>tween</code> row then allocates about 0.7 MB a frame again. No consumer is caught by it: klieg's two <code>poseAt</code> callers read the numbers out at once, and weasel's <code>poseOverrides.ts</code> and astv's six sites have no vec or color channel (2026-10-10). The owner has not seen this one.</li>
|
|
1203
|
+
<li><b>Step 7's names and shapes</b>: the type <code>Vec<N></code>; <code>angle({ turn })</code> with <code>turn</code> defaulting to 360; <code>input(read, of?)</code>, whose <code>touch()</code> reports a change; a keys patch's options as the flat fields <code>ease</code>, <code>easeBy</code>, <code>delayBy</code> and <code>lerpBy</code>, and a wave's as <code>patch.wave</code>, with <code>waveOptionsOf</code> removed; <code>handle.to</code> and <code>push</code> throwing for a patch that takes neither, and <code>read</code> giving undefined; and <code>fold</code> switching a rest-less channel on at 0.6, a mix's default band. All of it shipped in 0.8.0, so a rename is now a break (2026-10-10). The owner has not seen this one either.</li>
|
|
1204
|
+
<li><b>A key <code>at</code> returns outside <code>writes</code> is warned of</b> with a <code>warnOnce</code> per patch outside production, as a placement ending before its start already is, over a fault count the host reads, which blits has no pattern for. Only the first delta each voice's <code>at</code> returns is checked, in <code>contribution</code> (<code>src/contribution.ts</code>), so the lanes' fills check nothing. It timed level (msb-uai, 10 rounds, <code>AB_EACH=1 bench/ab.sh da301d9 650b3e9</code> over <code>fn</code> and <code>named</code>, geometric mean 0.992). To reverse, drop <code>checkWrites</code> and <code>unchecked</code> from <code>src/voice.ts</code> and the line calling it (2026-10-17).</li>
|
|
1205
|
+
<li><b><code>seek({ frame })</code> takes the number <code>mix.seq</code> and <code>transport.seq</code> read.</b> The getter is <code>seq</code> because <code>frame</code> is already an internal field on <code>Mixer</code> and <code>HistoryRecord.seq</code> already names the frame number publicly; the option is <code>frame</code>, as the review named it. <code>project({ frame })</code> came along so reads and seeks answer the same question one way. A frame number is opaque, not an index among frames at one time, and one history no longer holds throws. To rename, change <code>seq</code> in <code>src/types/mix.ts</code>, <code>src/types/transport.ts</code> and the getter in <code>src/mixer.ts</code> (2026-10-17).</li>
|
|
1206
|
+
<li><b>What <code>catchUp: 'fresh'</code> covers</b>: only where a stateful record met late starts stepping, from the first probe, with fresh state. The <code>stepMs</code> grid still counts from the origin (<code>meet</code> in <code>src/held.ts</code>), the fade-in still counts from the origin, a handle <code>seek</code>'s rebuild still steps from the voice's start, and a signal's kept state is unaffected. A projection that meets such a subject late marks it <code>unknown</code>, so <code>assess</code> says <code>held</code>. The determinism suite's <code>fresh</code> variant runs <code>seek</code>, <code>behind</code>, <code>now</code> and <code>frame</code> but not <code>again</code>, <code>ahead</code>, <code>taped</code> or <code>dt</code>, where fresh differs by design. To make fresh also cover rebuilds, call <code>meet</code> from <code>rebuild</code> in <code>src/contribution.ts</code> (2026-10-17).</li>
|
|
1207
|
+
<li><b>Playing backward</b> (see “Starting partway, and playing backward” in the <a href="#score">Score</a> section). The owner's calls: a valid use is not refused because no consumer has it yet; a cue takes <code>seek</code>; and, left blank, a voice cued backward starts at its end. Chosen while building: a fade in counts from no earlier than the voice's start, so <code>seek</code> does not use it up; and a voice with no end cued backward has no first pass, a property of the cue and not of the rate it has later (2026-10-10).</li>
|
|
1208
|
+
</ul>
|
|
1209
|
+
|
|
1210
|
+
<h3>Two copies loaded together</h3>
|
|
1211
|
+
<ul>
|
|
1212
|
+
<li><b>The mark another copy's motion patch is known by is named for the release</b> (<code>release</code> in <code>src/shared.ts</code>, held to <code>package.json</code> by <code>test/copies.test.ts</code>), not a hand-bumped <code>@1</code>, because the mix calls a wide, often-changed surface of the other copy's <code>Motions</code>. So an unreleased checkout and the release it grew from wrongly agree. To reverse, name the mark <code>motions@1</code> in <code>src/motion.ts</code> and drop <code>release</code> (2026-10-17).</li>
|
|
1213
|
+
</ul>
|
|
1214
|
+
|
|
1215
|
+
<h3>The owner's own calls, from before this list</h3>
|
|
1216
|
+
<ul>
|
|
1217
|
+
<li><b><code>setting.dt</code> for a patch with no <code>step</code></b> is the gap since the subject was last sampled, not the gap since the last sync. A read ahead or back still rates such a patch <code>exact</code>, though the <code>dt</code> it hands is one sample's gap and not what frame-by-frame play hands; rating it <code>stepped</code> needs the mix to know the patch read <code>dt</code>, a getter on the setting, untimed. Reopen it if a host scrubs a patch that reads <code>dt</code> and sees it jump (2026-10-10).</li>
|
|
1218
|
+
<li><b>A <code>color()</code> channel picks its lerp space</b> (<code>{ lerp: 'oklab' | 'oklch' }</code>, OKLab by default), after a red-to-cyan render showed OKLab washing to pale gray and OKLCH sweeping through the hues between (2026-10-07).</li>
|
|
1219
|
+
<li><b>Freezing</b>: the values <code>before</code>, <code>after</code> and <code>both</code>; a frozen voice ends when it is faded, not when its passes run out; and the name <code>freeze</code>, once the ticker's <code>hold()</code> had taken the word (built for 0.4.0, named 2026-10-05).</li>
|
|
1220
|
+
<li><b>Removing one subject from a voice extends <code>fade</code></b> (<code>handle.fade({ subject, over })</code>) rather than adding a removal verb, so the handle stays the only pathway for control; <code>mix.drop</code> already meant “forget this subject everywhere” (2026-10-02).</li>
|
|
1221
|
+
<li><b><code>tween</code> runs on its own clock</b>, like a spring's, not over the voice's period (2026-10-02).</li>
|
|
1222
|
+
<li><b><code>mix.pull</code> skips each subject's lookup for an array read again in the same order</b>, over a view object or numbers the host holds (2026-10-02).</li>
|
|
1223
|
+
<li><b>What lanes cost on rows they don't serve is accepted</b>: a voice per subject read 1.07× at 10,000 subjects and 1.2× at 100 to 1,000 (+0.04 ms at most), a mix probing 5% of its subjects 1.29× (+0.03 ms), and a projection every frame 1.10× (2026-10-02). See <a href="#against">Decided against</a> for writing the arithmetic out again.</li>
|
|
1037
1224
|
</ul>
|
|
1038
1225
|
|
|
1039
1226
|
<h2 id="tests">Tests</h2>
|
|
@@ -1045,11 +1232,11 @@ export const mixer: Engine; <span class="cm">// the one that ships</span></cod
|
|
|
1045
1232
|
<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>
|
|
1046
1233
|
<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>
|
|
1047
1234
|
<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>
|
|
1048
|
-
<li><b>
|
|
1235
|
+
<li><b>Role fold.</b> Two voices in a role driving the same value produce that value, on every stock channel — 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 role 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>
|
|
1049
1236
|
<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>
|
|
1050
1237
|
<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. A key outside <code>writes</code> is dropped, on lanes and off, and warned of once per patch outside production, and not at all in it.</li>
|
|
1051
1238
|
<li><b>Two copies.</b> A <code>keys</code> patch and a spring a second copy of the package made play on the first copy's mix as its own do: the keys patch keeps its easing, and the spring runs on lanes, never asked through <code>at</code>, and reads the same.</li>
|
|
1052
|
-
<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>
|
|
1239
|
+
<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. A subject a motion voice retargeted, then faded out of it or dropped, and a <code>to</code> later brought back, reads back and seeks back as it showed: on its retargeted stretch before it left, out between leaving and the <code>to</code>, and back after, whether or not a frame probed it while it was out. A retarget given a time before the frame it was made in reads back as in force only from that frame (tested 2026-10-24).</li>
|
|
1053
1240
|
<li><b>Seeking.</b> After <code>seek(t)</code> back under <code>stepMs</code>, with lanes and without, and through a change of the mix's rate, the mix plays on equal to what it showed at every frame, the host's recorded calls made again: cues, handle writes, fades, voice seeks, spring retargets and pushes, owners and their children, across two seeks before a subject is read again, and forward by a seek as by a sync. A call made after going back plays the new future and keeps the old one, which <code>switchBranch</code> plays again exactly. The suite runs on weasel-history's own <code>History</code>, a dev dependency. Handles held across a seek work, and <code>done</code> and <code>played</code> start over; events and hits after <code>t</code> come again and none at or before it does. <code>sync</code> refuses an earlier timestamp short of a <code>rebase</code>, and <code>project</code> takes mix time, which a <code>rebase</code> leaves where it was.</li>
|
|
1054
1241
|
<li><b>Placement.</b> astv's orb, landing text and tint, each anchored on the one before, list their marks and play to them; a waiting voice follows a target faded early; a placement that waits on itself is refused.</li>
|
|
1055
1242
|
<li><b>Lanes off against lanes on.</b> Every qualifying case runs with <code>lanes: false</code> and without, and each probe's channels must agree under <code>Object.is</code>: every patch form, <code>stagger</code>, <code>target</code>, <code>subjects</code>, loops, <code>rate</code>, <code>ramp</code>, <code>seek</code>, fades, <code>bounds</code>, a spring retargeted and pushed mid-flight, and a channel moving between a lane and the general path mid-animation. Subject numbers are freed and reused without showing an old subject's values. <code>npm run test:general</code> runs the whole suite on the general path: it sets <code>BLITS_LANES=off</code>, which turns lanes off in every mix that does not set <code>lanes</code> itself.</li>
|
|
@@ -1063,7 +1250,6 @@ export const mixer: Engine; <span class="cm">// the one that ships</span></cod
|
|
|
1063
1250
|
<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>
|
|
1064
1251
|
<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>
|
|
1065
1252
|
<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’s motion — 12 letters through four voices — costs 0.017 ms, and a frame of its effects — 60 parts under three effects — costs 0.134 ms, against a 16.7 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 ms, which is the collector catching up on exactly these allocations, so a subject count an order of magnitude past klieg’s would reopen it. <code>packages/core/test/bench/frame-cost.bench.ts</code> in klieg is the measurement</li>
|
|
1066
|
-
<li><b>A subject brought back, read back to while it was out.</b> Under <code>history</code> a voice keeps the record a subject left with, faded out or dropped, and a motion patch keeps its stretches, so a <code>seek</code> or <code>project</code> back to before it left finds both (built 2026-10-07). Untested: a read back to a moment between the subject leaving and a motion's <code>to</code> bringing it back, which may read it as never having left. Found 2026-10-02 while building subject fades.</li>
|
|
1067
1253
|
<li><b>Layered subjects.</b> A lamp that is part of a cell and also reached on its own, as a sparkle on one bulb of a chase, is its own subject and folds every voice of the cell again. Starting its fold from the cell's pose and adding only the voices that reach the lamp would save that, and only the mix knows which voices reach what. Unbuilt until a host needs it; the saving matters at thousands of subjects (<code>named N=10000 V=1</code> is 1.592 ms a frame on teitou, 2026-10-21). Raised 2026-10-22.</li>
|
|
1068
1254
|
</ul>
|
|
1069
1255
|
</main>
|