@msb235/blits 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,102 @@ 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.3.0
8
+
9
+ ### Breaking
10
+
11
+ - `spring` and `glide` build patches of a third form, `'motion'`, instead of `'fn'`, and carry
12
+ their kind and constants on `patch.motion`, a `MotionSpec`. `Patch.form` and `Engine.runs` take
13
+ `'motion'`, so an engine that declares `runs` must list it to run a spring or a glide. A motion
14
+ patch throws when two of its subjects, or a subject's start, target and velocity, or a `to` or
15
+ `push`, move on different numbers of axes. It makes a subject's first stretch at its first `to`
16
+ or `push` as well as at its first read, so `read(subject, at)` answers from then on.
17
+ - An untimed retarget lands at the subject's own voice time at the mix's latest sync, not at the
18
+ subject's next read, so a subject the host did not probe changes at the same moment as one it
19
+ did; `read` with no time answers at that time, counting every change due by then. For a subject
20
+ no frame of its voice has met yet, or still inside its stagger, an untimed change applies at its
21
+ first read, after any timed change due by then, and `read` with no time returns undefined, as it
22
+ does once the voice is gone. Changes apply in time order, not the order they were made.
23
+ - `drop(subject)` also forgets a motion patch's state for the subject, so it starts afresh.
24
+ - With lanes on, a stateless patch calling `setting.send` from `at` sends every frame for every
25
+ subject the mix has met, probed that frame or not; and a patch that first calls `setting.keep`
26
+ partway through playing can advance that state once more for one subject the host did not probe
27
+ that frame.
28
+ - `mixHex`, and so the `hex` channel, blends in OKLCH rather than sRGB, taking hue the short way
29
+ round and a gray end's hue from the other end. A crossfade from red to blue passes through
30
+ 0xba00c2 rather than 0x800080, and black to white through 0x636363 rather than 0x808080. A blend
31
+ costs about 45 ns where the two ends repeat frame to frame and 210 ns where every call brings new
32
+ colors, against 8 ns in sRGB. `hex({ space: 'srgb' })` keeps the sRGB blend.
33
+ - `Mix` has a `pull` method, so an engine's `create` must return a mix that implements it.
34
+
35
+ ### Added
36
+
37
+ - `tween(writes, { from, to, ms, ease? })`, a motion patch that eases each subject from one value
38
+ to another over `ms`, with `from` and `to` given per subject and read once when the mix first
39
+ meets it. `to(subject, target)` retargets one subject from where it is over a full `ms` again,
40
+ and `read` reports value and velocity as a spring's does. It runs on lanes as springs do, with
41
+ each subject's endpoints as data, so a tween over every subject no longer needs a `fn` that looks
42
+ its endpoints up on each call: in weasel's shape (10,000 string ids, endpoints in a map) a frame
43
+ costs 0.83 of that `fn`'s and pauses for collection a quarter as long. A motion lane no longer
44
+ allocates a delta per subject either, which takes a spring frame over 10,000 subjects to
45
+ 0.82–0.90 of the last build's. `MotionSpec` gains
46
+ `{ kind: 'tween', ms, ease }`.
47
+ - Lanes: a channel every voice writing it can run that way is computed for every subject at once,
48
+ in flat arrays, at the frame's first probe, rather than subject by subject, and gives the same
49
+ pose. `keys`, a stateless `fn` and `motion` voices with a number weight qualify. Three `keys`
50
+ voices over 10,000 subjects fell from 6.1 to 2.6 ms a frame. A lane whose subjects were mostly
51
+ left unprobed last frame stops filling and its subjects take the general path until probes pick
52
+ up again, so a host probing 5% of 10,000 subjects pays about what it would with lanes off, not
53
+ ten times it. `MixOptions.lanes: false` turns lanes off. They cost a little on frames they
54
+ don't serve: a voice per subject reads up to 7% slower at 10,000 subjects (about 20% at
55
+ 100–1,000, a few hundredths of a millisecond), a mix probing few of its subjects about 0.03 ms
56
+ more, and a projection made every frame about 10% more.
57
+ - `mix.pull(subjects, { channel: Float64Array, ... })` writes each subject's pose into one array per
58
+ channel, in the order given, giving what `probe(subject, out)` gives without a pose object per
59
+ subject. A channel of `n` numbers takes `n` places a subject; one with no value writes NaN. While
60
+ every voice runs on a lane it copies straight from the lanes: a frame of three `keys` voices
61
+ over 10,000 subjects costs 0.84 of the same frame read through `probe`, a spring 0.91.
62
+ - `mix.project(timestamp)` reads the mix at another time without moving it: `probe` gives the pose
63
+ then, and `assess` says per channel whether it is `exact`, `stepped` or `held`. Ahead it plays
64
+ what is cued forward; behind it needs `MixOptions.history: { ms, every? }`, which keeps every
65
+ handle change, the voices that left, and copies of stateful state, so a read back under `stepMs`
66
+ lands on the pose the mix showed, and with `inputs` it records what input weight signals and the
67
+ host fields patches `reads` held, so a read back over a `level` or a pointer is known. `Patch.clone` copies state `structuredClone` cannot.
68
+ - The score: a voice takes a `name` and an `anchor` placement, `start` or `in` and `out` or `end`,
69
+ each a timestamp or another voice's mark (`after`, `with`, `before`, or `of` plus `mark`),
70
+ selected by name, tag or written channel with a resolver. Each source can keep its own `score`,
71
+ with names namespaced per score. `mix.announce(name, { at? })` puts a named mark on a score for
72
+ anchors to wait on, and `mix.marks(from, to)` lists every mark the plan knows.
73
+ - `spring` and `glide` keep every stretch within the mix's history, so a read before a retarget
74
+ finds where the subject was.
75
+ - A cue can name its subjects, `subjects: [a, b]`, in place of a `target`. The mix files the voice
76
+ under each one, so a voice per subject costs the same as one voice: the first frame of 1,000
77
+ subjects on a voice each fell from about 500 ms to 1 ms.
78
+
79
+ ### Fixed
80
+
81
+ - A voice faded before its start plays once its start arrives. It used to stay out of every
82
+ subject's fold until some other voice was cued or left.
83
+ - A probe after a `seek` in the same frame reads the voice where the seek put it, rather than the
84
+ value an earlier probe that frame read before the seek. A stateful patch still steps once.
85
+ - A probe looks its subject up once rather than once per voice, which makes frames with several
86
+ voices 5–12% faster.
87
+ - A `keys` patch reads a keyed `vec` channel into an array its voice reuses, rather than a new one
88
+ each read, which halves collections at 10,000 subjects and leaves frame time unchanged.
89
+
90
+ - A finite loop's fade out starts when its last pass ended, not at the first frame after, so it
91
+ plays the same at any frame rate.
92
+ - The published `package.json` no longer lists `workspaces: ["site"]`, which only the repo's docs
93
+ site uses.
94
+
95
+ ## 0.2.1
96
+
97
+ ### Added
98
+
99
+ - Channel `bounds`: `sum`, `mul` and `max` take `{ bounds: [min, max] }`, and `vec` inherits its
100
+ axis channel's. The mix clamps the folded value to the range, so stacked voices and a retarget
101
+ that carries speed stop flat at the bound. Bounds are part of the channel's kind.
102
+
7
103
  ## 0.2.0
