@msb235/blits 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/CHANGELOG.md +95 -0
  2. package/README.md +12 -2
  3. package/dist/clock.d.ts +10 -0
  4. package/dist/clock.js +23 -0
  5. package/dist/clone.d.ts +5 -0
  6. package/dist/clone.js +7 -0
  7. package/dist/cue.js +5 -8
  8. package/dist/due.js +20 -13
  9. package/dist/everyone.js +3 -0
  10. package/dist/fade.d.ts +1 -0
  11. package/dist/fade.js +10 -4
  12. package/dist/fill.js +5 -0
  13. package/dist/fold.js +31 -25
  14. package/dist/frames.d.ts +21 -0
  15. package/dist/frames.js +59 -0
  16. package/dist/gone.d.ts +4 -4
  17. package/dist/gone.js +11 -10
  18. package/dist/handle.js +17 -9
  19. package/dist/held.d.ts +10 -0
  20. package/dist/held.js +28 -1
  21. package/dist/hosts.js +5 -1
  22. package/dist/keyfill.js +5 -1
  23. package/dist/locus.d.ts +4 -1
  24. package/dist/locus.js +14 -4
  25. package/dist/marks.js +4 -4
  26. package/dist/motion.d.ts +6 -3
  27. package/dist/motions.d.ts +5 -2
  28. package/dist/motions.js +3 -1
  29. package/dist/move.js +51 -14
  30. package/dist/origin.js +3 -2
  31. package/dist/owner.d.ts +2 -0
  32. package/dist/owner.js +10 -2
  33. package/dist/paging.js +1 -6
  34. package/dist/place.js +17 -3
  35. package/dist/project.d.ts +1 -1
  36. package/dist/project.js +12 -7
  37. package/dist/record.js +2 -1
  38. package/dist/revive.d.ts +2 -0
  39. package/dist/revive.js +2 -0
  40. package/dist/seek.js +4 -4
  41. package/dist/spans.d.ts +5 -4
  42. package/dist/spans.js +11 -10
  43. package/dist/stack.js +2 -2
  44. package/dist/strays.js +13 -5
  45. package/dist/transport.d.ts +2 -6
  46. package/dist/transport.js +7 -30
  47. package/dist/types/mix.d.ts +3 -2
  48. package/dist/types/patch.d.ts +7 -3
  49. package/dist/types/place.d.ts +4 -1
  50. package/dist/types/voice.d.ts +22 -4
  51. package/dist/unpage.d.ts +1 -1
  52. package/dist/unpage.js +3 -4
  53. package/dist/voice.d.ts +52 -0
  54. package/dist/voice.js +103 -2
  55. package/dist/warn.d.ts +2 -0
  56. package/dist/warn.js +17 -0
  57. package/dist/weigh.js +5 -0
  58. package/docs/schema.html +92 -29
  59. package/package.json +2 -2
package/dist/voice.js CHANGED
@@ -1,8 +1,8 @@
1
- import { elapsedWith, passesOf, rateWith, rebaseWith, timeWith } from './clock.js';
1
+ import { backWith, elapsedWith, frozenTime, passesOf, rateWith, rebaseWith, timeWith, } from './clock.js';
2
2
  import { curve } from './easing.js';
3
3
  import { motionOf } from './motion.js';
4
4
  import { Named } from './named.js';
5
- import { childPlayed, Holding, mixTime, ownedElapsed, ownerPatch } from './owner.js';
5
+ import { childPlayed, Holding, mixTime, ownedElapsed, ownerPatch, ownerReading, turnedBack, } from './owner.js';
6
6
  import { builtOf, intosOf } from './patch.js';
7
7
  import { reading } from './reading.js';
8
8
  import { Store } from './store.js';
