@msb235/blits 0.7.1 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. package/CHANGELOG.md +193 -0
  2. package/README.md +22 -4
  3. package/dist/book.js +4 -4
  4. package/dist/chain.d.ts +1 -1
  5. package/dist/chain.js +57 -54
  6. package/dist/channels.d.ts +26 -5
  7. package/dist/channels.js +12 -8
  8. package/dist/clock.d.ts +8 -1
  9. package/dist/clock.js +14 -3
  10. package/dist/clone.d.ts +8 -0
  11. package/dist/clone.js +22 -0
  12. package/dist/closed.d.ts +1 -1
  13. package/dist/closed.js +3 -2
  14. package/dist/color.d.ts +0 -11
  15. package/dist/color.js +4 -24
  16. package/dist/columns.js +0 -1
  17. package/dist/copyout.d.ts +6 -0
  18. package/dist/copyout.js +38 -0
  19. package/dist/crowd.d.ts +7 -5
  20. package/dist/crowd.js +32 -18
  21. package/dist/cue.d.ts +3 -1
  22. package/dist/cue.js +31 -20
  23. package/dist/departed.d.ts +34 -0
  24. package/dist/departed.js +73 -0
  25. package/dist/easing.d.ts +5 -0
  26. package/dist/easing.js +31 -2
  27. package/dist/everyone.d.ts +56 -0
  28. package/dist/everyone.js +221 -0
  29. package/dist/fade.d.ts +8 -1
  30. package/dist/fade.js +34 -6
  31. package/dist/fill.d.ts +22 -13
  32. package/dist/fill.js +104 -41
  33. package/dist/fit.js +2 -1
  34. package/dist/fold.d.ts +37 -0
  35. package/dist/fold.js +216 -31
  36. package/dist/frames.d.ts +21 -0
  37. package/dist/frames.js +59 -0
  38. package/dist/gather.d.ts +15 -9
  39. package/dist/gather.js +60 -25
  40. package/dist/gone.js +10 -0
  41. package/dist/handle.d.ts +16 -5
  42. package/dist/handle.js +56 -6
  43. package/dist/held.d.ts +8 -5
  44. package/dist/held.js +56 -58
  45. package/dist/history.d.ts +17 -13
  46. package/dist/history.js +47 -28
  47. package/dist/hosts.js +55 -8
  48. package/dist/index.d.ts +9 -4
  49. package/dist/index.js +5 -2
  50. package/dist/keyfill.d.ts +7 -0
  51. package/dist/keyfill.js +124 -0
  52. package/dist/keyrows.d.ts +1 -0
  53. package/dist/keyrows.js +1 -1
  54. package/dist/lane.d.ts +32 -2
  55. package/dist/lane.js +5 -0
  56. package/dist/lanes.d.ts +32 -8
  57. package/dist/lanes.js +69 -37
  58. package/dist/leavings.d.ts +26 -0
  59. package/dist/leavings.js +70 -0
  60. package/dist/locus.js +2 -2
  61. package/dist/meet.js +14 -8
  62. package/dist/mixer.d.ts +39 -7
  63. package/dist/mixer.js +50 -10
  64. package/dist/motion.d.ts +2 -0
  65. package/dist/motion.js +4 -1
  66. package/dist/motions.d.ts +22 -15
  67. package/dist/motions.js +40 -34
  68. package/dist/move.js +23 -16
  69. package/dist/moved.d.ts +11 -0
  70. package/dist/moved.js +29 -0
  71. package/dist/origin.d.ts +19 -0
  72. package/dist/origin.js +68 -0
  73. package/dist/owed.d.ts +4 -4
  74. package/dist/owed.js +8 -8
  75. package/dist/owner.d.ts +1 -1
  76. package/dist/owner.js +2 -2
  77. package/dist/pace.d.ts +13 -5
  78. package/dist/pace.js +21 -8
  79. package/dist/pack.d.ts +3 -1
  80. package/dist/pack.js +15 -24
  81. package/dist/paging.d.ts +8 -2
  82. package/dist/paging.js +39 -4
  83. package/dist/patch.d.ts +14 -9
  84. package/dist/patch.js +83 -21
  85. package/dist/place.js +18 -3
  86. package/dist/project.d.ts +12 -1
  87. package/dist/project.js +101 -83
  88. package/dist/pull.d.ts +2 -0
  89. package/dist/pull.js +8 -0
  90. package/dist/qualify.js +10 -2
  91. package/dist/reading.d.ts +5 -0
  92. package/dist/reading.js +5 -0
  93. package/dist/record.d.ts +9 -0
  94. package/dist/record.js +42 -0
  95. package/dist/relink.d.ts +11 -5
  96. package/dist/relink.js +7 -5
  97. package/dist/restored.d.ts +41 -0
  98. package/dist/restored.js +91 -0
  99. package/dist/revive.d.ts +10 -3
  100. package/dist/revive.js +31 -23
  101. package/dist/roster.d.ts +11 -0
  102. package/dist/roster.js +46 -0
  103. package/dist/rotation.d.ts +29 -0
  104. package/dist/rotation.js +91 -0
  105. package/dist/rows.d.ts +5 -5
  106. package/dist/rows.js +5 -5
  107. package/dist/sample.d.ts +3 -2
  108. package/dist/sample.js +32 -15
  109. package/dist/seek.js +97 -114
  110. package/dist/shared.d.ts +6 -0
  111. package/dist/shared.js +15 -0
  112. package/dist/signals.d.ts +24 -3
  113. package/dist/signals.js +50 -11
  114. package/dist/spans.js +7 -0
  115. package/dist/stack.d.ts +11 -0
  116. package/dist/stack.js +47 -0
  117. package/dist/standing.d.ts +10 -0
  118. package/dist/standing.js +79 -0
  119. package/dist/store.d.ts +3 -3
  120. package/dist/store.js +3 -3
  121. package/dist/strays.js +3 -0
  122. package/dist/tape.d.ts +8 -5
  123. package/dist/tape.js +21 -13
  124. package/dist/testing.d.ts +35 -0
  125. package/dist/testing.js +116 -0
  126. package/dist/touch.d.ts +6 -0
  127. package/dist/touch.js +28 -0
  128. package/dist/transport.d.ts +45 -6
  129. package/dist/transport.js +64 -19
  130. package/dist/turns.d.ts +18 -0
  131. package/dist/turns.js +72 -0
  132. package/dist/tweened.js +1 -1
  133. package/dist/types/channel.d.ts +46 -0
  134. package/dist/types/channel.js +1 -0
  135. package/dist/types/history.d.ts +139 -0
  136. package/dist/types/history.js +1 -0
  137. package/dist/types/mix.d.ts +336 -0
  138. package/dist/types/mix.js +1 -0
  139. package/dist/types/patch.d.ts +187 -0
  140. package/dist/types/patch.js +1 -0
  141. package/dist/types/place.d.ts +157 -0
  142. package/dist/types/place.js +1 -0
  143. package/dist/types/transport.d.ts +71 -0
  144. package/dist/types/transport.js +1 -0
  145. package/dist/types/voice.d.ts +323 -0
  146. package/dist/types/voice.js +1 -0
  147. package/dist/types.d.ts +7 -1162
  148. package/dist/unpage.d.ts +2 -2
  149. package/dist/unpage.js +53 -25
  150. package/dist/voice.d.ts +90 -22
  151. package/dist/voice.js +105 -43
  152. package/dist/wave.d.ts +42 -0
  153. package/dist/wave.js +62 -0
  154. package/dist/weigh.js +1 -1
  155. package/docs/schema.html +42 -36
  156. package/package.json +8 -3