8
104
 
9
105
  ### Breaking
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.3.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, on a branch not yet merged.
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,22 +1,53 @@
1
1
  import type { Channel, Kit } from './types.js';
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
+ */
29
+ export interface NumberOptions {
30
+ /** The range the value means anything in; the mix clamps to it. */
31
+ bounds?: readonly [min: number, max: number];
32
+ }
2
33
  /**
3
34
  * Position axes, rotation, crawl, yaw, pitch, offset.
4
35
  *
5
36
  * @category channel
6
37
  */
7
- export declare function sum(): Channel<number>;
38
+ export declare function sum(opts?: NumberOptions): Channel<number>;
8
39
  /**
9
40
  * Gain, scale, opacity, hold.
10
41
  *
11
42
  * @category channel
12
43
  */
13
- export declare function mul(): Channel<number>;
44
+ export declare function mul(opts?: NumberOptions): Channel<number>;
14
45
  /**
15
46
  * Dark, aperture.
16
47
  *
17
48
  * @category channel
18
49
  */
19
- export declare function max(): Channel<number>;
50
+ export declare function max(opts?: NumberOptions): Channel<number>;
20
51
  /**
21
52
  * A channel with no rest: the last influence to pass wins, and a weight can only gate it. The
22
53
  * default `lerp` steps at the midpoint, which is all a value with no arithmetic can promise; a
@@ -34,18 +65,21 @@ export declare function last<V>(opts?: {
34
65
  */
35
66
  export declare function vec(n: number, of: Channel<number>): Channel<number[]>;
36
67
  /**
37
- * sRGB, which is what klieg's light channel already assumes, so the port's arithmetic is
38
- * 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.
39
70
  *
40
71
  * @category channel
41
72
  */
42
73
  export declare function mixHex(a: number, b: number, u: number): number;
43
74
  /**
44
- * 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.
45
77
  *
46
78
  * @category channel
47
79
  */
48
- export declare function hex(): Channel<number>;
80
+ export declare function hex(opts?: {
81
+ space?: 'oklch' | 'srgb';
82
+ }): Channel<number>;
49
83
  /**
50
84
  * A kit is plain data; this only fixes the type.
51
85
  *
package/dist/channels.js CHANGED
@@ -1,45 +1,86 @@
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
+ }
32
+ /** Bounds change how a value folds at its edges, so they are part of the kind. */
33
+ const kindOf = (name, bounds) => bounds === undefined ? name : `${name}[${bounds[0]}, ${bounds[1]}]`;
2
34
  /**
3
35
  * Position axes, rotation, crawl, yaw, pitch, offset.
4
36
  *
5
37
  * @category channel
6
38
  */
