@msb235/blits 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/schema.html CHANGED
@@ -141,6 +141,7 @@
141
141
  <li class="grp">Behavior</li>
142
142
  <li><a href="#time">Time</a></li>
143
143
  <li><a href="#blend">Blending</a></li>
144
+ <li><a href="#score">Score</a></li>
144
145
  <li><a href="#state">Patch state</a></li>
145
146
  <li><a href="#engines">Engines</a></li>
146
147
  <li class="grp">Consumers</li>
@@ -159,7 +160,7 @@
159
160
  <h1>An effects schema, and the API around it</h1>
160
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>
161
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>
162
- <span class="status">Built · v0.1.0 · design 2026-09-15, package 2026-09-28</span>
163
+ <span class="status">Built · v0.3.0 · design 2026-09-15, package 2026-09-28</span>
163
164
  </header>
164
165
 
165
166
  <h2 id="picture">In one picture</h2>
@@ -238,7 +239,12 @@
238
239
  <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>
239
240
  <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>
240
241
  <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 &mdash; 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>
241
- <dt>Score</dt><dd>The source that holds a host's whole plan: what plays, over what, and when. It supplies the two things blits takes as given, the timing and the cast of subjects. klieg's is spread across handlers and a <code>Timeline</code>; another host's may be a document or a route table. It is coming inside, as a plan of voices placed on the mix clock that knows when each plays and never what it contributes; see Open.</dd>
242
+ <dt>Score</dt><dd>The source that holds a host's whole plan: what plays, over what, and when. It supplies the two things blits takes as given, the timing and the cast of subjects. klieg's is spread across handlers and a <code>Timeline</code>; another host's may be a document or a route table. Inside blits it is a plan of voices placed on the mix clock, which knows when each plays and never what it contributes; see Score.</dd>
243
+ </dl>
244
+
245
+ <p>Informal terms are used in design work and in the code but appear nowhere in the public API, so a consumer never has to learn them.</p>
246
+ <dl>
247
+ <dt>Lane</dt><dd>A channel the mix computes for every subject at once, in flat arrays indexed by a number it gives each subject, rather than subject by subject. A channel runs as a lane only while every voice writing it qualifies; otherwise it takes the general path, which folds subject by subject as the mix always has. Lanes are on by default and give the same pose; <code>MixOptions.lanes: false</code> turns them off.</dd>
242
248
  </dl>
243
249
 
244
250
  <h2 id="channels">Channels</h2>
