@msb235/blits 0.4.0 → 0.6.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 +289 -0
- package/README.md +2 -1
- package/dist/bare.d.ts +29 -0
- package/dist/bare.js +105 -0
- package/dist/blend.d.ts +34 -0
- package/dist/blend.js +102 -0
- package/dist/book.d.ts +53 -0
- package/dist/book.js +218 -0
- package/dist/chain.d.ts +22 -0
- package/dist/chain.js +150 -0
- package/dist/channels.d.ts +2 -0
- package/dist/channels.js +3 -1
- package/dist/clock.d.ts +50 -3
- package/dist/clock.js +102 -9
- package/dist/clone.d.ts +7 -0
- package/dist/clone.js +57 -0
- package/dist/columns.d.ts +29 -0
- package/dist/columns.js +183 -0
- package/dist/crowd.d.ts +128 -0
- package/dist/crowd.js +354 -0
- package/dist/cue.d.ts +3 -0
- package/dist/cue.js +115 -0
- package/dist/due.d.ts +12 -0
- package/dist/due.js +93 -0
- package/dist/fade.d.ts +19 -0
- package/dist/fade.js +133 -0
- package/dist/fill.d.ts +42 -0
- package/dist/fill.js +381 -0
- package/dist/fold.d.ts +51 -0
- package/dist/fold.js +475 -0
- package/dist/gather.d.ts +24 -0
- package/dist/gather.js +186 -0
- package/dist/handle.d.ts +31 -0
- package/dist/handle.js +56 -0
- package/dist/held.d.ts +14 -0
- package/dist/held.js +117 -0
- package/dist/history.d.ts +22 -0
- package/dist/history.js +149 -0
- package/dist/hosts.d.ts +16 -0
- package/dist/hosts.js +178 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +1 -0
- package/dist/keyrows.d.ts +36 -0
- package/dist/keyrows.js +138 -0
- package/dist/lane.d.ts +210 -0
- package/dist/lane.js +187 -0
- package/dist/lanes.d.ts +121 -246
- package/dist/lanes.js +150 -1578
- package/dist/locus.d.ts +40 -0
- package/dist/locus.js +89 -0
- package/dist/marks.d.ts +12 -0
- package/dist/marks.js +80 -0
- package/dist/meet.d.ts +16 -0
- package/dist/meet.js +113 -0
- package/dist/mixer.d.ts +248 -196
- package/dist/mixer.js +270 -2220
- package/dist/motion.d.ts +66 -21
- package/dist/motion.js +325 -165
- package/dist/move.d.ts +9 -0
- package/dist/move.js +190 -0
- package/dist/named.d.ts +12 -0
- package/dist/named.js +39 -0
- package/dist/numbers.d.ts +18 -6
- package/dist/numbers.js +59 -31
- package/dist/owed.d.ts +24 -0
- package/dist/owed.js +51 -0
- package/dist/owner.d.ts +43 -0
- package/dist/owner.js +117 -0
- package/dist/pace.d.ts +40 -0
- package/dist/pace.js +113 -0
- package/dist/patch.d.ts +51 -10
- package/dist/patch.js +140 -45
- package/dist/place.d.ts +20 -0
- package/dist/place.js +196 -0
- package/dist/project.d.ts +4 -0
- package/dist/project.js +233 -0
- package/dist/pull.d.ts +5 -0
- package/dist/pull.js +175 -0
- package/dist/qualify.d.ts +31 -0
- package/dist/qualify.js +345 -0
- package/dist/relink.d.ts +25 -0
- package/dist/relink.js +59 -0
- package/dist/rows.d.ts +33 -0
- package/dist/rows.js +282 -0
- package/dist/sample.d.ts +21 -0
- package/dist/sample.js +232 -0
- package/dist/signals.d.ts +13 -9
- package/dist/signals.js +14 -5
- package/dist/store.d.ts +10 -3
- package/dist/store.js +44 -9
- package/dist/strays.d.ts +21 -0
- package/dist/strays.js +84 -0
- package/dist/ticker.d.ts +48 -0
- package/dist/ticker.js +112 -0
- package/dist/tweened.d.ts +11 -0
- package/dist/tweened.js +48 -0
- package/dist/types.d.ts +237 -43
- package/dist/unreached.d.ts +5 -0
- package/dist/unreached.js +24 -0
- package/dist/voice.d.ts +240 -0
- package/dist/voice.js +324 -0
- package/dist/weigh.d.ts +26 -0
- package/dist/weigh.js +89 -0
- package/docs/schema.html +137 -56
- package/package.json +5 -2
package/docs/schema.html
CHANGED
|
@@ -160,7 +160,7 @@
|
|
|
160
160
|
<h1>An effects schema, and the API around it</h1>
|
|
161
161
|
<p class="lede">A small runtime package for concurrent effects. Each effect runs on its own clock with its own state and weight, and a mix folds them into one value per subject per frame by rules that belong to the channel, not the effect.</p>
|
|
162
162
|
<p class="meta"><b>For:</b> whoever builds <code>blits</code>, and whoever ports klieg, wod, sherpa or magicsmoke onto it. <b>Answers:</b> what the vocabulary is, what an author writes, what a host calls, and where a second engine would plug in. The package implements this page; where the two differ, the code in <code>src/</code> is the one that runs.</p>
|
|
163
|
-
<span class="status">Built · v0.
|
|
163
|
+
<span class="status">Built · v0.6.0 · design 2026-09-15, package 2026-09-28</span>
|
|
164
164
|
</header>
|
|
165
165
|
|
|
166
166
|
<h2 id="picture">In one picture</h2>
|
|
@@ -173,7 +173,7 @@
|
|
|
173
173
|
<text x="20" y="22" class="sub">voices, each on its own clock</text>
|
|
174
174
|
<rect x="20" y="32" width="170" height="44" rx="3" class="box"/>
|
|
175
175
|
<text x="32" y="50">flicker · loop · w 1.0</text>
|
|
176
|
-
<text x="32" y="66" class="sub">phase wraps once per
|
|
176
|
+
<text x="32" y="66" class="sub">phase wraps once per pass</text>
|
|
177
177
|
<rect x="20" y="86" width="170" height="44" rx="3" class="box hot"/>
|
|
178
178
|
<text x="32" y="104">power · state · w 0.4 ↑</text>
|
|
179
179
|
<text x="32" y="120" class="sub">fading in over 300 ms</text>
|
|
@@ -234,8 +234,10 @@
|
|
|
234
234
|
<dt>Engine</dt><dd>The implementation behind a mix. One ships. The seam is the mix's own interface.</dd>
|
|
235
235
|
<dt>Pose</dt><dd>What one subject looks like at one frame, once every voice has been folded in. Where a delta is partial, a pose is complete: every channel of the kit is filled. This is what a host writes.</dd>
|
|
236
236
|
<dt>Influence</dt><dd>What one voice does to one subject in one frame, once its weight is applied. Not a share of the whole: two voices each contributing <code>gain: 0.1</code> leave the subject at 0.01.</dd>
|
|
237
|
-
<dt>
|
|
238
|
-
<dt>
|
|
237
|
+
<dt>Duration</dt><dd>How long one pass of a patch lasts, in milliseconds, whether or not the voice loops. 0 means the patch has no passes.</dd>
|
|
238
|
+
<dt>Pass</dt><dd>One run through a patch, phase 0 to 1.</dd>
|
|
239
|
+
<dt>Period</dt><dd>The duration a looping voice repeats. <code>Patch.period</code> is a deprecated alias for <code>duration</code>.</dd>
|
|
240
|
+
<dt>Phase</dt><dd>Where a voice is within its current pass, 0 to 1, wrapping. The first thing a patch is handed, and what <code>stagger</code> spreads per subject.</dd>
|
|
239
241
|
<dt>Setting</dt><dd>What a patch may read and did not compute, for one subject this frame: the clock and <code>dt</code> the whole frame shares, the <code>elapsed</code>, <code>pass</code> and <code>weight</code> particular to the voice, its state, and whatever the host added.</dd>
|
|
240
242
|
<dt>System</dt><dd>One complete area of driven behavior: a kit, the subject type it drives, and the mix over them. klieg's motion, effects and lighting are three systems in one host. Two systems never share a mix, so a voice belongs to exactly one.</dd>
|
|
241
243
|
<dt>Source</dt><dd>Whatever decided a voice should exist: a score, a handler on an event, a chain handing over. There is no source type and nothing to implement — a source calls <code>cue</code> and gets a handle. The word is here so the vocabulary can name the seam it does not own.</dd>
|
|
@@ -293,27 +295,30 @@ type Kit<O> = { readonly [K in keyof O]-?: Channel<NonNullable<O[K]&
|
|
|
293
295
|
<h2 id="patches">Patches</h2>
|
|
294
296
|
<p>A patch is what an author writes. It is klieg's <code>EffectPiece</code> with three additions: the setting carries absolute time as well as normalized, a patch may declare state, and a patch declares which channels it contributes to.</p>
|
|
295
297
|
|
|
296
|
-
<pre><code>interface Patch<I, O, S = void> {
|
|
298
|
+
<pre><code>interface Patch<I, O, S = void, H = unknown> {
|
|
297
299
|
<span class="cm">/** Which authoring form built it: `'fn'` from `patch`, `'keys'` from `keys`, `'motion'` from `spring`,
|
|
298
300
|
`glide` or `tween`. An engine declares which forms it runs. */</span>
|
|
299
301
|
readonly form: 'fn' | 'keys' | 'motion';
|
|
300
|
-
<span class="cm">/** Milliseconds one pass lasts
|
|
301
|
-
`loop` is ignored, and the patch reads `setting.elapsed` instead. */</span>
|
|
302
|
-
|
|
302
|
+
<span class="cm">/** Milliseconds one pass lasts, whether or not the voice loops. 0 has no passes: `phase` and `pass`
|
|
303
|
+
stay 0, `loop` is ignored, and the patch reads `setting.elapsed` instead. */</span>
|
|
304
|
+
duration: number;
|
|
305
|
+
<span class="cm">/** @deprecated Use `duration`. A patch that sets only `period` still plays. */</span>
|
|
306
|
+
readonly period?: number;
|
|
303
307
|
<span class="cm">/** The channels this patch contributes to. Every key `at` sets, and no others. */</span>
|
|
304
308
|
readonly writes: readonly (keyof O)[];
|
|
305
309
|
<span class="cm">/** The channels it was written against. `cue` refuses a kit whose channel of the same name is another kind. */</span>
|
|
306
310
|
readonly kit?: Partial<Kit<O>>;
|
|
307
311
|
<span class="cm">/** The fields of `setting.host` it reads. `cue` refuses a mix whose host lacks one. */</span>
|
|
308
312
|
readonly reads?: readonly string[];
|
|
309
|
-
<span class="cm">/** `phase` is 0..1 across one
|
|
310
|
-
|
|
313
|
+
<span class="cm">/** `phase` is 0..1 across one pass, wrapping. Not asked for a subject its voice gives no weight, unless the
|
|
314
|
+
voice is fading to rest; `step` still runs. */</span>
|
|
315
|
+
at(phase: number, subject: I, setting: Setting<S, H>): Partial<O>;
|
|
311
316
|
|
|
312
317
|
<span class="cm">/** Per-subject state, created on the first frame this patch sees a subject. */</span>
|
|
313
318
|
state?(subject: I): S;
|
|
314
319
|
<span class="cm">/** Advances state once per subject per probed frame, before that frame's first `at`, by the whole gap
|
|
315
320
|
since that subject last advanced. `dt` is Infinity under reduced motion. */</span>
|
|
316
|
-
step?(state: S, dt: number, subject: I, setting: Setting<S>): void;
|
|
321
|
+
step?(state: S, dt: number, subject: I, setting: Setting<S, H>): void;
|
|
317
322
|
|
|
318
323
|
<span class="cm">/** Present when the patch was authored as keyframes, so an engine that reads data can. */</span>
|
|
319
324
|
readonly keys?: readonly Keyframe<O>[];
|
|
@@ -323,7 +328,7 @@ type Kit<O> = { readonly [K in keyof O]-?: Channel<NonNullable<O[K]&
|
|
|
323
328
|
clone?(state: S): S;
|
|
324
329
|
}
|
|
325
330
|
|
|
326
|
-
interface Setting<S = void> {
|
|
331
|
+
interface Setting<S = void, H = unknown> {
|
|
327
332
|
<span class="cm">/** The mix clock at this frame. Identical for every sample in the frame. */</span>
|
|
328
333
|
timestamp: number;
|
|
329
334
|
<span class="cm">/** The gap this subject is catching up by: milliseconds since it last advanced, which is the gap since
|
|
@@ -339,8 +344,8 @@ interface Setting<S = void> {
|
|
|
339
344
|
weight: number;
|
|
340
345
|
<span class="cm">/** This subject's state, when the patch declares one. */</span>
|
|
341
346
|
state: S;
|
|
342
|
-
<span class="cm">/** Anything the host adds for its own patches: klieg's pointer and pointerInWord go here. */</span>
|
|
343
|
-
host:
|
|
347
|
+
<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>
|
|
348
|
+
host: H;
|
|
344
349
|
<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
|
|
345
350
|
another time can copy it; a stateful signal keeps its state here and nowhere else. */</span>
|
|
346
351
|
keep<K>(owner: object, init: () => K): K;
|
|
@@ -352,8 +357,8 @@ interface Setting<S = void> {
|
|
|
352
357
|
<p>Three authoring forms build a patch. All return the same interface, so a voice cannot tell them apart.</p>
|
|
353
358
|
<div class="tbl"><table>
|
|
354
359
|
<tr><th>form</th><th>signature</th><th>what it is</th></tr>
|
|
355
|
-
<tr><td><code>fn</code></td><td><code>patch(
|
|
356
|
-
<tr><td><code>keys</code></td><td><code>keys(
|
|
360
|
+
<tr><td><code>fn</code></td><td><code>patch(duration, at, { writes, state?, step? })</code></td><td>Procedural. Flicker, roving, a hash walk, a starter strike. Only a JS engine can run one.</td></tr>
|
|
361
|
+
<tr><td><code>keys</code></td><td><code>keys(duration, stops, { ease?, easeBy?, delayBy? })</code></td><td>Declarative stops, klieg's transition sugar and wod's keyframes. Data preserved on <code>patch.keys</code> for an engine that reads data instead of calling functions.</td></tr>
|
|
357
362
|
<tr><td><code>motion</code></td><td><code>spring(writes, opts)</code>, <code>glide(writes, opts)</code>, <code>tween(writes, opts)</code></td><td>Momentum, or an eased curve between two values, solved in closed form per subject; <a href="#motion">below</a>. The kind and its constants are preserved on <code>patch.motion</code>, a <code>MotionSpec</code>.</td></tr>
|
|
358
363
|
</table></div>
|
|
359
364
|
|
|
@@ -366,8 +371,8 @@ interface Setting<S = void> {
|
|
|
366
371
|
<h2 id="voices">Voices</h2>
|
|
367
372
|
<p>A voice is a patch given a clock and a volume knob. This is the unit that answers "independent state and timeframe": two voices of the same patch are two clocks, two states, two weights.</p>
|
|
368
373
|
|
|
369
|
-
<pre><code>interface VoiceSpec<I, O> {
|
|
370
|
-
patch: Patch<I, O, any>;
|
|
374
|
+
<pre><code>interface VoiceSpec<I, O, H = unknown> {
|
|
375
|
+
patch: Patch<I, O, any, H>;
|
|
371
376
|
<span class="cm">/** Which subjects this voice reaches. Default: all of them. The predicate is fixed at `cue`; it runs per
|
|
372
377
|
subject the first time the mix sees that subject, and the answer is kept for as long as the state is. */</span>
|
|
373
378
|
target?: (subject: I) => boolean;
|
|
@@ -388,12 +393,12 @@ interface Setting<S = void> {
|
|
|
388
393
|
kept, like <code>target</code>; the subject's fade in starts when the voice clock reaches it. */</span>
|
|
389
394
|
stagger?: (subject: I) => number;
|
|
390
395
|
<span class="cm">/** Show the first frame before a subject starts, the last after its passes run out, or both. Default: neither,
|
|
391
|
-
so the voice shows nothing outside its passes. See
|
|
392
|
-
|
|
396
|
+
so the voice shows nothing outside its passes. See Freezing. <code>hold</code>, its name before 0.6.0, still works and is deprecated. */</span>
|
|
397
|
+
freeze?: 'before' | 'after' | 'both';
|
|
393
398
|
|
|
394
399
|
<span class="cm">// weight</span>
|
|
395
400
|
<span class="cm">/** Steady weight, or a signal read per subject per frame. Default 1. */</span>
|
|
396
|
-
weight?: number | Signal<I>;
|
|
401
|
+
weight?: number | Signal<I, H>;
|
|
397
402
|
<span class="cm">/** Ramp in and out, ms. Out applies on `fade()` and when a finite loop ends. */</span>
|
|
398
403
|
fade?: { in?: number; out?: number; ease?: Easing };
|
|
399
404
|
<span class="cm">/** Voices sharing a locus are alternatives: the mix folds them into one influence through each
|
|
@@ -406,7 +411,7 @@ interface Setting<S = void> {
|
|
|
406
411
|
the pose of the frame before this voice first contributes, so it never reads itself. With no
|
|
407
412
|
such frame on record it is this frame's pose with every other voice folded in. */</span>
|
|
408
413
|
from?: 'current';
|
|
409
|
-
<span class="cm">/** Words a source attaches to its voices, so their events can be
|
|
414
|
+
<span class="cm">/** Words a source attaches to its voices, so their events and handles can be taken as a set. */</span>
|
|
410
415
|
tags?: readonly string[];
|
|
411
416
|
<span class="cm">// score</span>
|
|
412
417
|
<span class="cm">/** What other voices call this one by. A label blits never reads. */</span>
|
|
@@ -415,12 +420,18 @@ interface Setting<S = void> {
|
|
|
415
420
|
score?: string;
|
|
416
421
|
<span class="cm">/** start or in, and out or end: each a timestamp or an anchor to another voice's mark. See Score. */</span>
|
|
417
422
|
anchor?: Placement;
|
|
423
|
+
<span class="cm">/** Events at times on this voice's clock, once per pass, for `book` to hand the host ahead of time. See Time. */</span>
|
|
424
|
+
hits?: readonly { at: number; event: unknown }[];
|
|
425
|
+
<span class="cm">/** The owner it plays under, which places and times it on the owner's clock. See Score. */</span>
|
|
426
|
+
owner?: Handle<I>;
|
|
418
427
|
}
|
|
419
428
|
|
|
420
429
|
interface Handle<I> {
|
|
421
430
|
readonly id: number;
|
|
422
|
-
<span class="cm">/** <code>
|
|
423
|
-
readonly state: 'pending' | 'live' | '
|
|
431
|
+
<span class="cm">/** <code>frozen</code>: a voice freezing after has played every pass and shows its last frame until faded. */</span>
|
|
432
|
+
readonly state: 'pending' | 'live' | 'frozen' | 'fading' | 'done';
|
|
433
|
+
<span class="cm">/** The owner's handle, for a voice an owner holds. */</span>
|
|
434
|
+
readonly owner: Handle<I> | undefined;
|
|
424
435
|
<span class="cm">/** Live. Writes land on the next sync. */</span>
|
|
425
436
|
weight: number;
|
|
426
437
|
rate: number;
|
|
@@ -445,14 +456,14 @@ interface Handle<I> {
|
|
|
445
456
|
|
|
446
457
|
<p><b>Fading one subject.</b> <code>fade({ subject, over })</code> ramps one subject out of a voice and leaves the voice playing for every other subject; <code>over: 0</code> takes it out at once. When the ramp ends the voice forgets the subject — its record, its lane position and a motion patch’s state for it — and no longer reaches it, so nothing it held stays stacked under whatever takes the subject over. A motion patch’s <code>to</code> brings it back, met afresh: <code>from</code>, <code>target</code> and <code>stagger</code> are asked again. Any other voice reaches it again only once <code>drop</code> forgets the subject. This is what lets a host that starts many short animations, one per node, run them as one voice on one lane instead of a voice per call, while still cancelling a node or handing it to a different patch.</p>
|
|
447
458
|
|
|
448
|
-
<p><b>
|
|
459
|
+
<p><b>Freezing.</b> A voice shows nothing outside its passes unless it freezes, the name SMIL gives the same thing (<code>fill="freeze"</code>). <code>freeze: 'before'</code> shows a subject the voice’s first frame from the cue until the subject starts: while the voice is pending, and while the subject waits out its <code>stagger</code>. <code>freeze: 'after'</code> shows the last frame of the last pass once a finite loop has played out, and the voice stays until <code>fade()</code> takes it out, ramping from that frame. <code>'both'</code> does both. wod asked for it to replace WAAPI’s <code>fill</code>: a spin lands and freezes on its angle, and staggered wedges wait on their first frame rather than popping in.</p>
|
|
449
460
|
<ul>
|
|
450
|
-
<li><b>A
|
|
451
|
-
<li><b><code>fade.in</code> counts from the first frame the voice shows</b>, so with <code>'before'</code> it ramps in on the
|
|
452
|
-
<li><b>A voice
|
|
453
|
-
<li><b>The host owns every
|
|
454
|
-
<li><b>Where it changes nothing.</b> A voice that loops for good never runs out of passes, so only <code>'before'</code> applies to it. A motion patch (<code>spring</code>, <code>glide</code>, <code>tween</code>) already
|
|
455
|
-
<li><b>What it costs.</b> A voice
|
|
461
|
+
<li><b>A frozen subject’s clock stands still.</b> It reads phase 0 of pass 0 before, and phase 1 of the last pass after, with <code>elapsed</code> pinned to that edge. <code>at</code> is asked for that frame. <code>step</code> does not run while a subject waits, so a stateful patch starts playing from the state it was cued with; after, it runs once more to where the last pass ended and then no more, so the frozen frame does not depend on when the last frame before it fell. A voice freezing before stays <code>pending</code> until it starts, and <code>weightOf</code> reports the weight it gives a waiting subject.</li>
|
|
462
|
+
<li><b><code>fade.in</code> counts from the first frame the voice shows</b>, so with <code>'before'</code> it ramps in on the frozen frame rather than at full weight, and does not dip when play begins.</li>
|
|
463
|
+
<li><b>A voice freezing after reaches <code>frozen</code>, not <code>fading</code>,</b> at the moment a voice without a freeze would begin its fade out. <code>played</code> resolves true then. Its <code>out</code> and <code>end</code> marks are unfixed until it is faded, like those of a voice that loops for good, so <code>{ after: 'spin' }</code> waits for the fade. A host that wants to start something when the passes run out awaits <code>played</code>.</li>
|
|
464
|
+
<li><b>The host owns every frozen voice.</b> One cued per spin and never faded stays in the mix for good, one more voice folded every frame. The next voice’s cue is the time to fade the last, at once or <code>at: 'rest'</code>.</li>
|
|
465
|
+
<li><b>Where it changes nothing.</b> A voice that loops for good never runs out of passes, so only <code>'before'</code> applies to it. A motion patch (<code>spring</code>, <code>glide</code>, <code>tween</code>) already keeps its target and has no passes, so <code>freeze</code> does nothing to it. Lanes give the same pose with it as without them, as for everything else.</li>
|
|
466
|
+
<li><b>What it costs.</b> A voice freezing before runs on the general path while it is pending, which takes the channels it writes off their lanes until it starts; one that is already live, waiting only on a subject’s stagger, stays on its lane. A mix with no freeze reads no slower (<code>keys</code> and <code>fn</code> rows of <code>bench/frame.mjs</code>, three passes alternated against <code>main</code> on a laptop, 2026-10-03).</li>
|
|
456
467
|
</ul>
|
|
457
468
|
|
|
458
469
|
<p>Weights are the only knob the mix owns. Fade, crossfade, handover, alternatives, and hinge's continuous mode are all weights over time; the section on blending shows each one as such. A patch never sees a weight applied to its output, it only sees the number in <code>setting.weight</code> if it cares.</p>
|
|
@@ -460,14 +471,14 @@ interface Handle<I> {
|
|
|
460
471
|
<h2 id="mix">Mix</h2>
|
|
461
472
|
<p>What a host holds. The methods are few because the voices carry the options.</p>
|
|
462
473
|
|
|
463
|
-
<pre><code>function mix<I, O>(kit: Kit<O>, opts?: MixOptions): Mix<I, O>;
|
|
474
|
+
<pre><code>function mix<I, O, H = unknown>(kit: Kit<O>, opts?: MixOptions<H>): Mix<I, O, H>;
|
|
464
475
|
|
|
465
|
-
interface MixOptions {
|
|
476
|
+
interface MixOptions<H = unknown> {
|
|
466
477
|
engine?: Engine; <span class="cm">// default: mixer</span>
|
|
467
478
|
<span class="cm">/** Reduced motion: fades and warm-ups snap, `dt` reads Infinity. Read once at creation, or a signal. */</span>
|
|
468
479
|
reduce?: boolean | (() => boolean);
|
|
469
|
-
<span class="cm">/** Merged into every `setting.host`. */</span>
|
|
470
|
-
host?:
|
|
480
|
+
<span class="cm">/** Merged into every `setting.host`. `H` types it for every patch and signal the mix cues, so none needs a cast. */</span>
|
|
481
|
+
host?: H;
|
|
471
482
|
<span class="cm">/** The band a rest-less channel's influence switches across. Default 0.4 / 0.6, a placeholder. */</span>
|
|
472
483
|
band?: { on: number; off: number };
|
|
473
484
|
<span class="cm">/** The most `dt` a step or signal is handed, however long the gap. Off by default. */</span>
|
|
@@ -481,12 +492,15 @@ interface MixOptions {
|
|
|
481
492
|
lanes?: boolean;
|
|
482
493
|
}
|
|
483
494
|
|
|
484
|
-
interface Mix<I, O> {
|
|
495
|
+
interface Mix<I, O, H = unknown> {
|
|
485
496
|
<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>
|
|
486
|
-
cue(spec: VoiceSpec<I, O>): Handle<I>;
|
|
497
|
+
cue(spec: VoiceSpec<I, O, H>): Handle<I>;
|
|
487
498
|
<span class="cm">/** N voices whose weights split one signal, k in 0..1, cued into one locus so they fold as
|
|
488
|
-
alternatives. Two patches crossfade; more make stops.
|
|
489
|
-
|
|
499
|
+
alternatives. Two patches crossfade; more make stops. The signal is read once per subject per
|
|
500
|
+
frame, and every member takes its share of that reading. */</span>
|
|
501
|
+
blend(patches: readonly Patch<I, O, any, H>[], by: Signal<I, H>, spec?: Omit<VoiceSpec<I, O, H>, 'patch' | 'weight'>): Handle<I>[];
|
|
502
|
+
<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>
|
|
503
|
+
owns(spec: OwnerSpec<I, H>): Handle<I>;
|
|
490
504
|
|
|
491
505
|
<span class="cm">/** The host reports the clock, once a frame. Nothing advances at the call: it publishes the reading, and
|
|
492
506
|
each subject catches up by the whole gap the first time it is probed afterward. The same timestamp twice
|
|
@@ -494,6 +508,10 @@ interface Mix<I, O> {
|
|
|
494
508
|
sync(timestamp: number): void;
|
|
495
509
|
<span class="cm">/** The time between the last sync and the next one does not count. Called when a hidden tab comes back. */</span>
|
|
496
510
|
rebase(): void;
|
|
511
|
+
<span class="cm">/** The mix's own rate, multiplied into every voice's; 0 pauses the whole mix. See Time. */</span>
|
|
512
|
+
rate: number;
|
|
513
|
+
<span class="cm">/** Moves the mix's rate to `rate` linearly over `over` ms of the host's clock. */</span>
|
|
514
|
+
ramp(rate: number, over: number): void;
|
|
497
515
|
<span class="cm">/** The merged pose for one subject at the synced frame. `out` is written in place when given. */</span>
|
|
498
516
|
probe(subject: I, out?: O): O;
|
|
499
517
|
<span class="cm">/** Each subject's pose written into one Float64Array per channel, in the order given: what probe(subject, out)
|
|
@@ -508,19 +526,29 @@ interface Mix<I, O> {
|
|
|
508
526
|
<span class="cm">/** Every mark the plan knows between two timestamps, earliest first. */</span>
|
|
509
527
|
marks(from: number, to: number): Marked[];
|
|
510
528
|
|
|
529
|
+
<span class="cm">/** A handle on every voice still in the mix, pending, live, frozen or fading, in cue order; given a tag, only
|
|
530
|
+
the voices carrying it. Each is the handle `cue` returned for that voice. */</span>
|
|
531
|
+
voices(tag?: string): Handle<I>[];
|
|
532
|
+
|
|
511
533
|
<span class="cm">/** Anything still contributing, fading, or pending. A host stops its loop when this is false. `cue` and
|
|
512
|
-
`blend` make it true again at once
|
|
513
|
-
the caller's to do on the same path that cued. */</span>
|
|
534
|
+
`blend` make it true again at once, and `onWake` says so. */</span>
|
|
514
535
|
readonly live: boolean;
|
|
515
|
-
<span class="cm">/** Another frame would change no pose: every voice is done,
|
|
516
|
-
subject has landed. A host may sleep its loop while this is true and `live` still is. */</span>
|
|
536
|
+
<span class="cm">/** Another frame would change no pose: every voice is done, frozen at a plain weight, or a motion whose every
|
|
537
|
+
subject has landed, or, at rate 0, every voice has started at a plain weight with no anchor. A host may sleep its loop while this is true and `live` still is. */</span>
|
|
517
538
|
readonly inert: boolean;
|
|
539
|
+
<span class="cm">/** Calls `fn` when a host call makes the mix need frames again after a sync: a cue, a fade, a rate, a seek, a
|
|
540
|
+
`drop`, a motion retargeted. Once until the next sync, and never during one: the only time the mix calls
|
|
541
|
+
back into the host, and always from inside a call the host made. A sleeping loop wakes on it. */</span>
|
|
542
|
+
onWake(fn: () => void): () => void;
|
|
518
543
|
<span class="cm">/** Fades every voice out: over `over` when given, over each voice's own `fade.out` otherwise. */</span>
|
|
519
544
|
mute(opts?: { over?: number }): void;
|
|
520
545
|
<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>
|
|
521
546
|
drop(subject: I): void;
|
|
522
547
|
<span class="cm">/** Every event sent since the last drain, earliest first. Drain after probing. */</span>
|
|
523
548
|
drain<E>(tag?: string): { timestamp: number; subject: I; voice: number; tags: readonly string[]; event: E }[];
|
|
549
|
+
<span class="cm">/** Hands the host each mark and hit `ahead` ms before it comes, on a clock of its own, and stops what moves. See Time. */</span>
|
|
550
|
+
book<E>(opts: { clock: () => number; ahead: number; late: number; tag?: string; score?: string;
|
|
551
|
+
take(item: Marked | BookedHit<E>, when: number, lateBy: number): { stop(): void } | undefined }): { stop(): void };
|
|
524
552
|
}</code></pre>
|
|
525
553
|
|
|
526
554
|
<p>A probe walks the voices that may reach its subject, not every voice. With no <code>target</code> in the mix that is all of them, so nothing is kept; once a voice targets, each subject keeps the list of voices whose <code>target</code> took it, rebuilt when a voice is cued, starts or leaves. One voice per subject on a shared mix then costs one voice per probe: 1000 subjects, each with its own voice, took 95 ms a frame before the list and 0.45 ms after (<code>npm run bench</code>, rows <code>own</code>). Building the list still asks every <code>target</code> of every new subject, so the first frame of that scene took 500–580 ms. A voice that names its subjects with <code>subjects</code> is filed under each of them at <code>cue</code>, and a subject's list is built from the voices filed under it and those that name none: the same scene's first frame took 1.0 ms, and 10.6 ms at 10,000 subjects (rows <code>named</code>).</p>
|
|
@@ -530,29 +558,61 @@ interface Mix<I, O> {
|
|
|
530
558
|
<h2 id="signals">Signals</h2>
|
|
531
559
|
<p>A signal is a 0..1 scalar resolved per subject per frame. It is the package's one input type from outside the clock, and it plugs in at exactly two places: a voice's <code>weight</code>, and <code>blend</code>'s selector.</p>
|
|
532
560
|
|
|
533
|
-
<pre><code>type Signal<I> = ((subject: I, setting: Setting) => number) & { readonly input?: boolean };
|
|
561
|
+
<pre><code>type Signal<I, H = unknown> = ((subject: I, setting: Setting<void, H>) => number) & { readonly input?: boolean };
|
|
534
562
|
|
|
535
563
|
<span class="cm">// stock combinators; klieg's own `near`, `dwell`, `level` are consumers' signals built on these</span>
|
|
536
564
|
function peak<I>(...signals: Signal<I>[]): Signal<I>; <span class="cm">// max</span>
|
|
537
|
-
function slew<I>(of: Signal<I>, { riseMs, fallMs }): Signal<I>; <span class="cm">// stateful: rate-limited follow toward its input</span>
|
|
538
|
-
function lag<I>(of: Signal<I>, { riseMs, fallMs, floor }): Signal<I>; <span class="cm">// stateful: exponential follow, time constants</span>
|
|
565
|
+
function slew<I>(of: Signal<I>, { riseMs, fallMs, from }): Signal<I>; <span class="cm">// stateful: rate-limited follow toward its input, from `from` or its input</span>
|
|
566
|
+
function lag<I>(of: Signal<I>, { riseMs, fallMs, floor, from }): Signal<I>; <span class="cm">// stateful: exponential follow, time constants</span>
|
|
539
567
|
function level<I>(initial?: number): Signal<I> & { set(v: number): void }; <span class="cm">// host-written</span>
|
|
540
568
|
function gate<I>(of: Signal<I>, band: { on: number; off: number }): Signal<I>; <span class="cm">// stateful: holds inside the band</span>
|
|
541
569
|
function gate<I>(of: Signal<I>, at: number): Signal<I>; <span class="cm">// sugar for on = off = at</span></code></pre>
|
|
542
570
|
|
|
543
|
-
<p>A stateful signal keeps its state in the mix, through <code>setting.keep(owner, init)</code>: per voice and per subject, keyed by the signal itself, made on first ask. So two voices handed the same <code>slew</code> each follow on their own, and a read at another time can copy what the mix holds instead of moving it. A signal measures its own gap, from the timestamp it last saw, rather than the gap its voice caught up by. <code>input</code> marks a signal that reads something the clock does not drive: <code>level</code> sets it, and <code>peak</code>, <code>slew</code>, <code>gate</code> and <code>blend</code>’s shares inherit it from what they read. <code>slew</code> is klieg's <code>dwell</code> with the cursor factored out; <code>gate</code> keeps one bit, which way it last switched.</p>
|
|
571
|
+
<p>A stateful signal keeps its state in the mix, through <code>setting.keep(owner, init)</code>: per voice and per subject, keyed by the signal itself, made on first ask. So two voices handed the same <code>slew</code> each follow on their own, except a blend’s members, which read their signal once and keep its state once between them; and a read at another time can copy what the mix holds instead of moving it. A signal measures its own gap, from the timestamp it last saw, rather than the gap its voice caught up by. <code>input</code> marks a signal that reads something the clock does not drive: <code>level</code> sets it, and <code>peak</code>, <code>slew</code>, <code>gate</code> and <code>blend</code>’s shares inherit it from what they read. <code>slew</code> is klieg's <code>dwell</code> with the cursor factored out; <code>gate</code> keeps one bit, which way it last switched.</p>
|
|
544
572
|
|
|
545
573
|
<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>
|
|
546
574
|
|
|
547
575
|
<h2 id="time">Time</h2>
|
|
548
576
|
<ul>
|
|
549
|
-
<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.</li>
|
|
577
|
+
<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>
|
|
550
578
|
<li><b>Everything in a frame reads one timestamp.</b> A second <code>sync</code> with the same value is a no-op, and every <code>probe</code> between syncs answers from the same frame. This is what makes state advance once however many probes a wrapper makes.</li>
|
|
551
|
-
<li><b>A voice's clock is <code>(timestamp − start) · rate</code>.</b> That reading is <code>setting.elapsed</code>, and its position within the
|
|
579
|
+
<li><b>A voice's clock is <code>(timestamp − start) · rate</code>.</b> That reading is <code>setting.elapsed</code>, and its position within the current pass, <code>(elapsed mod duration) / duration</code>, is the <code>phase</code> the patch is handed first. Both reach the patch. A rate change rebases so <code>elapsed</code> is continuous.</li>
|
|
580
|
+
<li><b>The mix has a rate of its own,</b> <code>mix.rate</code> and <code>mix.ramp(rate, over)</code>, shaped like a handle's and multiplied into every voice's: 0 pauses the whole mix, and a negative rate throws, since the mix only goes forward. It runs the mix clock: mix time is the host's timestamp, less what <code>rebase</code> took out, integrated at the mix's rate, and each voice integrates its own rate over mix time, so a ramp on both multiplies and no position jumps. Everything a voice owns runs on mix time and slows with it, which is what a pause has to mean for a fade caught halfway. A timestamp the host gives or reads stays on the host's clock, so a beat announced from an audio clock stays on the beat when the mix slows, and a start ahead of a paused mix waits for the host's clock to reach it. The mix's own ramp runs over host ms, since a ramp over its own slowing time would never arrive. A change made while paused lands at the next sync as always, and a weight signal is still asked at every probe, so one reading input follows it. At rate 0 the mix is <code>inert</code> once every voice not done has started, at a plain weight, with no anchor. Under <code>history</code> every rate change is kept, and a read back finds mix time through them as it finds a voice's clock through its handle's changes; without it a timestamp from before the last change converts at the rate in force, as one from before a <code>rebase</code> converts at the offset in force. weasel also scales a key and a handle; a mix leaves those levels to tags and to the host.
|
|
581
|
+
<div class="tbl"><table>
|
|
582
|
+
<tr><th>runs on mix time, at the mix's rate</th><th>stays on the host's clock</th></tr>
|
|
583
|
+
<tr><td>voice clocks, fades, a handle's <code>ramp</code>, <code>stagger</code>, an anchor's <code>by</code>, <code>stepMs</code>, <code>maxDt</code>, <code>history.ms</code>, every <code>dt</code>, <code>setting.timestamp</code> and what <code>drain</code> stamps</td><td><code>sync</code>, a <code>start</code>, an anchor given as a number, <code>announce</code>'s <code>at</code>, what <code>marks</code> reports, <code>project</code>'s timestamp, the mix's own <code>ramp</code>, and <code>book</code>'s <code>ahead</code>, <code>late</code> and <code>lateBy</code></td></tr>
|
|
584
|
+
</table></div></li>
|
|
552
585
|
<li><b><code>dt</code> is derived, never supplied.</b> The gap between this sync and the last, per subject, and <code>Infinity</code> under reduced motion. A patch that integrates guards against that with a snap, as klieg's already do.</li>
|
|
553
586
|
<li><b>Time away is the host's to call.</b> A hidden tab gets no frames, so its next <code>sync</code> can be an hour past the last. By default that hour is a gap like any other: fades and finite loops finish, and every subject catches up by all of it. A host that calls <code>mix.rebase()</code> on <code>visibilitychange</code> says the gap does not count, and the mix clock — what <code>setting.timestamp</code> reads, and what voices, fades and subjects run on — resumes where it was left. It is then the host's timestamp less the time taken out, and a <code>start</code> the host gives is read on the host's clock and converted. <code>maxDt</code> caps the <code>dt</code> a <code>step</code> or signal is handed for any gap, the net an integrator wants; it is off by default because a patch with a closed form wants the whole gap.</li>
|
|
554
587
|
<li><b>There is no frame rate, only an optional step interval.</b> A frame is a reading, so a stateless patch reads the same at any rate. A <code>step</code> handed the frame's gap does not: an integrator gives a different answer at 30 and 144 fps. <code>stepMs</code> runs <code>step</code> once per whole interval instead, on a grid counted from when the subject's delay ran out, carrying the remainder, so the answer and every event's time are the same at any frame rate. <code>maxDt</code> then caps how many intervals one sample runs. Signals and <code>at</code> still read the frame. The host's clock is whatever it passes <code>sync</code>: a rAF timestamp and <code>performance.now()</code> share an origin, and <code>Date.now()</code> does not.</li>
|
|
555
|
-
<li><b>Events come out by draining.</b> A patch calls <code>setting.send(event)</code>, usually from <code>step</code>, and the host calls <code>mix.drain()</code> after probing to take everything sent, earliest first. Events are made while a subject catches up, so a subject nobody probes has sent nothing yet, and promptness is the host's to buy by probing. Under <code>stepMs</code> each is stamped with the end of the interval it happened in, so the events and their times do not depend on how the probes are spaced. Time taken out by <code>rebase</code> sends nothing. The exception is a stateless patch sending from <code>at</code> on a lane: a lane fills every subject the mix has met, so it sends for each of them every frame, probed or not.
|
|
588
|
+
<li><b>Events come out by draining.</b> A patch calls <code>setting.send(event)</code>, usually from <code>step</code>, and the host calls <code>mix.drain()</code> after probing to take everything sent, earliest first. Events are made while a subject catches up, so a subject nobody probes has sent nothing yet, and promptness is the host's to buy by probing. Under <code>stepMs</code> each is stamped with the end of the interval it happened in, so the events and their times do not depend on how the probes are spaced. Time taken out by <code>rebase</code> sends nothing. The exception is a stateless patch sending from <code>at</code> on a lane: a lane fills every subject the mix has met, so it sends for each of them every frame, probed or not. Sent events are not known ahead, so they stay drain-only; what is known ahead is booked.</li>
|
|
589
|
+
<li id="book"><b>What the plan knows ahead is booked against an outside clock.</b> A host that schedules on a clock of its own needs to hear about a time before it arrives: a sound on an <code>AudioContext</code>, a MIDI message sent with a timestamp, a video that has to be loaded before the voice that shows it starts. <code>mix.book</code> hands it two kinds of item: every mark <code>marks</code> lists, and a voice's <b>hits</b>, events at times on its own clock.
|
|
590
|
+
<pre><code>mix.cue({ patch: spin, tags: ['sound'], hits: [{ at: 0, event: 'whoosh' }, { at: 1200, event: 'clunk' }] });
|
|
591
|
+
const sound = mix.book({
|
|
592
|
+
clock: () => audio.currentTime * 1000,
|
|
593
|
+
ahead: 100,
|
|
594
|
+
late: 40,
|
|
595
|
+
tag: 'sound',
|
|
596
|
+
take(item, when) {
|
|
597
|
+
if (!('hit' in item)) return; <span class="cm">// marks come too; this booker wants hits</span>
|
|
598
|
+
const src = play(item.event, when / 1000);
|
|
599
|
+
return { stop: () => src.stop() };
|
|
600
|
+
},
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
<span class="cm">// Not sound: load a clip 2 s before the voice that shows it starts, on the same clock as sync.</span>
|
|
604
|
+
mix.book({
|
|
605
|
+
clock: () => performance.now(),
|
|
606
|
+
ahead: 2000,
|
|
607
|
+
late: Infinity,
|
|
608
|
+
tag: 'video',
|
|
609
|
+
take(item) {
|
|
610
|
+
if ('hit' in item || item.mark !== 'start') return;
|
|
611
|
+
const video = load(clips[item.name]);
|
|
612
|
+
return { stop: () => video.release() };
|
|
613
|
+
},
|
|
614
|
+
});</code></pre>
|
|
615
|
+
A hit plays once per pass, for the voice and never per subject, so <code>stagger</code> does not spread it; it sits on the voice clock exactly where a mark does, through the voice's rate, ramp and seeks, its owners', and <code>mix.rate</code>. Booking runs at each <code>sync</code> and takes whatever falls within <code>ahead</code> host ms, which has to cover the longest gap between syncs. <code>when</code> is on the outside clock, by an offset that folds in 5% of each frame's difference and starts over from the clock's own reading on a jump past 50 ms (weasel's filter). Nothing is booked twice: a mark is known by its voice and which mark, a hit by its voice, index and pass. A booking whose time moves by more than 1 ms, or that goes away, has its <code>stop</code> called and is booked again at the same sync: a seek, a rate or ramp on the voice, an owner or the mix, a fade or <code>mute</code>, a <code>rebase</code>, an anchor's target moving, the voice leaving. A fade takes away the hits after the voice is gone, and the hits within the fade still play. An item first seen already past, from a voice cued partway through or a stalled frame, is taken at the outside clock's now with <code>lateBy</code> in host ms, unless that is more than <code>late</code>. What a seek jumps over is not late; it is skipped. Hits come as the voice clock runs forward, so a voice run backwards books none. <code>tag</code> and <code>score</code> split one mix between bookers, and <code>stop()</code> on what <code>book</code> returns stops every booking still ahead. <code>project</code> books nothing, and under <code>history</code> nothing about booking is kept or replayed: a read back says what was planned, not what was booked. A mix with no booker pays nothing for it.</li>
|
|
556
616
|
<li><b>A finite loop ends when its last pass does,</b> not at the frame that notices: its fade out counts from that moment, or it turns <code>held</code> then if it holds after, so it plays the same at any frame rate and a read at another time can find it. A fade's end and a handover's deadline are kept the same way.</li>
|
|
557
617
|
<li><b>Calls land on the next sync.</b> A host writes <code>handle.weight</code> or calls <code>fade</code> on its own clock; the mix applies it at the first timestamp that sees it. Nothing changes mid-frame.</li>
|
|
558
618
|
</ul>
|
|
@@ -564,7 +624,7 @@ function gate<I>(of: Signal<I>, at: number): Signal<I>;
|
|
|
564
624
|
<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>
|
|
565
625
|
<tr><td>Crossfade at a phase seam</td><td>Two voices in one <code>locus</code>, one fading out as the other fades in. The locus 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>
|
|
566
626
|
<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>
|
|
567
|
-
<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.</td><td><code>hinge</code>'s stops mode. Its blend mode is just <code>weight: signal</code>.</td></tr>
|
|
627
|
+
<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.</td><td><code>hinge</code>'s stops mode. Its blend mode is just <code>weight: signal</code>.</td></tr>
|
|
568
628
|
<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>
|
|
569
629
|
</table></div>
|
|
570
630
|
|
|
@@ -592,7 +652,7 @@ t.to(node, 40); <span class="cm">// from wherever it is, over a ful
|
|
|
592
652
|
<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>
|
|
593
653
|
<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>
|
|
594
654
|
<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>
|
|
595
|
-
<li><b>The state is the patch's own</b>, per subject, rather than the mix's, because the host retargets through the patch. Cue each one on one voice. A patch moves every subject on the same number of axes, and throws on a subject, a <code>to</code> or a <code>push</code> with another, leaving the subject as it was.</li>
|
|
655
|
+
<li><b>The state is the patch's own</b>, per subject, rather than the mix's, because the host retargets through the patch. Cue each one on one voice. A patch moves every subject on the same number of axes, and throws on a subject, a <code>to</code> or a <code>push</code> with another, leaving the subject as it was. Its values are the kind its channel holds, a number for a number and an array for an array: one every subject shares is refused at <code>cue</code>, one given per subject on that subject's first sample.</li>
|
|
596
656
|
<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>
|
|
597
657
|
<li><b>Handing motion between patches</b> is <code>read</code> on the old one and <code>from</code> plus <code>velocity</code> on the new: a fling that ends in a spring starts the spring at the glide's velocity.</li>
|
|
598
658
|
</ul>
|
|
@@ -618,6 +678,26 @@ interface Projection<I, O> {
|
|
|
618
678
|
<li><b>Several scores.</b> Each source keeps its own plan: a voice's <code>score</code> names it, and the mix plays every score it holds at once, the way a DAW plays every track. Names are namespaced by score, so a bare name in an anchor means a voice in the asker's score and a query names <code>score</code> to reach into another. A voice with none is in the mix's one unnamed score.</li>
|
|
619
679
|
<li><b>Waiting and following.</b> An anchor with no answer — a name not cued, the <code>out</code> of a voice that loops for good — leaves the voice <code>pending</code>. While it waits it follows its target, so a target faded early brings it forward; once it starts, its start is fixed. An <code>out</code> or <code>end</code> follows until the fade begins. A target that has left keeps the time it last gave, and can be anchored to fresh only while the mix's history reaches it. An anchored mark already past starts the voice partway through. A placement that waits on itself is refused at <code>cue</code>.</li>
|
|
620
680
|
<li><b>Announced marks.</b> The host puts a named mark on a score with <code>mix.announce('reply')</code>, or <code>announce('beat', { at })</code> for one it knows ahead, and anchors target it as they target a voice's marks: a voice placed <code>{ with: 'reply' }</code> waits pending until the reply is announced. A mark announced ahead is seen by a read ahead; one not yet announced makes a read ahead say <code>held</code> for whatever waits on it, and a read back sees only what had been announced by then. An announced mark stays while it is ahead or while history reaches it. Decided 2026-10-01: a <b>mark</b> is any named time an anchor can hang from, a voice's own four or one the host announced, and an <b>event</b> is only what a patch <code>send</code>s to the host. What fires a mark on a condition, such as a channel crossing a value, stays the host's: blits does not read values.</li>
|
|
681
|
+
<li id="owners"><b>Owners.</b> <code>mix.owns(spec)</code> cues an <b>owner</b>, a voice with no patch, and a voice cued with <code>owner: handle</code> plays under it, so a group can be placed, timed and faded as one. The mix's own clock is the outermost owner, and an owner is a link in the same chain: owners nest.
|
|
682
|
+
<pre><code>const intro = mix.owns({ anchor: { start: { after: 'title' } }, fade: { out: 300 }, name: 'intro' });
|
|
683
|
+
mix.cue({ patch: slide, owner: intro, start: 0, name: 'slide' });
|
|
684
|
+
mix.cue({ patch: glow, owner: intro, anchor: { start: { after: 'slide' } } });
|
|
685
|
+
intro.rate = 0.5; <span class="cm">// both run at half speed</span>
|
|
686
|
+
intro.seek(800); <span class="cm">// both move; their state stays where it is</span>
|
|
687
|
+
intro.fade(); <span class="cm">// one envelope over both</span></code></pre>
|
|
688
|
+
<div class="tbl"><table>
|
|
689
|
+
<tr><th>On the owner</th><th>What it does to the voices it holds</th></tr>
|
|
690
|
+
<tr><td><code>start</code>, <code>anchor</code>, <code>name</code>, <code>tags</code>, <code>score</code></td><td>Place the owner on its own owner's clock, as for any voice.</td></tr>
|
|
691
|
+
<tr><td>a child's <code>start</code> and <code>anchor</code></td><td>Counted on the owner's clock, in ms from when the owner starts. With neither, a child starts where that clock is when it is cued.</td></tr>
|
|
692
|
+
<tr><td>bare names in a child's anchors</td><td>Resolve among its siblings, the voices the same owner holds. A query naming a <code>score</code> looks at any depth.</td></tr>
|
|
693
|
+
<tr><td><code>rate</code>, <code>ramp</code></td><td>Multiply into each child's own rate: a child's clock reads its owner's clock where a voice's reads the mix's.</td></tr>
|
|
694
|
+
<tr><td><code>seek</code></td><td>Moves the owner's clock, and every child's phase with it. State stays where it is, as for a voice's own seek.</td></tr>
|
|
695
|
+
<tr><td><code>weight</code> (a number or a signal), <code>fade</code></td><td>Multiply into each child's weight per subject, as the child's own fade does.</td></tr>
|
|
696
|
+
<tr><td><code>freeze</code></td><td>Taken by every child with no freeze of its own.</td></tr>
|
|
697
|
+
<tr><td><code>played</code>, <code>done</code></td><td><code>played</code> once every child has finished its passes. The owner leaves once it has held a voice and has none left; a fade that ends takes its children with it.</td></tr>
|
|
698
|
+
<tr><td><code>loop</code></td><td>Refused at <code>owns</code>: a pass would have to restart stateful children (decided 2026-10-04).</td></tr>
|
|
699
|
+
</table></div>
|
|
700
|
+
What stays on the mix clock is what a voice's own rate leaves there: a child's fades, its <code>dt</code> and <code>setting.timestamp</code>, as a voice at half rate still fades over its <code>fade.out</code> in mix ms. An owner's fade in counts from its start. <code>mix.voices()</code> lists owners and children alike, and <code>handle.owner</code> names a child's owner. <code>marks</code> maps a child's marks to the host clock through every owner's clock, and lists one only while every clock above it is fixed. A hit a child books sits on that same chain, so an owner's rate or seek retracts it and books it again. Under <code>history</code> an owner's control changes are kept like a voice's, so a read back or ahead composes them. On lanes a child's clock is read through its owners and their weight folded into its fade, the same arithmetic the general path takes; a child whose owner weighs by a signal runs on the general path. A mix with no owner pays nothing for them.</li>
|
|
621
701
|
<li><b>What comes next.</b> <code>mix.marks(from, to)</code> lists every mark the plan has fixed in a range, with each voice's id, name and tags. One nothing has fixed is left out.</li>
|
|
622
702
|
<li><b>Reading another time.</b> <code>mix.project(t)</code> copies what each subject holds and reads it at <code>t</code>, sending nothing and committing nothing, so the live mix plays on exactly as if it had not been read. Ahead, it plays what is cued forward in one step across the gap. Behind, it needs <code>history</code>: the mix keeps every voice's control changes with when they landed, the voices that have left, and a copy of each stateful record per subject every <code>every</code> ms, and a read back restores the nearest copy at or before <code>t</code> and steps forward. A subject it kept nothing for starts from its voice's start with fresh state. A patch's own <code>clone</code> copies state that <code>structuredClone</code> cannot. With <code>inputs</code>, history also keeps what each input signal on a voice's weight read per subject, and a copy of the host fields patches <code>reads</code>, each time they changed, and a read back uses those instead of asking the signal or the host. Where anchors fixed a voice's start or out after the time read, it reads back as it stood then.</li>
|
|
623
703
|
<li><b>How sure.</b> <code>assess</code> says per channel <code>held</code> where a voice feeding it reads input from outside the clock with no recording of it (a signal marked <code>input</code>, a patch that <code>reads</code> host fields, or a <code>from: 'current'</code> start read back with nothing kept), <code>stepped</code> where state crossed a gap in one go without <code>stepMs</code>, or a stateful signal did, and <code>exact</code> otherwise. The least sure voice decides. Under <code>stepMs</code> a read back lands on the pose the mix showed, so a scrub back and forth gives the same picture.</li>
|
|
@@ -664,14 +744,15 @@ export const mixer: Engine; <span class="cm">// the one that ships</span></cod
|
|
|
664
744
|
<tr><th></th><th>runs on a lane</th><th>takes the general path</th></tr>
|
|
665
745
|
<tr><td>channel</td><td><code>sum</code>, <code>mul</code>, <code>max</code>, and <code>vec</code> over one of them, as the stock functions make them, bounds or not</td><td>anything else, including a custom channel claiming a stock kind</td></tr>
|
|
666
746
|
<tr><td>patch</td><td><code>keys</code>; <code>fn</code> with no <code>state</code> and no <code>step</code>; <code>motion</code></td><td>a stateful <code>fn</code>, or one once it calls <code>setting.keep</code>; <code>keys</code> whose <code>lerpBy</code> is not the channel's own <code>lerp</code>; under <code>history.inputs</code>, a patch that <code>reads</code> host fields</td></tr>
|
|
667
|
-
<tr><td>voice</td><td>a number weight, with fades and <code>mute</code>; every subject, <code>target</code> or <code>subjects</code>; <code>stagger</code>, <code>loop</code>, <code>rate</code>, <code>ramp</code>, <code>seek</code>, anchors</td><td>a <code>Signal</code> weight, <code>
|
|
747
|
+
<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; under <code>history.inputs</code>, a signal reading host input; a <code>locus</code> with a motion member, or any member that cannot run on a lane, which takes every member with it; <code>from: 'current'</code>, <code>fade({ at: 'rest' })</code></td></tr>
|
|
668
748
|
</table></div>
|
|
669
|
-
<p>A channel is a lane only while every voice writing it qualifies, and a voice runs on lanes only while every channel it writes is one, so no patch is called twice in a frame. The mix works this out again when a voice is cued or retires, starts a fade to rest, or first keeps state; never per probe. A voice that names one subject and writes
|
|
749
|
+
<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>
|
|
670
750
|
<ul>
|
|
671
751
|
<li><b>Subjects are numbered on first sight.</b> A subject's first probe takes the general path, where <code>target</code>, <code>stagger</code> and the fade-in origin are worked out, and gives it a number; so does each voice's first meeting with it later. <code>drop</code> frees the number, and for an object subject so does the host letting it go. A subject the host never probes is never numbered and costs a lane nothing.</li>
|
|
672
752
|
<li><b>The first probe after a <code>sync</code> fills every lane</b>, for every numbered subject each laned voice reaches, then copies the subject's values into the pose; later probes that frame only copy. A cue, retirement or handle change between two probes refills at the next one. So a frame costs the fill plus a probe per subject the host reads.</li>
|
|
673
753
|
<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>
|
|
674
|
-
<li><b>Single-subject voices share a crowd.</b> A laned voice whose <code>subjects</code> names one subject
|
|
754
|
+
<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 the crowd is more than half empty rows, when the next probe slides the rows left down over the empty ones, in order. Rebuilding the crowds through a full qualify instead made that one frame about 9 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>
|
|
755
|
+
<li><b>A locus runs on lanes in two passes.</b> The general path folds a locus at its first member’s place in the voice order, and which voice that is differs by subject. So a fill first takes each locus’s members in voice order and, for each subject, sums their weights and lerps their values by share of the weight taken, as <code>foldLocus</code> does, with the stock channels’ own lerp; then, folding the voices in order, it folds each subject’s locus in at whichever member met that subject first. A locus’s members are on lanes all together or not at all, since a member left on the general path would count in its sum there but not on a lane; a motion member keeps the whole locus off. A blend’s signal is read by whichever member fills a subject first, and kept by subject number for the others. On teitou (2026-10-04) three voices in a locus over 10k subjects take 0.72 of the time they did on the general path, and <code>mix.blend</code> of three by a stateless signal 0.84.</li>
|
|
675
756
|
<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 held and running backwards, is visited every frame. <code>test/due.test.ts</code> plays random mixes both ways, against a full walk, and compares states, poses and marks after every sync.</li>
|
|
676
757
|
<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>
|
|
677
758
|
<li><b>Two things differ, both documented on the API.</b> A stateless patch calling <code>setting.send</code> from <code>at</code> sends every frame for every subject a lane fills, probed or not. A patch that first calls <code>setting.keep</code> partway through playing, from a call for a subject the host did not probe that frame, advances that state once more for that subject than the general path would; a patch that keeps state from its first call, or declares <code>state</code>, never does.</li>
|
|
@@ -725,7 +806,7 @@ export const mixer: Engine; <span class="cm">// the one that ships</span></cod
|
|
|
725
806
|
<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>
|
|
726
807
|
<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>
|
|
727
808
|
<li><b>Normalizing a locus's weights per channel.</b> The other way to make a crossfade hold its value on <code>mul</code>: keep the scale-and-merge fold and pick a per-channel divisor. It costs the property the mixer's fold order rests on — 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>
|
|
728
|
-
<li><b>Removing a
|
|
809
|
+
<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, loci 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>
|
|
729
810
|
<li><b>A mix option for the lurch guard.</b> A global "normalize when over 1" would apply to effects that legitimately stack. The cap belongs to the voices that crossfade, so it is a <code>locus</code>.</li>
|
|
730
811
|
</ul>
|
|
731
812
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@msb235/blits",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Concurrent effects: each on its own clock with its own state and weight, folded into one value per subject per frame",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"animation",
|
|
@@ -45,7 +45,10 @@
|
|
|
45
45
|
"bench": "npm run build && node bench/frame.mjs",
|
|
46
46
|
"site": "npm run dev -w site",
|
|
47
47
|
"site:build": "npm run build -w site",
|
|
48
|
-
"site:smoke": "npm run smoke -w site"
|
|
48
|
+
"site:smoke": "npm run smoke -w site",
|
|
49
|
+
"playground": "npm run dev -w @blits/playground",
|
|
50
|
+
"playground:smoke": "npm run smoke -w @blits/playground",
|
|
51
|
+
"playground:check": "npm run typecheck -w @blits/playground && vitest run apps/playground/test && npm run playground:smoke"
|
|
49
52
|
},
|
|
50
53
|
"devDependencies": {
|
|
51
54
|
"@biomejs/biome": "^2.5.8",
|