7
- export function sum() {
8
- return {
9
- kind: 'sum',
39
+ export function sum(opts) {
40
+ const channel = {
41
+ kind: kindOf('sum', opts?.bounds),
42
+ bounds: opts?.bounds,
10
43
  rest: 0,
11
- merge: (a, b) => a + b,
12
- scale: (v, w) => v * w,
44
+ merge: add,
45
+ scale: times,
13
46
  lerp: mix,
14
47
  };
48
+ numerics.set(channel, { op: 'sum', axes: 1 });
49
+ return channel;
15
50
  }
16
51
  /**
17
52
  * Gain, scale, opacity, hold.
18
53
  *
19
54
  * @category channel
20
55
  */
21
- export function mul() {
22
- return {
23
- kind: 'mul',
56
+ export function mul(opts) {
57
+ const channel = {
58
+ kind: kindOf('mul', opts?.bounds),
59
+ bounds: opts?.bounds,
24
60
  rest: 1,
25
- merge: (a, b) => a * b,
26
- scale: (v, w) => 1 + (v - 1) * w,
61
+ merge: product,
62
+ scale: toward,
27
63
  lerp: mix,
28
64
  };
65
+ numerics.set(channel, { op: 'mul', axes: 1 });
66
+ return channel;
29
67
  }
30
68
  /**
31
69
  * Dark, aperture.
32
70
  *
33
71
  * @category channel
34
72
  */
35
- export function max() {
36
- return {
37
- kind: 'max',
73
+ export function max(opts) {
74
+ const channel = {
75
+ kind: kindOf('max', opts?.bounds),
76
+ bounds: opts?.bounds,
38
77
  rest: 0,
39
- merge: (a, b) => (a > b ? a : b),
40
- scale: (v, w) => v * w,
78
+ merge: larger,
79
+ scale: times,
41
80
  lerp: mix,
42
81
  };
82
+ numerics.set(channel, { op: 'max', axes: 1 });
83
+ return channel;
43
84
  }
44
85
  /**
45
86
  * A channel with no rest: the last influence to pass wins, and a weight can only gate it. The
@@ -56,17 +97,39 @@ export function last(opts) {
56
97
  lerp: opts?.lerp ?? ((a, b, u) => (u < 0.5 ? a : b)),
57
98
  };
58
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
+ }
59
115
  /**
60
116
  * Vec3 position, premultiplied light: one channel's arithmetic applied down an axis list.
61
117
  *
62
118
  * @category channel
63
119
  */
64
120
  export function vec(n, of) {
65
- const rest = of.rest === undefined ? undefined : new Array(n).fill(of.rest);
121
+ const rest = of.rest === undefined ? undefined : doubles(n, of.rest);
66
122
  const scale = of.scale;
67
123
  const fill = of.rest ?? 0;
68
- 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 = {
69
131
  kind: of.kind === undefined ? undefined : `vec(${n}, ${of.kind})`,
132
+ bounds: of.bounds,
70
133
  rest,
71
134
  merge: (a, b) => {
72
135
  const out = new Array(n);
@@ -89,22 +152,113 @@ export function vec(n, of) {
89
152
  into[i] = of.merge(into[i] ?? fill, scale(v[i] ?? fill, w));
90
153
  return into;
91
154
  },
92
- lerp: (a, b, u) => {
93
- const out = new Array(n);
94
- for (let i = 0; i < n; i++)
95
- out[i] = of.lerp(a[i] ?? fill, b[i] ?? fill, u);
96
- return out;
97
- },
155
+ lerp: (a, b, u) => lerpTo(undefined, a, b, u),
98
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;
99
162
  }
100
- 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;
101
190
  /**
102
- * sRGB, which is what klieg's light channel already assumes, so the port's arithmetic is
103
- * 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.
104
236
  *
105
237
  * @category channel
106
238
  */
107
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) {
108
262
  const ar = (a >> 16) & 0xff;
109
263
  const ag = (a >> 8) & 0xff;
110
264
  const ab = a & 0xff;
@@ -114,12 +268,13 @@ export function mixHex(a, b, u) {
114
268
  return ((clamp255(mix(ar, br, u)) << 16) | (clamp255(mix(ag, bg, u)) << 8) | clamp255(mix(ab, bb, u)));
115
269
  }
116
270
  /**
117
- * 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.
118
273
  *
119
274
  * @category channel
120
275
  */
121
- export function hex() {
122
- return { ...last({ lerp: mixHex }), kind: 'hex' };
276
+ export function hex(opts) {
277
+ return { ...last({ lerp: opts?.space === 'srgb' ? mixSrgb : mixHex }), kind: 'hex' };
123
278
  }
124
279
  /**
125
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
@@ -1,8 +1,9 @@
1
+ export type { NumberOptions } from './channels.js';
1
2
  export { hex, kit, last, max, mixHex, mul, sum, vec } from './channels.js';
2
3
  export { mix, mixer } from './mixer.js';
3
4
  export type { Motion, Moving, PerSubject, Value } from './motion.js';
4
- export { glide, spring } from './motion.js';
5
+ export { glide, spring, tween } from './motion.js';
5
6
  export type { KeysOptions, PatchOptions } from './patch.js';
6
7
  export { keys, patch } from './patch.js';
7
8
  export { gate, lag, level, peak, slew } from './signals.js';
8
- 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';