@@ -154,6 +154,12 @@ export class Voice {
154
154
  span;
155
155
  /** The state behind a motion patch, undefined for any other. */
156
156
  motion;
157
+ /**
158
+ * Whether it has no first pass: cued backward with no end to start from, it plays from 0 into
159
+ * the pass before, so no time on its clock is before its start. Never a motion, whose stretches
160
+ * begin at 0.
161
+ */
162
+ beginless;
157
163
  /** Its `freeze`, for a patch it applies to: motion keeps its target already. */
158
164
  freezesBefore;
159
165
  freezesAfter;
@@ -174,6 +180,8 @@ export class Voice {
174
180
  seen = 0;
175
181
  /** The longest stagger of any subject seen, so a finite loop waits for the last of them. */
176
182
  latest = 0;
183
+ /** The least stagger of any subject seen, 0 or below, which a voice running back ends at. */
184
+ soonest = 0;
177
185
  restedCount = 0;
178
186
  /** The mix time it was cued at; -Infinity before the first sync. */
179
187
  cuedAt = Number.NEGATIVE_INFINITY;
@@ -183,6 +191,12 @@ export class Voice {
183
191
  doneAt = Number.POSITIVE_INFINITY;
184
192
  /** The frame its leaving was decided in, which a seek or read back partitions by. */
185
193
  doneSeq = Number.POSITIVE_INFINITY;
194
+ /** The mix time of that frame, later than `doneAt` for a leaving put behind now. */
195
+ decidedAt = Number.NEGATIVE_INFINITY;
196
+ /** The latest mix time a seek or read back to finds it still playing. */
197
+ get reachedTo() {
198
+ return this.doneAt > this.decidedAt ? this.doneAt : this.decidedAt;
199
+ }
186
200
  /** Under `history`, its controls after each change, oldest first. */
187
201
  log = null;
188
202
  /** Under `history`, its controls as `cue` left them, which a voice parked by a seek takes back. */
@@ -363,6 +377,7 @@ export class Voice {
363
377
  this.early = null;
364
378
  this.seen = 0;
365
379
  this.latest = 0;
380
+ this.soonest = 0;
366
381
  this.restedCount = 0;
367
382
  this.cuedAt = Number.NEGATIVE_INFINITY;
368
383
  this.cuedSeq = Number.NEGATIVE_INFINITY;
@@ -419,7 +434,11 @@ export class Voice {
419
434
  this.setting = new VoiceSetting(host, send, this);
420
435
  this.rate = spec.rate ?? 1;
421
436
  this.weight = typeof spec.weight === 'number' ? spec.weight : 1;
437
+ const endless = !Number.isFinite(this.span);
438
+ this.beginless = this.rate < 0 && endless && this.motion === undefined;
422
439
  this.anchorNow = start;
440
+ // Where it starts: a voice cued backward at its end, where it has one.
441
+ this.anchorElapsed = spec.seek ?? (this.rate < 0 && !endless ? this.span : 0);
423
442
  if (now >= start)
424
443
  this.state = 'live';
425
444
  }
@@ -432,6 +451,88 @@ export class Voice {
432
451
  const t = timeWith(this, elapsed);
433
452
  return this.owner === null ? t : mixTime(this.owner, t);
434
453
  }
454
+ /**
455
+ * Whether its clock runs back by its controls: at a rate below 0, or on a ramp between rates
456
+ * neither of which is above it. A ramp through 0 says neither way until it has turned.
457
+ */
458
+ get backs() {
459
+ const r = this.ramp;
460
+ return r === null ? this.rate < 0 : r.from <= 0 && r.to <= 0 && (r.from < 0 || r.to < 0);
461
+ }
462
+ /** Whether its ramp takes its rate through 0, which turns its clock around partway. */
463
+ get turns() {
464
+ const r = this.ramp;
465
+ return r !== null && r.from * r.to < 0;
466
+ }
467
+ /**
468
+ * The mix time its passes run out by its controls now: its last pass's end running forward, its
469
+ * first pass's start running back. Infinity where they never do, and while a ramp has yet to
470
+ * turn it around.
471
+ */
472
+ endsAt() {
473
+ if (this.turns)
474
+ return Number.POSITIVE_INFINITY;
475
+ const backs = this.backs;
476
+ let to;
477
+ if (backs !== (this.owner !== null && turnedBack(this))) {
478
+ if (this.beginless)
479
+ return Number.POSITIVE_INFINITY;
480
+ to = this.soonest;
481
+ }
482
+ else {
483
+ if (!Number.isFinite(this.span))
484
+ return Number.POSITIVE_INFINITY;
485
+ to = this.span + this.latest;
486
+ }
487
+ const t = backs ? backWith(this, to) : timeWith(this, to);
488
+ return this.owner === null ? t : mixTime(this.owner, t);
489
+ }
490
+ /**
491
+ * The mix time its clock ran back to `elapsed` by `now`, by its controls now: where a subject
492
+ * `elapsed` into it reached its start. NaN where it has not, and for a voice an owner holds.
493
+ */
494
+ ranBackTo(elapsed, now) {
495
+ if (this.beginless || !this.backs || this.owner !== null)
496
+ return Number.NaN;
497
+ const t = backWith(this, elapsed);
498
+ return t >= this.anchorNow && t <= now ? t : Number.NaN;
499
+ }
500
+ /** Whether it has yet to start at mix time `now`: pending, or fading out from before its start. */
501
+ unstarted(now) {
502
+ const state = this.state;
503
+ if (state === 'pending')
504
+ return true;
505
+ if (state !== 'fading')
506
+ return false;
507
+ return !((this.owner === null ? now : ownerReading(this.owner, now)) >= this.start);
508
+ }
509
+ /**
510
+ * A subject's voice time at mix time `now`, `delay` into the voice, before its freezes apply. A
511
+ * voice yet to start reads where it will: its clock is anchored there, and read at any time
512
+ * before would have a voice sought ahead, or running back, play before its start.
513
+ */
514
+ rawAt(now, delay) {
515
+ return (this.unstarted(now) ? this.anchorElapsed : this.elapsedAt(now)) - delay;
516
+ }
517
+ /**
518
+ * Whether a subject at `raw` is before its start at mix time `now`: it shows nothing, or its
519
+ * first frame frozen.
520
+ */
521
+ before(raw, now) {
522
+ // Not `raw < 0`: see `frozenTime` for the clock that reads NaN before its start.
523
+ return (!(raw >= 0) && !this.beginless) || this.unstarted(now);
524
+ }
525
+ /**
526
+ * Whether a subject before its start shows its first frame: the voice freezes before, or is
527
+ * fading out from where it ran back to its start, as one fading from its end shows its last.
528
+ */
529
+ get holdsFirst() {
530
+ return this.freezesBefore || this.out?.back === true;
531
+ }
532
+ /** A subject's voice time at `raw` once the voice's freezes apply; NaN where it shows nothing. */
533
+ shownAt(raw) {
534
+ return this.beginless ? raw : frozenTime(raw, this.holdsFirst, this.freezesAfter, this.span);
535
+ }
435
536
  /** Records this voice's controls as they stand, from mix time `at` in frame `seq`. */
436
537
  note(at, seq, sync = false) {
437
538
  this.log?.push({
package/dist/warn.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ /** Warns outside production, once for each `about`. */
2
+ export declare function warnOnce(about: object, message: string): void;
package/dist/warn.js ADDED
@@ -0,0 +1,17 @@
1
+ /** Whether this is a production build: a bundler's `NODE_ENV`, or Node's; false where neither is. */
2
+ function production() {
3
+ try {
4
+ return process.env.NODE_ENV === 'production';
5
+ }
6
+ catch {
7
+ return false;
8
+ }
9
+ }
10
+ const warned = new WeakSet();
11
+ /** Warns outside production, once for each `about`. */
12
+ export function warnOnce(about, message) {
13
+ if (warned.has(about) || production())
14
+ return;
15
+ warned.add(about);
16
+ console.warn(`blits: ${message}`);
17
+ }
package/dist/weigh.js CHANGED
@@ -6,6 +6,9 @@ import { reading } from './reading.js';
6
6
  export function prime(voice, held, now, elapsed, pass) {
7
7
  const setting = voice.setting;
8
8
  setting.timestamp = now;
9
+ // With no step to move it, `stepped` follows the call before this frame's.
10
+ if (voice.patch.step === undefined && held.probed < now)
11
+ held.stepped = held.probed;
9
12
  setting.dt = this.capped(now - held.stepped);
10
13
  setting.elapsed = elapsed;
11
14
  setting.pass = pass;
@@ -43,6 +46,8 @@ export function ownerBase(owner, subject) {
43
46
  if (held.probed === this.frame)
44
47
  return held.phase;
45
48
  const kept = reading.kept;
49
+ // An owner's record keeps a frame in `probed`, not a time, so `prime` leaves its `stepped` be.
50
+ held.probed = Number.NaN;
46
51
  this.prime(owner, held, now, owner.elapsedAt(now), 0);
47
52
  const base = this.base(owner, subject, now, held);
48
53
  reading.kept = kept;
package/docs/schema.html CHANGED
@@ -161,7 +161,7 @@
161
161
  <h1>An effects schema, and the API around it</h1>
162
162
  <p class="lede">A small runtime package for concurrent effects. Each effect runs on its own clock with its own state and weight, and a mix folds them into one value per subject per frame by rules that belong to the channel, not the effect.</p>
163
163
  <p class="meta"><b>For:</b> whoever builds <code>blits</code>, and whoever ports klieg, wod, sherpa or magicsmoke onto it. <b>Answers:</b> what the vocabulary is, what an author writes, what a host calls, and where a second engine would plug in. The package implements this page; where the two differ, the code in <code>src/</code> is the one that runs.</p>
164
- <span class="status">Built · v0.8.0 · design 2026-09-15, package 2026-09-28</span>
164
+ <span class="status">Built · v0.9.0 · design 2026-09-15, package 2026-09-28</span>
165
165
  </header>
166
166
 
167
167
  <h2 id="picture">In one picture</h2>
@@ -267,8 +267,14 @@
267
267
  merge(a: V, b: V): V;
268
268
  <span class="cm">/** Fade toward `rest` by weight 0..1. Required when `rest` is set; absent otherwise. */</span>
269
269
  scale?(v: V, w: number): V;
270
- <span class="cm">/** Interpolate, for retargeting and for blending alternatives. */</span>
270
+ <span class="cm">/** Interpolate, for retargeting, for blending alternatives, and for folding a locus. */</span>
271
271
  lerp(a: V, b: V, u: number): V;
272
+ <span class="cm">/** `merge(into, scale(v, w))`, written into `into` and returned, so a channel whose values are objects need not
273
+ allocate per contribution. The mix only hands it a value it made. */</span>
274
+ fold?(into: V, v: V, w: number): V;
275
+ <span class="cm">/** A fresh copy of `v`, which the mix makes of `rest` for `fold` to write into. The mix copies arrays, typed
276
+ arrays, and plain objects itself. */</span>
277
+ copy?(v: V): V;
272
278
  }
273
279
 
274
280
  type Kit&lt;O&gt; = { readonly [K in keyof O]-?: Channel&lt;NonNullable&lt;O[K]&gt;&gt; };</code></pre>
@@ -291,7 +297,7 @@ type Kit&lt;O&gt; = { readonly [K in keyof O]-?: Channel&lt;NonNullable&lt;O[K]&
291
297
 
292
298
  <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> &mdash; 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.</p>
293
299
 
294
- <p>The stock set is not the limit: a consumer that needs arithmetic it lacks writes its own <code>Channel</code>, and nothing in the mix knows the difference. sherpa's <code>transform</code> is one, composed by the host from whatever it puts in a matrix.</p>
300
+ <p>The stock set is not the limit: a consumer that needs arithmetic it lacks writes its own <code>Channel</code>, and nothing in the mix knows the difference. sherpa's <code>transform</code> is one, composed by the host from whatever it puts in a matrix. A channel whose values are objects can add <code>fold</code>, which writes each contribution into the pose's own value where <code>merge</code> and <code>scale</code> would each make a new one. The mix starts that value as a copy of the channel's <code>rest</code>, and copies an array, a typed array, or a plain object itself; where <code>rest</code> is anything else, such as a class instance, the channel gives <code>copy</code>, or <code>cue</code> refuses a patch that writes it.</p>
295
301
 
296
302
  <p>A kit is plain data, so a consumer declares one in a line and the mix is typed by it:</p>
297
303
  <pre><code>const PART = kit&lt;PartOffset&gt;({
@@ -300,6 +306,10 @@ type Kit&lt;O&gt; = { readonly [K in keyof O]-?: Channel&lt;NonNullable&lt;O[K]&
300
306
  light: vec(3, sum()), color: color(last(), { lerp: 'oklch' }),
301
307
  });</code></pre>
302
308
 
309
+ <p><code>Vec&lt;N&gt;</code> is the type of the value a <code>vec(n, ch)</code> channel holds, <code>N</code> numbers: <code>Vec&lt;3&gt;</code> is <code>[number, number, number]</code>, and <code>number[]</code> where <code>N</code> is only known to be a number. A <code>quat()</code> holds a <code>Vec&lt;4&gt;</code>.</p>
310
+
311
+ <p><code>fold(kit, deltas, weights?)</code> folds a list of deltas into one pose by a kit's arithmetic, in order, with no mix and no clock: what a mix gives for one voice per delta at those weights. Each channel starts at its rest, a weight (default 1, held to 0..1) scales its delta toward rest, and the pose is clamped to each channel's <code>bounds</code>. A channel with no rest takes the last delta whose weight is 0.6 or more, where a mix's default band switches on, and is undefined where none is. The pose shares no array with a delta or with the kit.</p>
312
+
303
313
  <h2 id="patches">Patches</h2>
304
314
  <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>
305
315
 
@@ -309,7 +319,7 @@ type Kit&lt;O&gt; = { readonly [K in keyof O]-?: Channel&lt;NonNullable&lt;O[K]&
309
319
  readonly form: 'fn' | 'keys' | 'motion';
310
320
  <span class="cm">/** Milliseconds one pass lasts, whether or not the voice loops. 0 has no passes: `phase` and `pass`
311
321
  stay 0, `loop` is ignored, and the patch reads `setting.elapsed` instead. */</span>
312
- duration: number;
322
+ readonly duration: number;
313
323
  <span class="cm">/** The channels this patch contributes to. Every key `at` sets, and no others. */</span>
314
324
  readonly writes: readonly (keyof O)[];
315
325
  <span class="cm">/** The channels it was written against. `cue` refuses a kit whose channel of the same name is another kind. */</span>
@@ -329,10 +339,22 @@ type Kit&lt;O&gt; = { readonly [K in keyof O]-?: Channel&lt;NonNullable&lt;O[K]&
329
339
 
330
340
  <span class="cm">/** Present when the patch was authored as keyframes, so an engine that reads data can. */</span>
331
341
  readonly keys?: readonly Keyframe&lt;O&gt;[];
342
+ <span class="cm">/** Present on a `keys` patch that was given them: what `keys` took besides its stops. They are read off the
343
+ patch, so a copy that changes one, as `{ ...p, ease: 'linear' }` does, plays by its own. */</span>
344
+ readonly ease?: Easing;
345
+ easeBy?(channel: keyof O): Easing | undefined;
346
+ delayBy?(channel: keyof O): number;
347
+ lerpBy?(channel: keyof O): ((a: never, b: never, u: number) =&gt; unknown) | undefined;
348
+ <span class="cm">/** Present on a `wave` patch: what `wave` took, which a copy may change as it may a `keys` field. */</span>
349
+ readonly wave?: WaveOptions&lt;O&gt;;
332
350
  <span class="cm">/** Present on a `'motion'` patch: its kind and constants, for an engine that reads data. */</span>
333
351
  readonly motion?: MotionSpec;
334
352
  <span class="cm">/** A copy of `state` sharing nothing with it, for a read at another time. Default `structuredClone`. */</span>
335
353
  clone?(state: S): S;
354
+ <span class="cm">/** `state` as data that survives `structuredClone`, for a history `store` to keep, and a state made from that
355
+ data again. With a store, a patch with `state` or `step` needs both, or `cue` refuses it. */</span>
356
+ pack?(state: S): unknown;
357
+ unpack?(data: unknown): S;
336
358
  }
337
359
 
338
360
  interface Setting&lt;S = void, H = unknown&gt; {
@@ -342,9 +364,10 @@ interface Setting&lt;S = void, H = unknown&gt; {
342
364
  the previous sync only for a subject probed every frame. Infinity under reduced motion: snap, never
343
365
  integrate. */</span>
344
366
  dt: number;
345
- <span class="cm">/** Milliseconds this voice has been playing, rate applied. What lets a patch see across passes. */</span>
367
+ <span class="cm">/** Milliseconds this voice has been playing, rate applied. What lets a patch see across passes. Below 0 only
368
+ for a voice with no end cued backward, which plays into the passes before its first. */</span>
346
369
  elapsed: number;
347
- <span class="cm">/** Which pass this is, 0 first. */</span>
370
+ <span class="cm">/** Which pass this is, 0 first; below 0 where `elapsed` is. */</span>
348
371
  pass: number;
349
372
  <span class="cm">/** This voice's weight for this subject this frame, after its fade envelope, its signal and its share of any locus:
350
373
  the same number the mix is about to scale by. */</span>
@@ -364,8 +387,8 @@ interface Setting&lt;S = void, H = unknown&gt; {
364
387
  <p>Three authoring forms build a patch. All return the same interface, so a voice cannot tell them apart.</p>
365
388
  <div class="tbl"><table>
366
389
  <tr><th>form</th><th>signature</th><th>what it is</th></tr>
367
- <tr><td><code>fn</code></td><td><code>patch(duration, at, { writes, state?, step? })</code></td><td>Procedural. Flicker, roving, a hash walk, a starter strike. Only a JS engine can run one.</td></tr>
368
- <tr><td><code>keys</code></td><td><code>keys(duration, stops, { ease?, easeBy?, delayBy?, lerpBy? })</code></td><td>Declarative stops, klieg's transition sugar and wod's keyframes. Data preserved on <code>patch.keys</code>, and the options on the patch as fields of their own names, for an engine that reads data instead of calling functions.</td></tr>
390
+ <tr><td><code>fn</code></td><td><code>patch(duration, at, { writes?, kit?, reads?, state?, step?, pack?, unpack? })</code></td><td>Procedural. Flicker, roving, a hash walk, a starter strike. Only a JS engine can run one.</td></tr>
391
+ <tr><td><code>keys</code></td><td><code>keys(duration, stops, { ease?, easeBy?, delayBy?, lerpBy?, kit? })</code></td><td>Declarative stops, klieg's transition sugar and wod's keyframes. Data preserved on <code>patch.keys</code>, and the options on the patch as fields of their own names, for an engine that reads data instead of calling functions.</td></tr>
369
392
  <tr><td><code>motion</code></td><td><code>spring(writes, opts)</code>, <code>glide(writes, opts)</code>, <code>tween(writes, opts)</code></td><td>Momentum, or an eased curve between two values, solved in closed form per subject; <a href="#motion">below</a>. The kind and its constants are preserved on <code>patch.motion</code>, a <code>MotionSpec</code>.</td></tr>
370
393
  </table></div>
371
394
 
@@ -380,8 +403,12 @@ interface Setting&lt;S = void, H = unknown&gt; {
380
403
  <h2 id="voices">Voices</h2>
381
404
  <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>
382
405
 
383
- <pre><code>interface VoiceSpec&lt;I, O, H = unknown&gt; {
384
- patch: Patch&lt;I, O, any, H&gt;;
406
+ <pre><code><span class="cm">/** `SpanHints` are `faster`, `slower`, `overlap`, `ballast`, and `priority`, read only under a span. See Spans. */</span>
407
+ interface VoiceSpec&lt;I, O, H = unknown&gt; extends SpanHints {
408
+ patch: Patch&lt;I, O, unknown, H&gt;;
409
+ <span class="cm">/** What `history.revive` builds this spec from again, so a history `store` can page the voice out once it has
410
+ left. See History behind a store. */</span>
411
+ as?: Described;
385
412
  <span class="cm">/** Which subjects this voice reaches. Default: all of them. The predicate is fixed at `cue`; it runs per
386
413
  subject the first time the mix sees that subject, and the answer is kept for as long as the state is. */</span>
387
414
  target?: (subject: I) =&gt; boolean;
@@ -391,11 +418,14 @@ interface Setting&lt;S = void, H = unknown&gt; {
391
418
  subjects?: readonly I[];
392
419
 
393
420
  <span class="cm">// clock</span>
394
- <span class="cm">/** When the voice starts, in ms on the clock the host passes <code>sync</code>. Default: the next sync. */</span>
421
+ <span class="cm">/** When the voice starts, in ms on the clock the host passes <code>sync</code>. Default: the time the mix last synced to, and 0 for a voice cued before the first sync, which that sync finds already as far in as its timestamp. */</span>
395
422
  start?: number;
396
- /** Playback rate. 1 is real time; 0 freezes. */
423
+ <span class="cm">/** Playback rate. 1 is real time; 0 freezes; below 0 plays backward. See Starting partway, and playing backward. */</span>
397
424
  rate?: number;
398
- <span class="cm">/** true loops for good, false plays one pass, n plays n passes. A finite loop leaves when its passes are done — over <code>fade.out</code> where one is set, at once where none is — unless it holds after. */</span>
425
+ <span class="cm">/** Where in itself the voice starts, in voice ms, as `seek` on its handle sets. Default 0, or its end for one
426
+ cued backward. Refused where it is not a finite number. */</span>
427
+ seek?: number;
428
+ <span class="cm">/** true loops for good, false plays one pass, n plays n passes. A finite loop leaves when its passes are done — over <code>fade.out</code> where one is set, at once where none is — unless it freezes after. */</span>
399
429
  loop?: boolean | number;
400
430
  <span class="cm">/** Per-subject delay in voice ms. klieg's stagger grammar stays klieg's and passes in the one
401
431
  thing the mix needs: how long this subject waits. Asked once per subject on first sight and
@@ -435,28 +465,42 @@ interface Setting&lt;S = void, H = unknown&gt; {
435
465
  owner?: Handle&lt;I&gt;;
436
466
  }
437
467
 
438
- interface Handle&lt;I&gt; {
468
+ interface Handle&lt;I = unknown&gt; {
439
469
  readonly id: number;
440
- <span class="cm">/** <code>frozen</code>: a voice freezing after has played every pass and shows its last frame until faded. */</span>
470
+ <span class="cm">/** <code>frozen</code>: a voice freezing after has played every pass and shows its last frame until faded, or one
471
+ freezing before has run back to its start and shows its first. */</span>
441
472
  readonly state: 'pending' | 'live' | 'frozen' | 'fading' | 'done';
442
473
  <span class="cm">/** The owner's handle, for a voice an owner holds. */</span>
443
474
  readonly owner: Handle&lt;I&gt; | undefined;
444
- <span class="cm">/** Live. Writes land on the next sync. */</span>
475
+ <span class="cm">/** Live. A write shows at the next probe. */</span>
445
476
  weight: number;
477
+ <span class="cm">/** Playback rate now. Setting it changes speed at once; `ramp` eases into a new one. Below 0 plays backward. On a
478
+ voice still pending, a rate or a ramp applies from the voice's start. */</span>
446
479
  rate: number;
447
480
  <span class="cm">/** Moves this voice's clock, forward or back, and an owner's children with it. Each subject's state is made
448
481
  fresh and stepped from the voice's start to `elapsed` at its next probe, unless `state: 'keep'` leaves it
449
482
  where it was. Answers how sure that leaves it, as `assess` does. Mix time is not addressable this way,
450
- and nothing outside this voice moves. */</span>
483
+ and nothing outside this voice moves. A voice still pending starts on time, already at `elapsed`. */</span>
451
484
  seek(elapsed: number, opts?: { state?: 'rebuild' | 'keep' }): 'exact' | 'stepped' | 'held';
452
485
  <span class="cm">/** Moves the rate to `rate` linearly over `over` mix ms. The voice clock integrates it, so speed eases too. */</span>
453
486
  ramp(rate: number, over: number): void;
454
487
  <span class="cm">/** Ramps out over `fade.out` unless given a fade here; `at: 'rest'` waits per subject for a resting pose first.
455
488
  Given a `subject`, fades that subject alone out of this voice, which plays on for the rest. */</span>
456
489
  fade(opts?: { over?: number; at?: number | 'rest'; deadline?: number } | { subject: I; over?: number }): void;
490
+ <span class="cm">/** Turns a fade out around: the voice climbs back from where its fade had got to, up the same curve, over `over`
491
+ ms for the whole curve (default its `fade.in`, else its `fade.out`), and plays on as if never faded. A fade
492
+ waiting for rest is taken back at once; a voice not fading is left as it is. */</span>
493
+ rise(opts?: { over?: number }): void;
457
494
  <span class="cm">/** The weight this voice gave `subject` the last frame it was probed: after fades and its signal, before a
458
495
  locus folds it. 0 where it gave none. One number on a record the mix keeps anyway, so free unasked. */</span>
459
496
  weightOf(subject: I): number;
497
+ <span class="cm">/** A motion patch's own `to`, `push`, and `read`, reached without holding the patch; `at` is a voice time. `to`
498
+ throws for a voice not playing a `spring` or a `tween`, and `push` for one not playing a `spring` or a `glide`;
499
+ `read` gives undefined for a voice playing no motion patch. `Value` is a number or an array of them, and
500
+ `Motion` is `{ value, velocity }`. See Springs, glides and tweens. */</span>
501
+ to(subject: I, target: Value, at?: number): void;
502
+ push(subject: I, velocity: Value, at?: number): void;
503
+ read(subject: I, at?: number): Motion&lt;Value&gt; | undefined;
460
504
  <span class="cm">/** Resolves when the voice has been removed from the mix, however that happened. */</span>
461
505
  readonly done: Promise&lt;void&gt;;
462
506
  <span class="cm">/** Resolves true when a finite loop's last pass ends, for the latest-staggered subject it has seen, and false
@@ -466,9 +510,10 @@ interface Handle&lt;I&gt; {
466
510
 
467
511
  <p><b>Fading at a time.</b> <code>fade({ at, over })</code> with <code>at</code> a mix time begins the fade exactly there rather than at the call: ahead, the voice plays untouched until then, its <code>out</code> and <code>end</code> marks fixed from the call, so <code>marks</code>, <code>project</code> and anchors see them; at or behind now, the fade began then and is partway. A <code>rise</code> before it begins takes it back with nothing changed. It wins over an anchored <code>out</code>. Built 2026-10-07 for astv&rsquo;s rosters, whose departures begin exactly when an arrival ends; <code>at: 'rest'</code> keeps its meaning.</p>
468
512
 
469
- <p><b>Fading one subject.</b> <code>fade({ subject, over })</code> ramps one subject out of a voice and leaves the voice playing for every other subject; <code>over: 0</code> takes it out at once. When the ramp ends the voice forgets the subject &mdash; its record, its lane position and a motion patch&rsquo;s state for it &mdash; and no longer reaches it, so nothing it held stays stacked under whatever takes the subject over. A motion patch&rsquo;s <code>to</code> brings it back, met afresh: <code>from</code>, <code>target</code> and <code>stagger</code> are asked again. Any other voice reaches it again only once <code>drop</code> forgets the subject. This is what lets a host that starts many short animations, one per node, run them as one voice on one lane instead of a voice per call, while still cancelling a node or handing it to a different patch.</p>
513
+ <p><b>Fading one subject.</b> <code>fade({ subject, over })</code> ramps one subject out of a voice and leaves the voice playing for every other subject; <code>over: 0</code> takes it out at once. When the ramp ends the voice forgets the subject &mdash; its record, its lane position and a motion patch&rsquo;s state for it &mdash; and no longer reaches it, so nothing it held stays stacked under whatever takes the subject over. A motion patch&rsquo;s <code>to</code> brings it back, met afresh: <code>from</code>, <code>target</code> and <code>stagger</code> are asked again. The change that brings it back is made at the frame of the call, as it is for a subject never faded, however long it is before the host next reads the subject. 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>
470
514
 
471
515
  <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&rsquo;s first frame from the cue until the subject starts: while the voice is pending, and while the subject waits out its <code>stagger</code>. <code>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&rsquo;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>
516
+ <p><b>Starting partway, and playing backward.</b> <code>seek</code> on a cue is where the voice starts in itself, and a handle&rsquo;s <code>seek</code> on a voice still pending is the same thing: the voice starts on time, already at that position, and one freezing before shows that frame while it waits. A rate or a ramp set on a pending voice applies from its start too, so at rate 0 it starts on time and holds its first frame. A fade in counts from no earlier than the voice&rsquo;s start, so starting partway does not use it up. A rate below 0 plays the voice backward, and its start is to it what its end is to a voice playing forward: when its clock runs back to 0 for the earliest-staggered subject it has seen, <code>played</code> resolves, and it leaves over <code>fade.out</code>, showing its first frame through the fade. Where it freezes before, it turns <code>frozen</code> instead and holds that frame until it is faded or its handle sets it playing forward again. That is so whether it was cued backward, turned back by its handle, or eased into reverse by a <code>ramp</code> through 0. A stateful patch steps once more to the moment it reached its start, and no further. Cued backward with no <code>seek</code>, a voice starts at its end, <code>duration</code> times its passes; later-staggered subjects start that much short of their own ends, so a host that wants the whole film reversed passes <code>seek</code> the longest stagger further on. A voice with no end cued backward, a loop for good or a patch with no duration, starts at 0 and has no first pass either: it plays into the pass before, with <code>setting.pass</code> and <code>setting.elapsed</code> below 0, for every subject from the voice&rsquo;s start whatever its stagger, and runs on the general path. A loop for good cued forward and turned back later still ends at 0. A motion&rsquo;s stretches begin at 0, so one cued backward with no <code>seek</code> has ended as it starts. Marks follow: a voice running back coasts and fades where it reaches its start. A voice an owner holds runs back with its owner: at its own start it leaves over <code>fade.out</code> as above, and the owner, run back to its start, stays for what it holds and leaves with its last child. Not built: hits are not booked backward, and while a ramp through 0 has yet to turn a voice around its <code>coast</code>, <code>out</code>, and <code>end</code> marks are unknown.</p>
472
517
  <ul>
473
518
  <li><b>A frozen subject&rsquo;s clock stands still.</b> It reads phase 0 of pass 0 before, and phase 1 of the last pass after, with <code>elapsed</code> pinned to that edge. <code>at</code> is asked for that frame. <code>step</code> does not run while a subject waits, so a stateful patch starts playing from the state it was cued with; after, it runs once more to where the last pass ended and then no more, so the 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>
474
519
  <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>
@@ -487,11 +532,11 @@ interface Handle&lt;I&gt; {
487
532
 
488
533
  interface MixOptions&lt;H = unknown&gt; {
489
534
  engine?: Engine; <span class="cm">// default: mixer</span>
490
- <span class="cm">/** Reduced motion: fades and warm-ups snap, `dt` reads Infinity. Read once at creation, or a signal. */</span>
535
+ <span class="cm">/** Reduced motion: fades and warm-ups snap, `dt` reads Infinity. A function is asked once a sync. */</span>
491
536
  reduce?: boolean | (() =&gt; boolean);
492
537
  <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>
493
538
  host?: H;
494
- <span class="cm">/** The band a rest-less channel's contribution switches across. Default 0.4 / 0.6, a placeholder. */</span>
539
+ <span class="cm">/** The band a rest-less channel's contribution switches across. Default on at 0.6 and off at 0.4, a placeholder. */</span>
495
540
  band?: { on: number; off: number };
496
541
  <span class="cm">/** The most `dt` a step or signal is handed, however long the gap. Off by default. */</span>
497
542
  maxDt?: number;
@@ -501,10 +546,15 @@ interface MixOptions&lt;H = unknown&gt; {
501
546
  history?: { ms: number; every?: number; inputs?: boolean; tape?: TapeMaker; store?: HistoryStore;
502
547
  revive?(d: Described): VoiceSpec };
503
548
  <span class="cm">/** A subject's key for a history `store`, where subjects are objects. */</span>
504
- keyOf?(subject: I): string | number;
549
+ keyOf?(subject: never): string | number;
505
550
  <span class="cm">/** Whether a channel may run as a lane. On by default; the pose is the same either way, apart from
506
551
  a stateless patch's `send` from `at` and a patch that starts keeping state partway through. */</span>
507
552
  lanes?: boolean;
553
+ <span class="cm">/** The transport this mix plays on, shared with other mixes so they keep one clock. Its history is the
554
+ transport's, so the mix takes no `history` of its own. See Transport. */</span>
555
+ transport?: Transport;
556
+ <span class="cm">/** What `Marked.mix` and errors call this mix. */</span>
557
+ name?: string;
508
558
  }
509
559
 
510
560
  interface Mix&lt;I, O, H = unknown&gt; {
@@ -516,6 +566,9 @@ interface Mix&lt;I, O, H = unknown&gt; {
516
566
  blend(patches: readonly Patch&lt;I, O, any, H&gt;[], by: Signal&lt;I, H&gt;, spec?: Omit&lt;VoiceSpec&lt;I, O, H&gt;, 'patch' | 'weight' | 'locus'&gt;): Handle&lt;I&gt;[];
517
567
  <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>
518
568
  owns(spec: OwnerSpec&lt;I, H&gt;): Handle&lt;I&gt;;
569
+ <span class="cm">/** A span: an owner that lays out the voices it holds in `order` and fits them into its `duration`. Its handle
570
+ also carries `result`, how the last fit came out. See Score. */</span>
571
+ span(spec: SpanSpec&lt;I, H&gt;): SpanHandle&lt;I&gt;;
519
572
 
520
573
  <span class="cm">/** The host reports the clock, once a frame. Nothing advances at the call: it publishes the reading, and
521
574
  each subject catches up by the whole gap the first time it is probed afterward. The same timestamp twice
@@ -527,7 +580,11 @@ interface Mix&lt;I, O, H = unknown&gt; {
527
580
  rate: number;
528
581
  <span class="cm">/** Moves the mix's rate to `rate` linearly over `over` ms of the host's clock. */</span>
529
582
  ramp(rate: number, over: number): void;
530
- <span class="cm">/** The merged pose for one subject at the synced frame. `out` is written in place when given. */</span>
583
+ <span class="cm">/** The merged pose for one subject at the synced frame, written into `out` when given; without it each call
584
+ makes a new object. An array `out` already holds for a channel is written in place where it is the channel's
585
+ length: for a `vec` channel, a `quat` on a lane, and a `color` that averages in OKLab or is on a lane. A host
586
+ that kept one from an earlier probe into the same `out` sees it change, so it copies what has to outlast the
587
+ next probe. */</span>
531
588
  probe(subject: I, out?: O): O;
532
589
  <span class="cm">/** Each subject's pose written into one Float64Array per channel, in the order given: what probe(subject, out)
533
590
  gives, without a pose object per subject. A channel of n numbers takes n places a subject. */</span>
@@ -569,6 +626,9 @@ interface Mix&lt;I, O, H = unknown&gt; {
569
626
  mute(opts?: { over?: number }): void;
570
627
  <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>
571
628
  drop(subject: I): void;
629
+ <span class="cm">/** Says that something a patch reads from outside the mix has changed since the last sync, so the next probe
630
+ calls the patch again: for one subject, or with none for every subject. */</span>
631
+ touch(subject?: I): void;
572
632
  <span class="cm">/** Every event sent since the last drain, earliest first. Drain after probing. */</span>
573
633
  drain&lt;E&gt;(tag?: string): { timestamp: number; subject: I; voice: number; tags: readonly string[]; event: E }[];
574
634
  <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>
@@ -580,6 +640,8 @@ interface Mix&lt;I, O, H = unknown&gt; {
580
640
 
581
641
  <p>Subjects are not registered. State lives in a <code>WeakMap</code> keyed by the subject object, created on first probe and stepped by the gap in the timestamp since that subject was last stepped. That is the rule klieg's dwell arrived at, and it means a part a wrapper leaves unasked for a while catches up rather than resuming where it paused. A subject first asked for late catches up the same way, from when its delay ran out by the voice's clock as it ran then, so its fade-in and its state read the same however often the host probed it before.</p>
582
642
 
643
+ <p><b>Telling the mix what it cannot see.</b> Within a frame a patch's <code>at</code> is read once for a subject, and a later probe of that subject answers with what it read then. <code>mix.touch(subject)</code> says that something a patch reads from outside the mix has changed since the last sync, such as a table its <code>at</code> looks the subject up in, so the next probe calls the patch again; with no subject it does so for every subject. The clock stays where it is, no <code>step</code> runs again, no hit is booked again, and a motion patch, which reads nothing from outside the mix, is left where it is. It wakes a frame loop sleeping on <code>inert</code>, as any change does.</p>
644
+
583
645
  <h2 id="signals">Signals</h2>
584
646
  <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>
585
647
 
@@ -640,7 +702,7 @@ mix.book({
640
702
  });</code></pre>
641
703
  A hit plays once per pass, for the voice and never per subject, so <code>stagger</code> does not spread it; it sits on the voice clock exactly where a mark does, through the voice's rate, ramp and seeks, its owners', and <code>mix.rate</code>. Booking runs at each <code>sync</code> and takes whatever falls within <code>ahead</code> host ms, which has to cover the longest gap between syncs. <code>when</code> is on the outside clock, by an offset that folds in 5% of each frame's difference and starts over from the clock's own reading on a jump past 50&nbsp;ms (weasel's filter). Nothing is booked twice: a mark is known by its voice and which mark, a hit by its voice, index and pass. A booking whose time moves by more than 1&nbsp;ms, or that goes away, has its <code>stop</code> called and is booked again at the same sync: a seek, a rate or ramp on the voice, an owner or the mix, a fade or <code>mute</code>, a <code>rebase</code>, an anchor's target moving, the voice leaving. A fade takes away the hits after the voice is gone, and the hits within the fade still play. An item first seen already past, from a voice cued partway through or a stalled frame, is taken at the outside clock's now with <code>lateBy</code> in host ms, unless that is more than <code>late</code>. What a seek jumps over is not late; it is skipped. Hits come as the voice clock runs forward, so a voice run backwards books none. <code>tag</code> and <code>score</code> split one mix between bookers, and <code>stop()</code> on what <code>book</code> returns stops every booking still ahead. <code>project</code> books nothing, and under <code>history</code> nothing about booking is kept or replayed: a read back says what was planned, not what was booked. A mix with no booker pays nothing for it.</li>
642
704
  <li><b>A finite loop ends when its last pass does,</b> not at the frame that notices: its fade out counts from that moment, or it turns <code>frozen</code> then if it freezes after, so it plays the same at any frame rate and a read at another time can find it. A fade's end and a handover's deadline are kept the same way.</li>
643
- <li><b>Calls land on the next sync.</b> A host writes <code>handle.weight</code> or calls <code>fade</code> on its own clock; the mix applies it at the first timestamp that sees it. Nothing changes mid-frame.</li>
705
+ <li><b>Calls land at once, at the time the mix last synced to.</b> A host writes <code>handle.weight</code> or calls <code>fade</code>, <code>seek</code>, or <code>cue</code> on its own clock; the mix applies it there and then, as of its last <code>sync</code>, and the next <code>probe</code> shows it. So a subject probed before a call and again after it in one frame reads two poses for one timestamp: a host that wants one answer a frame makes its calls before it probes.</li>
644
706
  </ul>
645
707
 
646
708
  <h2 id="blend">Blending</h2>
@@ -654,7 +716,7 @@ mix.book({
654
716
  <tr><td>Retarget on interruption</td><td><code>from: 'current'</code> on a <code>keys</code> voice takes stop 0 from the subject's pose in the frame before it first contributes &mdash; every other voice folded in, itself excluded &mdash; and lerps from there through the channel's <code>lerp</code>. It also leaves at the velocity the subject had, taken from its last two probed poses: the first segment gains <code>c·u(1−u)²</code>, which is zero at both ends and flat at the end, so the segment's own curve still lands on its stop while the start matches the subject's speed. A subject that was still leaves at rest. With fewer than two poses on record, as on a mix whose host probes into its own object before any retarget is cued, it starts on the segment's own curve.</td><td>wod's base-sampling. New to klieg: a regroup could tween from mid-float instead of carrying it away.</td></tr>
655
717
  </table></div>
656
718
 
657
- <p><b>A locus is folded by <code>lerp</code>, not by scaling and merging.</b> Voices sharing a <code>locus</code> are alternatives in time, so the mix combines them into one contribution first: each member weighted by its share of the locus's summed weight, through the channel's own <code>lerp</code>, in cue order. Each channel of that contribution then contributes at <code>min(1, &Sigma;w)</code> over the members that wrote it, and is scaled toward rest like any other: a channel only one member writes fades with that member, rather than holding at the whole locus&rsquo;s weight while it leaves. On a <code>sum</code> channel this is exactly <code>w&#8321;a + w&#8322;b</code>, the arithmetic klieg's timeline already runs, so the cap it was written as is a special case of the fold rather than a rule of its own. On <code>mul</code>, <code>max</code> and <code>last</code> it is the difference between an alternative and a pile-up: two voices halfway through a handover, both holding a gain of 0.06, fold to 0.06 &mdash; which is what one alternative means &mdash; where scaling and merging reads 0.28, the product of two half-scaled copies. A grouped voice may write only channels that have a <code>lerp</code>, which every stock channel does. Cue order is visible only to a <code>lerp</code> that is not linear, such as a hue taking the short way round.</p>
719
+ <p><b>A locus is folded by <code>lerp</code>, not by scaling and merging.</b> Voices sharing a <code>locus</code> are alternatives in time, so the mix combines them into one contribution first: each member weighted by its share of the locus's summed weight, through the channel's own <code>lerp</code>, in cue order. Each channel of that contribution then contributes at <code>min(1, &Sigma;w)</code> over the members that wrote it, and is scaled toward rest like any other: a channel only one member writes fades with that member, rather than holding at the whole locus&rsquo;s weight while it leaves. On a <code>sum</code> channel this is exactly <code>w&#8321;a + w&#8322;b</code>, the arithmetic klieg's timeline already runs, so the cap it was written as is a special case of the fold rather than a rule of its own. On <code>mul</code>, <code>max</code> and <code>last</code> it is the difference between an alternative and a pile-up: two voices halfway through a handover, both holding a gain of 0.06, fold to 0.06 &mdash; which is what one alternative means &mdash; where scaling and merging reads 0.28, the product of two half-scaled copies. A grouped voice may write only channels that have a <code>lerp</code>, which every stock channel does. A stock <code>last()</code> channel has nothing to interpolate, so the locus takes its heaviest member&rsquo;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 locus can read differently by the order they were cued in.</p>
658
720
 
659
721
  <div class="callout">
660
722
  <div class="eyebrow">the one rule</div>
@@ -704,7 +766,7 @@ interface Projection&lt;I, O&gt; {
704
766
  <ul>
705
767
  <li><b>Placing.</b> A cue anchors at most one of <code>start</code> and <code>in</code>, and one of <code>out</code> and <code>end</code>, to a timestamp or to another voice's mark: <code>anchor: { in: 12_400 }</code> is fully in by 12.4&nbsp;s, <code>anchor: { start: { after: 'intro' } }</code> starts once <code>intro</code> is gone. Anchoring <code>in</code> or <code>end</code> counts the voice's own fade. A query selects by <code>name</code>, <code>tag</code> or a channel the voice <code>writes</code>, and its resolver picks among several, <code>last</code> cued by default.</li>
706
768
  <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>
707
- <li><b>Waiting and following.</b> An anchor with no answer &mdash; a name not cued, the <code>out</code> of a voice that loops for good &mdash; leaves the voice <code>pending</code>. While it waits it follows its target, so a target faded early brings it forward; once it starts, its start is fixed. An <code>out</code> or <code>end</code> follows until the fade begins. A target that has left keeps the time it last gave, and can be anchored to fresh after it left: within history's reach the mix keeps every voice that left, and past it, or with no history, it keeps the marks of the first and last voice to leave under each name, tag and channel, which answer a query only where no voice the mix still knows does. An anchored mark already past starts the voice partway through. A placement that waits on itself is refused at <code>cue</code>.</li>
769
+ <li><b>Waiting and following.</b> An anchor with no answer &mdash; a name not cued, the <code>out</code> of a voice that loops for good &mdash; leaves the voice <code>pending</code>. While it waits it follows its target, so a target faded early brings it forward; once it starts, its start is fixed. An <code>out</code> or <code>end</code> follows until the fade begins. A target that has left keeps the time it last gave, and can be anchored to fresh after it left: within history's reach the mix keeps every voice that left, and past it, or with no history, it keeps the marks of the first and last voice to leave under each name, tag and channel, which answer a query only where no voice the mix still knows does. An anchored mark already past starts the voice partway through. A fade out begins no earlier than its voice starts: an <code>end</code> that leaves less room than <code>fade.out</code> shortens the fade to fit, and an <code>end</code> before the start makes the voice an instant, which starts and ends at its start, shows nothing, and warns once outside production. A placement that waits on itself is refused at <code>cue</code>.</li>
708
770
  <li id="joins"><b>Joins.</b> <code>{ all: [...] }</code> waits on several anchors and answers the latest of them, once every one has answered: <code>{ start: { all: [{ after: 'change-3' }, { of: 'orb-7', mark: 'end' }] } }</code> starts once both are done. <code>{ any: [...] }</code> answers the earliest, once one of them has passed or every one has answered, since a member still unknown might yet answer earlier. A join holds only the anchors listed in it, each with its own <code>by</code>, and nests. A member whose target has left keeps the time it last gave, as a lone anchor does, so one target finishing early no longer leaves the whole join unanswered. Added 2026-10-07, named after <code>Promise.all</code> and <code>Promise.any</code>, which behave the same way.</li>
709
771
  <li><b>Announced marks.</b> The host puts a named mark on a score with <code>mix.announce('reply')</code>, or <code>announce('beat', { at })</code> for one it knows ahead, and anchors target it as they target a voice's marks: a voice placed <code>{ with: 'reply' }</code> waits pending until the reply is announced. A mark announced ahead is seen by a read ahead; one not yet announced makes a read ahead say <code>held</code> for whatever waits on it, and a read back sees only what had been announced by then. An announced mark stays while it is ahead or while history reaches it. Decided 2026-10-01: a <b>mark</b> is any named time an anchor can hang from, a voice's own five or one the host announced, and an <b>event</b> is only what a patch <code>send</code>s to the host. What fires a mark on a condition, such as a channel crossing a value, stays the host's: blits does not read values.</li>
710
772
  <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.
@@ -766,6 +828,7 @@ phase.result; <span class="cm">// { budget, length, over, skipped, fell }</spa
766
828
  <li><b>Branches.</b> A call the host makes while recorded calls lie ahead starts a new branch, and the tape keeps the old future beside it rather than dropping it: scrubbing never loses a recording, and neither does a new call. <code>mix.tape.branches()</code> lists the futures at the tape's current entry and <code>switchBranch</code> picks one. To reach one, seek to just before the call that started the other; a call made at the same mix time as a recorded call it follows cannot be reached apart from that call.</li>
767
829
  <li><b>The host's clock.</b> The host goes on passing its own clock: the next <code>sync</code> reads <code>t</code> plus the host's time since its last sync, and <code>rebase</code> composes with it. <code>sync</code> refuses an earlier timestamp short of a <code>rebase</code>. <code>seek</code>, <code>project</code> and <code>mix.now</code> are mix time, since once the mix replays a recording one host time can name two moments.</li>
768
830
  <li><b>Events.</b> Playing past a time again sends its events and books its marks and hits again, since the host knows it went back; what was sent or booked at or before <code>t</code> is not sent again, and undrained events after <code>t</code> are dropped.</li>
831
+ <li><b>Reading at the mix's own time.</b> <code>project(mix.now)</code> is what <code>probe</code> gives. A read ahead lands what a sync there would, and a read at <code>now</code> lands nothing: a voice a handle <code>seek</code> put past its end holds its last frame until the next sync, in both.</li>
769
832
  <li><b>Reading ahead past the tape.</b> A read ahead of the mix after a seek back throws for a time past a call the tape will make again: a recorded call is bound to the live mix and its voices, and a projection is a copy, so it would leave the call out. A seek there makes it.</li>
770
833
  </ul>
771
834
  Decided 2026-10-06 and 2026-10-07, reversing the 2026-10-01 decision that mix time is only ever a reading. The mix goes back by itself rather than through snapshots the host saves and loads, since a host-kept log of its own calls would be a second record of what the mix did. What happened after <code>t</code> plays again rather than vanishing, because scrubbing must never cut off a recorded future. A call made while rewound branches rather than dropping the old future, and the mix drops its own copies of state after the fork rather than keeping them per branch, so memory stays inside the horizon. History lives only for the session. The mix's own logs hold what happened up to the playhead and the tape holds the calls after it, so nothing is recorded twice and the logs never branch. A recorded call's <code>invert</code> does nothing, since the mix restores rather than undoing; so the mix moves its tape back only with <code>goto</code>, forward one <code>redo</code> at a time, and opens no journals.</li>
@@ -818,7 +881,7 @@ t.sync(now); <span class="cm">// once a frame, after the frame's cues</span></co
818
881
  <span class="cm">/** Which patch forms this engine can run. A voice whose patch it cannot run is refused at `cue`, by name. */</span>
819
882
  readonly runs: ReadonlySet&lt;'fn' | 'keys' | 'motion'&gt;;
820
883
  <span class="cm">/** Builds the mix. Everything on `Mix` is the engine's to implement. */</span>
821
- create&lt;I, O&gt;(kit: Kit&lt;O&gt;, opts: MixOptions): Mix&lt;I, O&gt;;
884
+ create&lt;I, O, H = unknown&gt;(kit: Kit&lt;O&gt;, opts: MixOptions&lt;H&gt;): Mix&lt;I, O, H&gt;;
822
885
  }
823
886
 
824
887
  export const mixer: Engine; <span class="cm">// the one that ships</span></code></pre>
@@ -835,9 +898,9 @@ export const mixer: Engine; <span class="cm">// the one that ships</span></cod
835
898
  <p>Inside <code>mixer</code>, a channel can run as a <b>lane</b>: computed for every subject at once, in <code>Float64Array</code>s indexed by a number the mix gives each subject, instead of subject by subject. A host never asks for one. Lanes are on by default, and <code>MixOptions.lanes: false</code> turns them off, to rule one out or to compare against.</p>
836
899
  <div class="tbl"><table>
837
900
  <tr><th></th><th>runs on a lane</th><th>takes the general path</th></tr>
838
- <tr><td>channel</td><td><code>sum</code>, <code>mul</code>, <code>max</code>, <code>vec</code> over one of them, <code>angle</code>, <code>quat</code>, and a <code>color</code> that interpolates in OKLab, as the stock functions make them, bounds or not</td><td>anything else, including a custom channel claiming a stock kind</td></tr>
901
+ <tr><td>channel</td><td><code>sum</code>, <code>mul</code>, <code>max</code>, <code>vec</code> over one of them, <code>angle</code>, <code>quat</code>, and a <code>color</code> that interpolates in OKLab, as the stock functions make them, bounds or not</td><td>anything else, including a custom channel claiming a stock kind; a <code>color(last())</code> while a voice writing it plays a motion patch, is in a <code>locus</code>, or names one subject with <code>subjects</code></td></tr>
839
902
  <tr><td>patch</td><td><code>keys</code>; <code>fn</code> with no <code>state</code> and no <code>step</code>; <code>motion</code></td><td>a stateful <code>fn</code>, or one once it calls <code>setting.keep</code>; <code>keys</code> whose <code>lerpBy</code> is not the channel's own <code>lerp</code>; a patch that <code>reads</code> host fields</td></tr>
840
- <tr><td>voice</td><td>a number weight, with fades and <code>mute</code>; a <code>Signal</code> weight that keeps no state, weighed for each subject by the general path&rsquo;s own <code>weigh</code>; every subject, <code>target</code> or <code>subjects</code>; <code>stagger</code>, <code>loop</code>, <code>rate</code>, <code>ramp</code>, <code>seek</code>, anchors</td><td>a <code>Signal</code> weight once it calls <code>setting.keep</code>, which the stock stateful signals do from their first call, made by the general path; an input signal <code>input</code> did not make, or any input signal under <code>history.inputs</code>; a <code>locus</code> with a motion member, or any member that cannot run on a lane, which takes every member with it; <code>from: 'current'</code>, <code>fade({ at: 'rest' })</code></td></tr>
903
+ <tr><td>voice</td><td>a number weight, with fades and <code>mute</code>; a <code>Signal</code> weight that keeps no state, weighed for each subject by the general path&rsquo;s own <code>weigh</code>; every subject, <code>target</code> or <code>subjects</code>; <code>stagger</code>, <code>loop</code>, <code>rate</code>, <code>ramp</code>, <code>seek</code>, anchors</td><td>a <code>Signal</code> weight once it calls <code>setting.keep</code>, which the stock stateful signals do from their first call, made by the general path; an input signal <code>input</code> did not make, or any input signal under <code>history.inputs</code>; a <code>locus</code> with a motion member, or any member that cannot run on a lane, which takes every member with it; <code>from: 'current'</code>, <code>fade({ at: 'rest' })</code>; a voice with no end cued backward, and one fading out from where it ran back to its start</td></tr>
841
904
  </table></div>
842
905
  <p>A channel is a lane only while every voice writing it qualifies, and a voice runs on lanes only while every channel it writes is one, so no patch is called twice in a frame. The mix works this out again when a voice is cued or retires, starts a fade to rest, or first keeps state; never per probe. A voice that names one subject and writes only laned channels is the exception: it joins the crowd of voices writing those channels, below, or leaves it, without the mix qualifying anything again, since a voice that fits joining laned channels and a laned voice leaving cannot move the fixed point. Such a voice also relinks only its own subject&rsquo;s chain, and only a voice over every subject makes every subject meet the lanes again. A projection always reads through the general path.</p>
843
906
  <ul>
@@ -918,7 +981,7 @@ export const mixer: Engine; <span class="cm">// the one that ships</span></cod
918
981
  <li><b>Reading another time.</b> A projection ahead gives what the mix probes when it gets there, and moves nothing: a mix read ahead every frame plays exactly as one never read. A read back under <code>stepMs</code> equals the pose the mix showed at every frame, through fades, rate and weight changes, seeks and voices that have left, and a round trip back and forth repeats it. History stays inside its horizon.</li>
919
982
  <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>
920
983
  <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>
921
- <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.</li>
984
+ <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>
922
985
  <li><b>Engine refusal.</b> An engine whose <code>runs</code> lacks <code>fn</code> throws at <code>cue</code> naming the voice.</li>
923
986
  <li><b>The klieg extraction</b> is judged by klieg's own suites and the Playwright baselines, unchanged.</li>
924
987
  </ul>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@msb235/blits",
3
- "version": "0.8.0",
3
+ "version": "0.9.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",
@@ -42,7 +42,7 @@
42
42
  "test": "vitest run",
43
43
  "test:watch": "vitest",
44
44
  "test:general": "BLITS_LANES=off vitest run",
45
- "typecheck": "tsc -b && tsc -p tsconfig.test.json && tsc -b packages/quarks && tsc -p packages/quarks/tsconfig.test.json",
45
+ "typecheck": "tsc -b && tsc -p tsconfig.test.json && tsc -b packages/quarks && tsc -p packages/quarks/tsconfig.test.json && tsc -b packages/tone && tsc -p packages/tone/tsconfig.test.json",
46
46
  "lint": "biome check .",
47
47
  "check": "npm run lint && npm run typecheck && npm run test",
48
48
  "prepack": "rm -rf dist .tsbuildinfo && npm run build",