package/CHANGELOG.md CHANGED
@@ -4,6 +4,199 @@ 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.8.1
8
+
9
+ ### Fixed
10
+
11
+ - A frame under `history` costs what it did in 0.7.1 again. 0.8.0 added two costs to every frame:
12
+ a sync shifted the whole list of frames history can reach to let go of the oldest, 7,200 of
13
+ them at 60 fps under the default 120 s; and an object a voice gave a channel with no `rest` to
14
+ fold into, such as `last<Row>()`, was copied for every subject on every frame. The frames now
15
+ leave from the front without a shift, and such a channel's pose holds the patch's own object, as
16
+ in 0.7.1; an array is still copied. astv's motion bench on one machine, mean µs a frame over
17
+ two runs, 0.7.1, 0.8.0, then this: one window landing 6 text changes of 5 rows, 19.6, 28.8,
18
+ 19.5; its busy stage, 354.6, 402.2, 340.7; 10 flights a second, 6.4, 8.0, 6.6.
19
+
20
+ ## 0.8.0
21
+
22
+ ### Breaking
23
+
24
+ - `handle.seek(elapsed)` rebuilds each subject's state for the new position: fresh from the
25
+ patch's `state`, with what signals keep started over, and stepped from the voice's start to
26
+ `elapsed` at the next probe, on the `stepMs` grid where there is one. It left state where it was,
27
+ so a spring seeked back to 0 read about 111 where a fresh one reads 0. `seek(elapsed, { state:
28
+ 'keep' })` keeps the old behavior. The same holds for an owner's seek and its children.
29
+ - A `keys` channel given a `delayBy` waits, then travels in the time left, landing with the other
30
+ channels at the end of the duration, as klieg's own `delayBy` does. It shifted the phase without
31
+ compressing it, so a channel waiting 200 ms of 1000 stopped 80% of the way there.
32
+ - In a `locus`, each channel folds at the summed weight of the members that write it, not the whole
33
+ locus's. A channel only one member writes fades with that member rather than holding at full
34
+ weight until it snaps to rest.
35
+ - `cue` refuses a `loop` that is not `true`, `false` or a whole number of passes; `spring` refuses
36
+ a stiffness or mass that is not positive, or a negative damping; `glide` refuses an `ms` that is
37
+ not positive. Each played wrongly: a fractional loop jumped at its end, and a spring with no
38
+ stiffness snapped to its target.
39
+ - `sync(NaN)` throws. It slipped past the check that the clock only goes forward.
40
+ - A voice refuses a rate that is not finite and a weight that is NaN, at `cue` and on its handle.
41
+ Both were accepted. A negative voice rate still is.
42
+ - `{ steps, jump: 'start' }` reads `1/n` at 0, as CSS does. It read 0.
43
+ - A history store's contract changed. `cut` takes the `seq` of the frame a seek went back to, not
44
+ a mix time, and every `Paged` record carries the `seq` it was made in; keep one record per key
45
+ and `seq`, since rate 0 lets several frames share a mix time. The transport pages its announced
46
+ marks, rate changes and frames too, as `mark`, `pace` and `frame` records with `mix: ''`, and a
47
+ `left` record's `data` is now `[record, sync]`. A store written for 0.7 forgets the wrong records
48
+ on a seek back.
49
+ - `vec(3, sum())` is typed `Channel<[number, number, number]>`, a tuple of the length it was given,
50
+ so a kit over a tuple pose needs no cast. A length that is not a literal still gives
51
+ `Channel<number[]>`. Types only: code that calls such a channel's `merge`, `scale` or `lerp`
52
+ itself with a `number[]` annotates the channel as `Channel<number[]>`.
53
+ - `probe(subject, out)` writes into the array `out` already holds for a `vec` channel, or a `color`
54
+ that averages in OKLab or runs on a lane, where that array is the channel's length. It put a new
55
+ array there on every probe. A host that kept `out.position` from one probe and compared it with
56
+ the next now holds the same array both times: copy what has to outlast the next probe. A probe
57
+ with no `out` still makes its own arrays.
58
+ - The deprecated names are removed. A voice's or an owner's `hold` is `freeze`, as since 0.6.0. A
59
+ patch's `period` is `duration`: a hand-made patch that set only `period` now has no duration.
60
+ `hex()` is gone, and `hex({ space: 'srgb' })` with it: `last<number>({ lerp: mixHex })` is the
61
+ same channel for a color kept as 0xrrggbb, and `color(last(), { lerp: 'oklch' })` with `toHex`
62
+ at write is the one that also runs with `color()`'s coverage. Nothing blends red, green and blue
63
+ each on its own any more; a host that wants that gives `last` a `lerp` of its own.
64
+
65
+ ### Added
66
+
67
+ - `angle({ turn })`, a channel for a rotation about one axis: it adds as `sum` does, and `lerp` and
68
+ a weight below 1 go the short way round, so 350 to 10 passes 0. `turn` is one full turn in the
69
+ channel's unit, default 360. It runs on lanes.
70
+ - `quat()`, a channel for a rotation in space as a unit quaternion `[x, y, z, w]`: contributions
71
+ compose in cue order, and `lerp` and a weight take the short arc. It runs on lanes, through its
72
+ own `fold`.
73
+ - `Vec<N>`, the tuple of `N` numbers a `vec` channel holds.
74
+ - `fold(kit, deltas, weights?)` folds deltas into one pose by a kit's arithmetic with no mix and no
75
+ clock: what a mix gives for voices contributing those deltas at those weights, every weight 1
76
+ where none is given.
77
+ - `project(time)` reads an earlier time on a mix made without `history`, where how the mix stands
78
+ now says what it showed then: every voice plays as it was cued and reads by its clock alone (no
79
+ state, motion, anchor or owner, no write to its handle, no channel without a rest at a weight
80
+ that moves), the mix's rate was never set, and `time` is no earlier than the last cue, `drop`,
81
+ `touch`, `announce` or `rebase`, or the last frame a voice left in. Anywhere else it throws and
82
+ says which of those it met, where it threw for any time behind the mix. A host that cued its
83
+ voices once need not build a second mix to read an earlier frame.
84
+ - `handle.to`, `handle.push` and `handle.read` reach the `to`, `push` and `read` of the motion
85
+ patch a voice plays, so a host holding only the handle need not keep the patch or cast it. `to`
86
+ and `push` throw for a voice whose patch does not take them; `read` gives undefined.
87
+ - `mix.touch(subject?)` says that something a patch reads from outside the mix changed since the
88
+ last sync, so the next probe calls the patch again where it would have answered with what it
89
+ read this frame: for one subject, or for all. A host need not move the clock to show the change.
90
+ - `input(read)` makes a signal of the host's own that reads something outside the clock. The host
91
+ calls its `touch()` after each change, and a voice weighted by it keeps its lane, where one
92
+ weighted by a signal flagged `input` by hand never runs on one. `input(read, of)` marks a signal
93
+ built on others, an input where any of them is. `level` is `input` over one number.
94
+
95
+ - `@msb235/blits/testing`, for a library writing patches: `setting()` makes a `Setting` to call a
96
+ patch outside a mix, and `checkPatch(patch, { subject, kit })` lists what a mix cannot rely on.
97
+ - `handle.seek` answers how sure the state it leaves is, as a `Doubt`: `exact`, `stepped` or
98
+ `held`. `SeekOptions` is exported.
99
+ - `wave(duration, { shape, cycles, phase, depth, kit })` swings each numeric channel `depth` names
100
+ up to `depth` either side of its rest in `kit` (0 without one), as a sine, triangle, saw, or
101
+ square, `cycles` times a pass. It is an `fn` patch that keeps its options on `patch.wave` for an
102
+ engine that reads data; `waveAt` is the unit wave.
103
+ - A `keys` patch carries the options it was given as fields: `ease`, `easeBy`, `delayBy` and
104
+ `lerpBy`, beside `keys` and `kit`. They were kept in a table keyed by the patch object, so a copy
105
+ such as `{ ...p, duration: 2000 }` lost its easing; a copy now plays by the fields it carries,
106
+ and may change one.
107
+ - `Channel.copy`, for a channel whose `rest` is an object the mix cannot copy itself, such as a class
108
+ instance, and that has `fold`; `cue` refuses such a channel without one. The mix copies arrays,
109
+ typed arrays and plain objects itself.
110
+ - `npm run test:general` runs the suite with lanes off, so every test checks the general path
111
+ against the lanes.
112
+
113
+ ### Fixed
114
+
115
+ - A handle `seek` made in the frame its voice was cued in put every subject's origin before the
116
+ voice began, so the fade in read as over at once, and under a fade out a subject first probed
117
+ after the seek read a weight the others did not. The origin is now the cue's frame, as it is for
118
+ a seek in any later frame.
119
+ - A rate above 0 set on a voice still pending applies from the voice's start. It was taken from
120
+ the moment it was set, so the voice's clock passed 0 before the start: the voice then began
121
+ partway in, and a subject it froze before played, and stepped its state, while the voice was
122
+ still pending.
123
+ - `fade({ at: 'rest' })` begun after a subject's probe in the same frame finds that subject at rest
124
+ at its next probe, where it stayed in the voice until the frame after.
125
+ - Seeking back many times no longer throws `Maximum call stack size exceeded`. Every `seek` to an
126
+ earlier time wrapped each voice's records in another layer, kept for the life of the mix, so a
127
+ scrubbed mix grew with every seek and a subject not probed since was found through all of them:
128
+ 20,000 seeks overflowed the stack. A voice now keeps one store however often the mix seeks. Two
129
+ seeks back with no probe between could also read a wrong pose, as two fuzzer programs did.
130
+ - An anchor cued after its target had left waited for good, and kept the mix from coming to rest,
131
+ unless the mix's history still reached the target. A mix now keeps the marks of the first and last
132
+ voice to leave under each name, tag and channel, and an anchor no voice the mix still knows answers
133
+ reads those, placing its voice as it would have while the target was there. It holds that much and
134
+ no more, however many voices leave.
135
+ - A channel whose `rest` is an object or a typed array had it handed to `fold` by reference, so it
136
+ accumulated across frames and every subject's pose shared it.
137
+ - `fade()` during a fade-in made the weight rise before it fell: the fade-in kept climbing under
138
+ the fade-out. It now stops where the fade-out begins.
139
+ - A seek put a voice's controls back without its rise ramp, so a seek to before a later `rise()`
140
+ read the voice at 0.
141
+ - A `from: 'current'` retarget under an easing that jumps or leaves vertically at its start
142
+ (`steps` with `jump: 'start'`, `bezier [0,1,0,1]`) threw the value hundreds of thousands out. Such
143
+ an easing now takes no slope bend.
144
+ - A channel with no `rest` stayed switched on across a weight of 0, so a weight going 0.7, 0, 0.5
145
+ found it on at 0.5.
146
+ - `slew` and `lag` step by no more than `maxDt`, as `step` does. After a long gap a slew finished
147
+ at once.
148
+ - A `keys` patch reads a channel whose stops are all plain numbers, lerped straight across, by a
149
+ search and lerp of its own, giving the same bits: `patch.at` over 10,000 four-stop tracks takes
150
+ about 0.70 of the time it did.
151
+ - Two copies of blits loaded together, as a library pinning its own copy brings, read each other's
152
+ patches and channels: a `keys` patch one copy made kept its easing on the other's mix, which read
153
+ it linear, and the facts a stock channel carries for lanes are shared too.
154
+ - Under `history`, a read back to a frame in which the host changed a voice between the sync and
155
+ the first probe shows the change in that frame, as the live probe did. It showed from the next
156
+ frame.
157
+ - A subject's fade-in start and step origin came from its voice's rate and anchor as they stood at
158
+ the subject's first probe, so a rate change, ramp or handle seek before then moved them and the
159
+ numbers depended on when the host had looked. Every voice now keeps the clocks it ran on, and a
160
+ subject's origin is when its voice's clock first read the subject's delay. A stateful subject met
161
+ late steps from that origin, capped by `maxDt`, where it started stepping from first sight. A
162
+ read ahead no longer loses a stateful voice whose anchored start falls inside it.
163
+ - A seek or read back was keyed on mix time, which rate 0, a backdated `fade({ at })` and several
164
+ handle calls in one frame can all share, so it could restore the wrong moment or drop a voice
165
+ whose fade was backdated. The transport now numbers its frames, history entries carry the frame
166
+ they were made in, and a seek or read to mix time `t` lands on the last frame at or before `t`.
167
+ The tape stamps calls by host time, so frames rate 0 holds at one mix time stay apart.
168
+ - A frame that passed both a voice's start and the `fade({ at })` set for it began the fade a frame
169
+ late, so 16 ms and 32 ms frames disagreed.
170
+ - With a history store, the transport kept every announced mark, rate change and frame for good, and
171
+ a long run slowed quadratically: 20,000 frames took 19.8 s, against 0.17 s now.
172
+ - Lanes and the general path disagreed in three places. A `level` set between two probes in one
173
+ frame now reaches the next probe on lanes too; `inert` reads the same for a motion voice at weight
174
+ 0, which lanes kept awake until it landed; and `project` reads a subject faded out of a voice as
175
+ out of it after a retarget or a `drop` brought it back, on both paths. A voice weighted by an input
176
+ signal not built on `level`, or whose patch reads host fields, never runs on a lane, since a
177
+ change to either between two probes would not reach it.
178
+ - A span that skipped several children at cue played the last of them in full on the next sync, and
179
+ then reported none skipped.
180
+ - A pose read NaN for a voice still waiting on a `start` the host gave, once the voice was set to
181
+ rate 0 while the mix's rate was 0. It reads as any voice before its start does.
182
+ - A frame allocates much less, which is most of what made the garbage collector run during one. A
183
+ probe into `out` reuses its arrays, a motion voice off lanes reuses its last delta, and a fill
184
+ no longer boxes a number per subject. `bench/frame.mjs`, 10,000 subjects probed into one `out`:
185
+ three `keys` voices went from 1.8 MB a frame to 0.16 MB and take 0.81 of the time; a `tween`
186
+ voice from 0.72 MB to 0.02 MB, 0.90 of the time; a `tween` voice with lanes off from 2.6 MB to
187
+ 0.32 MB, 0.82 of the time.
188
+ - Cueing a voice over every subject costs much less. A voice with no `subjects`, `target`,
189
+ `stagger` or signal weight, whose patch keeps no state, kept a record for each subject probed;
190
+ it keeps one for them all, and a read ahead copies that one. `bench/frame.mjs`, 10,000 subjects
191
+ each under a `tween` voice of its own, with a voice over all of them replaced every frame: 9.3 MB
192
+ a frame to 2.7 MB, in 0.74 of the time. A read 500 ms ahead of 1,000 subjects under three voices,
193
+ made and probed every frame: 2.4 MB to 1.5 MB, in 0.80 of the time. A mix made with
194
+ `lanes: false`, or with `history`, keeps a record for each subject as before.
195
+ - Under `history`, dropping subjects and reading them back each took time that grew with the square
196
+ of how many had left: every drop went through every record its voice had kept, and so did every
197
+ read back. 8,000 subjects dropped in one frame took 173 ms and take 7; reading them back took 180
198
+ ms and takes 3 (`bench/drops.mjs`).
199
+
7
200
  ## 0.7.1