@@ -288,8 +294,9 @@ type Kit&lt;O&gt; = { readonly [K in keyof O]-?: Channel&lt;NonNullable&lt;O[K]&
288
294
  <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>
289
295
 
290
296
  <pre><code>interface Patch&lt;I, O, S = void&gt; {
291
- <span class="cm">/** Which authoring form built it. An engine declares which forms it runs. */</span>
292
- readonly form: 'fn' | 'keys';
297
+ <span class="cm">/** Which authoring form built it: `'fn'` from `patch`, `'keys'` from `keys`, `'motion'` from `spring`,
298
+ `glide` or `tween`. An engine declares which forms it runs. */</span>
299
+ readonly form: 'fn' | 'keys' | 'motion';
293
300
  <span class="cm">/** Milliseconds one pass lasts. 0 is aperiodic: one pass that never ends, so `phase` and `pass` stay 0,
294
301
  `loop` is ignored, and the patch reads `setting.elapsed` instead. */</span>
295
302
  period: number;
@@ -310,6 +317,10 @@ type Kit&lt;O&gt; = { readonly [K in keyof O]-?: Channel&lt;NonNullable&lt;O[K]&
310
317
 
311
318
  <span class="cm">/** Present when the patch was authored as keyframes, so an engine that reads data can. */</span>
312
319
  readonly keys?: readonly Keyframe&lt;O&gt;[];
320
+ <span class="cm">/** Present on a `'motion'` patch: its kind and constants, for an engine that reads data. */</span>
321
+ readonly motion?: MotionSpec;
322
+ <span class="cm">/** A copy of `state` sharing nothing with it, for a read at another time. Default `structuredClone`. */</span>
323
+ clone?(state: S): S;
313
324
  }
314
325
 
315
326
  interface Setting&lt;S = void&gt; {
@@ -338,16 +349,17 @@ interface Setting&lt;S = void&gt; {
338
349
  send(event: unknown): void;
339
350
  }</code></pre>
340
351
 
341
- <p>Two authoring forms build a patch. Both return the same interface, so a voice cannot tell them apart.</p>
352
+ <p>Three authoring forms build a patch. All return the same interface, so a voice cannot tell them apart.</p>
342
353
  <div class="tbl"><table>
343
354
  <tr><th>form</th><th>signature</th><th>what it is</th></tr>
344
355
  <tr><td><code>fn</code></td><td><code>patch(period, 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>
345
356
  <tr><td><code>keys</code></td><td><code>keys(period, 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
+ <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>
346
358
  </table></div>
347
359
 
348
360
  <p><code>writes</code> is the patch's half of the contract the rest of the package already states: a channel kit declares what a consumer has, an engine declares its <code>runs</code> set, and both are checked at <code>cue</code>. Without it the only way to learn what a patch touches is to call it, per subject, per frame. With it the mix refuses a patch contributing a channel the kit lacks, resolves each voice's channels once at <code>cue</code> instead of walking the whole kit per probe, and can answer what a voice affects without probing it. A <code>keys</code> patch derives it from its stops.</p>
349
361
 
350
- <p>Two small names the page leans on: <code>Easing</code> is a curve on 0..1, given as data where it can be &mdash; CSS&rsquo;s names (<code>'ease-in-out'</code>), <code>{ bezier: [x1, y1, x2, y2] }</code> or <code>{ steps, jump? }</code>, which a WAAPI or GPU engine can read &mdash; or as a <code>(u: number) =&gt; number</code>, which only the CPU engine runs, and <code>Keyframe&lt;O&gt;</code> is one stop, <code>{ at: number; delta: Partial&lt;O&gt;; ease?: Easing }</code>, kept on <code>patch.keys</code> so an engine can read what a <code>keys</code> patch is made of. <code>mixHex</code> is the <code>hex</code> channel's <code>lerp</code>, shipped in sRGB; the color space is open below.</p>
362
+ <p>Two small names the page leans on: <code>Easing</code> is a curve on 0..1, given as data where it can be &mdash; CSS&rsquo;s names (<code>'ease-in-out'</code>), <code>{ bezier: [x1, y1, x2, y2] }</code> or <code>{ steps, jump? }</code>, which a WAAPI or GPU engine can read &mdash; or as a <code>(u: number) =&gt; number</code>, which only the CPU engine runs, and <code>Keyframe&lt;O&gt;</code> is one stop, <code>{ at: number; delta: Partial&lt;O&gt;; ease?: Easing }</code>, kept on <code>patch.keys</code> so an engine can read what a <code>keys</code> patch is made of. <code>mixHex</code> is the <code>hex</code> channel's <code>lerp</code>, which blends in OKLCH with hue taken the short way round, so a crossfade between hues keeps its lightness and saturation; a mix outside sRGB&rsquo;s gamut is clipped per channel. <code>hex({ space: 'srgb' })</code> blends red, green and blue each on its own instead, as klieg&rsquo;s light channel does.</p>
351
363
 
352
364
  <p>Wrappers stay wrappers. klieg's <code>turns</code>, <code>roving</code>, <code>intermittent</code>, <code>hinge</code> and <code>partition</code> take a patch and return a patch, and none of them changes except to pass <code>writes</code> through &mdash; a wrapper reshapes one influence and never adds a channel to it. Composition at the patch level is for reshaping one influence; composition at the mix level is for running several. The two do not merge.</p>
353
365
 
@@ -359,6 +371,10 @@ interface Setting&lt;S = void&gt; {
359
371
  <span class="cm">/** Which subjects this voice reaches. Default: all of them. The predicate is fixed at `cue`; it runs per
360
372
  subject the first time the mix sees that subject, and the answer is kept for as long as the state is. */</span>
361
373
  target?: (subject: I) =&gt; boolean;
374
+ <span class="cm">/** The subjects this voice reaches, fixed at `cue` and matched by identity; a probe of any other never
375
+ asks this voice. Takes the place of <code>target</code>, and a cue giving both is refused: a predicate over a
376
+ fixed list is that list filtered, which the host can do before cueing. */</span>
377
+ subjects?: readonly I[];
362
378
 
363
379
  <span class="cm">// clock</span>
364
380
  <span class="cm">/** When the voice starts, in ms on the clock the host passes <code>sync</code>. Default: the next sync. */</span>
@@ -389,6 +405,13 @@ interface Setting&lt;S = void&gt; {
389
405
  from?: 'current';
390
406
  <span class="cm">/** Words a source attaches to its voices, so their events can be drained as a set. */</span>
391
407
  tags?: readonly string[];
408
+ <span class="cm">// score</span>
409
+ <span class="cm">/** What other voices call this one by. A label blits never reads. */</span>
410
+ name?: string;
411
+ <span class="cm">/** The plan it belongs to; a bare name in an anchor means a voice in the same score. */</span>
412
+ score?: string;
413
+ <span class="cm">/** start or in, and out or end: each a timestamp or an anchor to another voice's mark. See Score. */</span>
414
+ anchor?: Placement;
392
415
  }
393
416
 
394
417
  interface Handle&lt;I&gt; {
@@ -431,6 +454,11 @@ interface MixOptions {
431
454
  maxDt?: number;
432
455
  <span class="cm">/** Run every `step` once per interval of this many ms instead of once by the frame's gap. Off by default. */</span>
433
456
  stepMs?: number;
457
+ <span class="cm">/** How far back `project` may read, and how often a stateful voice's state is copied on the way. Off by default. */</span>
458
+ history?: { ms: number; every?: number; inputs?: boolean };
459
+ <span class="cm">/** Whether a channel may run as a lane. On by default; the pose is the same either way, apart from
460
+ a stateless patch's `send` from `at` and a patch that starts keeping state partway through. */</span>
461
+ lanes?: boolean;
434
462
  }
435
463
 
436
464
  interface Mix&lt;I, O&gt; {
@@ -448,8 +476,17 @@ interface Mix&lt;I, O&gt; {
448
476
  rebase(): void;
449
477
  <span class="cm">/** The merged pose for one subject at the synced frame. `out` is written in place when given. */</span>
450
478
  probe(subject: I, out?: O): O;
479
+ <span class="cm">/** Each subject's pose written into one Float64Array per channel, in the order given: what probe(subject, out)
480
+ gives, without a pose object per subject. A channel of n numbers takes n places a subject. */</span>
481
+ pull(subjects: Iterable&lt;I&gt;, into: { [K in numeric channels of O]?: Float64Array }): void;
451
482
  <span class="cm">/** Every channel at rest for this subject this frame, so a host can skip the write. */</span>
452
483
  atRest(subject: I): boolean;
484
+ <span class="cm">/** The mix read at another timestamp without moving it: ahead plays what is cued forward, behind needs `history`. */</span>
485
+ project(timestamp: number): Projection&lt;I, O&gt;;
486
+ <span class="cm">/** Puts a named mark on a score, now or at a timestamp, for anchors to target. */</span>
487
+ announce(name: string, opts?: { at?: number; score?: string; tags?: readonly string[] }): void;
488
+ <span class="cm">/** Every mark the plan knows between two timestamps, earliest first. */</span>
489
+ marks(from: number, to: number): Marked[];
453
490
 
454
491
  <span class="cm">/** Anything still contributing, fading, or pending. A host stops its loop when this is false. `cue` and
455
492
  `blend` make it true again at once; the mix never calls back into the host, so restarting the loop is
@@ -457,13 +494,13 @@ interface Mix&lt;I, O&gt; {
457
494
  readonly live: boolean;
458
495
  <span class="cm">/** Fades every voice out: over `over` when given, over each voice's own `fade.out` otherwise. */</span>
459
496
  mute(opts?: { over?: number }): void;
460
- <span class="cm">/** Forgets per-subject state. Call when a subject is retired, or let the WeakMap do it. */</span>
497
+ <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>
461
498
  drop(subject: I): void;
462
499
  <span class="cm">/** Every event sent since the last drain, earliest first. Drain after probing. */</span>
463
500
  drain&lt;E&gt;(tag?: string): { timestamp: number; subject: I; voice: number; tags: readonly string[]; event: E }[];
464
501
  }</code></pre>
465
502
 
466
- <p>A probe walks the voices that may reach its subject, not every voice. With no <code>target</code> in the mix that is all of them, so nothing is kept; once a voice targets, each subject keeps the list of voices whose <code>target</code> took it, rebuilt when a voice is cued, starts or leaves. One voice per subject on a shared mix then costs one voice per probe: 1000 subjects, each with its own voice, took 95&nbsp;ms a frame before the list and 0.45&nbsp;ms after (<code>npm run bench</code>, rows <code>own</code>).</p>
503
+ <p>A probe walks the voices that may reach its subject, not every voice. With no <code>target</code> in the mix that is all of them, so nothing is kept; once a voice targets, each subject keeps the list of voices whose <code>target</code> took it, rebuilt when a voice is cued, starts or leaves. One voice per subject on a shared mix then costs one voice per probe: 1000 subjects, each with its own voice, took 95&nbsp;ms a frame before the list and 0.45&nbsp;ms after (<code>npm run bench</code>, rows <code>own</code>). Building the list still asks every <code>target</code> of every new subject, so the first frame of that scene took 500&ndash;580&nbsp;ms. A voice that names its subjects with <code>subjects</code> is filed under each of them at <code>cue</code>, and a subject's list is built from the voices filed under it and those that name none: the same scene's first frame took 1.0&nbsp;ms, and 10.6&nbsp;ms at 10,000 subjects (rows <code>named</code>).</p>
467
504
 
468
505
  <p>Subjects are not registered. State lives in a <code>WeakMap</code> keyed by the subject object, created on first probe and stepped by the gap in the timestamp since that subject was last stepped. That is the rule klieg's dwell arrived at, and it means a part a wrapper leaves unasked for a while catches up rather than resuming where it paused.</p>
469
506
 
@@ -492,7 +529,8 @@ function gate&lt;I&gt;(of: Signal&lt;I&gt;, at: number): Signal&lt;I&gt;;
492
529
  <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>
493
530
  <li><b>Time away is the host's to call.</b> A hidden tab gets no frames, so its next <code>sync</code> can be an hour past the last. By default that hour is a gap like any other: fades and finite loops finish, and every subject catches up by all of it. A host that calls <code>mix.rebase()</code> on <code>visibilitychange</code> says the gap does not count, and the mix clock &mdash; what <code>setting.timestamp</code> reads, and what voices, fades and subjects run on &mdash; resumes where it was left. It is then the host's timestamp less the time taken out, and a <code>start</code> the host gives is read on the host's clock and converted. <code>maxDt</code> caps the <code>dt</code> a <code>step</code> or signal is handed for any gap, the net an integrator wants; it is off by default because a patch with a closed form wants the whole gap.</li>
494
531
  <li><b>There is no frame rate, only an optional step interval.</b> A frame is a reading, so a stateless patch reads the same at any rate. A <code>step</code> handed the frame's gap does not: an integrator gives a different answer at 30 and 144&nbsp;fps. <code>stepMs</code> runs <code>step</code> once per whole interval instead, on a grid counted from when the subject's delay ran out, carrying the remainder, so the answer and every event's time are the same at any frame rate. <code>maxDt</code> then caps how many intervals one sample runs. Signals and <code>at</code> still read the frame. The host's clock is whatever it passes <code>sync</code>: a rAF timestamp and <code>performance.now()</code> share an origin, and <code>Date.now()</code> does not.</li>
495
- <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. This is the small half of weasel's events: no booking ahead against an outside clock, and no retraction.</li>
532
+ <li><b>Events come out by draining.</b> A patch calls <code>setting.send(event)</code>, usually from <code>step</code>, and the host calls <code>mix.drain()</code> after probing to take everything sent, earliest first. Events are made while a subject catches up, so a subject nobody probes has sent nothing yet, and promptness is the host's to buy by probing. Under <code>stepMs</code> each is stamped with the end of the interval it happened in, so the events and their times do not depend on how the probes are spaced. Time taken out by <code>rebase</code> sends nothing. The exception is a stateless patch sending from <code>at</code> on a lane: a lane fills every subject the mix has met, so it sends for each of them every frame, probed or not. This is the small half of weasel's events: no booking ahead against an outside clock, and no retraction.</li>
533
+ <li><b>A finite loop ends when its last pass does,</b> not at the frame that notices: its fade out counts from that moment, so it plays the same at any frame rate and a read at another time can find it. A fade's end and a handover's deadline are kept the same way.</li>
496
534
  <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>
497
535
  </ul>
498
536
 
@@ -514,23 +552,55 @@ function gate&lt;I&gt;(of: Signal&lt;I&gt;, at: number): Signal&lt;I&gt;;
514
552
  <p>A weight scales an influence toward its channel's rest. A lerp moves between two values. Every blend is one or the other, chosen by the channel. A consumer that needs a third arithmetic adds a channel, not a mix option.</p>
515
553
  </div>
516
554
 
517
- <h2 id="motion">Springs and glides</h2>
518
- <p>Motion that has momentum is not a curve over a known duration, so it is neither <code>keys</code> nor <code>Easing</code>. Two patches carry it, each solved in closed form per subject, so where a subject is at a given moment does not depend on how the frames that sample it were spaced. Each keeps one stretch per subject: released at a voice time from a position and a velocity, heading somewhere. A change mid-flight ends the stretch and starts the next from where the subject is <em>and how fast it is moving</em>, which is what keeps a retarget from kinking.</p>
555
+ <h2 id="motion">Springs, glides and tweens</h2>
556
+ <p>Motion that has momentum is not a curve over a known duration, so it is neither <code>keys</code> nor <code>Easing</code>. Two patches carry it, both of form <code>motion</code>, each solved in closed form per subject, so where a subject is at a given moment does not depend on how the frames that sample it were spaced. Each keeps one stretch per subject: released at a voice time from a position and a velocity, heading somewhere. A change mid-flight ends the stretch and starts the next from where the subject is <em>and how fast it is moving</em>, which is what keeps a retarget from kinking.</p>
519
557
  <pre><code>const s = spring&lt;Node, Pose&gt;('x', { from: 0, to: 100, stiffness: 170, damping: 26 });
520
558
  mix.cue({ patch: s });
521
559
  s.to(node, 40); <span class="cm">// heads elsewhere from wherever it is, keeping its velocity</span>
522
560
  s.push(node, 600); <span class="cm">// a kick: same target, new velocity</span>
523
561
  s.read(node); <span class="cm">// { value, velocity }, for handing the motion to another patch</span>
524
562
 
525
- const g = glide&lt;Node, Pose&gt;('x', { from: 0, velocity: 1200, ms: 325 }); <span class="cm">// a fling: rests 390 past the start</span></code></pre>
563
+ const g = glide&lt;Node, Pose&gt;('x', { from: 0, velocity: 1200, ms: 325 }); <span class="cm">// a fling: rests 390 past the start</span>
564
+
565
+ const t = tween&lt;Node, Pose&gt;('x', { from: (n) =&gt; n.x, to: (n) =&gt; n.goal, ms: 400, ease: 'ease-out' });
566
+ t.to(node, 40); <span class="cm">// from wherever it is, over a full 400 ms again</span></code></pre>
567
+ <p>A <b>tween</b> has no momentum: it is an easing over <code>ms</code> from one value to another. It is a motion patch rather than a <code>keys</code> one because its endpoints are each subject's own data, read once when the mix first meets the subject, and it retargets one subject at a time like a spring. A retarget starts from where the subject is and runs the easing again from 0, so its velocity jumps where a spring's would not. A voice's <code>loop</code> and <code>period</code> do not repeat it. It takes the easings <code>keys</code> takes; its velocity, which <code>read</code> reports, is the easing's slope, which a lane never computes, since a mix only wants the value.</p>
526
568
  <ul>
527
- <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 at the subject's next read; naming one is what makes a retarget land the same at any frame rate.</li>
528
- <li><b>History is one stretch.</b> A read earlier than the latest change evaluates the latest stretch backward, not what happened. Keeping more waits on the history horizon in <code>NOTES-ON-SCRUBBING.md</code>.</li>
529
- <li><b>A value 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.</li>
530
- <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.</li>
569
+ <li><b>Time is the voice's</b>, so <code>rate</code> and <code>seek</code> apply. A change takes effect at the voice time it names, or, named none, at the subject's own voice time at the mix's latest frame, whether or not the host probed it then; naming one is what makes a retarget land the same at any frame rate. A subject no frame of its voice has met yet, or still inside its stagger, takes an unnamed change at its first read, after any named change due by then. <code>read</code> with no time answers at that same latest frame, counting every change due by then, and answers nothing once the voice is gone.</li>
570
+ <li><b>History is kept to the mix's horizon.</b> Each subject keeps every stretch a read may still reach, so a read earlier than a change finds the stretch that was playing then. A mix without <code>history</code> keeps only the stretch playing now.</li>
571
+ <li><b>A spring or a glide settles.</b> Within <code>settle</code> (1e-4) of where it heads, and that slow, it lands there exactly, so <code>atRest</code> sees it. <code>settle: 0</code> keeps the curve exact. A tween needs none: it lands on its target exactly at <code>ms</code>.</li>
572
+ <li><b>The state is the patch's own</b>, per subject, rather than the mix's, because the host retargets through the patch. Cue each one on one voice. A patch moves every subject on the same number of axes, and throws on a subject, a <code>to</code> or a <code>push</code> with another, leaving the subject as it was.</li>
573
+ <li><b>The constants are data.</b> <code>patch.motion</code>, a <code>MotionSpec</code>, is <code>{ kind: 'spring', stiffness, damping, mass, settle }</code> or <code>{ kind: 'glide', ms, settle }</code> or <code>{ kind: 'tween', ms, ease }</code>, so an engine can run one without calling <code>at</code>. Where each subject starts and heads stays on the patch, since either may be a function of the subject.</li>
531
574
  <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>
532
575
  </ul>
533
576
 
577
+ <h2 id="score">Score</h2>
578
+ <p>The score is the plan: when each voice plays, for how long, and relative to which other voice. It never says what a voice contributes, which stays in the patch and the channels, and it never reads a channel's value. A voice has four <b>marks</b> on the mix clock: <code>start</code> (its fade in begins), <code>in</code> (full weight), <code>out</code> (its fade out begins) and <code>end</code> (gone).</p>
579
+ <pre><code>type Mark = 'start' | 'in' | 'out' | 'end';
580
+ interface Placement { start?: number | Anchor; in?: number | Anchor; out?: number | Anchor; end?: number | Anchor }
581
+ type Anchor =
582
+ | { of: string | Query; mark: Mark; by?: number }
583
+ | { after: string | Query; by?: number } <span class="cm">// its end</span>
584
+ | { with: string | Query; by?: number } <span class="cm">// its start</span>
585
+ | { before: string | Query; by?: number }; <span class="cm">// its start, less by</span>
586
+ interface Query { score?: string; name?: string; tag?: string; writes?: string; resolver?: 'last' | 'first' | 'next' | 'earliest' | 'latest' }
587
+
588
+ interface Projection&lt;I, O&gt; {
589
+ readonly timestamp: number;
590
+ probe(subject: I, out?: O): O;
591
+ assess(subject: I): { [K in keyof O]-?: 'exact' | 'stepped' | 'held' };
592
+ }</code></pre>
593
+ <ul>
594
+ <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>
595
+ <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>
596
+ <li><b>Waiting and following.</b> An anchor with no answer &mdash; a name not cued, the <code>out</code> of a voice that loops for good &mdash; leaves the voice <code>pending</code>. While it waits it follows its target, so a target faded early brings it forward; once it starts, its start is fixed. An <code>out</code> or <code>end</code> follows until the fade begins. A target that has left keeps the time it last gave, and can be anchored to fresh only while the mix's history reaches it. An anchored mark already past starts the voice partway through. A placement that waits on itself is refused at <code>cue</code>.</li>
597
+ <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>
598
+ <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>
599
+ <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>
600
+ <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>
601
+ <li><b>Cost.</b> A projection copies each subject's record on first probe: about 3&nbsp;ms for 1000 subjects under three voices, three times a live frame (<code>npm run bench</code>, rows <code>ahead</code> and <code>back</code>). That is cheap enough to make one per frame for a continuous scrub. History costs one control entry per handle change and one copy per stateful subject per <code>every</code>, all dropped past the horizon.</li>
602
+ </ul>
603
+
534
604
  <h2 id="state">Patch state</h2>
535
605
  <p>klieg built three stateful patches in one week, <code>power</code>, <code>kicks</code> and <code>dwell</code>, and each rediscovered the same three rules: key state by the subject object, step once per frame by the gap in the timestamp, snap under infinite <code>dt</code>. Those rules are the package's, applied by the mix, and a patch declares only what its state is and how it advances.</p>
536
606
 
@@ -550,21 +620,41 @@ const g = glide&lt;Node, Pose&gt;('x', { from: 0, velocity: 1200, ms: 325 }); <
550
620
  <pre><code>interface Engine {
551
621
  readonly name: string;
552
622
  <span class="cm">/** Which patch forms this engine can run. A voice whose patch it cannot run is refused at `cue`, by name. */</span>
553
- readonly runs: ReadonlySet&lt;'fn' | 'keys'&gt;;
623
+ readonly runs: ReadonlySet&lt;'fn' | 'keys' | 'motion'&gt;;
554
624
  <span class="cm">/** Builds the mix. Everything on `Mix` is the engine's to implement. */</span>
555
625
  create&lt;I, O&gt;(kit: Kit&lt;O&gt;, opts: MixOptions): Mix&lt;I, O&gt;;
556
626
  }
557
627
 
558
628
  export const mixer: Engine; <span class="cm">// the one that ships</span></code></pre>
559
629
 
560
- <p><b>mixer</b> runs both forms on the CPU, samples per subject on demand, and is the extraction of what klieg does today. It folds each influence into the subject's accumulator as it probes, rather than collecting every layer and merging at the end as klieg's <code>EffectFrame</code> does: weights are scalars that no pose depends on, so they resolve for the subject first, and a locus folds to one influence before that influence is merged. That order is what would let a later revision hand patches one scratch object to write into instead of returning a fresh partial per patch per subject per frame, which <code>writes</code> makes safe to hand out without clearing. What later engines would look like, so the seam is judged against something real and none of them is being built:</p>
630
+ <p><b>mixer</b> runs every form on the CPU, samples per subject on demand except where a channel runs as a lane (<a href="#lanes">below</a>), and is the extraction of what klieg does today. It folds each influence into the subject's accumulator as it probes, rather than collecting every layer and merging at the end as klieg's <code>EffectFrame</code> does: weights are scalars that no pose depends on, so they resolve for the subject first, and a locus folds to one influence before that influence is merged. That order is what would let a later revision hand patches one scratch object to write into instead of returning a fresh partial per patch per subject per frame, which <code>writes</code> makes safe to hand out without clearing. What later engines would look like, so the seam is judged against something real and none of them is being built:</p>
561
631
  <ul>
562
- <li><b>A baked engine</b> runs <code>keys</code> voices only and emits Web Animations or CSS keyframes, so a DOM consumer like wod gets compositor-thread playback. It refuses <code>fn</code> patches at <code>cue</code>.</li>
632
+ <li><b>A baked engine</b> runs <code>keys</code> voices only and emits Web Animations or CSS keyframes, so a DOM consumer like wod gets compositor-thread playback. It refuses <code>fn</code> and <code>motion</code> patches at <code>cue</code>.</li>
563
633
  <li><b>A recording engine</b> wraps another engine and captures every pose into keyframes, turning a procedural patch into data for the baked one.</li>
564
634
  <li><b>A GPU engine</b> writes per-subject deltas into a buffer instead of returning them, for a part count where a JS loop per frame is the cost. Same kit, same voices; <code>probe</code> becomes a read from the buffer.</li>
565
635
  </ul>
566
636
  <p>The seam holds only if nothing outside the engine assumes probing is a function call. Two consequences for the schema: <code>probe</code> takes an <code>out</code> parameter so a buffer-backed engine can fill in place, and <code>keys</code> patches keep their stops on the object rather than closing over them.</p>
567
637
 
638
+ <h2 id="lanes">Lanes</h2>
639
+ <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>
640
+ <div class="tbl"><table>
641
+ <tr><th></th><th>runs on a lane</th><th>takes the general path</th></tr>
642
+ <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>
643
+ <tr><td>patch</td><td><code>keys</code>; <code>fn</code> with no <code>state</code> and no <code>step</code>; <code>motion</code></td><td>a stateful <code>fn</code>, or one once it calls <code>setting.keep</code>; <code>keys</code> whose <code>lerpBy</code> is not the channel's own <code>lerp</code>; under <code>history.inputs</code>, a patch that <code>reads</code> host fields</td></tr>
644
+ <tr><td>voice</td><td>a number weight, with fades and <code>mute</code>; every subject, <code>target</code> or <code>subjects</code>; <code>stagger</code>, <code>loop</code>, <code>rate</code>, <code>ramp</code>, <code>seek</code>, anchors</td><td>a <code>Signal</code> weight, <code>locus</code>, <code>from: 'current'</code>, <code>fade({ at: 'rest' })</code></td></tr>
645
+ </table></div>
646
+ <p>A channel is a lane only while every voice writing it qualifies, and a voice runs on lanes only while every channel it writes is one, so no patch is called twice in a frame. The mix works this out again when a voice is cued or retires, starts a fade to rest, or first keeps state; never per probe. A projection always reads through the general path.</p>
647
+ <ul>
648
+ <li><b>Subjects are numbered on first sight.</b> A subject's first probe takes the general path, where <code>target</code>, <code>stagger</code> and the fade-in origin are worked out, and gives it a number; so does each voice's first meeting with it later. <code>drop</code> frees the number, and for an object subject so does the host letting it go. A subject the host never probes is never numbered and costs a lane nothing.</li>
649
+ <li><b>The first probe after a <code>sync</code> fills every lane</b>, for every numbered subject each laned voice reaches, then copies the subject's values into the pose; later probes that frame only copy. A cue, retirement or handle change between two probes refills at the next one. So a frame costs the fill plus a probe per subject the host reads.</li>
650
+ <li><b>A lane rests while its subjects go unprobed.</b> When fewer than 15% of a <code>keys</code> lane's subjects were probed in the last frame with a probe, or fewer than 60% of a <code>fn</code> or <code>motion</code> lane's, the lane stops filling and probes of its subjects take the general path; it fills again once that share reaches 25%, or 80%. Those lines sit either side of where filling and probing break even, measured at 1,000 and 10,000 subjects: about 19% for <code>keys</code>, 71&ndash;77% for a spring, and 63&ndash;100% for a stateless <code>fn</code>. Both paths give the same pose, so the switch only changes the cost.</li>
651
+ <li><b>Lanes give the same pose, to the bit.</b> Both paths call the same functions for phase, envelope, weight clamp, each stock channel's fold, the easing curves and the spring solver, and clamp <code>bounds</code> the same way after. <code>test/lanes.test.ts</code> runs every case both ways and compares each probe with <code>Object.is</code>. <code>weightOf</code>, <code>atRest</code> and a finite loop's end read what the fill keeps.</li>
652
+ <li><b>Two things differ, both documented on the API.</b> A stateless patch calling <code>setting.send</code> from <code>at</code> sends every frame for every subject a lane fills, probed or not. A patch that first calls <code>setting.keep</code> partway through playing, from a call for a subject the host did not probe that frame, advances that state once more for that subject than the general path would; a patch that keeps state from its first call, or declares <code>state</code>, never does.</li>
653
+ <li><b>Motion runs on lanes as data.</b> <code>spring</code>, <code>glide</code> and <code>tween</code> are their own form so a lane can sample each subject's stretch from the patch's arrays rather than calling <code>at</code>. A fill samples only where sampling changes nothing; a subject with a change to apply is left to the general path, so a retarget lands where it would with lanes off.</li>
654
+ </ul>
655
+ <p>What they cost, measured on the fleet on 2026-10-02 against the build before them (the table is in <code>spikes/gpu-engine/README.md</code>, under Lanes, as built): three <code>keys</code> voices over 10,000 subjects fell from 6.1 to 2.6&nbsp;ms a frame, and a stateless <code>fn</code> gained up to 37%. A spring frame first rose from 4.3 to 5.4&nbsp;ms; once the spring solver stopped allocating per sample, a rerun put a spring frame at 0.53&ndash;0.72 of the earlier build's, <code>keys</code> at 0.38&ndash;0.57 and a stateless <code>fn</code> at 0.70&ndash;0.88. A host that probed 5% of the subjects it had met paid for all of them, 1.3&nbsp;ms against 0.1 at 10,000, until resting lanes brought that to about 0.15&nbsp;ms (one local machine). And a probe costs about 110&ndash;140&nbsp;ns at 10,000 subjects even when a lane did all the work, so a host probing every subject pays 1.1&ndash;1.4&nbsp;ms before any arithmetic.</p>
656
+ <p><b>Reading in bulk.</b> <code>mix.pull(subjects, { position, gain })</code> writes each subject's values into the host's own arrays instead of a pose object. It does everything a probe does for each subject in turn, so it gives the same values, to the bit; <code>test/lanes.test.ts</code> runs every case through it too. While every voice is on a lane, a subject the lanes filled is copied straight from them; any other subject is folded through the general path into one scratch pose and copied from that. Measured on the fleet on 2026-10-02 (msb-uai, three passes of <code>bench/frame.mjs</code>), a frame read through <code>pull</code> costs 0.84 of one read through <code>probe(subject, out)</code> for three <code>keys</code> voices over 10,000 subjects, 0.89 at 1,000, 0.91 for a spring and 0.93 for a stateless <code>fn</code>. What is left of a probe&rsquo;s cost is per subject: finding its record, and the lane bookkeeping that decides which subjects a fill skips. Taking that out means keeping subject numbers between calls and doing the bookkeeping for a whole call at once; it is not built.</p>
657
+
568
658
  <h2 id="kits">Channel kits</h2>
569
659
  <p>What each consumer declares. Each row is one system &mdash; a kit, the subject type it drives, and the mix over them &mdash; and the rules are the ones each repo already applies; the kit makes them one line each.</p>
570
660
  <div class="tbl"><table>
@@ -583,7 +673,7 @@ export const mixer: Engine; <span class="cm">// the one that ships</span></cod
583
673
  <li><b>A published patch names its kit.</b> <code>patch(…, { kit })</code> and <code>keys(…, { kit })</code> record the channels the patch was written against, and <code>cue</code> refuses a mix whose channel of the same name is another <code>kind</code>, naming both. Without it, a <code>sum</code> offset cued into a mix whose <code>offset</code> multiplies would fold wrong and say nothing. A custom channel has no kind and matches only itself, so a package that defines one exports the object for hosts to put in their kits.</li>
584
674
  <li><b>A published patch names what it reads from the host.</b> <code>reads: ['pointer']</code>, and <code>cue</code> refuses a mix whose <code>host</code> lacks the field. A patch meant for anyone reads nothing from the host.</li>
585
675
  <li><b>A package tags its voices and drains by its tag.</b> <code>cue({ …, tags: ['magicsmoke'] })</code>, then <code>mix.drain('magicsmoke')</code> takes that package's events and leaves every other package's queued. A tag is the vocabulary's word for a label a source attaches so a query can select voices as a set; draining is its first query.</li>
586
- <li><b>A package that sends events publishes what handles them.</b> blits mixes numbers and times events; it draws nothing. A patch that only sends is half a piece, and the other half is the package's renderer, which the host would hand the drained events. Nothing works this way yet: magicsmoke keeps its fault mix private, and the host calls <code>smoke.cue</code> and <code>smoke.sync</code> while magicsmoke syncs, probes, drains and renders inside. Exporting the patch and the renderer separately, for a host that owns the mix, is possible later and not designed.</li>
676
+ <li><b>A package that sends events publishes what handles them.</b> blits mixes numbers and times events; it draws nothing. A patch that only sends is half a piece, and the other half is the package's renderer, which the host would hand the drained events. The first is <code>@msb235/blits-quarks</code> in <code>packages/quarks</code>: a patch sends <code>{ count }</code> under a tag, and the driver drains that tag and emits the particles at the event's subject. magicsmoke still keeps its fault mix private, and the host calls <code>smoke.cue</code> and <code>smoke.sync</code> while magicsmoke syncs, probes, drains and renders inside. Exporting the patch and the renderer separately, for a host that owns the mix, is possible later and not designed.</li>
587
677
  </ul>
588
678
  <p>A host adds channels of its own beside the shared ones: a kit is a plain object, so <code>kit({ ...shared, light: vec(3, sum()), dark: max() })</code> is the whole mechanism, as sherpa's <code>transform</code> already is. Two conventions keep that safe. A host's own channel never takes a name the shared list holds, so klieg's <code>light</code> stays <code>light</code> only while the list has none. A package that names a kind of its own prefixes it with the package, <code>klieg:light</code> rather than <code>light</code>, because <code>cue</code> trusts two equal kinds to fold the same and nothing stops two packages claiming one bare word for different arithmetic.</p>
589
679
 
@@ -625,16 +715,18 @@ export const mixer: Engine; <span class="cm">// the one that ships</span></cod
625
715
  <li><b>Locus fold.</b> Two voices in a locus driving the same value produce that value, on every stock channel &mdash; the invariant the old cap met on <code>sum</code> alone. At unequal values they produce the channel's <code>lerp</code> at their weight ratio, which on <code>sum</code> is the number scaling and merging already gives and is what keeps klieg's baselines green. A locus whose weights sum below 1 scales the folded value toward rest by the sum; above 1 it caps at 1.</li>
626
716
  <li><b>Handover.</b> <code>fade({ at: 'rest' })</code> removes per subject at the first resting frame, and at the deadline for a patch that never rests.</li>
627
717
  <li><b>Declared writes.</b> A patch's <code>at</code> sets no key outside its <code>writes</code>, on every subject and every pass. A voice whose patch writes a channel the kit lacks throws at <code>cue</code> naming both.</li>
718
+ <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>
719
+ <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>
720
+ <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>
628
721
  <li><b>Engine refusal.</b> An engine whose <code>runs</code> lacks <code>fn</code> throws at <code>cue</code> naming the voice.</li>
629
722
  <li><b>The klieg extraction</b> is judged by klieg's own suites and the Playwright baselines, unchanged.</li>
630
723
  </ul>
631
724
 
632
725
  <h2 id="open">Open</h2>
633
726
  <ul>
634
- <li><b>The shared channel list.</b> Channels a second package might write onto another's subject, fixed with a kind, a unit and a rest so that hosts and packages agree past what <code>kind</code> checks. Candidates, names unpicked: <code>opacity</code> mul 0..1; <code>scale</code> mul factor; <code>position</code> vec(3, sum) world units; <code>rotation</code> vec(3, sum) radians; <code>bump</code> vec(2, sum) CSS px, picked on 2026-09-30 over <code>offset</code>, which blits already split into delta and pose; <code>turn</code> sum radians, the flat rotation, counting past 2π so a wheel can spin many times, which is why it blends in a straight line &mdash; an orientation that wraps, where 350° and 10° must meet at 0°, needs its own channel with a <code>lerp</code> the short way round, and weasel's rig plans one; <code>color</code> hex sRGB. Undecided too: whether wod's radial push takes a shared name, and whether sherpa's <code>clip</code> and wod's <code>aperture</code> are one shared <code>reveal</code>.</li>
635
- <li><b>How the score comes inside.</b> Decided 2026-09-29: it comes inside, provided it depends on no implementation. It places voices from any number of unrelated sources and knows when each plays, for how long, at what weight and relative to which other voice &mdash; never what a voice contributes. Meaning stays in the patches and the kit&rsquo;s channels, which is the line the fold already draws. Scores compose dynamically, favored where it holds up: each source keeps its own, the mix reads every score it holds the way a DAW plays every track, and a source joins or leaves without touching anyone else&rsquo;s plan. One shared score is the case with one source. Until now a score was a word for a seam blits does not own: blits takes timing as given and reads only what has been cued, so it sees no further ahead than its pending voices, and a host that knows what is coming does the arithmetic itself &mdash; to be faded in by <code>T</code>, it cues with <code>start: T − fade.in</code>. A score inside blits would be data: voices placed on the mix clock, at a time or relative to one another (after, with, before, fully in by), which the mix plays by reading it at the current timestamp, the way a DAW plays its arrangement at the playhead. The fold does not change, since the mix already folds any number of voices at a time. What changes is that the whole plan can be read at any time: <code>mix.project(at)</code> for what is coming, and, because the plan is kept rather than used up, a way back that the mix&rsquo;s lack of history rules out today. A series, voices that hand over in order, is one arrangement a score can hold rather than an object of its own. Reading at another time is where state gets in the way, and the approach is for the mix to own all of it. Signals keep their state in the mix through <code>setting.keep</code> rather than in a closure (built 2026-09-29), so a read at another time copies it instead of moving the live signal. A read ahead copies the state (<code>structuredClone</code> by default, a <code>clone</code> on the patch or signal where the state is not plain data), steps it to <code>at</code>, probes, and throws the copy away. It takes one <code>step</code> per interval between the events the score already knows &mdash; a voice starting, a fade ending, a loop running out &mdash; since any of them can change what a stateful signal follows, and each step covers its whole interval, however long. That leans on a contract the whole-gap rule already assumes and nothing states: one step across an interval is as good as any number of steps across it. Steps need not be uniform or frame by frame. An answer is exact across known events. An input the clock drives continuously, such as a slew following a sine, is exact only to the length of the step, which is an error of stepping rather than of knowledge: a finer split, or a patch or signal that gives its own closed form for a long gap, removes it. An input from outside the clock &mdash; a <code>level</code> the host sets, a pointer &mdash; has no known future, so it is held where it is and the answer says <code>held</code>. Reading back is the same read from an earlier start: the nearest state known before <code>t</code>, stepped forward to it. Voices without state need none, since the score keeps the plan. Stateful ones start from a checkpoint where history holds one, and otherwise from the voice&rsquo;s own start, where its state was made fresh, exact where only the clock drove it. An outside input&rsquo;s past is known where history recorded it and <code>held</code> where it did not, the same label as the future. So how much history the mix keeps, checkpoints and recorded inputs and the voices that have left, is a cost to tune and not a design question: it moves where answers turn <code>held</code>, never whether a read works. The proposed shape of a placement, unbuilt: a voice has four marks on the mix clock &mdash; <code>start</code> (fade-in begins), <code>in</code> (full weight), <code>out</code> (fade-out begins) and <code>end</code> (gone) &mdash; and a cue anchors at most one mark on each side, <code>start</code> or <code>in</code> and <code>out</code> or <code>end</code>, to a time or to another voice&rsquo;s mark with an offset: <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. A voice is named by a <code>name</code> its source gives it, a label blits never reads. Names are namespaced by score: a bare name names a voice in the same score, and <code>{ score, name }</code> one in another, so a score carries a name of its own. A mix-wide namespace can come later for convenience, if placing across scores feels awkward without it. What an anchor takes is a query over the merged score that answers with a time, rerun while it is bound: a selector (name, score, the channels a voice <code>writes</code>, and tags a source attaches), a mark, and, where more than one voice matches, a resolver (<code>next</code>, <code>first</code>, <code>last</code>, <code>earliest</code>, <code>latest</code>). A name is the simplest selector; <code>anchor: { in: { before: { writes: 'gain', next: true }, by: 200 } }</code> is fully in 200&nbsp;ms before the next voice that writes <code>gain</code>. A selector reads only what the score knows &mdash; never a channel&rsquo;s value, so &ldquo;when gain crosses 0.5&rdquo; is refused rather than searched for. The same queries answer what is playing at a time and where the next event is, which is where a read ahead splits its steps. A read ahead is a projection: <code>m.project(t)</code> returns one with <code>probe(subject, out?)</code> and <code>assess(subject)</code>, which says per channel whether the answer is <code>exact</code>, <code>stepped</code> or <code>held</code>, the least sure voice that fed the channel deciding. A projection at an earlier timestamp throws until reading back exists. A patch or signal that says nothing is taken as driven by the clock; one that reads outside input declares it, as <code>level</code> and anything built on host input do. Decided 2026-09-29: the other default would read every existing <code>fn</code> patch as <code>held</code>, and an assessment is only as honest as the declarations either way. A locus and a tag are the same thing twice over &mdash; a string several voices share to make a set &mdash; and differ only in what the mix does with the set, so they become one: a voice carries tags, and a tag can be declared to fold its voices as alternatives, through each channel&rsquo;s <code>lerp</code> by share, which is what <code>locus</code> does today. A voice blends in at most one such tag; <code>blend</code> declares one for its voices, so a crossfade stays one call. Proposed 2026-09-29, unbuilt: <code>VoiceSpec.locus</code> is still what runs. An anchor can also target an <b>event</b>: a named instant a source raises, which blits never interprets, only times. One scheduled in a score is known ahead, so a read ahead sees it and a voice can arrive by it; one the host fires when something happens, such as a click or a reply, is unknown until it fires, so a voice anchored to it waits pending and a read ahead answers <code>held</code> for whatever depends on it. Marks and events are one concept (decided 2026-09-29): a voice is a source that raises four events about itself, its <code>start</code>, <code>in</code>, <code>out</code> and <code>end</code>, so an anchor has one kind of target and one query selects events whoever raised them. A mark placed on a voice, such as the hit 340&nbsp;ms in, is one more event that voice raises. The <code>out</code> of a voice that loops for good is unknown until something fades it, and waits exactly as an event the host has not fired yet does. What fires an event on a condition is a <b>trigger</b>, and a trigger on a channel&rsquo;s value belongs to the host: blits does not read values, so the host watches the condition and fires the event. Proposed 2026-09-29, unbuilt. An anchor whose query has no answer yet &mdash; a name not cued, no voice matching, the <code>out</code> of a voice that loops for good &mdash; leaves the voice <code>pending</code> until it resolves; a cycle is refused at <code>cue</code>; and an anchored mark already past starts the voice partway through, the way playback starts in the middle of a region. A voice placed relative to another resolves against the mix&rsquo;s merged view of every score it holds: it follows its target while that voice is there, however it is sought, faded or retimed, and keeps the time it last resolved to once the target leaves, whichever score the target belonged to.</li>
727
+ <li><b>The shared channel list.</b> Channels a second package might write onto another's subject, fixed with a kind, a unit and a rest so that hosts and packages agree past what <code>kind</code> checks. Candidates, names unpicked: <code>opacity</code> mul 0..1; <code>scale</code> mul factor; <code>position</code> vec(3, sum) world units; <code>rotation</code> vec(3, sum) radians; <code>bump</code> vec(2, sum) CSS px, picked on 2026-09-30 over <code>offset</code>, which blits already split into delta and pose; <code>turn</code> sum radians, the flat rotation, counting past 2π so a wheel can spin many times, which is why it blends in a straight line &mdash; an orientation that wraps, where 350° and 10° must meet at 0°, needs its own channel with a <code>lerp</code> the short way round, and weasel's rig plans one; <code>color</code> 0xrrggbb sRGB, blended in OKLCH. Undecided too: whether wod's radial push takes a shared name, and whether sherpa's <code>clip</code> and wod's <code>aperture</code> are one shared <code>reveal</code>.</li>
728
+ <li><b>What the score still lacks.</b> The score is built (see Score). Decided 2026-09-29 and not built: <b>marks placed inside a voice</b>, such as a hit 340&nbsp;ms in, as named marks beside its four. <b>Tags absorb loci</b>: a tag declared to fold its voices as alternatives does what <code>locus</code> does now; where the declaration lives is not decided. <b>Splitting a read ahead at known events</b>, one step per interval between a voice starting, a fade ending and a loop running out; today a read ahead crosses the gap in one step and says <code>stepped</code> where that matters.</li>
636
729
  <li><b>The package name.</b> Published as <code>@msb235/blits</code> for now, decided 2026-09-30, while npm answers a request for the bare <code>blits</code>, which the registry 404s. Moving to <code>blits</code> later changes every import. <code>@lightningjs/blits</code> is a live TV app framework with its own animation plugin, so searches for the name land there first.</li>
637
- <li><b>Color's lerp.</b> The stock <code>hex</code> channel ships sRGB, which is what klieg's light channel assumes and what keeps the port's arithmetic identical. OKLCH reads better for a crossfade between hues and would replace it rather than join it; the choice waits on seeing a hue crossfade in the port.</li>
638
730
  <li><b>Whether <code>at</code> writes into a buffer: no, it returns.</b> A patch allocates one partial delta per subject per frame, and <code>writes</code> plus the fold order would let the mix hand it one scratch object instead. The port measured what that would buy: a frame of klieg&rsquo;s motion — 12 letters through four voices — costs 0.017&nbsp;ms, and a frame of its effects — 60 parts under three effects — costs 0.134&nbsp;ms, against a 16.7&nbsp;ms budget. Under 1% of a frame is not worth the nicest property <code>at</code> has, which is being a pure function a test calls once. The p99 of the effects frame is 1.3&nbsp;ms, which is the collector catching up on exactly these allocations, so a subject count an order of magnitude past klieg&rsquo;s would reopen it. <code>packages/core/test/bench/frame-cost.bench.ts</code> in klieg is the measurement</li>
639
731
  <li><b>The stock band's width.</b> Whether <code>gate</code>'s default band is symmetric around <code>at</code> and how wide it is. It has to clear the frame noise in a real signal, so it is a measurement from klieg's <code>near</code> and <code>dwell</code>, not a guess.</li>
640
732
  </ul>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@msb235/blits",
3
- "version": "0.2.1",
3
+ "version": "0.3.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",
@@ -37,7 +37,7 @@
37
37
  "build": "tsc -b",
38
38
  "test": "vitest run",
39
39
  "test:watch": "vitest",
40
- "typecheck": "tsc -b && tsc -p tsconfig.test.json",
40
+ "typecheck": "tsc -b && tsc -p tsconfig.test.json && tsc -b packages/quarks && tsc -p packages/quarks/tsconfig.test.json",
41
41
  "lint": "biome check .",
42
42
  "check": "npm run lint && npm run typecheck && npm run test",
43
43
  "prepack": "npm run build",
@@ -52,8 +52,5 @@
52
52
  "@types/node": "^26.2.0",
53
53
  "typescript": "^7.0.2",
54
54
  "vitest": "^4.1.10"
55
- },
56
- "workspaces": [
57
- "site"
58
- ]
55
+ }
59
56
  }