@msb235/blits 0.2.1 → 0.4.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 CHANGED
@@ -4,6 +4,146 @@ 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.4.0
8
+
9
+ ### Breaking
10
+
11
+ - A tween's `MotionSpec` reads `ms: undefined` where its `ms` is a function of the subject.
12
+ - `Handle.state` can read `'held'`. Only a voice cued with `hold` reaches it, so nothing changes at
13
+ run time for one cued without, but a `switch` over every state no longer covers them all.
14
+
15
+ ### Added
16
+
17
+ - `mix.inert` is true when another frame would change no pose, so a host's loop may sleep while
18
+ `live` is still true: every voice is done, held after its passes at a plain weight, or a motion
19
+ whose every subject has landed on its target. A retarget, a fade or a cue makes it false again.
20
+ - `handle.fade({ subject, over })` fades one subject out of a voice, which plays on for the rest.
21
+ Once the ramp ends the voice forgets the subject (its record, its lane position and a motion
22
+ patch's state for it) and no longer reaches it; a motion patch's `to` brings it back, met afresh.
23
+ `mix.drop(subject)` clears it too. It keeps the voice on its lane.
24
+ - `tween`'s `ms` may be a function of the subject, asked each time one of its stretches starts.
25
+ - `hold: 'before' | 'after' | 'both'` on a voice. Before, a subject shows the voice's first frame
26
+ while the voice is pending and while the subject waits out its `stagger`; after, a finite loop
27
+ shows its last frame once its passes are done and stays, `held`, until faded. `fade.in` counts
28
+ from the first frame shown. What WAAPI calls `fill`.
29
+ - `handle.played` resolves true when a finite loop's last pass ends, false if the voice leaves
30
+ first. It never rejects.
31
+
32
+ ### Fixed
33
+
34
+ - A `keys` or stateless `fn` voice naming one subject and writing one channel fills from its
35
+ channel's crowd, as a motion voice does, where it took the general path. On teitou 10k `fn`
36
+ voices of one subject each read by `pull` take 0.7 of the time they did.
37
+ - A voice naming its subjects that is cued, starts or retires relinks only those subjects, and
38
+ joins or leaves its crowd in place, where it made every subject relink and the lanes qualify
39
+ every voice again: a frame stopping one of 10k such voices and cueing another fell from about
40
+ 9 ms to 0.6 by `pull` on teitou. `mix.drop` looks only at the voices over every subject and
41
+ those naming the one dropped, where it walked every voice.
42
+ - `mix.drop` frees a subject from a voice faded off it with `handle.fade({ subject })`, and a mix
43
+ whose last ramp of that kind is dropped can read `inert` again; it also frees the subject from a
44
+ motion patch that another voice plays on after the voice naming the subject left.
45
+ - `pull` reads a list it read before in the same order from the lanes in one run, checking the
46
+ frame once rather than per subject: a tween voice over 10k subjects read by `pull` takes about
47
+ 0.83 of the time it did on teitou.
48
+ - A lane gives a `vec(1)` channel an array of one, as the general path does, where it gave a
49
+ bare number; and a motion voice on one folds without making an array per subject per frame.
50
+ - A `tween` divides elapsed by its length in milliseconds, so a point a whole fraction of the way
51
+ in lands exactly: 150 ms into a 200 ms linear tween from 0 to 100 reads 75, not 74.99999999999999.
52
+ - `pull` over a list read again in the same order skips looking each subject up, and copies lane
53
+ values a column at a time; a motion voice's lane fills in one loop. One tween voice over 10k
54
+ subjects read by `pull` takes 0.59–0.66 of the time it did on teitou; a spring 0.68–0.74.
55
+ - Motion voices of one subject each fill from one crowd of flat rows per channel, and `sync` visits
56
+ only voices with something due: 10k tween voices of one subject each take 0.54–0.59 of the time
57
+ they did on teitou, 100k take 0.31–0.33.
58
+
59
+ ## 0.3.0
60
+
61
+ ### Breaking
62
+
63
+ - `spring` and `glide` build patches of a third form, `'motion'`, instead of `'fn'`, and carry
64
+ their kind and constants on `patch.motion`, a `MotionSpec`. `Patch.form` and `Engine.runs` take
65
+ `'motion'`, so an engine that declares `runs` must list it to run a spring or a glide. A motion
66
+ patch throws when two of its subjects, or a subject's start, target and velocity, or a `to` or
67
+ `push`, move on different numbers of axes. It makes a subject's first stretch at its first `to`
68
+ or `push` as well as at its first read, so `read(subject, at)` answers from then on.
69
+ - An untimed retarget lands at the subject's own voice time at the mix's latest sync, not at the
70
+ subject's next read, so a subject the host did not probe changes at the same moment as one it
71
+ did; `read` with no time answers at that time, counting every change due by then. For a subject
72
+ no frame of its voice has met yet, or still inside its stagger, an untimed change applies at its
73
+ first read, after any timed change due by then, and `read` with no time returns undefined, as it
74
+ does once the voice is gone. Changes apply in time order, not the order they were made.
75
+ - `drop(subject)` also forgets a motion patch's state for the subject, so it starts afresh.
76
+ - With lanes on, a stateless patch calling `setting.send` from `at` sends every frame for every
77
+ subject the mix has met, probed that frame or not; and a patch that first calls `setting.keep`
78
+ partway through playing can advance that state once more for one subject the host did not probe
79
+ that frame.
80
+ - `mixHex`, and so the `hex` channel, blends in OKLCH rather than sRGB, taking hue the short way
81
+ round and a gray end's hue from the other end. A crossfade from red to blue passes through
82
+ 0xba00c2 rather than 0x800080, and black to white through 0x636363 rather than 0x808080. A blend
83
+ costs about 45 ns where the two ends repeat frame to frame and 210 ns where every call brings new
84
+ colors, against 8 ns in sRGB. `hex({ space: 'srgb' })` keeps the sRGB blend.
85
+ - `Mix` has a `pull` method, so an engine's `create` must return a mix that implements it.
86
+
87
+ ### Added
88
+
89
+ - `tween(writes, { from, to, ms, ease? })`, a motion patch that eases each subject from one value
90
+ to another over `ms`, with `from` and `to` given per subject and read once when the mix first
91
+ meets it. `to(subject, target)` retargets one subject from where it is over a full `ms` again,
92
+ and `read` reports value and velocity as a spring's does. It runs on lanes as springs do, with
93
+ each subject's endpoints as data, so a tween over every subject no longer needs a `fn` that looks
94
+ its endpoints up on each call: in weasel's shape (10,000 string ids, endpoints in a map) a frame
95
+ costs 0.83 of that `fn`'s and pauses for collection a quarter as long. A motion lane no longer
96
+ allocates a delta per subject either, which takes a spring frame over 10,000 subjects to
97
+ 0.82–0.90 of the last build's. `MotionSpec` gains
98
+ `{ kind: 'tween', ms, ease }`.
99
+ - Lanes: a channel every voice writing it can run that way is computed for every subject at once,
100
+ in flat arrays, at the frame's first probe, rather than subject by subject, and gives the same
101
+ pose. `keys`, a stateless `fn` and `motion` voices with a number weight qualify. Three `keys`
102
+ voices over 10,000 subjects fell from 6.1 to 2.6 ms a frame. A lane whose subjects were mostly
103
+ left unprobed last frame stops filling and its subjects take the general path until probes pick
104
+ up again, so a host probing 5% of 10,000 subjects pays about what it would with lanes off, not
105
+ ten times it. `MixOptions.lanes: false` turns lanes off. They cost a little on frames they
106
+ don't serve: a voice per subject reads up to 7% slower at 10,000 subjects (about 20% at
107
+ 100–1,000, a few hundredths of a millisecond), a mix probing few of its subjects about 0.03 ms
108
+ more, and a projection made every frame about 10% more.
109
+ - `mix.pull(subjects, { channel: Float64Array, ... })` writes each subject's pose into one array per
110
+ channel, in the order given, giving what `probe(subject, out)` gives without a pose object per
111
+ subject. A channel of `n` numbers takes `n` places a subject; one with no value writes NaN. While
112
+ every voice runs on a lane it copies straight from the lanes: a frame of three `keys` voices
113
+ over 10,000 subjects costs 0.84 of the same frame read through `probe`, a spring 0.91.
114
+ - `mix.project(timestamp)` reads the mix at another time without moving it: `probe` gives the pose
115
+ then, and `assess` says per channel whether it is `exact`, `stepped` or `held`. Ahead it plays
116
+ what is cued forward; behind it needs `MixOptions.history: { ms, every? }`, which keeps every
117
+ handle change, the voices that left, and copies of stateful state, so a read back under `stepMs`
118
+ lands on the pose the mix showed, and with `inputs` it records what input weight signals and the
119
+ host fields patches `reads` held, so a read back over a `level` or a pointer is known. `Patch.clone` copies state `structuredClone` cannot.
120
+ - The score: a voice takes a `name` and an `anchor` placement, `start` or `in` and `out` or `end`,
121
+ each a timestamp or another voice's mark (`after`, `with`, `before`, or `of` plus `mark`),
122
+ selected by name, tag or written channel with a resolver. Each source can keep its own `score`,
123
+ with names namespaced per score. `mix.announce(name, { at? })` puts a named mark on a score for
124
+ anchors to wait on, and `mix.marks(from, to)` lists every mark the plan knows.
125
+ - `spring` and `glide` keep every stretch within the mix's history, so a read before a retarget
126
+ finds where the subject was.
127
+ - A cue can name its subjects, `subjects: [a, b]`, in place of a `target`. The mix files the voice
128
+ under each one, so a voice per subject costs the same as one voice: the first frame of 1,000
129
+ subjects on a voice each fell from about 500 ms to 1 ms.
130
+
131
+ ### Fixed
132
+
133
+ - A voice faded before its start plays once its start arrives. It used to stay out of every
134
+ subject's fold until some other voice was cued or left.
135
+ - A probe after a `seek` in the same frame reads the voice where the seek put it, rather than the
136
+ value an earlier probe that frame read before the seek. A stateful patch still steps once.
137
+ - A probe looks its subject up once rather than once per voice, which makes frames with several
138
+ voices 5–12% faster.
139
+ - A `keys` patch reads a keyed `vec` channel into an array its voice reuses, rather than a new one
140
+ each read, which halves collections at 10,000 subjects and leaves frame time unchanged.
141
+
142
+ - A finite loop's fade out starts when its last pass ended, not at the first frame after, so it
143
+ plays the same at any frame rate.
144
+ - The published `package.json` no longer lists `workspaces: ["site"]`, which only the repo's docs
145
+ site uses.
146
+
7
147
  ## 0.2.1
