@msb235/blits 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +34 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/mixer.js +92 -14
- package/dist/motion.d.ts +72 -0
- package/dist/motion.js +167 -0
- package/dist/patch.d.ts +13 -3
- package/dist/patch.js +37 -8
- package/dist/types.d.ts +7 -0
- package/docs/schema.html +21 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,9 +4,42 @@ This package follows [semver](https://semver.org). Below 1.0.0, a breaking chang
|
|
|
4
4
|
version and everything else the patch. Each release lists its changes as **Breaking**, **Added** and
|
|
5
5
|
**Fixed**, and the release workflow refuses a tag with no section here.
|
|
6
6
|
|
|
7
|
+
## 0.2.0
|
|
8
|
+
|
|
9
|
+
### Breaking
|
|
10
|
+
|
|
11
|
+
- A `keys` voice cued with `from: 'current'` now leaves at the velocity the subject had, taken from
|
|
12
|
+
its last two probed poses, instead of on the first segment's own curve. A subject that was still
|
|
13
|
+
leaves at rest. Only the first segment changes, and it still lands on its stop.
|
|
14
|
+
- `Handle` gains `ramp`, so an object written to stand in for a handle needs one.
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
|
|
18
|
+
- `handle.ramp(rate, over)`: eases a voice's playback rate to a new one over `over` ms, so a pause
|
|
19
|
+
or a slow-motion does not snap. The voice clock integrates the ramp.
|
|
20
|
+
|
|
21
|
+
- `spring` and `glide`: momentum in closed form, per subject, so where a subject is does not depend
|
|
22
|
+
on frame rate. `spring.to` retargets and `push` sets a velocity mid-flight, each starting the next
|
|
23
|
+
stretch from where the subject is and how fast it moves; `read` returns both, for handing motion
|
|
24
|
+
to another patch.
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
|
|
28
|
+
- Keyed stops, and a `from: 'current'` retarget, interpolate through the channel's own `lerp`, as
|
|
29
|
+
the schema says. They used plain numeric interpolation unless `lerpBy` named one, so a keyed
|
|
30
|
+
`hex` color blended its packed integer instead of its components. `lerpBy` still wins, and a
|
|
31
|
+
`keys` patch read outside a mix uses the `kit` it names.
|
|
32
|
+
|
|
7
33
|
## 0.1.1
|
|
8
34
|
|
|
9
|
-
|
|
35
|
+
### Fixed
|
|
36
|
+
|
|
37
|
+
- Published from the release workflow through npm's trusted publishing, with a provenance
|
|
38
|
+
statement. The code is 0.1.0's; only the version and this changelog differ.
|
|
39
|
+
|
|
40
|
+
## 0.1.0
|
|
41
|
+
|
|
42
|
+
First release, published by hand. A mix folds concurrent effects into one value per subject per frame: each effect runs
|
|
10
43
|
on its own clock with its own state and weight, and the rules for combining them belong to the
|
|
11
44
|
channel being written rather than to the effect writing it.
|
|
12
45
|
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
export { hex, kit, last, max, mixHex, mul, sum, vec } from './channels.js';
|
|
2
2
|
export { mix, mixer } from './mixer.js';
|
|
3
|
+
export type { Motion, Moving, PerSubject, Value } from './motion.js';
|
|
4
|
+
export { glide, spring } from './motion.js';
|
|
3
5
|
export type { KeysOptions, PatchOptions } from './patch.js';
|
|
4
6
|
export { keys, patch } from './patch.js';
|
|
5
7
|
export { gate, lag, level, peak, slew } from './signals.js';
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { hex, kit, last, max, mixHex, mul, sum, vec } from './channels.js';
|
|
2
2
|
export { mix, mixer } from './mixer.js';
|
|
3
|
+
export { glide, spring } from './motion.js';
|
|
3
4
|
export { keys, patch } from './patch.js';
|
|
4
5
|
export { gate, lag, level, peak, slew } from './signals.js';
|
package/dist/mixer.js
CHANGED
|
@@ -26,15 +26,22 @@ class Voice {
|
|
|
26
26
|
state = 'pending';
|
|
27
27
|
rate;
|
|
28
28
|
weight;
|
|
29
|
-
/**
|
|
29
|
+
/**
|
|
30
|
+
* elapsed = anchorElapsed + the rate integrated from anchorNow, rebased on a rate change or a seek.
|
|
31
|
+
* Without a ramp the rate is constant; with one it moves linearly from `from` to `to` over `over`
|
|
32
|
+
* mix ms starting at the anchor, and holds `to` after.
|
|
33
|
+
*/
|
|
30
34
|
anchorNow;
|
|
31
35
|
anchorElapsed = 0;
|
|
36
|
+
ramp = null;
|
|
32
37
|
out = null;
|
|
33
38
|
subjects = new Store();
|
|
34
39
|
/** Kit slot of each channel the patch writes, in `writes` order. */
|
|
35
40
|
slots;
|
|
36
41
|
/** The stops built ahead of time, for a `keys` patch. */
|
|
37
42
|
built;
|
|
43
|
+
/** The kit's `lerp` for each channel the patch writes, which keyed stops interpolate through. */
|
|
44
|
+
lerps;
|
|
38
45
|
ease;
|
|
39
46
|
/** Reused for every call this voice makes, so it is valid only during the call. */
|
|
40
47
|
setting;
|
|
@@ -45,7 +52,7 @@ class Voice {
|
|
|
45
52
|
restedCount = 0;
|
|
46
53
|
resolve;
|
|
47
54
|
done;
|
|
48
|
-
constructor(id, spec, patch, fade, now, start, slotOf, host, send) {
|
|
55
|
+
constructor(id, spec, patch, fade, now, start, slotOf, channels, host, send) {
|
|
49
56
|
this.id = id;
|
|
50
57
|
this.spec = spec;
|
|
51
58
|
this.patch = patch;
|
|
@@ -53,6 +60,7 @@ class Voice {
|
|
|
53
60
|
this.start = start;
|
|
54
61
|
this.slots = patch.writes.map((k) => slotOf.get(k));
|
|
55
62
|
this.built = patch.form === 'keys' && patch.keys ? builtOf(patch) : null;
|
|
63
|
+
this.lerps = this.slots.map((slot) => channels[slot].lerp);
|
|
56
64
|
this.ease = fade.ease === undefined ? undefined : curve(fade.ease);
|
|
57
65
|
this.setting = {
|
|
58
66
|
timestamp: 0,
|
|
@@ -75,10 +83,33 @@ class Voice {
|
|
|
75
83
|
this.state = 'live';
|
|
76
84
|
}
|
|
77
85
|
elapsedAt(now) {
|
|
78
|
-
|
|
79
|
-
|
|
86
|
+
const dt = now - this.anchorNow;
|
|
87
|
+
const r = this.ramp;
|
|
88
|
+
if (r === null)
|
|
89
|
+
return this.anchorElapsed + dt * this.rate;
|
|
90
|
+
if (dt <= 0)
|
|
91
|
+
return this.anchorElapsed + dt * r.from;
|
|
92
|
+
const d = r.to - r.from;
|
|
93
|
+
if (dt <= r.over)
|
|
94
|
+
return this.anchorElapsed + r.from * dt + (d * dt * dt) / (2 * r.over);
|
|
95
|
+
return this.anchorElapsed + r.from * r.over + (d * r.over) / 2 + r.to * (dt - r.over);
|
|
96
|
+
}
|
|
97
|
+
rateAt(now) {
|
|
98
|
+
const r = this.ramp;
|
|
99
|
+
if (r === null)
|
|
100
|
+
return this.rate;
|
|
101
|
+
const u = (now - this.anchorNow) / r.over;
|
|
102
|
+
return u >= 1 ? r.to : u <= 0 ? r.from : r.from + (r.to - r.from) * u;
|
|
103
|
+
}
|
|
104
|
+
/** Moves the anchor to `now`, carrying what is left of a ramp. */
|
|
80
105
|
rebase(now) {
|
|
106
|
+
const rate = this.rateAt(now);
|
|
81
107
|
this.anchorElapsed = this.elapsedAt(now);
|
|
108
|
+
const r = this.ramp;
|
|
109
|
+
if (r !== null) {
|
|
110
|
+
const left = this.anchorNow + r.over - now;
|
|
111
|
+
this.ramp = left > 0 ? { from: rate, to: r.to, over: left } : null;
|
|
112
|
+
}
|
|
82
113
|
this.anchorNow = now;
|
|
83
114
|
}
|
|
84
115
|
}
|
|
@@ -86,6 +117,7 @@ class Mixer {
|
|
|
86
117
|
kit;
|
|
87
118
|
opts;
|
|
88
119
|
voices = [];
|
|
120
|
+
/** Each subject's last two poses and when they were probed, for `from: 'current'`. */
|
|
89
121
|
pose = new Store();
|
|
90
122
|
nextId = 1;
|
|
91
123
|
/** The mix clock: the host's timestamp less every gap `rebase` has taken out. */
|
|
@@ -169,7 +201,7 @@ class Mixer {
|
|
|
169
201
|
if (spec.from === 'current' && patch.form !== 'keys')
|
|
170
202
|
throw new Error("blits: from: 'current' needs a keys patch");
|
|
171
203
|
const start = spec.start !== undefined ? spec.start - this.offset : Number.isNaN(this.now) ? 0 : this.now;
|
|
172
|
-
const voice = new Voice(this.nextId++, spec, patch, spec.fade ?? {}, this.now, start, this.slotOf, this.opts.host, this.send);
|
|
204
|
+
const voice = new Voice(this.nextId++, spec, patch, spec.fade ?? {}, this.now, start, this.slotOf, this.channels, this.opts.host, this.send);
|
|
173
205
|
this.voices.push(voice);
|
|
174
206
|
this.version++;
|
|
175
207
|
if (spec.target !== undefined)
|
|
@@ -246,16 +278,29 @@ class Mixer {
|
|
|
246
278
|
const pose = this.fold(subject, out);
|
|
247
279
|
// Keeping last frame's pose is free when the mix allocated it; a host sampling into its own
|
|
248
280
|
// object only pays for the copy once something in the mix has asked to retarget from it.
|
|
281
|
+
let kept;
|
|
249
282
|
if (out === undefined)
|
|
250
|
-
|
|
283
|
+
kept = pose;
|
|
251
284
|
else if (this.wantsPose) {
|
|
252
|
-
|
|
285
|
+
kept = {};
|
|
253
286
|
for (const key of this.names) {
|
|
254
287
|
const v = pose[key];
|
|
255
288
|
if (v !== undefined)
|
|
256
289
|
kept[key] = copy(v);
|
|
257
290
|
}
|
|
258
|
-
|
|
291
|
+
}
|
|
292
|
+
if (kept !== undefined) {
|
|
293
|
+
const rec = this.pose.get(subject);
|
|
294
|
+
if (rec === undefined)
|
|
295
|
+
this.pose.set(subject, { pose: kept, at: this.now, prev: undefined, prevAt: Number.NaN });
|
|
296
|
+
else if (rec.at === this.now)
|
|
297
|
+
rec.pose = kept;
|
|
298
|
+
else {
|
|
299
|
+
rec.prev = rec.pose;
|
|
300
|
+
rec.prevAt = rec.at;
|
|
301
|
+
rec.pose = kept;
|
|
302
|
+
rec.at = this.now;
|
|
303
|
+
}
|
|
259
304
|
}
|
|
260
305
|
return pose;
|
|
261
306
|
}
|
|
@@ -320,14 +365,22 @@ class Mixer {
|
|
|
320
365
|
voice.weight = w;
|
|
321
366
|
},
|
|
322
367
|
get rate() {
|
|
323
|
-
return voice.
|
|
368
|
+
return voice.rateAt(Number.isNaN(mix.now) ? voice.start : mix.now);
|
|
324
369
|
},
|
|
325
370
|
set rate(r) {
|
|
326
371
|
voice.rebase(Number.isNaN(mix.now) ? voice.start : mix.now);
|
|
372
|
+
voice.ramp = null;
|
|
373
|
+
voice.rate = r;
|
|
374
|
+
},
|
|
375
|
+
ramp(r, over) {
|
|
376
|
+
const now = Number.isNaN(mix.now) ? voice.start : mix.now;
|
|
377
|
+
voice.rebase(now);
|
|
378
|
+
const from = voice.rateAt(now);
|
|
379
|
+
voice.ramp = over > 0 && r !== from ? { from, to: r, over } : null;
|
|
327
380
|
voice.rate = r;
|
|
328
381
|
},
|
|
329
382
|
seek(elapsed) {
|
|
330
|
-
voice.
|
|
383
|
+
voice.rebase(Number.isNaN(mix.now) ? voice.start : mix.now);
|
|
331
384
|
voice.anchorElapsed = elapsed;
|
|
332
385
|
},
|
|
333
386
|
fade(opts) {
|
|
@@ -388,7 +441,8 @@ class Mixer {
|
|
|
388
441
|
return held;
|
|
389
442
|
const reaches = voice.spec.target ? voice.spec.target(subject) : true;
|
|
390
443
|
const delay = reaches && voice.spec.stagger ? voice.spec.stagger(subject) : 0;
|
|
391
|
-
// When the voice clock reads `delay`, from where it is anchored now
|
|
444
|
+
// When the voice clock reads `delay`, from where it is anchored now; during a ramp this assumes
|
|
445
|
+
// the rate it is ramping to.
|
|
392
446
|
const since = voice.rate > 0
|
|
393
447
|
? voice.anchorNow + (delay - voice.anchorElapsed) / voice.rate
|
|
394
448
|
: voice.start + delay;
|
|
@@ -487,11 +541,13 @@ class Mixer {
|
|
|
487
541
|
if (voice.built) {
|
|
488
542
|
let base;
|
|
489
543
|
if (voice.spec.from === 'current') {
|
|
490
|
-
if (held.base === undefined)
|
|
544
|
+
if (held.base === undefined) {
|
|
491
545
|
held.base = this.baseFor(voice, subject);
|
|
546
|
+
held.slope = this.slopeFor(voice, subject);
|
|
547
|
+
}
|
|
492
548
|
base = held.base;
|
|
493
549
|
}
|
|
494
|
-
delta = readKeys(voice.built, phase, held.delta ?? {}, base);
|
|
550
|
+
delta = readKeys(voice.built, phase, held.delta ?? {}, base, voice.lerps, held.slope);
|
|
495
551
|
}
|
|
496
552
|
else {
|
|
497
553
|
delta = voice.patch.at(phase, subject, setting);
|
|
@@ -543,7 +599,7 @@ class Mixer {
|
|
|
543
599
|
* samples into its own object — it is this frame's pose with every other voice folded in.
|
|
544
600
|
*/
|
|
545
601
|
baseFor(voice, subject) {
|
|
546
|
-
let prior = this.pose.get(subject);
|
|
602
|
+
let prior = this.pose.get(subject)?.pose;
|
|
547
603
|
if (prior === undefined && !this.folding.has(voice.id)) {
|
|
548
604
|
this.folding.add(voice.id);
|
|
549
605
|
try {
|
|
@@ -565,6 +621,28 @@ class Mixer {
|
|
|
565
621
|
}
|
|
566
622
|
return base;
|
|
567
623
|
}
|
|
624
|
+
/**
|
|
625
|
+
* How fast each numeric channel this voice writes was moving, from the subject's last two probed
|
|
626
|
+
* poses, in units per ms. Undefined with fewer than two on record, which starts the voice at rest.
|
|
627
|
+
*/
|
|
628
|
+
slopeFor(voice, subject) {
|
|
629
|
+
const rec = this.pose.get(subject);
|
|
630
|
+
if (rec?.prev === undefined || !(rec.at > rec.prevAt))
|
|
631
|
+
return undefined;
|
|
632
|
+
const dt = rec.at - rec.prevAt;
|
|
633
|
+
const now = rec.pose;
|
|
634
|
+
const before = rec.prev;
|
|
635
|
+
const slope = {};
|
|
636
|
+
for (const key of voice.patch.writes) {
|
|
637
|
+
const a = before[key];
|
|
638
|
+
const b = now[key];
|
|
639
|
+
if (typeof a === 'number' && typeof b === 'number')
|
|
640
|
+
slope[key] = (b - a) / dt;
|
|
641
|
+
else if (Array.isArray(a) && Array.isArray(b) && a.length === b.length)
|
|
642
|
+
slope[key] = b.map((v, i) => (v - a[i]) / dt);
|
|
643
|
+
}
|
|
644
|
+
return slope;
|
|
645
|
+
}
|
|
568
646
|
isRest(delta) {
|
|
569
647
|
for (const key of Object.keys(delta)) {
|
|
570
648
|
if (delta[key] === undefined)
|
package/dist/motion.d.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { Patch } from './types.js';
|
|
2
|
+
/** A number, or one number per axis. */
|
|
3
|
+
export type Value = number | readonly number[];
|
|
4
|
+
/** One value for every subject, or one per subject. */
|
|
5
|
+
export type PerSubject<I, V> = V | ((subject: I) => V);
|
|
6
|
+
/**
|
|
7
|
+
* Where a subject is and how fast it is moving, at one moment of voice time.
|
|
8
|
+
*
|
|
9
|
+
* @category patch
|
|
10
|
+
*/
|
|
11
|
+
export interface Motion<V extends Value = number> {
|
|
12
|
+
value: V;
|
|
13
|
+
/** Units per second. */
|
|
14
|
+
velocity: V;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* A patch whose motion is a closed form per subject, so it lands in the same place at any frame
|
|
18
|
+
* rate, and whose changes mid-flight start from where the subject is and how fast it moves.
|
|
19
|
+
*
|
|
20
|
+
* @category patch
|
|
21
|
+
*/
|
|
22
|
+
export type Moving<I, O, V extends Value> = Patch<I, O, void> & {
|
|
23
|
+
/** Where `subject` is and how fast it moves at voice time `at`, default its last read. */
|
|
24
|
+
read(subject: I, at?: number): Motion<V> | undefined;
|
|
25
|
+
/** Sets `subject` moving at `velocity`, units per second, from voice time `at`, default its next read. */
|
|
26
|
+
push(subject: I, velocity: V, at?: number): void;
|
|
27
|
+
};
|
|
28
|
+
interface Common<I, V extends Value> {
|
|
29
|
+
/** Units per second each subject starts moving at. Default 0. */
|
|
30
|
+
velocity?: PerSubject<I, V>;
|
|
31
|
+
/**
|
|
32
|
+
* Within this distance of where it heads, and this slow, a subject lands there exactly, so a mix
|
|
33
|
+
* can see it at rest. Default 1e-4; 0 keeps the curve exact.
|
|
34
|
+
*/
|
|
35
|
+
settle?: number;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* A damped spring toward a target, solved in closed form. `to(subject, target)` retargets one
|
|
39
|
+
* subject mid-flight, and the new stretch starts from where it is and how fast it moves, so there is
|
|
40
|
+
* no kink. Time is the voice's, so `rate` and `seek` apply; a read earlier than the latest change
|
|
41
|
+
* evaluates the latest stretch, not history. Its state is its own, kept per subject: cue each spring
|
|
42
|
+
* on one voice.
|
|
43
|
+
*
|
|
44
|
+
* @category patch
|
|
45
|
+
*/
|
|
46
|
+
export declare function spring<I, O, V extends Value = number>(writes: keyof O, opts: Common<I, V> & {
|
|
47
|
+
to: PerSubject<I, V>;
|
|
48
|
+
/** Where each subject starts. Default: `to`, at rest. */
|
|
49
|
+
from?: PerSubject<I, V>;
|
|
50
|
+
/** Per second squared. Default 170. */
|
|
51
|
+
stiffness?: number;
|
|
52
|
+
/** Per second. Default 26, just short of critical at the default stiffness. */
|
|
53
|
+
damping?: number;
|
|
54
|
+
mass?: number;
|
|
55
|
+
}): Moving<I, O, V> & {
|
|
56
|
+
/** Heads `subject` for `target` from voice time `at`, default its next read, keeping its velocity. */
|
|
57
|
+
to(subject: I, target: V, at?: number): void;
|
|
58
|
+
};
|
|
59
|
+
/**
|
|
60
|
+
* Coasting under friction, a fling: a subject keeps its velocity, which falls by a factor of e every
|
|
61
|
+
* `ms`, and comes to rest `velocity · ms / 1000` past where it was released. `push` sets it moving
|
|
62
|
+
* again from wherever it is. Closed form, so frame rate does not change where it stops.
|
|
63
|
+
*
|
|
64
|
+
* @category patch
|
|
65
|
+
*/
|
|
66
|
+
export declare function glide<I, O, V extends Value = number>(writes: keyof O, opts: Common<I, V> & {
|
|
67
|
+
/** Where each subject starts. */
|
|
68
|
+
from: PerSubject<I, V>;
|
|
69
|
+
/** The friction's time constant, ms. Default 325, a touch scroll's. */
|
|
70
|
+
ms?: number;
|
|
71
|
+
}): Moving<I, O, V>;
|
|
72
|
+
export {};
|
package/dist/motion.js
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { Store } from './store.js';
|
|
2
|
+
const axes = (v) => (typeof v === 'number' ? [v] : [...v]);
|
|
3
|
+
const per = (p, subject) => typeof p === 'function' ? p(subject) : p;
|
|
4
|
+
function moving(writes, shape) {
|
|
5
|
+
const held = new Store();
|
|
6
|
+
const entry = (subject) => {
|
|
7
|
+
let h = held.get(subject);
|
|
8
|
+
if (h === undefined) {
|
|
9
|
+
h = { segment: null, pending: [], last: Number.NaN };
|
|
10
|
+
held.set(subject, h);
|
|
11
|
+
}
|
|
12
|
+
return h;
|
|
13
|
+
};
|
|
14
|
+
const evaluate = (seg, at) => {
|
|
15
|
+
const t = Math.max(0, at - seg.at) / 1000;
|
|
16
|
+
const x = new Array(seg.to.length);
|
|
17
|
+
const v = new Array(seg.to.length);
|
|
18
|
+
let still = shape.settle > 0;
|
|
19
|
+
for (let i = 0; i < seg.to.length; i++) {
|
|
20
|
+
const to = seg.to[i];
|
|
21
|
+
const [y, dy] = shape.solve(seg.x0[i] - to, seg.v0[i], t);
|
|
22
|
+
x[i] = to + y;
|
|
23
|
+
v[i] = dy;
|
|
24
|
+
if (Math.abs(y) > shape.settle || Math.abs(dy) > shape.settle)
|
|
25
|
+
still = false;
|
|
26
|
+
}
|
|
27
|
+
if (still)
|
|
28
|
+
return { x: [...seg.to], v: seg.to.map(() => 0) };
|
|
29
|
+
return { x, v };
|
|
30
|
+
};
|
|
31
|
+
const release = (at, x, v, was, subject) => ({
|
|
32
|
+
at,
|
|
33
|
+
x0: x,
|
|
34
|
+
v0: v,
|
|
35
|
+
to: shape.aim(x, v, was, subject),
|
|
36
|
+
});
|
|
37
|
+
/** Brings a subject's motion up to `now`, applying every change due by then, in order. */
|
|
38
|
+
const advance = (subject, h, now) => {
|
|
39
|
+
h.segment ??= release(0, shape.from(subject), shape.velocity(subject), null, subject);
|
|
40
|
+
while (h.pending.length > 0) {
|
|
41
|
+
const change = h.pending[0];
|
|
42
|
+
const at = change.at ?? now;
|
|
43
|
+
if (at > now)
|
|
44
|
+
break;
|
|
45
|
+
h.pending.shift();
|
|
46
|
+
const seg = h.segment;
|
|
47
|
+
const { x, v } = evaluate(seg, at);
|
|
48
|
+
const vel = change.v ?? v;
|
|
49
|
+
h.segment = release(at, x, vel, change.to ?? seg.to, subject);
|
|
50
|
+
}
|
|
51
|
+
return h.segment;
|
|
52
|
+
};
|
|
53
|
+
const out = (subject, xs) => {
|
|
54
|
+
const t = shape.template(subject);
|
|
55
|
+
return (typeof t === 'number' ? xs[0] : xs);
|
|
56
|
+
};
|
|
57
|
+
const patch = {
|
|
58
|
+
form: 'fn',
|
|
59
|
+
period: 0,
|
|
60
|
+
writes: [writes],
|
|
61
|
+
at(_phase, subject, setting) {
|
|
62
|
+
const h = entry(subject);
|
|
63
|
+
const seg = advance(subject, h, setting.elapsed);
|
|
64
|
+
h.last = setting.elapsed;
|
|
65
|
+
return { [writes]: out(subject, evaluate(seg, setting.elapsed).x) };
|
|
66
|
+
},
|
|
67
|
+
read(subject, at) {
|
|
68
|
+
const h = held.get(subject);
|
|
69
|
+
if (h === undefined || h.segment === null)
|
|
70
|
+
return undefined;
|
|
71
|
+
const { x, v } = evaluate(h.segment, at ?? h.last);
|
|
72
|
+
return { value: out(subject, x), velocity: out(subject, v) };
|
|
73
|
+
},
|
|
74
|
+
push(subject, velocity, at) {
|
|
75
|
+
entry(subject).pending.push({ at, v: axes(velocity) });
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
const change = (subject, c) => entry(subject).pending.push(c);
|
|
79
|
+
return { patch, change };
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* A damped spring toward a target, solved in closed form. `to(subject, target)` retargets one
|
|
83
|
+
* subject mid-flight, and the new stretch starts from where it is and how fast it moves, so there is
|
|
84
|
+
* no kink. Time is the voice's, so `rate` and `seek` apply; a read earlier than the latest change
|
|
85
|
+
* evaluates the latest stretch, not history. Its state is its own, kept per subject: cue each spring
|
|
86
|
+
* on one voice.
|
|
87
|
+
*
|
|
88
|
+
* @category patch
|
|
89
|
+
*/
|
|
90
|
+
export function spring(writes, opts) {
|
|
91
|
+
const k = opts.stiffness ?? 170;
|
|
92
|
+
const c = opts.damping ?? 26;
|
|
93
|
+
const m = opts.mass ?? 1;
|
|
94
|
+
const w0 = Math.sqrt(k / m);
|
|
95
|
+
const zeta = c / (2 * Math.sqrt(k * m));
|
|
96
|
+
let solve;
|
|
97
|
+
if (zeta < 1) {
|
|
98
|
+
const wd = w0 * Math.sqrt(1 - zeta * zeta);
|
|
99
|
+
solve = (y0, v0, t) => {
|
|
100
|
+
const e = Math.exp(-zeta * w0 * t);
|
|
101
|
+
const b = (v0 + zeta * w0 * y0) / wd;
|
|
102
|
+
const cos = Math.cos(wd * t);
|
|
103
|
+
const sin = Math.sin(wd * t);
|
|
104
|
+
const y = e * (y0 * cos + b * sin);
|
|
105
|
+
return [y, -zeta * w0 * y + e * wd * (b * cos - y0 * sin)];
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
else if (zeta === 1) {
|
|
109
|
+
solve = (y0, v0, t) => {
|
|
110
|
+
const e = Math.exp(-w0 * t);
|
|
111
|
+
const b = v0 + w0 * y0;
|
|
112
|
+
const y = e * (y0 + b * t);
|
|
113
|
+
return [y, e * b - w0 * y];
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
else {
|
|
117
|
+
const s = Math.sqrt(zeta * zeta - 1);
|
|
118
|
+
const r1 = -w0 * (zeta - s);
|
|
119
|
+
const r2 = -w0 * (zeta + s);
|
|
120
|
+
solve = (y0, v0, t) => {
|
|
121
|
+
const a = (v0 - r2 * y0) / (r1 - r2);
|
|
122
|
+
const b = y0 - a;
|
|
123
|
+
const e1 = Math.exp(r1 * t);
|
|
124
|
+
const e2 = Math.exp(r2 * t);
|
|
125
|
+
return [a * e1 + b * e2, a * r1 * e1 + b * r2 * e2];
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
const target = (subject) => axes(per(opts.to, subject));
|
|
129
|
+
const { patch, change } = moving(writes, {
|
|
130
|
+
from: (s) => axes(per(opts.from ?? opts.to, s)),
|
|
131
|
+
velocity: (s) => opts.velocity === undefined ? target(s).map(() => 0) : axes(per(opts.velocity, s)),
|
|
132
|
+
aim: (_x, _v, was, s) => was ?? target(s),
|
|
133
|
+
solve,
|
|
134
|
+
settle: opts.settle ?? 1e-4,
|
|
135
|
+
template: (s) => per(opts.to, s),
|
|
136
|
+
});
|
|
137
|
+
return Object.assign(patch, {
|
|
138
|
+
to: (subject, goal, at) => change(subject, { at, to: axes(goal) }),
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Coasting under friction, a fling: a subject keeps its velocity, which falls by a factor of e every
|
|
143
|
+
* `ms`, and comes to rest `velocity · ms / 1000` past where it was released. `push` sets it moving
|
|
144
|
+
* again from wherever it is. Closed form, so frame rate does not change where it stops.
|
|
145
|
+
*
|
|
146
|
+
* @category patch
|
|
147
|
+
*/
|
|
148
|
+
export function glide(writes, opts) {
|
|
149
|
+
const tau = (opts.ms ?? 325) / 1000;
|
|
150
|
+
// Released at x moving at v, a glide comes to rest at x + v·τ; y is the distance still to go,
|
|
151
|
+
// which friction closes as e^(−t/τ), and its derivative at release is v again.
|
|
152
|
+
const solve = (y0, _v0, t) => {
|
|
153
|
+
const e = Math.exp(-t / tau);
|
|
154
|
+
return [y0 * e, (-y0 / tau) * e];
|
|
155
|
+
};
|
|
156
|
+
const { patch } = moving(writes, {
|
|
157
|
+
from: (s) => axes(per(opts.from, s)),
|
|
158
|
+
velocity: (s) => opts.velocity === undefined
|
|
159
|
+
? axes(per(opts.from, s)).map(() => 0)
|
|
160
|
+
: axes(per(opts.velocity, s)),
|
|
161
|
+
aim: (x, v) => x.map((xi, i) => xi + v[i] * tau),
|
|
162
|
+
solve,
|
|
163
|
+
settle: opts.settle ?? 1e-4,
|
|
164
|
+
template: (s) => per(opts.from, s),
|
|
165
|
+
});
|
|
166
|
+
return patch;
|
|
167
|
+
}
|
package/dist/patch.d.ts
CHANGED
|
@@ -33,7 +33,10 @@ export interface KeysOptions<O> {
|
|
|
33
33
|
easeBy?: (channel: keyof O) => Easing | undefined;
|
|
34
34
|
/** Milliseconds one channel waits before it starts moving, within the period. */
|
|
35
35
|
delayBy?: (channel: keyof O) => number;
|
|
36
|
-
/**
|
|
36
|
+
/**
|
|
37
|
+
* How one channel interpolates, where it should differ from the channel's own `lerp`. A mix hands
|
|
38
|
+
* every keyed channel its kit's `lerp` unless this names one.
|
|
39
|
+
*/
|
|
37
40
|
lerpBy?: (channel: keyof O) => ((a: never, b: never, u: number) => unknown) | undefined;
|
|
38
41
|
/** The channels these stops were written against, which `cue` checks a mix's kit against. */
|
|
39
42
|
kit?: Partial<Kit<O>>;
|
|
@@ -46,6 +49,7 @@ interface Point {
|
|
|
46
49
|
/** The curve into this point from the one before: the stop's own, else the channel's. */
|
|
47
50
|
ease: Curve | undefined;
|
|
48
51
|
}
|
|
52
|
+
type Lerp = (a: never, b: never, u: number) => unknown;
|
|
49
53
|
/** One channel of a stop list, sorted and resolved once, so a read is a binary search. */
|
|
50
54
|
interface Track {
|
|
51
55
|
channel: string;
|
|
@@ -60,8 +64,14 @@ export interface Built {
|
|
|
60
64
|
tracks: Track[];
|
|
61
65
|
period: number;
|
|
62
66
|
}
|
|
63
|
-
/**
|
|
64
|
-
|
|
67
|
+
/**
|
|
68
|
+
* Evaluates built stops into `out`; a channel with nothing to read at this phase is left undefined.
|
|
69
|
+
* `lerps` holds the mix's channel `lerp` per track, in `writes` order, for a track the patch did not
|
|
70
|
+
* give one.
|
|
71
|
+
*/
|
|
72
|
+
export declare function readKeys(built: Built, phase: number, out: Record<string, unknown>, base?: Record<string, unknown>, lerps?: readonly (Lerp | undefined)[],
|
|
73
|
+
/** Per channel, how fast a retargeted subject was moving, units per ms. */
|
|
74
|
+
slopes?: Record<string, unknown>): Record<string, unknown>;
|
|
65
75
|
/** The built form of a patch's stops, made on first ask for a patch `keys()` did not build. */
|
|
66
76
|
export declare function builtOf<I, O, S>(p: Patch<I, O, S>): Built;
|
|
67
77
|
/** Evaluates a stop list into a fresh delta. */
|
package/dist/patch.js
CHANGED
|
@@ -59,7 +59,7 @@ function build(stops, writes, period, opts) {
|
|
|
59
59
|
all,
|
|
60
60
|
tail: all.filter((pt) => pt.at !== 0),
|
|
61
61
|
delay: opts.delayBy?.(channel) ?? 0,
|
|
62
|
-
lerp: opts.lerpBy?.(channel),
|
|
62
|
+
lerp: opts.lerpBy?.(channel) ?? opts.kit?.[channel]?.lerp,
|
|
63
63
|
});
|
|
64
64
|
}
|
|
65
65
|
return { tracks, period };
|
|
@@ -68,7 +68,7 @@ function build(stops, writes, period, opts) {
|
|
|
68
68
|
* Reads one track at a phase. `base` stands in for the value at phase 0, which is how
|
|
69
69
|
* `from: 'current'` starts a voice wherever the subject already is.
|
|
70
70
|
*/
|
|
71
|
-
function read(track, phase, base) {
|
|
71
|
+
function read(track, phase, base, lerp, slope) {
|
|
72
72
|
const pts = base === undefined ? track.all : track.tail;
|
|
73
73
|
const o = base === undefined ? 0 : 1;
|
|
74
74
|
const n = pts.length + o;
|
|
@@ -95,17 +95,46 @@ function read(track, phase, base) {
|
|
|
95
95
|
const aValue = lo - 1 < o ? base : pts[lo - 1 - o].value;
|
|
96
96
|
const u = (phase - aAt) / (b.at - aAt);
|
|
97
97
|
const eased = b.ease ? b.ease(u) : u;
|
|
98
|
-
|
|
99
|
-
|
|
98
|
+
const by = track.lerp ?? lerp;
|
|
99
|
+
const value = by
|
|
100
|
+
? by(aValue, b.value, eased)
|
|
100
101
|
: interpolate(aValue, b.value, eased);
|
|
102
|
+
// Leaving a retarget's base, bend the first segment so it starts at the slope the subject had:
|
|
103
|
+
// add c·u(1−u)², which is 0 at both ends, flat at the end, and fixes the slope at the start.
|
|
104
|
+
if (slope === undefined || lo - 1 >= o)
|
|
105
|
+
return value;
|
|
106
|
+
const len = b.at - aAt;
|
|
107
|
+
const e0 = b.ease ? b.ease(1e-6) / 1e-6 : 1;
|
|
108
|
+
const bend = (s, a, z) => (s * len - (z - a) * e0) * u * (1 - u) * (1 - u);
|
|
109
|
+
if (typeof value === 'number' && typeof slope === 'number')
|
|
110
|
+
return value + bend(slope, aValue, b.value);
|
|
111
|
+
if (Array.isArray(value) && Array.isArray(slope))
|
|
112
|
+
return value.map((v, i) => typeof v === 'number' && typeof slope[i] === 'number'
|
|
113
|
+
? v +
|
|
114
|
+
bend(slope[i], aValue[i], b.value[i])
|
|
115
|
+
: v);
|
|
116
|
+
return value;
|
|
101
117
|
}
|
|
102
|
-
/**
|
|
103
|
-
|
|
118
|
+
/**
|
|
119
|
+
* Evaluates built stops into `out`; a channel with nothing to read at this phase is left undefined.
|
|
120
|
+
* `lerps` holds the mix's channel `lerp` per track, in `writes` order, for a track the patch did not
|
|
121
|
+
* give one.
|
|
122
|
+
*/
|
|
123
|
+
export function readKeys(built, phase, out, base, lerps,
|
|
124
|
+
/** Per channel, how fast a retargeted subject was moving, units per ms. */
|
|
125
|
+
slopes) {
|
|
104
126
|
const period = built.period;
|
|
105
|
-
for (
|
|
127
|
+
for (let i = 0; i < built.tracks.length; i++) {
|
|
128
|
+
const track = built.tracks[i];
|
|
106
129
|
const delay = track.delay;
|
|
107
130
|
const shifted = delay === 0 || period === 0 ? phase : Math.max(0, (phase * period - delay) / period);
|
|
108
|
-
const
|
|
131
|
+
const perMs = slopes?.[track.channel];
|
|
132
|
+
const slope = perMs === undefined || period === 0
|
|
133
|
+
? undefined
|
|
134
|
+
: typeof perMs === 'number'
|
|
135
|
+
? perMs * period
|
|
136
|
+
: perMs.map((s) => s * period);
|
|
137
|
+
const value = read(track, shifted, base?.[track.channel], lerps?.[i], slope);
|
|
109
138
|
if (value !== undefined)
|
|
110
139
|
out[track.channel] = value;
|
|
111
140
|
else if (out[track.channel] !== undefined)
|
package/dist/types.d.ts
CHANGED
|
@@ -227,7 +227,14 @@ export interface Handle<I = unknown> {
|
|
|
227
227
|
readonly state: 'pending' | 'live' | 'fading' | 'done';
|
|
228
228
|
/** Live. Writes land on the next sync. */
|
|
229
229
|
weight: number;
|
|
230
|
+
/** Playback rate now. Setting it changes speed at once; `ramp` eases into a new one. */
|
|
230
231
|
rate: number;
|
|
232
|
+
/**
|
|
233
|
+
* Moves the rate to `rate` linearly over `over` mix ms, so a pause or a slow-motion eases in
|
|
234
|
+
* rather than snapping. The voice clock integrates the ramp, so its position stays continuous and
|
|
235
|
+
* its speed does too. A later `rate` write or `ramp` replaces it; `seek` keeps it.
|
|
236
|
+
*/
|
|
237
|
+
ramp(rate: number, over: number): void;
|
|
231
238
|
/**
|
|
232
239
|
* Moves this voice's clock, forward or back. Phase is computed from the reading rather than
|
|
233
240
|
* accumulated into, so state is left where it is and never run forward to meet the new position.
|
package/docs/schema.html
CHANGED
|
@@ -398,6 +398,8 @@ interface Handle<I> {
|
|
|
398
398
|
into, so nothing has to unwind: state is left where it is and is never run forward to meet the new
|
|
399
399
|
position. Mix time is not addressable this way, and nothing outside this voice moves. */</span>
|
|
400
400
|
seek(elapsed: number): void;
|
|
401
|
+
<span class="cm">/** Moves the rate to `rate` linearly over `over` mix ms. The voice clock integrates it, so speed eases too. */</span>
|
|
402
|
+
ramp(rate: number, over: number): void;
|
|
401
403
|
<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
404
|
fade(opts?: { over?: number; at?: 'rest'; deadline?: number }): void;
|
|
403
405
|
<span class="cm">/** The weight this voice gave `subject` the last frame it was probed: after fades and its signal, before a
|
|
@@ -499,7 +501,7 @@ function gate<I>(of: Signal<I>, at: number): Signal<I>;
|
|
|
499
501
|
<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
502
|
<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
503
|
<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 — every other voice folded in, itself excluded — and lerps from there through the channel's <code>lerp</code
|
|
504
|
+
<tr><td>Retarget on interruption</td><td><code>from: 'current'</code> on a <code>keys</code> voice takes stop 0 from the subject's pose in the frame before it first contributes — every other voice folded in, itself excluded — and lerps from there through the channel's <code>lerp</code>. It also leaves at the velocity the subject had, taken from its last two probed poses: the first segment gains <code>c·u(1−u)²</code>, which is zero at both ends and flat at the end, so the segment's own curve still lands on its stop while the start matches the subject's speed. A subject that was still leaves at rest. With fewer than two poses on record, as on a mix whose host probes into its own object before any retarget is cued, it starts on the segment's own curve.</td><td>wod's base-sampling. New to klieg: a regroup could tween from mid-float instead of carrying it away.</td></tr>
|
|
503
505
|
</table></div>
|
|
504
506
|
|
|
505
507
|
<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, Σw)</code> and is scaled toward rest like any other. On a <code>sum</code> channel this is exactly <code>w₁a + w₂b</code>, the arithmetic klieg's timeline already runs, so the cap it was written as is a special case of the fold rather than a rule of its own. On <code>mul</code>, <code>max</code> and <code>last</code> it is the difference between an alternative and a pile-up: two voices halfway through a handover, both holding a gain of 0.06, fold to 0.06 — which is what one alternative means — where scaling and merging reads 0.28, the product of two half-scaled copies. A grouped voice may write only channels that have a <code>lerp</code>, which every stock channel does. Cue order is visible only to a <code>lerp</code> that is not linear, such as a hue taking the short way round.</p>
|
|
@@ -509,6 +511,23 @@ function gate<I>(of: Signal<I>, at: number): Signal<I>;
|
|
|
509
511
|
<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
512
|
</div>
|
|
511
513
|
|
|
514
|
+
<h2 id="motion">Springs and glides</h2>
|
|
515
|
+
<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>
|
|
516
|
+
<pre><code>const s = spring<Node, Pose>('x', { from: 0, to: 100, stiffness: 170, damping: 26 });
|
|
517
|
+
mix.cue({ patch: s });
|
|
518
|
+
s.to(node, 40); <span class="cm">// heads elsewhere from wherever it is, keeping its velocity</span>
|
|
519
|
+
s.push(node, 600); <span class="cm">// a kick: same target, new velocity</span>
|
|
520
|
+
s.read(node); <span class="cm">// { value, velocity }, for handing the motion to another patch</span>
|
|
521
|
+
|
|
522
|
+
const g = glide<Node, Pose>('x', { from: 0, velocity: 1200, ms: 325 }); <span class="cm">// a fling: rests 390 past the start</span></code></pre>
|
|
523
|
+
<ul>
|
|
524
|
+
<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>
|
|
525
|
+
<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>
|
|
526
|
+
<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>
|
|
527
|
+
<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>
|
|
528
|
+
<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>
|
|
529
|
+
</ul>
|
|
530
|
+
|
|
512
531
|
<h2 id="state">Patch state</h2>
|
|
513
532
|
<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
533
|
|
|
@@ -609,7 +628,7 @@ export const mixer: Engine; <span class="cm">// the one that ships</span></cod
|
|
|
609
628
|
|
|
610
629
|
<h2 id="open">Open</h2>
|
|
611
630
|
<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>
|
|
631
|
+
<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 — 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>
|
|
613
632
|
<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 — never what a voice contributes. Meaning stays in the patches and the kit’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’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 — 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’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 — a voice starting, a fade ending, a loop running out — 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 — a <code>level</code> the host sets, a pointer — 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’s own start, where its state was made fresh, exact where only the clock drove it. An outside input’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 — <code>start</code> (fade-in begins), <code>in</code> (full weight), <code>out</code> (fade-out begins) and <code>end</code> (gone) — 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’s mark with an offset: <code>anchor: { in: 12_400 }</code> is fully in by 12.4 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 ms before the next voice that writes <code>gain</code>. A selector reads only what the score knows — never a channel’s value, so “when gain crosses 0.5” 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 — a string several voices share to make a set — 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’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 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’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 — a name not cued, no voice matching, the <code>out</code> of a voice that loops for good — 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’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
633
|
<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
634
|
<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>
|
package/package.json
CHANGED