8
201
 
9
202
  ### Fixed
package/README.md CHANGED
@@ -69,7 +69,7 @@ and weight), **mix** (the live voices over one kit), **signal** (a 0..1 scalar f
69
69
  clock), **handle** (the live controls on one voice), **engine** (the implementation behind a mix).
70
70
 
71
71
  For time: **score** (the plan a source's voices are placed on), **mark** (a voice's start, fade-in
72
- done, fade-out begun, or end, or a named point the host announces), **anchor** (a time given by
72
+ done, last pass ended, fade-out begun, or end, or a named point the host announces), **anchor** (a time given by
73
73
  another voice's mark), **span** (an owner fitting the voices it holds into a duration), **project**
74
74
  (read the mix at another time without moving it), **seek** (move the mix to a time and play on).
75
75
 
@@ -89,8 +89,26 @@ npm run site # dev server on port 4880
89
89
  npm run site:smoke # build, then load every page headless and fail on any error
90
90
  ```
91
91
 
92
- `.github/workflows/site.yml` deploys it to GitHub Pages. It runs only by hand until Pages is enabled
93
- on the repo.
92
+ `.github/workflows/site.yml` deploys it to GitHub Pages on every push to `main`, and by hand.
93
+
94
+ ## Writing patches for blits in another library
95
+
96
+ A library that ships patches exports them with the channels they write, as a `Kit` of its own
97
+ for the host to spread into its kit, and checks them in its own tests against
98
+ `@msb235/blits/testing`:
99
+
100
+ ```ts
101
+ import { checkPatch, setting } from '@msb235/blits/testing';
102
+
103
+ expect(checkPatch(glow, { subject, kit })).toEqual([]); // each string names a problem
104
+ glow.at(0.5, subject, setting({ weight: 0.5 })); // a Setting for calling a patch outside a mix
105
+ ```
106
+
107
+ `checkPatch` reads the patch across its phase from fresh state and reports what a mix cannot rely
108
+ on: writing outside `writes`, a delta that changes between identical reads, a touched subject,
109
+ state shared between subjects, a `step` that lands differently from the same start, a `clone`,
110
+ `pack` or `unpack` that does not give back an equal state, a missing host field, and a kit that
111
+ `cue` would refuse. Two copies of blits loaded together read each other's patches and channels.
94
112
 
95
113
  ## Packages
96
114
 
@@ -100,7 +118,7 @@ so the engine keeps no dependencies; its README says how it works.
100
118
 
101
119
  ## Status
102
120
 
103
- v0.7.1, with five consumers. magicsmoke runs every fault on it, wod its wheel, tricks and
121
+ v0.8.1, with five consumers. magicsmoke runs every fault on it, wod its wheel, tricks and
104
122
  transitions, weasel its animator's tweens, sherpa its seams, and
105
123
  [klieg](https://github.com/orochi235/klieg) composes all three of its systems — letter motion,
106
124
  part effects, and the environment a sign is lit by — on a mix each.
package/dist/book.js CHANGED
@@ -111,7 +111,7 @@ export class Book {
111
111
  if (voice.spec.hits !== undefined && voice.state !== 'done')
112
112
  this.tracks.set(voice, {
113
113
  seenTo: voice.elapsedAt(host.now),
114
- seeks: voice.seeks,
114
+ jumps: voice.jumps,
115
115
  booked: new Map(),
116
116
  met: this.round,
117
117
  });
@@ -166,13 +166,13 @@ export class Book {
166
166
  continue;
167
167
  let t = this.tracks.get(voice);
168
168
  if (t === undefined) {
169
- t = { seenTo: Number.NEGATIVE_INFINITY, seeks: voice.seeks, booked: new Map(), met: round };
169
+ t = { seenTo: Number.NEGATIVE_INFINITY, jumps: voice.jumps, booked: new Map(), met: round };
170
170
  this.tracks.set(voice, t);
171
171
  }
172
172
  t.met = round;
173
173
  // A seek counts from where it put the clock: what it jumped over is not late.
174
- if (t.seeks !== voice.seeks) {
175
- t.seeks = voice.seeks;
174
+ if (t.jumps !== voice.jumps) {
175
+ t.jumps = voice.jumps;
176
176
  t.seenTo = voice.elapsedAt(prev);
177
177
  }
178
178
  const end = host.endOf(voice);
package/dist/chain.d.ts CHANGED
@@ -9,7 +9,7 @@ import type { Subject, Voice } from './voice.js';
9
9
  export declare function chain<I, O>(this: Mixer<I, O>, subject: I): Subject<unknown>;
10
10
  export declare function relink<I, O>(this: Mixer<I, O>, subject: I, was: Subject<unknown> | undefined): Subject<unknown>;
11
11
  /** A voice's record for a subject where its chain links it: the voice plays or freezes, and reaches it. */
12
- export declare function linkable<I, O>(this: Mixer<I, O>, voice: Voice<I, O>, subject: I, now: number, slot: number): Subject<unknown> | null;
12
+ export declare function linkable<I, O>(this: Mixer<I, O>, voice: Voice<I, O>, subject: I, slot: number): Subject<unknown> | null;
13
13
  /** Whether a voice reaches a subject by what its spec says: the subjects it names, or its `target`. */
14
14
  export declare function aims<I, O>(this: Mixer<I, O>, voice: Voice<I, O>, subject: I): boolean;
15
15
  /**
package/dist/chain.js CHANGED
@@ -1,27 +1,8 @@
1
+ import { everyoneOf, sight } from './everyone.js';
2
+ import { record } from './record.js';
1
3
  /** The first record of a subject no live voice reaches, so a probe of it still makes one lookup. */
2
4
  function stub() {
3
- return {
4
- reaches: false,
5
- delay: 0,
6
- since: 0,
7
- shown: 0,
8
- weight: 0,
9
- rested: false,
10
- bands: null,
11
- state: undefined,
12
- stepped: 0,
13
- ticks: 0,
14
- probed: Number.NaN,
15
- delta: null,
16
- phase: 0,
17
- seeks: 0,
18
- kept: null,
19
- voice: null,
20
- next: null,
21
- version: Number.NaN,
22
- loci: null,
23
- slot: -1,
24
- };
5
+ return record(null, false, undefined);
25
6
  }
26
7
  /**
27
8
  * The first of this subject's records, linked through every live voice that reaches it. A voice
@@ -33,40 +14,15 @@ export function chain(subject) {
33
14
  const was = this.chains.get(subject);
34
15
  return was !== undefined && was.version === this.version ? was : this.relink(subject, was);
35
16
  }
36
- // Apart from `chain` so that its closures' context is made only when a chain is relinked, and
37
- // `chain` stays small enough for V8 to inline where a probe links.
17
+ // Apart from `chain`, which stays small enough for V8 to inline where a probe links.
38
18
  export function relink(subject, was) {
39
- const now = this.now;
40
19
  const slot = was !== undefined ? was.slot : this.lanes !== null ? this.lanes.number(subject) : -1;
41
20
  // Where only voices over every subject changed since, those alone are taken off or put on.
42
21
  let first = was === undefined
43
22
  ? undefined
44
- : this.steps.patch(was.voice === null ? null : was, was.version, (voice) => this.linkable(voice, subject, now, slot));
45
- if (first === undefined) {
46
- let prev = null;
47
- first = null;
48
- const link = (voice) => {
49
- const held = this.linkable(voice, subject, now, slot);
50
- if (held === null)
51
- return;
52
- held.voice = voice;
53
- held.next = null;
54
- if (prev === null)
55
- first = held;
56
- else
57
- prev.next = held;
58
- prev = held;
59
- };
60
- const named = this.naming === 0 ? undefined : this.named.get(subject);
61
- let j = 0;
62
- for (const voice of this.general) {
63
- while (named !== undefined && j < named.length && named[j].id < voice.id)
64
- link(named[j++]);
65
- link(voice);
66
- }
67
- while (named !== undefined && j < named.length)
68
- link(named[j++]);
69
- }
23
+ : this.steps.patch(was.voice === null ? null : was, was.version, this, subject, slot);
24
+ if (first === undefined)
25
+ first = linkAll(this, subject, slot);
70
26
  const head = first ?? stub();
71
27
  head.version = this.version;
72
28
  if (was !== undefined && was !== head) {
@@ -81,19 +37,59 @@ export function relink(subject, was) {
81
37
  this.chains.set(subject, head);
82
38
  return head;
83
39
  }
40
+ /** A subject's chain linked afresh through every voice that reaches it, in voice order. */
41
+ function linkAll(mix, subject, slot) {
42
+ let first = null;
43
+ let prev = null;
44
+ const named = mix.naming === 0 ? undefined : mix.named.get(subject);
45
+ let j = 0;
46
+ const general = mix.general;
47
+ for (let i = 0; i <= general.length; i++) {
48
+ const voice = i < general.length ? general[i] : null;
49
+ // The voices naming the subject that come before this one, then this one.
50
+ for (;;) {
51
+ const mine = named !== undefined && j < named.length ? named[j] : null;
52
+ const next = mine !== null && (voice === null || mine.id < voice.id) ? mine : voice;
53
+ if (next === null)
54
+ break;
55
+ if (next === mine)
56
+ j++;
57
+ const held = mix.linkable(next, subject, slot);
58
+ if (held !== null) {
59
+ held.voice = next;
60
+ held.next = null;
61
+ if (prev === null)
62
+ first = held;
63
+ else
64
+ prev.next = held;
65
+ prev = held;
66
+ }
67
+ if (next === voice)
68
+ break;
69
+ }
70
+ }
71
+ return first;
72
+ }
84
73
  /** A voice's record for a subject where its chain links it: the voice plays or freezes, and reaches it. */
85
- export function linkable(voice, subject, now, slot) {
74
+ export function linkable(voice, subject, slot) {
86
75
  const state = voice.state;
87
76
  if (state === 'done' || (state === 'pending' && !voice.freezesBefore))
88
77
  return null;
89
- const held = this.held(voice, subject, now, slot);
78
+ // A voice sharing one record is in no chain: a fold takes it from `sharers` at its place.
79
+ if (voice.sharing && everyoneOf(this, voice) !== null) {
80
+ // A projection numbers no subject, and keeps nothing by subject.
81
+ if (slot >= 0)
82
+ sight(voice, slot, this.lanes.cap);
83
+ return null;
84
+ }
85
+ const held = this.held(voice, subject, slot);
90
86
  return held.reaches ? held : null;
91
87
  }
92
88
  /** Whether a voice reaches a subject by what its spec says: the subjects it names, or its `target`. */
93
89
  export function aims(voice, subject) {
94
90
  // A read back to before a subject left still finds it reached.
95
91
  const left = voice.parted?.get(subject);
96
- if (left !== undefined && !(this.now < left))
92
+ if (left !== undefined && !(this.now < left.at))
97
93
  return false;
98
94
  if (voice.named !== null)
99
95
  return voice.named.has(subject);
@@ -128,6 +124,13 @@ export function index(mix, voice) {
128
124
  return;
129
125
  if (voice.named === null) {
130
126
  mix.general.push(voice);
127
+ if (voice.sharing) {
128
+ const sharers = mix.sharers;
129
+ let i = sharers.length;
130
+ while (i > 0 && sharers[i - 1].id > voice.id)
131
+ i--;
132
+ sharers.splice(i, 0, voice);
133
+ }
131
134
  return;
132
135
  }
133
136
  mix.naming++;
@@ -14,14 +14,17 @@ export declare function lerpInto(channel: Channel<unknown>): LerpInto | undefine
14
14
  /**
15
15
  * What a lane needs of a stock numeric channel beyond the channel: its arithmetic by name, its axes.
16
16
  * `'last'` has no rest: the last contribution to pass the band replaces, and a weight only gates it.
17
+ * `'own'` folds its axes as one value, through the channel's own `fold`.
17
18
  */
18
19
  export interface Numeric {
19
- op: 'sum' | 'mul' | 'max' | 'last';
20
+ op: 'sum' | 'mul' | 'max' | 'last' | 'own';
20
21
  axes: number;
22
+ /** An angle's full turn: each value is taken the short way round before it folds. */
23
+ turn?: number;
21
24
  }
22
25
  /**
23
- * The stock arithmetic of a channel `sum`, `mul`, `max` or `vec` made, keyed by the object itself,
24
- * so a custom channel that claims a stock `kind` is not trusted with a lane.
26
+ * The stock arithmetic of a channel `sum`, `mul`, `max` or `vec` made, or `vouch` was given, keyed
27
+ * by the object itself, so a custom channel that claims a stock `kind` is not trusted with a lane.
25
28
  */
26
29
  export declare function numericOf(channel: Channel<unknown>): Numeric | undefined;
27
30
  /** Vouches for a stock channel made outside this module, as `color` makes its own. */
@@ -30,7 +33,10 @@ export declare function vouch(channel: Channel<unknown>, numeric: Numeric): void
30
33
  export declare function widen(channel: Channel<unknown>, axes: number): void;
31
34
  /** How many numbers a channel holds a subject: its rest's length, a recorded width, or one. */
32
35
  export declare function axesOf(channel: Channel<unknown>): number;
33
- /** `merge(acc, scale(v, w))` for a stock numeric channel, in exactly its arithmetic. */
36
+ /**
37
+ * `merge(acc, scale(v, w))` for a stock numeric channel, in exactly its arithmetic. Not for an
38
+ * `'own'` channel, and an angle's `v` is taken the short way round first.
39
+ */
34
40
  export declare function foldNumber(op: Numeric['op'], acc: number, v: number, w: number): number;
35
41
  /**
36
42
  * What a stock numeric channel takes.
@@ -69,15 +75,30 @@ export declare function max(opts?: NumberOptions): Channel<number>;
69
75
  export declare function last<V>(opts?: {
70
76
  lerp?: (a: V, b: V, u: number) => V;
71
77
  }): Channel<V>;
78
+ /**
79
+ * `n` copies of `v` in an array the engine stores as doubles from the start, so a copy of it, and
80
+ * every fraction written into that copy, keeps one element kind: a pose's arrays then look alike to
81
+ * every fold, whichever path made them.
82
+ */
83
+ export declare function doubles(n: number, v: number): number[];
84
+ type Tuple<N extends number, T extends number[] = []> = T['length'] extends N ? T : Tuple<N, [...T, number]>;
85
+ /**
86
+ * `N` numbers: a tuple where `N` is a literal, as `Vec<3>` is `[number, number, number]`, and
87
+ * `number[]` where it is only known to be a number.
88
+ *
89
+ * @category channel
90
+ */
91
+ export type Vec<N extends number> = number extends N ? number[] : Tuple<N>;
72
92
  /**
73
93
  * Vec3 position, premultiplied light: one channel's arithmetic applied down an axis list.
74
94
  *
75
95
  * @category channel
76
96
  */
77
- export declare function vec(n: number, of: Channel<number>): Channel<number[]>;
97
+ export declare function vec<N extends number>(n: N, of: Channel<number>): Channel<Vec<N>>;
78
98
  /**
79
99
  * A kit is plain data; this only fixes the type.
80
100
  *
81
101
  * @category channel
82
102
  */
83
103
  export declare function kit<O>(channels: Kit<O>): Kit<O>;
104
+ export {};
package/dist/channels.js CHANGED
@@ -1,7 +1,8 @@
1
+ import { shared } from './shared.js';
1
2
  /** The stock numeric channels' `lerp`, which `vec` applies per axis and lanes call directly. */
2
3
  export const lerpNumber = (a, b, u) => a + (b - a) * u;
3
4
  const mix = lerpNumber;
4
- const inPlace = new WeakMap();
5
+ const inPlace = shared('lerp-into@1');
5
6
  /**
6
7
  * The in-place form of a stock array channel's `lerp`. Keyed by the channel rather than its `lerp`,
7
8
  * since only the stock fold and merge are known to copy the array rather than keep it.
@@ -15,10 +16,10 @@ const times = (v, w) => v * w;
15
16
  const product = (a, b) => a * b;
16
17
  const toward = (v, w) => 1 + (v - 1) * w;
17
18
  const larger = (a, b) => (a > b ? a : b);
18
- const numerics = new WeakMap();
19
+ const numerics = shared('numeric@1');
19
20
  /**
20
- * The stock arithmetic of a channel `sum`, `mul`, `max` or `vec` made, keyed by the object itself,
21
- * so a custom channel that claims a stock `kind` is not trusted with a lane.
21
+ * The stock arithmetic of a channel `sum`, `mul`, `max` or `vec` made, or `vouch` was given, keyed
22
+ * by the object itself, so a custom channel that claims a stock `kind` is not trusted with a lane.
22
23
  */
23
24
  export function numericOf(channel) {
24
25
  return numerics.get(channel);
@@ -27,7 +28,7 @@ export function numericOf(channel) {
27
28
  export function vouch(channel, numeric) {
28
29
  numerics.set(channel, numeric);
29
30
  }
30
- const widths = new WeakMap();
31
+ const widths = shared('width@1');
31
32
  /** Records how many numbers a rest-less array channel holds a subject, which its rest cannot say. */
32
33
  export function widen(channel, axes) {
33
34
  widths.set(channel, axes);
@@ -37,7 +38,10 @@ export function axesOf(channel) {
37
38
  const rest = channel.rest;
38
39
  return Array.isArray(rest) ? rest.length : (widths.get(channel) ?? 1);
39
40
  }
40
- /** `merge(acc, scale(v, w))` for a stock numeric channel, in exactly its arithmetic. */
41
+ /**
42
+ * `merge(acc, scale(v, w))` for a stock numeric channel, in exactly its arithmetic. Not for an
43
+ * `'own'` channel, and an angle's `v` is taken the short way round first.
44
+ */
41
45
  export function foldNumber(op, acc, v, w) {
42
46
  if (op === 'sum')
43
47
  return add(acc, times(v, w));
@@ -120,7 +124,7 @@ export function last(opts) {
120
124
  * every fraction written into that copy, keeps one element kind: a pose's arrays then look alike to
121
125
  * every fold, whichever path made them.
122
126
  */
123
- function doubles(n, v) {
127
+ export function doubles(n, v) {
124
128
  if (n === 0)
125
129
  return [];
126
130
  const a = [0.5];
@@ -174,7 +178,7 @@ export function vec(n, of) {
174
178
  };
175
179
  inPlace.set(channel, lerpTo);
176
180
  const inner = numerics.get(of);
177
- if (inner !== undefined && inner.axes === 1)
181
+ if (inner !== undefined && inner.axes === 1 && inner.turn === undefined)
178
182
  numerics.set(channel, { ...inner, axes: n });
179
183
  return channel;
180
184
  }
package/dist/clock.d.ts CHANGED
@@ -24,9 +24,16 @@ export declare function envelope(fadeIn: number, out: {
24
24
  } | null): number;
25
25
  /**
26
26
  * A subject's voice time `raw` once the voice's freezes apply: 0 before it starts if it freezes
27
- * before, else NaN, where it shows nothing; `span` once its passes end if it freezes after.
27
+ * before, else NaN, where it shows nothing; `span` once its passes end if it freezes after. A `raw`
28
+ * of NaN is before its start: a pending voice at rate 0 whose pinned start a mix at rate 0 puts at
29
+ * infinity reads its clock as infinity times 0.
28
30
  */
29
31
  export declare function frozenTime(raw: number, before: boolean, after: boolean, span: number): number;
32
+ /**
33
+ * `Object.is` for two numbers, which V8 answers by boxing both where it does not inline the call: 32
34
+ * bytes a subject in a fill's loop (bench/allocs.mjs, 2026-10-09).
35
+ */
36
+ export declare function same(a: number, b: number): boolean;
30
37
  /** A weight held to 0..1. */
31
38
  export declare function clampWeight(raw: number): number;
32
39
  /**
package/dist/clock.js CHANGED
@@ -28,7 +28,9 @@ export function passAt(elapsed, duration, passes) {
28
28
  export function envelope(fadeIn, out, ease, reduced, now, since, back = null) {
29
29
  let w = 1;
30
30
  if (fadeIn > 0 && !reduced) {
31
- const u = (now - since) / fadeIn;
31
+ // A fade in stops climbing where a fade out begins, or the two ramps multiply into a rise.
32
+ const end = out && !out.rest && out.at < now ? out.at : now;
33
+ const u = (end - since) / fadeIn;
32
34
  if (u < 1)
33
35
  w *= ease ? ease(Math.max(0, u)) : Math.max(0, u);
34
36
  }
@@ -48,13 +50,22 @@ export function envelope(fadeIn, out, ease, reduced, now, since, back = null) {
48
50
  }
49
51
  /**
50
52
  * A subject's voice time `raw` once the voice's freezes apply: 0 before it starts if it freezes
51
- * before, else NaN, where it shows nothing; `span` once its passes end if it freezes after.
53
+ * before, else NaN, where it shows nothing; `span` once its passes end if it freezes after. A `raw`
54
+ * of NaN is before its start: a pending voice at rate 0 whose pinned start a mix at rate 0 puts at
55
+ * infinity reads its clock as infinity times 0.
52
56
  */
53
57
  export function frozenTime(raw, before, after, span) {
54
- if (raw < 0)
58
+ if (!(raw >= 0))
55
59
  return before ? 0 : Number.NaN;
56
60
  return after && raw > span ? span : raw;
57
61
  }
62
+ /**
63
+ * `Object.is` for two numbers, which V8 answers by boxing both where it does not inline the call: 32
64
+ * bytes a subject in a fill's loop (bench/allocs.mjs, 2026-10-09).
65
+ */
66
+ export function same(a, b) {
67
+ return a === b ? a !== 0 || 1 / a === 1 / b : Number.isNaN(a) && Number.isNaN(b);
68
+ }
58
69
  /** A weight held to 0..1. */
59
70
  export function clampWeight(raw) {
60
71
  return raw < 0 ? 0 : raw > 1 ? 1 : raw;
package/dist/clone.d.ts CHANGED
@@ -4,4 +4,12 @@
4
4
  * anything it would refuse, goes to it. A read back copies a record per voice and subject.
5
5
  */
6
6
  export declare function clone<T>(v: T): T;
7
+ /** A copy a fold may write into: arrays, typed arrays and plain objects copied, the rest as is. */
7
8
  export declare function copy(v: unknown): unknown;
9
+ /**
10
+ * A value that starts a channel no fold writes into: only `merged` touches it after, and that
11
+ * writes into arrays alone, so an object is the patch's own, not a copy made each frame.
12
+ */
13
+ export declare function first(v: unknown): unknown;
14
+ /** Whether `copy` can copy `v` faithfully, when it is an object. */
15
+ export declare function copyable(v: object): boolean;