8
148
 
9
149
  ### Added
package/README.md CHANGED
@@ -68,10 +68,17 @@ npm run site:smoke # build, then load every page headless and fail on any erro
68
68
  `.github/workflows/site.yml` deploys it to GitHub Pages. It runs only by hand until Pages is enabled
69
69
  on the repo.
70
70
 
71
+ ## Packages
72
+
73
+ `packages/quarks` is `@msb235/blits-quarks`, a driver that turns poses into
74
+ [three.quarks](https://github.com/Alchemist0823/three.quarks) particles. It is published separately
75
+ so the engine keeps no dependencies; its README says how it works.
76
+
71
77
  ## Status
72
78
 
73
- v0.1.0, and one real consumer: [klieg](https://github.com/orochi235/klieg) composes all three of
74
- its systems — letter motion, part effects, and the environment a sign is lit by — on a mix each.
79
+ v0.4.0, with two consumers. magicsmoke runs every fault on it, and
80
+ [klieg](https://github.com/orochi235/klieg) composes all three of its systems — letter motion,
81
+ part effects, and the environment a sign is lit by — on a mix each.
75
82
  That port was an extraction rather than a rewrite, so it is also the evidence that the arithmetic
76
83
  here is the arithmetic a working renderer already had: klieg's suite of 1974 cases and its
77
84
  screenshot baselines came through it unchanged.
@@ -1,5 +1,31 @@
1
1
  import type { Channel, Kit } from './types.js';
2
- /** What a stock numeric channel takes. */
2
+ /**
3
+ * Writes `lerp(a, b, u)` into `out` and returns it, or returns a new array where `out` is absent or
4
+ * the wrong length. The caller must own `out`: nothing else may hold it.
5
+ */
6
+ export type LerpInto = (out: unknown[] | undefined, a: unknown, b: unknown, u: number) => unknown[];
7
+ /**
8
+ * The in-place form of a stock array channel's `lerp`. Keyed by the channel rather than its `lerp`,
9
+ * since only the stock fold and merge are known to copy the array rather than keep it.
10
+ */
11
+ export declare function lerpInto(channel: Channel<unknown>): LerpInto | undefined;
12
+ /** What a lane needs of a stock numeric channel beyond the channel: its arithmetic by name, its axes. */
13
+ export interface Numeric {
14
+ op: 'sum' | 'mul' | 'max';
15
+ axes: number;
16
+ }
17
+ /**
18
+ * The stock arithmetic of a channel `sum`, `mul`, `max` or `vec` made, keyed by the object itself,
19
+ * so a custom channel that claims a stock `kind` is not trusted with a lane.
20
+ */
21
+ export declare function numericOf(channel: Channel<unknown>): Numeric | undefined;
22
+ /** `merge(acc, scale(v, w))` for a stock numeric channel, in exactly its arithmetic. */
23
+ export declare function foldNumber(op: Numeric['op'], acc: number, v: number, w: number): number;
24
+ /**
25
+ * What a stock numeric channel takes.
26
+ *
27
+ * @category channel
28
+ */
3
29
  export interface NumberOptions {
4
30
  /** The range the value means anything in; the mix clamps to it. */
5
31
  bounds?: readonly [min: number, max: number];
@@ -39,18 +65,21 @@ export declare function last<V>(opts?: {
39
65
  */
40
66
  export declare function vec(n: number, of: Channel<number>): Channel<number[]>;
41
67
  /**
42
- * sRGB, which is what klieg's light channel already assumes, so the port's arithmetic is
43
- * unchanged. OKLCH reads better across hues and is the open question, not a second function.
68
+ * OKLCH, hue the short way round, so a crossfade between hues keeps its lightness and saturation
69
+ * instead of dipping through a muddy middle. A mix that leaves sRGB's gamut is clipped per channel.
44
70
  *
45
71
  * @category channel
46
72
  */
47
73
  export declare function mixHex(a: number, b: number, u: number): number;
48
74
  /**
49
- * Color, as 0xrrggbb.
75
+ * Color, as 0xrrggbb, blended in OKLCH as `mixHex` does, or with `space: 'srgb'` channel by
76
+ * channel in sRGB, which keeps a port's arithmetic identical to a host that blended that way.
50
77
  *
51
78
  * @category channel
52
79
  */
53
- export declare function hex(): Channel<number>;
80
+ export declare function hex(opts?: {
81
+ space?: 'oklch' | 'srgb';
82
+ }): Channel<number>;
54
83
  /**
55
84
  * A kit is plain data; this only fixes the type.
56
85
  *
package/dist/channels.js CHANGED
@@ -1,4 +1,34 @@
1
1
  const mix = (a, b, u) => a + (b - a) * u;
2
+ const inPlace = new WeakMap();
3
+ /**
4
+ * The in-place form of a stock array channel's `lerp`. Keyed by the channel rather than its `lerp`,
5
+ * since only the stock fold and merge are known to copy the array rather than keep it.
6
+ */
7
+ export function lerpInto(channel) {
8
+ return inPlace.get(channel);
9
+ }
10
+ /** The stock channels' arithmetic, once: their `merge` and `scale` are these, and so is `foldNumber`. */
11
+ const add = (a, b) => a + b;
12
+ const times = (v, w) => v * w;
13
+ const product = (a, b) => a * b;
14
+ const toward = (v, w) => 1 + (v - 1) * w;
15
+ const larger = (a, b) => (a > b ? a : b);
16
+ const numerics = new WeakMap();
17
+ /**
18
+ * The stock arithmetic of a channel `sum`, `mul`, `max` or `vec` made, keyed by the object itself,
19
+ * so a custom channel that claims a stock `kind` is not trusted with a lane.
20
+ */
21
+ export function numericOf(channel) {
22
+ return numerics.get(channel);
23
+ }
24
+ /** `merge(acc, scale(v, w))` for a stock numeric channel, in exactly its arithmetic. */
25
+ export function foldNumber(op, acc, v, w) {
26
+ if (op === 'sum')
27
+ return add(acc, times(v, w));
28
+ if (op === 'mul')
29
+ return product(acc, toward(v, w));
30
+ return larger(acc, times(v, w));
31
+ }
2
32
  /** Bounds change how a value folds at its edges, so they are part of the kind. */
3
33
  const kindOf = (name, bounds) => bounds === undefined ? name : `${name}[${bounds[0]}, ${bounds[1]}]`;
4
34
  /**
@@ -7,14 +37,16 @@ const kindOf = (name, bounds) => bounds === undefined ? name : `${name}[${bounds
7
37
  * @category channel
8
38
  */
9
39
  export function sum(opts) {
10
- return {
40
+ const channel = {
11
41
  kind: kindOf('sum', opts?.bounds),
12
42
  bounds: opts?.bounds,
13
43
  rest: 0,
14
- merge: (a, b) => a + b,
15
- scale: (v, w) => v * w,
44
+ merge: add,
45
+ scale: times,
16
46
  lerp: mix,
17
47
  };
48
+ numerics.set(channel, { op: 'sum', axes: 1 });
49
+ return channel;
18
50
  }
19
51
  /**
20
52
  * Gain, scale, opacity, hold.
@@ -22,14 +54,16 @@ export function sum(opts) {
22
54
  * @category channel
23
55
  */
24
56
  export function mul(opts) {
25
- return {
57
+ const channel = {
26
58
  kind: kindOf('mul', opts?.bounds),
27
59
  bounds: opts?.bounds,
28
60
  rest: 1,
29
- merge: (a, b) => a * b,
30
- scale: (v, w) => 1 + (v - 1) * w,
61
+ merge: product,
62
+ scale: toward,
31
63
  lerp: mix,
32
64
  };
65
+ numerics.set(channel, { op: 'mul', axes: 1 });
66
+ return channel;
33
67
  }
34
68
  /**
35
69
  * Dark, aperture.
@@ -37,14 +71,16 @@ export function mul(opts) {
37
71
  * @category channel
38
72
  */
39
73
  export function max(opts) {
40
- return {
74
+ const channel = {
41
75
  kind: kindOf('max', opts?.bounds),
42
76
  bounds: opts?.bounds,
43
77
  rest: 0,
44
- merge: (a, b) => (a > b ? a : b),
45
- scale: (v, w) => v * w,
78
+ merge: larger,
79
+ scale: times,
46
80
  lerp: mix,
47
81
  };
82
+ numerics.set(channel, { op: 'max', axes: 1 });
83
+ return channel;
48
84
  }
49
85
  /**
50
86
  * A channel with no rest: the last influence to pass wins, and a weight can only gate it. The
@@ -61,16 +97,37 @@ export function last(opts) {
61
97
  lerp: opts?.lerp ?? ((a, b, u) => (u < 0.5 ? a : b)),
62
98
  };
63
99
  }
100
+ /**
101
+ * `n` copies of `v` in an array the engine stores as doubles from the start, so a copy of it, and
102
+ * every fraction written into that copy, keeps one element kind: a pose's arrays then look alike to
103
+ * every fold, whichever path made them.
104
+ */
105
+ function doubles(n, v) {
106
+ if (n === 0)
107
+ return [];
108
+ const a = [0.5];
109
+ for (let i = 1; i < n; i++)
110
+ a.push(0.5);
111
+ for (let i = 0; i < n; i++)
112
+ a[i] = v;
113
+ return a;
114
+ }
64
115
  /**
65
116
  * Vec3 position, premultiplied light: one channel's arithmetic applied down an axis list.
66
117
  *
67
118
  * @category channel
68
119
  */
69
120
  export function vec(n, of) {
70
- const rest = of.rest === undefined ? undefined : new Array(n).fill(of.rest);
121
+ const rest = of.rest === undefined ? undefined : doubles(n, of.rest);
71
122
  const scale = of.scale;
72
123
  const fill = of.rest ?? 0;
73
- return {
124
+ const lerpTo = (out, a, b, u) => {
125
+ const o = out?.length === n ? out : new Array(n);
126
+ for (let i = 0; i < n; i++)
127
+ o[i] = of.lerp(a[i] ?? fill, b[i] ?? fill, u);
128
+ return o;
129
+ };
130
+ const channel = {
74
131
  kind: of.kind === undefined ? undefined : `vec(${n}, ${of.kind})`,
75
132
  bounds: of.bounds,
76
133
  rest,
@@ -95,22 +152,113 @@ export function vec(n, of) {
95
152
  into[i] = of.merge(into[i] ?? fill, scale(v[i] ?? fill, w));
96
153
  return into;
97
154
  },
98
- lerp: (a, b, u) => {
99
- const out = new Array(n);
100
- for (let i = 0; i < n; i++)
101
- out[i] = of.lerp(a[i] ?? fill, b[i] ?? fill, u);
102
- return out;
103
- },
155
+ lerp: (a, b, u) => lerpTo(undefined, a, b, u),
104
156
  };
157
+ inPlace.set(channel, lerpTo);
158
+ const inner = numerics.get(of);
159
+ if (inner !== undefined && inner.axes === 1)
160
+ numerics.set(channel, { ...inner, axes: n });
161
+ return channel;
105
162
  }
106
- const clamp255 = (v) => (v < 0 ? 0 : v > 255 ? 255 : Math.round(v));
163
+ const LINEAR = Array.from({ length: 256 }, (_, c) => {
164
+ const v = c / 255;
165
+ return v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
166
+ });
167
+ /** The linear value halfway between byte c - 1 and c: at or above it, a value rounds to c. */
168
+ const ROUNDS_UP = LINEAR.map((_, c) => {
169
+ const v = (c - 0.5) / 255;
170
+ return v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
171
+ });
172
+ /** The byte each 1/4096 of linear light starts at; a bin holds at most one rounding point. */
173
+ const BINS = 4096;
174
+ const FIRST = Array.from({ length: BINS + 1 }, (_, i) => {
175
+ let c = 0;
176
+ while (c < 255 && i / BINS >= ROUNDS_UP[c + 1])
177
+ c++;
178
+ return c;
179
+ });
180
+ const toByte = (v) => {
181
+ if (v <= 0)
182
+ return 0;
183
+ if (v >= 1)
184
+ return 255;
185
+ const c = FIRST[(v * BINS) | 0];
186
+ return c < 255 && v >= ROUNDS_UP[c + 1] ? c + 1 : c;
187
+ };
188
+ /** Below this chroma a color is gray and its hue is noise, so the lerp takes the other end's. */
189
+ const GRAY = 1e-4;
107
190
  /**
108
- * sRGB, which is what klieg's light channel already assumes, so the port's arithmetic is
109
- * unchanged. OKLCH reads better across hues and is the open question, not a second function.
191
+ * Colors already turned into OKLCH, direct-mapped on a byte of their hash: a mix reads the same
192
+ * few keyframe colors every frame, and the table stays 256 colors however many it meets.
193
+ */
194
+ const SLOTS = 256;
195
+ const slotColor = new Int32Array(SLOTS).fill(-1);
196
+ const slotL = new Float64Array(SLOTS);
197
+ const slotC = new Float64Array(SLOTS);
198
+ const slotH = new Float64Array(SLOTS);
199
+ /** 0xrrggbb to its slot, filled with [L, C, h], h in radians; Björn Ottosson's OKLab matrices. */
200
+ function oklch(rgb) {
201
+ const slot = Math.imul(rgb, 0x9e3779b1) >>> 24;
202
+ if (slotColor[slot] === rgb)
203
+ return slot;
204
+ const r = LINEAR[(rgb >> 16) & 0xff];
205
+ const g = LINEAR[(rgb >> 8) & 0xff];
206
+ const b = LINEAR[rgb & 0xff];
207
+ const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
208
+ const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
209
+ const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
210
+ const A = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s;
211
+ const B = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s;
212
+ slotColor[slot] = rgb;
213
+ slotL[slot] = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s;
214
+ slotC[slot] = Math.sqrt(A * A + B * B);
215
+ slotH[slot] = Math.atan2(B, A);
216
+ return slot;
217
+ }
218
+ /** [L, C, h] to 0xrrggbb, clipping each channel to sRGB's gamut. */
219
+ function fromOklch(L, C, h) {
220
+ const A = C * Math.cos(h);
221
+ const B = C * Math.sin(h);
222
+ const l_ = L + 0.3963377774 * A + 0.2158037573 * B;
223
+ const m_ = L - 0.1055613458 * A - 0.0638541728 * B;
224
+ const s_ = L - 0.0894841775 * A - 1.291485548 * B;
225
+ const l = l_ * l_ * l_;
226
+ const m = m_ * m_ * m_;
227
+ const s = s_ * s_ * s_;
228
+ const r = 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s;
229
+ const g = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s;
230
+ const b = -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s;
231
+ return (toByte(r) << 16) | (toByte(g) << 8) | toByte(b);
232
+ }
233
+ /**
234
+ * OKLCH, hue the short way round, so a crossfade between hues keeps its lightness and saturation
235
+ * instead of dipping through a muddy middle. A mix that leaves sRGB's gamut is clipped per channel.
110
236
  *
111
237
  * @category channel
112
238
  */
113
239
  export function mixHex(a, b, u) {
240
+ if (u === 0 || a === b)
241
+ return a;
242
+ if (u === 1)
243
+ return b;
244
+ const sa = oklch(a);
245
+ const al = slotL[sa];
246
+ const ac = slotC[sa];
247
+ const ah = slotH[sa];
248
+ const sb = oklch(b);
249
+ const bc = slotC[sb];
250
+ const from = ac < GRAY ? slotH[sb] : ah;
251
+ const to = bc < GRAY ? ah : slotH[sb];
252
+ let turn = to - from;
253
+ if (turn > Math.PI)
254
+ turn -= 2 * Math.PI;
255
+ else if (turn < -Math.PI)
256
+ turn += 2 * Math.PI;
257
+ return fromOklch(mix(al, slotL[sb], u), mix(ac, bc, u), from + turn * u);
258
+ }
259
+ const clamp255 = (v) => (v < 0 ? 0 : v > 255 ? 255 : Math.round(v));
260
+ /** Each of red, green and blue on its own, as klieg's light channel blends. */
261
+ function mixSrgb(a, b, u) {
114
262
  const ar = (a >> 16) & 0xff;
115
263
  const ag = (a >> 8) & 0xff;
116
264
  const ab = a & 0xff;
@@ -120,12 +268,13 @@ export function mixHex(a, b, u) {
120
268
  return ((clamp255(mix(ar, br, u)) << 16) | (clamp255(mix(ag, bg, u)) << 8) | clamp255(mix(ab, bb, u)));
121
269
  }
122
270
  /**
123
- * Color, as 0xrrggbb.
271
+ * Color, as 0xrrggbb, blended in OKLCH as `mixHex` does, or with `space: 'srgb'` channel by
272
+ * channel in sRGB, which keeps a port's arithmetic identical to a host that blended that way.
124
273
  *
125
274
  * @category channel
126
275
  */
127
- export function hex() {
128
- return { ...last({ lerp: mixHex }), kind: 'hex' };
276
+ export function hex(opts) {
277
+ return { ...last({ lerp: opts?.space === 'srgb' ? mixSrgb : mixHex }), kind: 'hex' };
129
278
  }
130
279
  /**
131
280
  * A kit is plain data; this only fixes the type.
@@ -0,0 +1,19 @@
1
+ import type { Curve } from './easing.js';
2
+ /** How many passes a voice's `loop` plays. */
3
+ export declare function passesOf(loop: boolean | number | undefined): number;
4
+ /**
5
+ * The phase `elapsed` voice ms into a patch of `period` ms playing `passes` passes, held at 1 once
6
+ * they are done. A pure function rather than one writing phase and pass together, so a hot caller
7
+ * keeps both in registers.
8
+ */
9
+ export declare function phaseAt(elapsed: number, period: number, passes: number): number;
10
+ /** The pass `elapsed` voice ms falls in, held at the last once they are done. */
11
+ export declare function passAt(elapsed: number, period: number, passes: number): number;
12
+ /** A voice's own fade this frame, 0..1: in from `since` over `fadeIn`, and out along its ramp. */
13
+ export declare function envelope(fadeIn: number, out: {
14
+ at: number;
15
+ over: number;
16
+ rest: boolean;
17
+ } | null, ease: Curve | undefined, reduced: boolean, now: number, since: number): number;
18
+ /** A weight held to 0..1. */
19
+ export declare function clampWeight(raw: number): number;
package/dist/clock.js ADDED
@@ -0,0 +1,44 @@
1
+ /** How many passes a voice's `loop` plays. */
2
+ export function passesOf(loop) {
3
+ const l = loop ?? true;
4
+ return l === true ? Number.POSITIVE_INFINITY : l === false ? 1 : l;
5
+ }
6
+ /** Whether `elapsed` voice ms is past the last of `passes` passes of a patch of `period` ms. */
7
+ const over = (elapsed, period, passes) => Number.isFinite(passes) && elapsed >= period * passes;
8
+ /**
9
+ * The phase `elapsed` voice ms into a patch of `period` ms playing `passes` passes, held at 1 once
10
+ * they are done. A pure function rather than one writing phase and pass together, so a hot caller
11
+ * keeps both in registers.
12
+ */
13
+ export function phaseAt(elapsed, period, passes) {
14
+ if (!(period > 0))
15
+ return 0;
16
+ return over(elapsed, period, passes) ? 1 : (elapsed % period) / period;
17
+ }
18
+ /** The pass `elapsed` voice ms falls in, held at the last once they are done. */
19
+ export function passAt(elapsed, period, passes) {
20
+ if (!(period > 0))
21
+ return 0;
22
+ return over(elapsed, period, passes) ? passes - 1 : Math.floor(elapsed / period);
23
+ }
24
+ /** A voice's own fade this frame, 0..1: in from `since` over `fadeIn`, and out along its ramp. */
25
+ export function envelope(fadeIn, out, ease, reduced, now, since) {
26
+ let w = 1;
27
+ if (fadeIn > 0 && !reduced) {
28
+ const u = (now - since) / fadeIn;
29
+ if (u < 1)
30
+ w *= ease ? ease(Math.max(0, u)) : Math.max(0, u);
31
+ }
32
+ if (out && !out.rest) {
33
+ if (reduced || out.over === 0)
34
+ return 0;
35
+ const u = 1 - (now - out.at) / out.over;
36
+ const clamped = u < 0 ? 0 : u > 1 ? 1 : u;
37
+ w *= ease ? ease(clamped) : clamped;
38
+ }
39
+ return w;
40
+ }
41
+ /** A weight held to 0..1. */
42
+ export function clampWeight(raw) {
43
+ return raw < 0 ? 0 : raw > 1 ? 1 : raw;
44
+ }
package/dist/index.d.ts CHANGED
@@ -2,8 +2,8 @@ export type { NumberOptions } from './channels.js';
2
2
  export { hex, kit, last, max, mixHex, mul, sum, vec } from './channels.js';
3
3
  export { mix, mixer } from './mixer.js';
4
4
  export type { Motion, Moving, PerSubject, Value } from './motion.js';
5
- export { glide, spring } from './motion.js';
5
+ export { glide, spring, tween } from './motion.js';
6
6
  export type { KeysOptions, PatchOptions } from './patch.js';
7
7
  export { keys, patch } from './patch.js';
8
8
  export { gate, lag, level, peak, slew } from './signals.js';
9
- export type { Channel, Easing, Engine, FadeOptions, FadeSpec, Handle, Keyframe, Kit, Mix, MixOptions, Patch, Sent, Setting, Signal, VoiceSpec, } from './types.js';
9
+ export type { Anchor, Channel, Columns, Doubt, Easing, Engine, FadeOptions, FadeSpec, Handle, Keyframe, Kit, Mark, Marked, Mix, MixOptions, MotionSpec, Patch, Placement, Projection, Query, Sent, Setting, Signal, VoiceSpec, } from './types.js';
package/dist/index.js CHANGED
@@ -1,5 +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
+ export { glide, spring, tween } from './motion.js';
4
4
  export { keys, patch } from './patch.js';
5
5
  export { gate, lag, level, peak, slew } from './signals.js';