@msb235/blits 0.0.0-stage → 0.1.1

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.
@@ -0,0 +1,622 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
6
+ <meta name="color-scheme" content="light dark">
7
+ <title>blits schema</title>
8
+ <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Instrument+Sans:ital,wght@0,400;0,500;0,600;1,400&family=Literata:ital,opsz,wght@0,7..72,400;0,7..72,500;1,7..72,400&family=JetBrains+Mono:wght@400;500&display=swap">
9
+ <style>
10
+ :root {
11
+ --ground: #f6f4ef;
12
+ --ink: #1d1a16;
13
+ --muted: #6b645a;
14
+ --rule: #ddd7cb;
15
+ --accent: #b8641a;
16
+ --accent-ink: #7a4110;
17
+ --code-bg: #eeeae1;
18
+ --code-ink: #2a2622;
19
+ --mark: #fff3dc;
20
+ --nav-bg: #f0ece3;
21
+ }
22
+ @media (prefers-color-scheme: dark) {
23
+ :root:not([data-theme="light"]) {
24
+ --ground: #15130f;
25
+ --ink: #ece6da;
26
+ --muted: #9a9081;
27
+ --rule: #2c2822;
28
+ --accent: #e8a24a;
29
+ --accent-ink: #f2c179;
30
+ --code-bg: #1e1b16;
31
+ --code-ink: #e2dccf;
32
+ --mark: #2a2214;
33
+ --nav-bg: #1a1713;
34
+ }
35
+ }
36
+ :root[data-theme="dark"] {
37
+ --ground: #15130f;
38
+ --ink: #ece6da;
39
+ --muted: #9a9081;
40
+ --rule: #2c2822;
41
+ --accent: #e8a24a;
42
+ --accent-ink: #f2c179;
43
+ --code-bg: #1e1b16;
44
+ --code-ink: #e2dccf;
45
+ --mark: #2a2214;
46
+ --nav-bg: #1a1713;
47
+ }
48
+
49
+ body {
50
+ background: var(--ground);
51
+ color: var(--ink);
52
+ font-family: Literata, Georgia, 'Times New Roman', serif;
53
+ font-size: 17px;
54
+ line-height: 1.55;
55
+ padding-inline: 16px;
56
+ padding-block: 0 64px;
57
+ }
58
+ .page {
59
+ max-width: 1080px;
60
+ margin: 0 auto;
61
+ display: grid;
62
+ grid-template-columns: 1fr;
63
+ gap: 32px;
64
+ }
65
+ @media (min-width: 960px) {
66
+ .page { grid-template-columns: 200px minmax(0, 72ch); gap: 56px; }
67
+ nav.toc { position: sticky; top: env(safe-area-inset-top, 0px); align-self: start; max-height: 100vh; overflow-y: auto; padding-top: 48px; }
68
+ }
69
+
70
+ h1, h2, h3, nav, .eyebrow, th, dt, .status { font-family: 'Instrument Sans', 'Helvetica Neue', Arial, sans-serif; }
71
+ h1 { font-size: 2.4rem; font-weight: 600; letter-spacing: -0.01em; line-height: 1.05; margin: 0 0 8px; text-wrap: balance; }
72
+ h2 { font-size: 1.35rem; font-weight: 600; margin: 56px 0 12px; padding-top: 16px; border-top: 1px solid var(--rule); text-wrap: balance; }
73
+ h3 { font-size: 1.05rem; font-weight: 600; margin: 28px 0 8px; }
74
+ p { margin: 0 0 14px; }
75
+ header { padding-top: 48px; }
76
+ .eyebrow { text-transform: uppercase; letter-spacing: 0.08em; font-size: 0.72rem; color: var(--accent); font-weight: 600; margin-bottom: 12px; }
77
+ .status { display: inline-block; font-size: 0.78rem; font-weight: 500; color: var(--accent-ink); background: var(--mark); padding: 3px 9px; border-radius: 3px; margin-top: 10px; }
78
+ .lede { font-size: 1.05rem; color: var(--ink); }
79
+ .meta { color: var(--muted); font-size: 0.95rem; }
80
+ .meta b { color: var(--ink); font-weight: 500; }
81
+
82
+ nav.toc ol { list-style: none; margin: 0; padding: 0; font-size: 0.86rem; line-height: 1.4; }
83
+ nav.toc li { margin: 0 0 7px; }
84
+ nav.toc a { color: var(--muted); text-decoration: none; }
85
+ nav.toc a:hover, nav.toc a:focus-visible { color: var(--accent); }
86
+ nav.toc .grp { color: var(--ink); font-weight: 600; margin-top: 14px; font-size: 0.72rem; letter-spacing: 0.06em; text-transform: uppercase; }
87
+
88
+ a { color: var(--accent-ink); }
89
+ a:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
90
+
91
+ pre, code { font-family: 'JetBrains Mono', 'SF Mono', Menlo, monospace; }
92
+ code { font-size: 0.86em; background: var(--code-bg); color: var(--code-ink); padding: 1px 5px; border-radius: 3px; }
93
+ pre { background: var(--code-bg); color: var(--code-ink); padding: 14px 16px; border-radius: 4px; overflow-x: auto; font-size: 0.8rem; line-height: 1.5; margin: 0 0 18px; }
94
+ pre code { background: none; padding: 0; font-size: inherit; }
95
+ .cm { color: var(--muted); }
96
+
97
+ .tbl { overflow-x: auto; margin: 0 0 18px; }
98
+ table { border-collapse: collapse; width: 100%; font-size: 0.92rem; }
99
+ th, td { text-align: left; vertical-align: top; padding: 8px 10px 8px 0; border-bottom: 1px solid var(--rule); }
100
+ th { font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); font-weight: 600; }
101
+ td code { white-space: nowrap; }
102
+ td.num { font-variant-numeric: tabular-nums; text-align: right; }
103
+
104
+ dl { margin: 0 0 18px; display: grid; grid-template-columns: max-content 1fr; gap: 6px 18px; }
105
+ dt { font-weight: 600; font-size: 0.95rem; padding-top: 2px; }
106
+ dd { margin: 0; }
107
+ @media (max-width: 560px) { dl { grid-template-columns: 1fr; gap: 2px; } dd { margin-bottom: 10px; } }
108
+
109
+ ul { padding-left: 1.2em; margin: 0 0 14px; }
110
+ li { margin-bottom: 6px; }
111
+ li > b, p > b { font-weight: 500; }
112
+
113
+ figure { margin: 18px 0 22px; }
114
+ figure svg { width: 100%; height: auto; display: block; }
115
+ figcaption { font-size: 0.85rem; color: var(--muted); margin-top: 8px; font-family: 'Instrument Sans', sans-serif; }
116
+ .dia text { font-family: 'Instrument Sans', sans-serif; font-size: 12px; fill: var(--ink); }
117
+ .dia .sub { fill: var(--muted); font-size: 10.5px; }
118
+ .dia .box { fill: var(--code-bg); stroke: var(--rule); }
119
+ .dia .hot { stroke: var(--accent); }
120
+ .dia .arrow { stroke: var(--muted); fill: none; }
121
+ .dia .head { fill: var(--muted); }
122
+
123
+ .callout { border-left: 3px solid var(--accent); padding: 4px 0 4px 14px; margin: 0 0 18px; color: var(--ink); }
124
+ .callout .eyebrow { margin-bottom: 4px; }
125
+ </style>
126
+ </head>
127
+ <body>
128
+
129
+ <div class="page">
130
+ <nav class="toc" aria-label="Sections">
131
+ <ol>
132
+ <li class="grp">Shape</li>
133
+ <li><a href="#picture">In one picture</a></li>
134
+ <li><a href="#vocab">Vocabulary</a></li>
135
+ <li class="grp">Schema</li>
136
+ <li><a href="#channels">Channels</a></li>
137
+ <li><a href="#patches">Patches</a></li>
138
+ <li><a href="#voices">Voices</a></li>
139
+ <li><a href="#mix">Mix</a></li>
140
+ <li><a href="#signals">Signals</a></li>
141
+ <li class="grp">Behavior</li>
142
+ <li><a href="#time">Time</a></li>
143
+ <li><a href="#blend">Blending</a></li>
144
+ <li><a href="#state">Patch state</a></li>
145
+ <li><a href="#engines">Engines</a></li>
146
+ <li class="grp">Consumers</li>
147
+ <li><a href="#kits">Channel kits</a></li>
148
+ <li><a href="#klieg">The klieg port</a></li>
149
+ <li class="grp">Record</li>
150
+ <li><a href="#against">Decided against</a></li>
151
+ <li><a href="#tests">Tests</a></li>
152
+ <li><a href="#open">Open</a></li>
153
+ </ol>
154
+ </nav>
155
+
156
+ <main>
157
+ <header>
158
+ <div class="eyebrow">blits · design</div>
159
+ <h1>An effects schema, and the API around it</h1>
160
+ <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
+ <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
+ </header>
164
+
165
+ <h2 id="picture">In one picture</h2>
166
+ <p>A host syncs the mix once a frame. For each subject it wants drawn, the mix probes every voice that reaches that subject, scales each delta toward its channel's rest by the voice's current weight, merges them channel by channel, and hands back one pose. The host writes that pose wherever it goes: a three.js mesh, an SVG wedge, a CSS transform, an audio gain.</p>
167
+
168
+ <figure>
169
+ <svg class="dia" viewBox="0 0 720 272" role="img" aria-label="Pipeline: voices sample a patch, weights scale toward rest, channels join, one pose per subject">
170
+ <defs><marker id="ah" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0 0L10 5L0 10z" class="head"/></marker></defs>
171
+ <!-- voices -->
172
+ <text x="20" y="22" class="sub">voices, each on its own clock</text>
173
+ <rect x="20" y="32" width="170" height="44" rx="3" class="box"/>
174
+ <text x="32" y="50">flicker · loop · w 1.0</text>
175
+ <text x="32" y="66" class="sub">phase wraps once per period</text>
176
+ <rect x="20" y="86" width="170" height="44" rx="3" class="box hot"/>
177
+ <text x="32" y="104">power · state · w 0.4 ↑</text>
178
+ <text x="32" y="120" class="sub">fading in over 300 ms</text>
179
+ <rect x="20" y="140" width="170" height="44" rx="3" class="box"/>
180
+ <text x="32" y="158">slam → rest · once · w 1.0</text>
181
+ <text x="32" y="174" class="sub">keys, retargeted from current</text>
182
+ <!-- sample -->
183
+ <path d="M190 54 H240" class="arrow" marker-end="url(#ah)"/>
184
+ <path d="M190 108 H240" class="arrow" marker-end="url(#ah)"/>
185
+ <path d="M190 162 H240" class="arrow" marker-end="url(#ah)"/>
186
+ <rect x="242" y="32" width="120" height="152" rx="3" class="box"/>
187
+ <text x="256" y="56">patch.at(phase,</text>
188
+ <text x="256" y="72"> subject, setting)</text>
189
+ <text x="256" y="104" class="sub">partial delta:</text>
190
+ <text x="256" y="120" class="sub">{ gain: 0.1 }</text>
191
+ <text x="256" y="136" class="sub">{ gain: 0 }</text>
192
+ <text x="256" y="152" class="sub">{ position: […] }</text>
193
+ <!-- scale -->
194
+ <path d="M362 108 H412" class="arrow" marker-end="url(#ah)"/>
195
+ <rect x="414" y="32" width="120" height="152" rx="3" class="box"/>
196
+ <text x="428" y="56">scale(v, w)</text>
197
+ <text x="428" y="72" class="sub">toward rest</text>
198
+ <text x="428" y="104" class="sub">gain 1→0.1 at w 1</text>
199
+ <text x="428" y="120" class="sub">gain 1→0.6 at w .4</text>
200
+ <text x="428" y="136" class="sub">position · 1</text>
201
+ <text x="428" y="160" class="sub">rest-less channels gate</text>
202
+ <!-- join -->
203
+ <path d="M534 108 H584" class="arrow" marker-end="url(#ah)"/>
204
+ <rect x="586" y="32" width="114" height="152" rx="3" class="box hot"/>
205
+ <text x="600" y="56">join per</text>
206
+ <text x="600" y="72">channel</text>
207
+ <text x="600" y="104" class="sub">gain: mul → 0.06</text>
208
+ <text x="600" y="120" class="sub">position: sum</text>
209
+ <text x="600" y="136" class="sub">dark: max</text>
210
+ <text x="600" y="152" class="sub">color: last</text>
211
+ <!-- out -->
212
+ <path d="M643 184 V214" class="arrow" marker-end="url(#ah)"/>
213
+ <text x="512" y="238">one pose, this subject, this frame</text>
214
+ <text x="20" y="264" class="sub">The host calls mix.sync(timestamp) once, then mix.probe(subject) per subject. Same timestamp → same answer, however many times it asks.</text>
215
+ </svg>
216
+ <figcaption>Three voices reaching one part of a tube sign. The channel kit decides how the three gains combine; the voices only decide what they contribute and how much of it is on.</figcaption>
217
+ </figure>
218
+
219
+ <p>Everything klieg does today fits this picture, in three copies with three channel sets. wod's transition voices are the same picture with one subject type and a retargeting tween. sherpa's seam is two voices. magicsmoke's fault is one voice whose weight is a signal.</p>
220
+
221
+ <h2 id="vocab">Vocabulary</h2>
222
+ <p>Every other name in the package is built from these.</p>
223
+ <dl>
224
+ <dt>Delta</dt><dd>A partial record of channel values. A patch writes only the channels it drives; an absent channel means no influence.</dd>
225
+ <dt>Channel</dt><dd>One field of a delta, with its own arithmetic: how two influences merge, what rest is if there is one, how to scale toward rest, how to lerp.</dd>
226
+ <dt>Kit</dt><dd>The channel set for one kind of delta. klieg's pose, klieg's part, wod's presence are each a kit.</dd>
227
+ <dt>Subject</dt><dd>What is being driven: a letter, a part, a wedge, a page, or a unit value for a system with no subject dimension such as klieg's lighting.</dd>
228
+ <dt>Patch</dt><dd>A pure function of phase and a subject that returns a delta. Optionally stateful. This is klieg's word and it keeps it.</dd>
229
+ <dt>Voice</dt><dd>A patch plus its own playback: start, rate, loop, weight envelope, target, and blend options. The unit a host cues and fades.</dd>
230
+ <dt>Mix</dt><dd>The set of live voices over one kit, synced once a frame and probed per subject. What a host holds.</dd>
231
+ <dt>Signal</dt><dd>A 0..1 scalar resolved per subject per frame from something outside the clock: the cursor, a hover value, another voice's state.</dd>
232
+ <dt>Handle</dt><dd>What <code>cue</code> returns: the live controls on one voice, and its completion.</dd>
233
+ <dt>Engine</dt><dd>The implementation behind a mix. One ships. The seam is the mix's own interface.</dd>
234
+ <dt>Pose</dt><dd>What one subject looks like at one frame, once every voice has been folded in. Where a delta is partial, a pose is complete: every channel of the kit is filled. This is what a host writes.</dd>
235
+ <dt>Influence</dt><dd>What one voice does to one subject in one frame, once its weight is applied. Not a share of the whole: two voices each contributing <code>gain: 0.1</code> leave the subject at 0.01.</dd>
236
+ <dt>Period</dt><dd>How long one pass of a patch lasts, in milliseconds. 0 means aperiodic.</dd>
237
+ <dt>Phase</dt><dd>Where a voice is within its period, 0 to 1, wrapping. The first thing a patch is handed, and what <code>stagger</code> spreads per subject.</dd>
238
+ <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
+ <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
+ <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
+ </dl>
243
+
244
+ <h2 id="channels">Channels</h2>
245
+ <p>The arithmetic of blending is a property of the operand type, so it lives on the channel and nowhere else. Every merge, fade, crossfade, and retarget in the package is one of these four operations.</p>
246
+
247
+ <pre><code>interface Channel&lt;V&gt; {
248
+ <span class="cm">/** Names the arithmetic: 'sum', 'vec(3, sum)'. Two channels of one kind fold identically. Absent, a channel
249
+ matches only itself. */</span>
250
+ kind?: string;
251
+ <span class="cm">/** Identity. Absent means the channel has none: `color` replaces rather than contributes. */</span>
252
+ rest?: V;
253
+ <span class="cm">/** Fold two influences into one. */</span>
254
+ merge(a: V, b: V): V;
255
+ <span class="cm">/** Fade toward `rest` by weight 0..1. Required when `rest` is set; absent otherwise. */</span>
256
+ scale?(v: V, w: number): V;
257
+ <span class="cm">/** Interpolate, for retargeting and for blending alternatives. */</span>
258
+ lerp(a: V, b: V, u: number): V;
259
+ }
260
+
261
+ type Kit&lt;O&gt; = { readonly [K in keyof O]-?: Channel&lt;NonNullable&lt;O[K]&gt;&gt; };</code></pre>
262
+
263
+ <p>Stock channels cover every case the four repos hold today:</p>
264
+ <div class="tbl"><table>
265
+ <tr><th>channel</th><th>rest</th><th>join</th><th>scale</th><th>lerp</th><th>used by</th></tr>
266
+ <tr><td><code>sum()</code></td><td class="num">0</td><td>a + b</td><td>v · w</td><td>linear</td><td>position axes, rotation, crawl, yaw, pitch, offset</td></tr>
267
+ <tr><td><code>mul()</code></td><td class="num">1</td><td>a · b</td><td>1 + (v − 1) · w</td><td>linear</td><td>gain, scale, opacity, hold</td></tr>
268
+ <tr><td><code>max()</code></td><td class="num">0</td><td>max(a, b)</td><td>v · w</td><td>linear</td><td>dark, aperture</td></tr>
269
+ <tr><td><code>last()</code></td><td>none</td><td>b</td><td>—</td><td>caller's</td><td>color, offsetAngle</td></tr>
270
+ <tr><td><code>vec(n, ch)</code></td><td>per axis</td><td>per axis</td><td>per axis</td><td>per axis</td><td>Vec3 position, premultiplied light</td></tr>
271
+ </table></div>
272
+
273
+ <p>A channel with no rest cannot fade. The mix handles that in one way: below a weight threshold the influence 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 influence. Retargeting is the way a rest-less channel does move smoothly, since a lerp from a sampled value needs no identity.</p>
274
+
275
+ <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>
276
+
277
+ <p>A kit is plain data, so a consumer declares one in a line and the mix is typed by it:</p>
278
+ <pre><code>const PART = kit&lt;PartOffset&gt;({
279
+ gain: mul(), scale: mul(), dark: max(), crawl: sum(),
280
+ position: vec(3, sum()), rotation: vec(3, sum()),
281
+ light: vec(3, sum()), color: hex(),
282
+ });</code></pre>
283
+
284
+ <h2 id="patches">Patches</h2>
285
+ <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>
286
+
287
+ <pre><code>interface Patch&lt;I, O, S = void&gt; {
288
+ <span class="cm">/** Which authoring form built it. An engine declares which forms it runs. */</span>
289
+ readonly form: 'fn' | 'keys';
290
+ <span class="cm">/** Milliseconds one pass lasts. 0 is aperiodic: one pass that never ends, so `phase` and `pass` stay 0,
291
+ `loop` is ignored, and the patch reads `setting.elapsed` instead. */</span>
292
+ period: number;
293
+ <span class="cm">/** The channels this patch contributes to. Every key `at` sets, and no others. */</span>
294
+ readonly writes: readonly (keyof O)[];
295
+ <span class="cm">/** The channels it was written against. `cue` refuses a kit whose channel of the same name is another kind. */</span>
296
+ readonly kit?: Partial&lt;Kit&lt;O&gt;&gt;;
297
+ <span class="cm">/** The fields of `setting.host` it reads. `cue` refuses a mix whose host lacks one. */</span>
298
+ readonly reads?: readonly string[];
299
+ <span class="cm">/** `phase` is 0..1 across one period, wrapping. */</span>
300
+ at(phase: number, subject: I, setting: Setting&lt;S&gt;): Partial&lt;O&gt;;
301
+
302
+ <span class="cm">/** Per-subject state, created on the first frame this patch sees a subject. */</span>
303
+ state?(subject: I): S;
304
+ <span class="cm">/** Advances state once per subject per probed frame, before that frame's first `at`, by the whole gap
305
+ since that subject last advanced. `dt` is Infinity under reduced motion. */</span>
306
+ step?(state: S, dt: number, subject: I, setting: Setting&lt;S&gt;): void;
307
+
308
+ <span class="cm">/** Present when the patch was authored as keyframes, so an engine that reads data can. */</span>
309
+ readonly keys?: readonly Keyframe&lt;O&gt;[];
310
+ }
311
+
312
+ interface Setting&lt;S = void&gt; {
313
+ <span class="cm">/** The mix clock at this frame. Identical for every sample in the frame. */</span>
314
+ timestamp: number;
315
+ <span class="cm">/** The gap this subject is catching up by: milliseconds since it last advanced, which is the gap since
316
+ the previous sync only for a subject probed every frame. Infinity under reduced motion: snap, never
317
+ integrate. */</span>
318
+ dt: number;
319
+ <span class="cm">/** Milliseconds this voice has been playing, rate applied. What lets a patch see across passes. */</span>
320
+ elapsed: number;
321
+ <span class="cm">/** Which pass this is, 0 first. */</span>
322
+ pass: number;
323
+ <span class="cm">/** This voice's weight for this subject this frame, after its fade envelope, its signal and its share of any locus:
324
+ the same number the mix is about to scale by. */</span>
325
+ weight: number;
326
+ <span class="cm">/** This subject's state, when the patch declares one. */</span>
327
+ state: S;
328
+ <span class="cm">/** Anything the host adds for its own patches: klieg's pointer and pointerInWord go here. */</span>
329
+ host: unknown;
330
+ <span class="cm">/** The state `owner` keeps for this voice and subject, made by `init` on first ask. Held by the mix, so a read at
331
+ another time can copy it; a stateful signal keeps its state here and nowhere else. */</span>
332
+ keep&lt;K&gt;(owner: object, init: () =&gt; K): K;
333
+ <span class="cm">/** Reports an event for this voice and subject at `timestamp`. Queued until the host drains it; the mix
334
+ never calls back. Meant for `step`. */</span>
335
+ send(event: unknown): void;
336
+ }</code></pre>
337
+
338
+ <p>Two authoring forms build a patch. Both return the same interface, so a voice cannot tell them apart.</p>
339
+ <div class="tbl"><table>
340
+ <tr><th>form</th><th>signature</th><th>what it is</th></tr>
341
+ <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>
342
+ <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>
343
+ </table></div>
344
+
345
+ <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>
346
+
347
+ <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>
348
+
349
+ <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>
350
+
351
+ <h2 id="voices">Voices</h2>
352
+ <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>
353
+
354
+ <pre><code>interface VoiceSpec&lt;I, O&gt; {
355
+ patch: Patch&lt;I, O, any&gt;;
356
+ <span class="cm">/** Which subjects this voice reaches. Default: all of them. The predicate is fixed at `cue`; it runs per
357
+ subject the first time the mix sees that subject, and the answer is kept for as long as the state is. */</span>
358
+ target?: (subject: I) =&gt; boolean;
359
+
360
+ <span class="cm">// clock</span>
361
+ <span class="cm">/** When the voice starts, in ms on the clock the host passes <code>sync</code>. Default: the next sync. */</span>
362
+ start?: number;
363
+ /** Playback rate. 1 is real time; 0 freezes. */
364
+ rate?: number;
365
+ <span class="cm">/** true loops for good, false plays one pass, n plays n passes. A finite loop leaves when its passes are done — over <code>fade.out</code> where one is set, at once where none is. */</span>
366
+ loop?: boolean | number;
367
+ <span class="cm">/** Per-subject delay in voice ms. klieg's stagger grammar stays klieg's and passes in the one
368
+ thing the mix needs: how long this subject waits. Asked once per subject on first sight and
369
+ kept, like <code>target</code>; the subject's fade in starts when the voice clock reaches it. */</span>
370
+ stagger?: (subject: I) =&gt; number;
371
+
372
+ <span class="cm">// weight</span>
373
+ <span class="cm">/** Steady weight, or a signal read per subject per frame. Default 1. */</span>
374
+ weight?: number | Signal&lt;I&gt;;
375
+ <span class="cm">/** Ramp in and out, ms. Out applies on `fade()` and when a finite loop ends. */</span>
376
+ fade?: { in?: number; out?: number; ease?: Easing };
377
+ <span class="cm">/** Voices sharing a locus are alternatives: the mix folds them into one influence through each
378
+ channel's `lerp` before anything else sees them. A crossfade between two behaviors, not what
379
+ klieg's phases are: those layer. */</span>
380
+ locus?: string;
381
+
382
+ <span class="cm">// blend</span>
383
+ <span class="cm">/** A `keys` patch whose first stop reads "wherever this subject is now", lerped over the pass. Read from
384
+ the pose of the frame before this voice first contributes, so it never reads itself. With no
385
+ such frame on record it is this frame's pose with every other voice folded in. */</span>
386
+ from?: 'current';
387
+ <span class="cm">/** Words a source attaches to its voices, so their events can be drained as a set. */</span>
388
+ tags?: readonly string[];
389
+ }
390
+
391
+ interface Handle&lt;I&gt; {
392
+ readonly id: number;
393
+ readonly state: 'pending' | 'live' | 'fading' | 'done';
394
+ <span class="cm">/** Live. Writes land on the next sync. */</span>
395
+ weight: number;
396
+ rate: number;
397
+ <span class="cm">/** Moves this voice's clock, forward or back. Phase is computed from the reading rather than accumulated
398
+ into, so nothing has to unwind: state is left where it is and is never run forward to meet the new
399
+ position. Mix time is not addressable this way, and nothing outside this voice moves. */</span>
400
+ seek(elapsed: number): void;
401
+ <span class="cm">/** Ramps out over `fade.out` unless given a fade here; `at: 'rest'` waits per subject for a resting pose first. */</span>
402
+ fade(opts?: { over?: number; at?: 'rest'; deadline?: number }): void;
403
+ <span class="cm">/** The weight this voice gave `subject` the last frame it was probed: after fades and its signal, before a
404
+ locus folds it. 0 where it gave none. One number on a record the mix keeps anyway, so free unasked. */</span>
405
+ weightOf(subject: I): number;
406
+ <span class="cm">/** Resolves when the voice has been removed from the mix, however that happened. */</span>
407
+ readonly done: Promise&lt;void&gt;;
408
+ }</code></pre>
409
+
410
+ <p>Weights are the only knob the mix owns. Fade, crossfade, handover, alternatives, and hinge's continuous mode are all weights over time; the section on blending shows each one as such. A patch never sees a weight applied to its output, it only sees the number in <code>setting.weight</code> if it cares.</p>
411
+
412
+ <h2 id="mix">Mix</h2>
413
+ <p>What a host holds. The methods are few because the voices carry the options.</p>
414
+
415
+ <pre><code>function mix&lt;I, O&gt;(kit: Kit&lt;O&gt;, opts?: MixOptions): Mix&lt;I, O&gt;;
416
+
417
+ interface MixOptions {
418
+ engine?: Engine; <span class="cm">// default: mixer</span>
419
+ <span class="cm">/** Reduced motion: fades and warm-ups snap, `dt` reads Infinity. Read once at creation, or a signal. */</span>
420
+ reduce?: boolean | (() =&gt; boolean);
421
+ <span class="cm">/** Merged into every `setting.host`. */</span>
422
+ host?: unknown;
423
+ <span class="cm">/** The band a rest-less channel's influence switches across. Default 0.4 / 0.6, a placeholder. */</span>
424
+ band?: { on: number; off: number };
425
+ <span class="cm">/** The most `dt` a step or signal is handed, however long the gap. Off by default. */</span>
426
+ maxDt?: number;
427
+ <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>
428
+ stepMs?: number;
429
+ }
430
+
431
+ interface Mix&lt;I, O&gt; {
432
+ <span class="cm">/** Cues a voice. Throws when the engine cannot run the patch's form, or the kit lacks a channel it `writes`. */</span>
433
+ cue(spec: VoiceSpec&lt;I, O&gt;): Handle&lt;I&gt;;
434
+ <span class="cm">/** N voices whose weights split one signal, k in 0..1, cued into one locus so they fold as
435
+ alternatives. Two patches crossfade; more make stops. */</span>
436
+ blend(patches: readonly Patch&lt;I, O&gt;[], by: Signal&lt;I&gt;, spec?: Omit&lt;VoiceSpec&lt;I, O&gt;, 'patch' | 'weight'&gt;): Handle&lt;I&gt;[];
437
+
438
+ <span class="cm">/** The host reports the clock, once a frame. Nothing advances at the call: it publishes the reading, and
439
+ each subject catches up by the whole gap the first time it is probed afterward. The same timestamp twice
440
+ changes nothing, and the reading only ever goes forward. */</span>
441
+ sync(timestamp: number): void;
442
+ <span class="cm">/** The time between the last sync and the next one does not count. Called when a hidden tab comes back. */</span>
443
+ rebase(): void;
444
+ <span class="cm">/** The merged pose for one subject at the synced frame. `out` is written in place when given. */</span>
445
+ probe(subject: I, out?: O): O;
446
+ <span class="cm">/** Every channel at rest for this subject this frame, so a host can skip the write. */</span>
447
+ atRest(subject: I): boolean;
448
+
449
+ <span class="cm">/** Anything still contributing, fading, or pending. A host stops its loop when this is false. `cue` and
450
+ `blend` make it true again at once; the mix never calls back into the host, so restarting the loop is
451
+ the caller's to do on the same path that cued. */</span>
452
+ readonly live: boolean;
453
+ <span class="cm">/** Fades every voice out: over `over` when given, over each voice's own `fade.out` otherwise. */</span>
454
+ mute(opts?: { over?: number }): void;
455
+ <span class="cm">/** Forgets per-subject state. Call when a subject is retired, or let the WeakMap do it. */</span>
456
+ drop(subject: I): void;
457
+ <span class="cm">/** Every event sent since the last drain, earliest first. Drain after probing. */</span>
458
+ drain&lt;E&gt;(tag?: string): { timestamp: number; subject: I; voice: number; tags: readonly string[]; event: E }[];
459
+ }</code></pre>
460
+
461
+ <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>
462
+
463
+ <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>
464
+
465
+ <h2 id="signals">Signals</h2>
466
+ <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>
467
+
468
+ <pre><code>type Signal&lt;I&gt; = ((subject: I, setting: Setting) =&gt; number) &amp; { readonly input?: boolean };
469
+
470
+ <span class="cm">// stock combinators; klieg's own `near`, `dwell`, `level` are consumers' signals built on these</span>
471
+ function peak&lt;I&gt;(...signals: Signal&lt;I&gt;[]): Signal&lt;I&gt;; <span class="cm">// max</span>
472
+ function slew&lt;I&gt;(of: Signal&lt;I&gt;, { riseMs, fallMs }): Signal&lt;I&gt;; <span class="cm">// stateful: rate-limited follow toward its input</span>
473
+ function lag&lt;I&gt;(of: Signal&lt;I&gt;, { riseMs, fallMs, floor }): Signal&lt;I&gt;; <span class="cm">// stateful: exponential follow, time constants</span>
474
+ function level&lt;I&gt;(initial?: number): Signal&lt;I&gt; &amp; { set(v: number): void }; <span class="cm">// host-written</span>
475
+ function gate&lt;I&gt;(of: Signal&lt;I&gt;, band: { on: number; off: number }): Signal&lt;I&gt;; <span class="cm">// stateful: holds inside the band</span>
476
+ function gate&lt;I&gt;(of: Signal&lt;I&gt;, at: number): Signal&lt;I&gt;; <span class="cm">// sugar for on = off = at</span></code></pre>
477
+
478
+ <p>A stateful signal keeps its state in the mix, through <code>setting.keep(owner, init)</code>: per voice and per subject, keyed by the signal itself, made on first ask. So two voices handed the same <code>slew</code> each follow on their own, and a read at another time can copy what the mix holds instead of moving it. A signal measures its own gap, from the timestamp it last saw, rather than the gap its voice caught up by. <code>input</code> marks a signal that reads something the clock does not drive: <code>level</code> sets it, and <code>peak</code>, <code>slew</code>, <code>gate</code> and <code>blend</code>&rsquo;s shares inherit it from what they read. <code>slew</code> is klieg's <code>dwell</code> with the cursor factored out; <code>gate</code> keeps one bit, which way it last switched.</p>
479
+
480
+ <p><code>slew</code> and <code>lag</code> are the package's two decay primitives. <code>slew</code> follows its input at a rate limit, no faster than <code>riseMs</code> per unit climbing or <code>fallMs</code> draining; it stays linear, because klieg's three decays are and the port's baselines depend on it. <code>lag</code> closes <code>1 − exp(−gap / ms)</code> of the distance per read, so it reads the same at any frame spacing while its input holds still; it lands on its input within <code>floor</code> (1e-6), because an exponential never reaches rest and <code>atRest</code> needs it to, and <code>floor: 0</code> keeps the curve exact. magicsmoke's intensity ease is a <code>lag</code>. It is not <code>Easing</code>, which is a curve over a known duration, and it is not called <code>ease</code> for that reason &mdash; <code>fade.ease</code> and <code>keys({ ease })</code> already mean the curve. A decay tail is a <code>slew</code> to 0 with no rise; a fade is a <code>slew</code> on a weight; klieg's <code>kicks</code> is a <code>slew</code> to 0 that <code>peak</code> pokes back up. Nothing else in the package stores a value in order to follow it. <code>gate</code> is the other half of that pair and not a substitute for it: a slew sets how fast a value may move, a band sets whether it may move at all. A slew cannot quiet a gate, because a rate limit delays a crossing without deciding it, and an input resting on the threshold drags the slewed value onto the threshold with it.</p>
481
+
482
+ <h2 id="time">Time</h2>
483
+ <ul>
484
+ <li><b>The mix is pull-based on a host clock.</b> The host owns the frame loop and calls <code>sync(timestamp)</code> with milliseconds on any monotonic clock it likes. klieg passes its <code>Clock.now()</code>; wod passes <code>performance.now()</code>; a test passes integers.</li>
485
+ <li><b>Everything in a frame reads one timestamp.</b> A second <code>sync</code> with the same value is a no-op, and every <code>probe</code> between syncs answers from the same frame. This is what makes state advance once however many probes a wrapper makes.</li>
486
+ <li><b>A voice's clock is <code>(timestamp − start) · rate</code>.</b> That reading is <code>setting.elapsed</code>, and its position within the period, <code>(elapsed mod period) / period</code>, is the <code>phase</code> the patch is handed first. Both reach the patch. A rate change rebases so <code>elapsed</code> is continuous.</li>
487
+ <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>
488
+ <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>
489
+ <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>
490
+ <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>
491
+ <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>
492
+ </ul>
493
+
494
+ <h2 id="blend">Blending</h2>
495
+ <p>The four behaviors asked for, each as the weight or lerp it reduces to. Nothing here is a mechanism of its own.</p>
496
+ <div class="tbl"><table>
497
+ <tr><th>behavior</th><th>how</th><th>what it replaces</th></tr>
498
+ <tr><td>Fade in and out</td><td><code>fade: { in, out }</code> ramps the voice's weight; each channel's <code>scale</code> takes it toward rest.</td><td>klieg's <code>Timeline.weight</code>, the <code>blendMs</code> ramps, and <code>carry</code>.</td></tr>
499
+ <tr><td>Crossfade at a phase seam</td><td>Two voices in one <code>locus</code>, one fading out as the other fades in. The locus folds them by the channel's <code>lerp</code> at their weight ratio, which on <code>sum</code> is the lurch guard klieg's timeline applies when a hold is shorter than the blend, and on every other channel is the crossfade that guard was standing in for.</td><td>enter → active → exit as three voices.</td></tr>
500
+ <tr><td>Handover at rest</td><td><code>fade({ at: 'rest', deadline })</code> removes the voice per subject at the first frame its pose equals rest, or at the deadline. The next voice was cued already and simply becomes the only contributor.</td><td><code>turns</code>' handover rule, made available to any pair.</td></tr>
501
+ <tr><td>Blend between alternatives</td><td><code>mix.blend([a, b, c], by: k)</code> cues three voices whose weights are the hat functions of k at 0, ½, 1. Continuous, no rebuild, and hinge's quantization goes away for patches whose channels have rest.</td><td><code>hinge</code>'s stops mode. Its blend mode is just <code>weight: signal</code>.</td></tr>
502
+ <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>.</td><td>wod's base-sampling. New to klieg: a regroup could tween from mid-float instead of carrying it away.</td></tr>
503
+ </table></div>
504
+
505
+ <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 influence first: each member weighted by its share of the locus's summed weight, through the channel's own <code>lerp</code>, in cue order. That one influence then contributes at weight <code>min(1, &Sigma;w)</code> and is scaled toward rest like any other. 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>
506
+
507
+ <div class="callout">
508
+ <div class="eyebrow">the one rule</div>
509
+ <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>
510
+ </div>
511
+
512
+ <h2 id="state">Patch state</h2>
513
+ <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>
514
+
515
+ <pre><code>const power = patch&lt;PartInfo, PartOffset, PowerState&gt;(0, (phase, part, setting) =&gt; {
516
+ const s = setting.state;
517
+ return s.mode === 'on' ? {} : { gain: s.gain };
518
+ }, {
519
+ state: () =&gt; ({ mode: 'on', gain: 1, since: 0 }),
520
+ step: (s, dt, part, setting) =&gt; { <span class="cm">/* advance the strike, the flare, the warm-up */</span> },
521
+ });</code></pre>
522
+
523
+ <p>Sign-wide state, which <code>power</code> has, is per-subject state on a shared subject: the patch's <code>state</code> returns one object from a closure and every part reads it. The mix does not need to know.</p>
524
+
525
+ <h2 id="engines">Engines</h2>
526
+ <p>The engine is the implementation behind <code>Mix</code>. The schema above is data plus pure functions, so a mix can be interpreted by more than one thing. One engine ships, and the seam is designed so the second one does not need the first rewritten.</p>
527
+
528
+ <pre><code>interface Engine {
529
+ readonly name: string;
530
+ <span class="cm">/** Which patch forms this engine can run. A voice whose patch it cannot run is refused at `cue`, by name. */</span>
531
+ readonly runs: ReadonlySet&lt;'fn' | 'keys'&gt;;
532
+ <span class="cm">/** Builds the mix. Everything on `Mix` is the engine's to implement. */</span>
533
+ create&lt;I, O&gt;(kit: Kit&lt;O&gt;, opts: MixOptions): Mix&lt;I, O&gt;;
534
+ }
535
+
536
+ export const mixer: Engine; <span class="cm">// the one that ships</span></code></pre>
537
+
538
+ <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>
539
+ <ul>
540
+ <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>
541
+ <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>
542
+ <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>
543
+ </ul>
544
+ <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>
545
+
546
+ <h2 id="kits">Channel kits</h2>
547
+ <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>
548
+ <div class="tbl"><table>
549
+ <tr><th>consumer</th><th>subject</th><th>kit</th><th>notes</th></tr>
550
+ <tr><td>klieg motion</td><td><code>LetterInfo</code></td><td>position vec3 sum · rotation vec3 sum · scale mul · opacity mul</td><td>Three voices in locus <code>phase</code>, fades of <code>blendMs</code>.</td></tr>
551
+ <tr><td>klieg effects</td><td><code>PartInfo</code></td><td>gain mul · scale mul · dark max · crawl sum · position vec3 sum · rotation vec3 sum · light vec3 sum · color last</td><td>Light is premultiplied by amount before it reaches the channel, as <code>mergeOffsets</code> does.</td></tr>
552
+ <tr><td>klieg lighting</td><td>unit</td><td>yaw sum · pitch sum</td><td>One subject, the frame. A mix with no subject dimension is a mix over one subject.</td></tr>
553
+ <tr><td>wod transitions</td><td>wedge <code>Segment</code></td><td>hold mul · opacity mul · scale mul · offset sum · rotate sum · aperture max · offsetAngle last</td><td>Every voice is <code>keys</code> with <code>from: 'current'</code>; the voices map goes away.</td></tr>
554
+ <tr><td>sherpa seams</td><td>page</td><td>opacity mul · transform (host's) · clip max</td><td>Exit and enter as two voices; overlap is whether both are live at once.</td></tr>
555
+ <tr><td>magicsmoke faults</td><td>fault</td><td>none: the patch writes no channels</td><td>One voice per fault, on a mix per fault that magicsmoke keeps to itself. Its weight is the intensity, a number or any signal. Its state is the seeded discharge process, stepped under <code>stepMs: 5</code>, easing exponentially toward <code>setting.weight</code> and sending each discharge as an event. Stopping is <code>handle.fade()</code>; <code>blow</code> writes into the fault's own state through magicsmoke's handle, not as a second voice. The mix never sets <code>reduce</code>, which would step off the grid, and <code>cue</code> passes the last synced timestamp as <code>start</code>, the old process's clock origin. Jolt, whine and hum stay off blits: the browser already composites jolt, and whine and hum fade on the audio clock. magicsmoke imports <code>Signal</code>, <code>Handle</code>, <code>Setting</code>, <code>slew</code> and <code>level</code>, so those are surface to keep stable.</td></tr>
556
+ </table></div>
557
+
558
+ <h2 id="publishing">Pieces from other packages</h2>
559
+ <p>A package can publish blits pieces for a host to mix with its own: signals, patches, and the channels its patches write. Nothing compares classes, so a patch made by one copy of blits cues into a mix made by another, and a patch written against a few channels cues into a mix over more. Four rules keep that safe.</p>
560
+ <ul>
561
+ <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>
562
+ <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>
563
+ <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>
564
+ <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>
565
+ </ul>
566
+ <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>
567
+
568
+ <p>The cleanest seam needs none of these: a <code>Signal</code> is a number per subject, so a package exporting one and a host using it as a weight agree on nothing but the subject type. Taking signals in is the same seam the other way: magicsmoke's faults take their weight as any <code>Signal</code> the host builds with <code>slew</code>, <code>lag</code> or <code>level</code>.</p>
569
+
570
+ <h2 id="klieg">The klieg port</h2>
571
+ <p>All three klieg systems move in one pass, as chosen. The port is an extraction first and a feature second, and the boundary between the two is where the baselines are checked.</p>
572
+ <ul>
573
+ <li><b>Step one reproduced the arithmetic exactly, and is done.</b> <code>Timeline.poseAt</code> is a voice per layer of each phase, weighted <code>min(inW, outW)</code>; <code>EffectFrame.resolve</code> is a mix over parts with the same modulo pass and the same stagger call; the sign&rsquo;s environment is a mix with one subject. <code>mergeOffsets</code>, <code>addScaled</code>, <code>accumulate</code>, <code>scaleOffset</code> and <code>mergeEnv</code> are all the channel kits now. klieg&rsquo;s vitest suites and the Playwright baselines hold unchanged.</li>
574
+ <li><b>Stagger stayed klieg's</b>, and the two are not the same operation. A voice's <code>stagger</code> delays a subject by milliseconds; klieg's warps the pass into <code>(t &minus; start) / span</code> and clamps, so a part reaches the end of its pass with the word rather than after it. The port computes it in the patch, from <code>setting.elapsed</code>, and <code>orderKey</code> stays klieg's with it.</li>
575
+ <li><b>A phase is a layer, not an alternative.</b> The three phases cue as separate voices, not as one locus: a locus folds through <code>lerp</code>, which is identically scaling and merging on <code>sum</code> but not on <code>mul</code> — and <code>scale</code> and <code>opacity</code> are <code>mul</code> channels that two phases both write inside a blend window. klieg&rsquo;s own guard against three phases overlapping at once, scaling every weight by <code>1/&Sigma;w</code> when the sum runs past 1, stays klieg&rsquo;s for the same reason: it is a property of its phase table, not of the fold. A locus is still the right shape for alternatives that replace one another, which is what <code>mix.blend</code> cues.</li>
576
+ <li><b>Step two turns on what the extraction bought.</b> <code>power</code>, <code>kicks</code> and <code>dwell</code> lose their hand-rolled frame keying, and <code>slew</code> takes the decay math with it: <code>dwell</code>'s <code>toward</code> and <code>kicks</code>' drain are one function, leaving <code>kicks</code> its falloff and its event queue. <code>power</code> keeps its state machine, which is not a decay. <code>hinge</code>'s blend mode becomes <code>weight: signal</code> and its stops mode becomes <code>mix.blend</code>; the old names stay as sugar. A host can cue an effect to a running fire and fade it in, which has no equivalent today.</li>
577
+ <li><b>Wrappers do not move.</b> <code>turns</code>, <code>roving</code>, <code>intermittent</code>, <code>partition</code> and the <code>Sequence</code>'s <code>carry</code> and <code>within</code> stay patch-level and unchanged, except that <code>carry</code> can be deleted once a regroup uses <code>from: 'current'</code>.</li>
578
+ <li><b><code>FrameCtx</code> becomes <code>Setting</code> with klieg's fields on <code>host</code>.</b> <code>pointer</code>, <code>pointerInWord</code> and <code>dt</code> keep their meanings. Every klieg patch signature changes by one property path, and a codemod does it.</li>
579
+ <li><b>klieg gains one runtime dependency</b>, zero-dependency itself, ESM-only, with types.</li>
580
+ </ul>
581
+
582
+ <h2 id="against">Decided against</h2>
583
+ <ul>
584
+ <li><b>Position and rotation as one channel.</b> A channel is the smallest thing a patch writes, so a rotation-only patch would write a zero position, and in a locus that zero pulls the other members' positions toward it by its share; apart, a locus blends each channel only among the voices that write it. Rotation could also not change its arithmetic, to quaternions say, without position. The name it was offered under, <code>pose</code>, is already the whole of what a mix resolves for a subject. Decided 2026-09-30.</li>
585
+ <li><b>A signal graph.</b> Everything as nodes evaluated once per frame. Most general, and every existing patch would be re-expressed as nodes; a misbehaving sign would be read as a graph rather than a list. Signals stay scalars and plug in at two places.</li>
586
+ <li><b>Keyframes as the only form.</b> The platform's shape and wod's. It cannot carry a hash-driven flicker or a permutation walk without an escape hatch that is the procedural form under another name. Both forms are first-class instead.</li>
587
+ <li><b>Adopting a tweening library.</b> GSAP, anime.js and Motion own a property and write absolute values, so concurrent writers conflict. anime.js v4's additive mode is the closest and still has no rest rule per channel. three's <code>AnimationMixer</code> has the right model and is keyframe-only over object property paths.</li>
588
+ <li><b>Subject registration.</b> A <code>mix.subjects(list)</code> call would let the mix step every state eagerly. It puts a second source of truth about the pool in the host. Lazy state keyed by identity, stepped by the gap in the timestamp, needs nothing from the host and is the rule klieg already proved.</li>
589
+ <li><b>A slew in place of the band.</b> Smoothing a gate's input instead of giving it two edges. It delays the crossing without deciding it, so an input that rests on the threshold chatters anyway, and the rate needed to help at all costs visible lag.</li>
590
+ <li><b>Normalizing a locus's weights per channel.</b> The other way to make a crossfade hold its value on <code>mul</code>: keep the scale-and-merge fold and pick a per-channel divisor. It costs the property the mixer's fold order rests on &mdash; that weights are scalars resolved for the subject before any channel is touched &mdash; and it has no answer for <code>last</code>, which has no arithmetic to normalize. The <code>lerp</code> fold gets the same result from the rule the package already has.</li>
591
+ <li><b>A mix option for the lurch guard.</b> A global "normalize when over 1" would apply to effects that legitimately stack. The cap belongs to the voices that crossfade, so it is a <code>locus</code>.</li>
592
+ </ul>
593
+
594
+ <h2 id="tests">Tests</h2>
595
+ <p>These are the package's suite, in <code>test/</code>. Everything below the klieg extraction is written and green.</p>
596
+ <ul>
597
+ <li><b>Channel laws</b>, property-tested per stock channel over a deterministic sample: <code>merge(v, rest) = v</code>, <code>scale(v, 1) = v</code>, <code>scale(v, 0) = rest</code>, <code>lerp(a, b, 0) = a</code>, <code>lerp(a, b, 1) = b</code>. Each holds on the channel's own domain &mdash; <code>max</code>'s rest is an identity for values at or above it, which is what <code>dark</code> and <code>aperture</code> are.</li>
598
+ <li><b>One frame, one answer.</b> A stateful patch probed 12 times at one timestamp steps once; a second <code>sync</code> with the same timestamp is a no-op.</li>
599
+ <li><b>Catch-up.</b> A subject unsampled for three frames steps by the whole gap on the fourth.</li>
600
+ <li><b>Fade continuity.</b> A voice stopped mid-pass with a fade produces no frame-to-frame jump larger than its per-frame ramp, on every channel with rest.</li>
601
+ <li><b>Retarget continuity.</b> A <code>from: 'current'</code> voice cued on a moving subject has a first pose equal to the subject's previous pose.</li>
602
+ <li><b>Band quiet.</b> A signal held inside a <code>gate</code>'s band for a whole pass produces no switch, whatever it does inside it. Same for a rest-less channel's influence under a weight that wanders between <code>off</code> and <code>on</code>.</li>
603
+ <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>
604
+ <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>
605
+ <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>
606
+ <li><b>Engine refusal.</b> An engine whose <code>runs</code> lacks <code>fn</code> throws at <code>cue</code> naming the voice.</li>
607
+ <li><b>The klieg extraction</b> is judged by klieg's own suites and the Playwright baselines, unchanged.</li>
608
+ </ul>
609
+
610
+ <h2 id="open">Open</h2>
611
+ <ul>
612
+ <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; <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>
613
+ <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>
614
+ <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>
615
+ <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>
616
+ <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>
617
+ <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>
618
+ </ul>
619
+ </main>
620
+ </div>
621
+ </body>
622
+ </html>