@msb235/blits 0.6.0 → 0.7.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 (100) hide show
  1. package/CHANGELOG.md +152 -0
  2. package/README.md +31 -7
  3. package/dist/bare.js +7 -1
  4. package/dist/book.d.ts +7 -0
  5. package/dist/book.js +35 -4
  6. package/dist/chain.d.ts +2 -1
  7. package/dist/chain.js +7 -3
  8. package/dist/channels.d.ts +12 -19
  9. package/dist/channels.js +18 -118
  10. package/dist/clock.d.ts +9 -2
  11. package/dist/clock.js +10 -2
  12. package/dist/closed.d.ts +15 -0
  13. package/dist/closed.js +144 -0
  14. package/dist/color.d.ts +61 -0
  15. package/dist/color.js +229 -0
  16. package/dist/columns.js +2 -1
  17. package/dist/crowd.d.ts +1 -1
  18. package/dist/crowd.js +4 -1
  19. package/dist/cue.d.ts +2 -0
  20. package/dist/cue.js +80 -27
  21. package/dist/due.js +3 -0
  22. package/dist/fade.d.ts +13 -0
  23. package/dist/fade.js +57 -4
  24. package/dist/fill.d.ts +1 -1
  25. package/dist/fill.js +15 -8
  26. package/dist/fit.d.ts +144 -0
  27. package/dist/fit.js +181 -0
  28. package/dist/fold.d.ts +3 -3
  29. package/dist/fold.js +18 -11
  30. package/dist/gather.d.ts +6 -0
  31. package/dist/gather.js +18 -0
  32. package/dist/gone.d.ts +32 -0
  33. package/dist/gone.js +134 -0
  34. package/dist/handle.d.ts +27 -2
  35. package/dist/handle.js +60 -11
  36. package/dist/held.js +4 -0
  37. package/dist/history.d.ts +15 -2
  38. package/dist/history.js +56 -9
  39. package/dist/hosts.d.ts +2 -2
  40. package/dist/hosts.js +20 -6
  41. package/dist/index.d.ts +8 -2
  42. package/dist/index.js +5 -1
  43. package/dist/lane.d.ts +1 -1
  44. package/dist/lanes.d.ts +32 -9
  45. package/dist/lanes.js +53 -24
  46. package/dist/locus.d.ts +3 -3
  47. package/dist/locus.js +1 -1
  48. package/dist/marks.d.ts +3 -0
  49. package/dist/marks.js +80 -12
  50. package/dist/meet.d.ts +3 -2
  51. package/dist/meet.js +15 -3
  52. package/dist/mixer.d.ts +56 -36
  53. package/dist/mixer.js +158 -87
  54. package/dist/motion.d.ts +2 -199
  55. package/dist/motion.js +2 -652
  56. package/dist/motions.d.ts +266 -0
  57. package/dist/motions.js +724 -0
  58. package/dist/move.d.ts +1 -1
  59. package/dist/move.js +28 -8
  60. package/dist/owner.d.ts +1 -1
  61. package/dist/owner.js +3 -3
  62. package/dist/pace.d.ts +2 -0
  63. package/dist/pace.js +8 -0
  64. package/dist/pack.d.ts +31 -0
  65. package/dist/pack.js +65 -0
  66. package/dist/paging.d.ts +63 -0
  67. package/dist/paging.js +110 -0
  68. package/dist/patch.d.ts +3 -0
  69. package/dist/patch.js +2 -0
  70. package/dist/place.js +235 -43
  71. package/dist/project.d.ts +7 -4
  72. package/dist/project.js +117 -78
  73. package/dist/pull.js +4 -3
  74. package/dist/qualify.js +4 -2
  75. package/dist/revive.d.ts +58 -0
  76. package/dist/revive.js +158 -0
  77. package/dist/sample.d.ts +1 -1
  78. package/dist/sample.js +6 -1
  79. package/dist/scored.d.ts +57 -0
  80. package/dist/scored.js +196 -0
  81. package/dist/seek.d.ts +3 -0
  82. package/dist/seek.js +311 -0
  83. package/dist/spans.d.ts +29 -0
  84. package/dist/spans.js +165 -0
  85. package/dist/strays.d.ts +1 -1
  86. package/dist/strays.js +21 -0
  87. package/dist/tape.d.ts +21 -0
  88. package/dist/tape.js +65 -0
  89. package/dist/ticker.d.ts +28 -3
  90. package/dist/ticker.js +3 -0
  91. package/dist/transport.d.ts +125 -0
  92. package/dist/transport.js +283 -0
  93. package/dist/types.d.ts +404 -38
  94. package/dist/unpage.d.ts +8 -0
  95. package/dist/unpage.js +165 -0
  96. package/dist/voice.d.ts +75 -7
  97. package/dist/voice.js +119 -4
  98. package/dist/weigh.js +2 -2
  99. package/docs/schema.html +120 -31
  100. package/package.json +2 -1
package/CHANGELOG.md CHANGED
@@ -4,6 +4,158 @@ 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.7.1
8
+
9
+ ### Fixed
10
+
11
+ - Under `history`, a mix whose subjects come and go no longer slows as the voices that have left
12
+ pile up. `drop` and a freed slot visit only the voices that left that can hold the subject,
13
+ history lets go of them from the front of the list, and the tape is pruned a second at a time
14
+ rather than every sync. A 200-subject roster replacing 4 subjects a frame climbed to about 1.2 ms
15
+ a frame as 14,000 voices that had left built up; it now holds at about 0.065 ms.
16
+ - An anchor naming a score reads only the voices on that score, and only in the mixes on the
17
+ transport that hold one; it reads each voice that has left once rather than every frame, and
18
+ picks in one pass instead of sorting. astv's busy stage, whose rail waits on the latest `coast`
19
+ of its motion score, takes about 0.45 ms a frame in astv's motion bench, against 4.3 ms on 0.7.0.
20
+ With 200 mixes on scores of their own beside it, a frame's sync takes about 14 µs, against 21
21
+ before the transport listed the mixes holding each score (Node, medians of six alternated runs).
22
+
23
+ ## 0.7.0
24
+
25
+ ### Breaking
26
+
27
+ - With lanes on, a stateless patch's `send` from `at` sends only for subjects probed this frame
28
+ or in the last frame with a probe, where it sent for every subject the lane had met.
29
+ - A `keys` patch's stops are not read for a subject its voice gives no weight, as `at` already
30
+ was not, unless the voice is fading to rest or is in a locus. A `lerp` or `lerpBy` that counted
31
+ its calls no longer sees those frames. A `from: 'current'` voice still takes its base on its
32
+ first read.
33
+ - `sync` throws for a timestamp earlier than the last sync's, short of a `rebase`. It used to take
34
+ one and half work: stateless voices moved back while a stateful patch without `stepMs` was handed
35
+ a negative `dt`. A host moving the mix calls `seek`.
36
+ - `project` takes mix time, not the host's timestamp. The two differ only on a mix with its own
37
+ rate or one that was rebased, where `project(mix.now - 300)` reads 300 ms of mix time back.
38
+ - `Mix` has `seek`, `now` and `tape`, which an engine of its own has to provide.
39
+ - `Mix` has `span`, which an engine of its own has to provide.
40
+ - `Mark` has a fifth value, `coast`, so a `switch` over it that checks every case needs one more.
41
+ - `project` ahead of the mix after a `seek` back throws for a time past a call the tape will make
42
+ again, which it used to leave out; a seek there makes it.
43
+ - An engine of its own is handed `MixOptions.transport` and `name`, and a `Mix` that can share a
44
+ transport has to keep its clock there.
45
+ - `Mix` and `Transport` have `prepare`, which an engine of its own has to provide.
46
+
47
+ ### Added
48
+
49
+ - `transport(opts)` makes one clock several mixes share, each over its own kit, by passing it as
50
+ `MixOptions.transport` with a `name`. `sync`, `rebase`, `rate`, `ramp` and `seek` on the
51
+ transport move every mix on it, and a mix's own throw. A query naming a `score` looks in every
52
+ mix on the transport, so an anchor in one mix waits on a voice in another; one naming no score
53
+ stays in its own mix. The transport has `announce` (on a named score), `marks`, `project(time)`
54
+ with `.of(mix)`, `live`, `inert`, `onWake` and `drop(mix)`. One tape records every mix's calls
55
+ and a seek replays them in the order they were made; under history a seek back before a `drop`
56
+ puts the mix back. `Marked.mix` names the mix a mark came from, and voice ids are unique across a
57
+ transport. A mix made alone has a transport of its own, so nothing changes for it.
58
+ - `fade({ at, over })` with `at` a mix time begins the fade exactly there: ahead, the voice plays
59
+ untouched until then with its `out` and `end` fixed from the call, and `rise` before then takes it
60
+ back with nothing changed; at or behind now, the fade began then and is partway. It wins over an
61
+ anchored `out`.
62
+ - A voice **coasts** when its last pass ends, for its latest-staggered subject: the moment
63
+ `played` resolves true. `coast` is a mark like the other four, listed by `marks`, booked by
64
+ `book`, read ahead by `project`, and waited on with `{ of, mark: 'coast' }`. It falls with `out`
65
+ on a voice that does not freeze, and where a frozen voice starts showing its last frame, which
66
+ has no `out` until it is faded. An owner coasts with its last child, a span where its fit ends. A
67
+ voice that loops for good, or fades before its last pass ends, never coasts.
68
+ - `color()`, a channel holding color as OKLab `[L, a, b, coverage]` with its merge rule as a
69
+ parameter. `color()` averages: voices stack as a premultiplied sum, so it runs on lanes, and the
70
+ pose carries the summed weight as coverage. `color(last())` replaces. `{ lerp: 'oklch' }`
71
+ interpolates round the hue instead of across OKLab, off lanes. `oklab(0xrrggbb)` makes a value;
72
+ `toHex(pose, under)` lays a pose over the subject's own color and `css(pose)` writes an
73
+ `oklab()` string with the coverage as alpha. `color(last())` runs on lanes too, the last voice
74
+ past the band winning, unless a voice writing it is a motion, sits in a locus or names one
75
+ subject. `hex` still works, but it is deprecated for
76
+ `color(last(), { lerp: 'oklch' })`, which replaces and interpolates as it does, and a later
77
+ release removes it.
78
+ - `mix.span(spec)` cues an owner that lays out the voices it holds in an `order` (`queue`,
79
+ `stagger`, `together`) and fits them into its `duration`, again whenever one joins or leaves.
80
+ Each child says how it may give way with `faster`, `slower`, `overlap`, `ballast` and `priority`. The
81
+ span first retimes its children by one factor toward the budget, faster where they run past it
82
+ and slower where they leave room, each as far as its hints allow; then its `fit` decides the
83
+ rest: `condense`, `shed`, `conclude` and `overrun` are exported, `pipe` runs several in turn,
84
+ and `lax` is every one with `overrun` last.
85
+ Where nothing fits, `spill: 'instant'` jumps the weaker children to their end at once and
86
+ `'overrun'` lets the span run long. A span with a budget stays until it has passed, so late
87
+ children can join, and `handle.result` says how the last fit came out.
88
+ - `ticker().wake()` asks for a frame. A mix changed before `stop` and not synced since fires
89
+ `onWake` no more, so nothing else starts the loop again; `add` asks for a frame too, as it
90
+ always did, now documented.
91
+ - An anchor can wait on several: `{ all: [...] }` answers the latest of them and `{ any: [...] }`
92
+ the earliest, and both nest.
93
+ - `handle.rise({ over })` turns a fade out around: the voice climbs back from where its fade had
94
+ got to, up the same curve, and plays on as if never faded, so a host can take back a leave
95
+ without the jump a fresh fade in from 0 makes. It is recorded, so history and `seek` replay it.
96
+ A fade waiting for rest is taken back at once; a voice not fading is left as it is.
97
+ - `mix.seek(time)` moves the live mix to a mix time and plays on from there, under `history` with
98
+ a `tape`. `history.tape` takes weasel-history's `createHistory` (`@weasel-js/history`), or
99
+ anything of the `Tape` shape, and the mix records every call the host makes on it, its handles
100
+ and its spring and glide patches. Back, the mix restores itself as it stood at the end of that
101
+ frame: voices that left since return on the handles the host holds, with `done` and `played`
102
+ starting over, and voices cued since wait `pending` on theirs. Forward, by a sync or a later
103
+ seek, the recorded calls play again at the mix times they were made. A call made while recorded
104
+ calls lie ahead starts a branch, and the tape keeps the old future: `mix.tape.branches()` lists
105
+ them and `switchBranch` picks one. State steps once from the nearest copy history kept, exact
106
+ under `stepMs`. Playing past a time again sends its events and books its marks and hits again.
107
+ The host keeps passing its own clock.
108
+ - `mix.now` reads the mix clock, which `seek` and `project` take.
109
+ - `history.store` pages out what `history.ms` would drop, as plain data, so `ms` bounds memory
110
+ rather than how far back a seek reaches. `store.page` takes records leaving memory,
111
+ `store.load(t)` gives back what a restore to `t` needs, and `store.cut(t)` forgets what a seek
112
+ back left behind. `transport.prepare(t)` (and `mix.prepare` for a mix alone) loads before a seek
113
+ or read past memory; without it, it throws `HistoryMiss` before anything moves. Object subjects
114
+ need `MixOptions.keyOf`, a patch with `state` or `step` needs `pack` and `unpack` (which `patch`
115
+ now takes), and every mix on a transport with a store needs a unique `name`. A voice cued with
116
+ `as: { kind, data }` leaves memory once it has left and history no longer reaches its end, and
117
+ `history.revive` builds its spec again when a seek needs it, behind the handle the host holds.
118
+ With a store, blits never prunes the tape; the host bounds it. Without one, nothing changes.
119
+
120
+ ### Fixed
121
+
122
+ - A lane fills only the subjects probed this frame or in the last frame with a probe, not every
123
+ subject it has met; one probed in neither takes the general path if it is probed before the next
124
+ fill. Three `keys` voices over 10k subjects, every subject probed once and then 40% each frame
125
+ (`view`), take 0.561 ms a frame against 0.878. No other row moves past its run-to-run spread:
126
+ `signal` and `named` at 10k read 1.06 and 1.09 here and 0.96 and 0.91 in a run against the
127
+ build before (teitou, medians of six alternated runs, each in its own process).
128
+ - A second `seek` or `project` back no longer reaches into time the first one's history had
129
+ already let go of, where it read wrong values: after seeking back to 1600 under `ms: 500` from
130
+ 2000, a seek to 1200 read 0 where the mix had shown 90. It throws as a read older than history
131
+ does.
132
+ - Under `history`, a subject faded out of a voice or dropped after the moment a `seek` or a
133
+ `project` reads back to comes back with the record it had, and with a motion patch's state for
134
+ it. It used to come back as never seen, its state started afresh.
135
+ - `project` across a `rebase` reads the moment the host saw: it converted a host timestamp with
136
+ the offset in force now, so a 1000 ms rise synced at 0 and 200, rebased and synced at 10200 read
137
+ 0 at 200, where the host had seen 20. Taking mix time, it reads 20.
138
+
139
+ - A probe and `atRest` no longer allocate on the heap to find a subject's voices. Each passed the
140
+ mix's clock, a fractional number, to a function too large for V8 to inline, which stores such a
141
+ number on the heap to pass it; and the function that links a subject's voices made a closure
142
+ context on every call, though only relinking uses one. Collections over 5000 frames of 10k
143
+ subjects fall from 666 to 375 with a probe and `atRest` of each (`probed`), 279 to 121 with
144
+ `atRest` alone (`rest`), 428 to 285 with a voice per subject (`named`), and 741 to 524 with three
145
+ `fn` voices over every subject (`fn`). A frame takes 0.857 ms against 0.885 for `rest`, 1.263
146
+ against 1.325 for `named`, and 2.074 against 2.156 for `fn`; `probed`, at 1.507 against 1.504,
147
+ and the rows read by `pull` are unchanged (teitou, medians of twelve alternated runs).
148
+ - A fill no longer stamps every subject: the lanes keep which fill ran a lane, and a subject is
149
+ stamped only when it is left to the general path. A spring is solved by a function per kind of
150
+ spring, small enough for V8 to inline where it samples, where one function over all four left
151
+ spring fills running at one of two speeds. At 10k subjects, a frame takes 0.438 ms against 0.473
152
+ with a spring voice read by `pull` (`spring^`), 0.411 against 0.463 with a spring voice per
153
+ subject (`springs^`), 0.937 against 0.996 and 0.884 against 0.935 for the same by `probe`,
154
+ 0.306 against 0.319 with one subject replaced each frame (`turnover^`), 0.226 against 0.233 with
155
+ one voice replaced each frame (`churn^`), and 0.167 against 0.170 with a tween voice per subject
156
+ (`tweens^`); a probe and `atRest` of every subject (`probed`) is unchanged, 1.497 against 1.504
157
+ (teitou, medians of eight alternated runs, twelve for `probed`).
158
+
7
159
  ## 0.6.0
8
160
 
9
161
  ### Breaking
package/README.md CHANGED
@@ -1,22 +1,24 @@
1
1
  # blits
2
2
 
3
- Concurrent effects, mixed. Each effect runs on its own clock with its own state and weight, and a
4
- mix folds them into one value per subject per frame by rules that belong to the channel, not the
5
- effect.
3
+ Effects over time, at once or in order. Each effect runs on its own clock with its own state and
4
+ weight. Effects that overlap are folded by a mix into one value per subject per frame, by rules
5
+ that belong to the channel, not the effect. Effects that follow one another are placed on a score,
6
+ at a time or against another effect's start or end, and the mix can be read ahead, read back, and
7
+ moved to any moment, as a scrubber does.
6
8
 
7
9
  It is the thing klieg does three times over — motion, effects, lighting — and wod does once for
8
10
  transitions, extracted so those repos, sherpa and magicsmoke can share one vocabulary and one
9
11
  engine.
10
12
 
11
13
  ```ts
12
- import { hex, kit, max, mix, mul, patch, sum, vec } from '@msb235/blits';
14
+ import { color, kit, max, mix, mul, patch, sum, vec } from '@msb235/blits';
13
15
 
14
16
  // What a part of a sign can move on. The arithmetic lives here and nowhere else.
15
17
  const PART = kit<PartPose>({
16
18
  gain: mul(),
17
19
  dark: max(),
18
20
  position: vec(3, sum()),
19
- color: hex(),
21
+ color: color(),
20
22
  });
21
23
 
22
24
  const flicker = patch<Part, PartPose>(
@@ -39,16 +41,38 @@ function frame(timestamp: number) {
39
41
  document.addEventListener('visibilitychange', () => document.hidden || m.rebase());
40
42
 
41
43
  handle.fade({ over: 500 }); // the voice ramps out and leaves
44
+ handle.rise({ over: 500 }); // or, before it is gone, climbs back from where it had got to
45
+ ```
46
+
47
+ In order, on the same mix:
48
+
49
+ ```ts
50
+ // Place a voice at a time on the host's clock, or against another voice's marks.
51
+ m.cue({ patch: typeIn, name: 'title', start: 1000, loop: false });
52
+ m.cue({ patch: tint, loop: false, anchor: { start: { after: 'title', by: 200 } } });
53
+
54
+ // A span fits the voices it holds into a budget, one after another by default.
55
+ const intro = m.span({ duration: 2000 });
56
+ for (const line of lines) m.cue({ patch: typeIn, loop: false, owner: intro });
57
+
58
+ // Read the mix half a second ahead without moving it, or move it: a scrubber.
59
+ const next = m.project(m.now + 500).probe(part);
60
+ m.seek(1200); // back as well as forward, given `history` with a tape
42
61
  ```
43
62
 
44
63
  ## The words
45
64
 
46
- Ten of them carry the rest: **delta** (a partial record of channel values), **channel** (one field
65
+ These carry the rest: **delta** (a partial record of channel values), **channel** (one field
47
66
  with its own arithmetic), **kit** (the channel set for one kind of delta), **subject** (what is
48
67
  driven), **patch** (a pure function of phase and a subject), **voice** (a patch with its own clock
49
68
  and weight), **mix** (the live voices over one kit), **signal** (a 0..1 scalar from outside the
50
69
  clock), **handle** (the live controls on one voice), **engine** (the implementation behind a mix).
51
70
 
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
73
+ another voice's mark), **span** (an owner fitting the voices it holds into a duration), **project**
74
+ (read the mix at another time without moving it), **seek** (move the mix to a time and play on).
75
+
52
76
  The design — the full vocabulary, the channel table, the time model, blending, the engine seam, and
53
77
  what is still open — is `docs/schema.html`, which ships in the package: open it from
54
78
  `node_modules/@msb235/blits/`. The naming work behind it is
@@ -76,7 +100,7 @@ so the engine keeps no dependencies; its README says how it works.
76
100
 
77
101
  ## Status
78
102
 
79
- v0.6.0, with five consumers. magicsmoke runs every fault on it, wod its wheel, tricks and
103
+ v0.7.1, with five consumers. magicsmoke runs every fault on it, wod its wheel, tricks and
80
104
  transitions, weasel its animator's tweens, sherpa its seams, and
81
105
  [klieg](https://github.com/orochi235/klieg) composes all three of its systems — letter motion,
82
106
  part effects, and the environment a sign is lit by — on a mix each.
package/dist/bare.js CHANGED
@@ -23,6 +23,7 @@ export function bareRows(lanes, c, p, id) {
23
23
  const fills = lanes.fills;
24
24
  const from = lanes.frameProbes;
25
25
  const probed = lanes.probes !== from;
26
+ const lately = lanes.lately;
26
27
  const now = lanes.now;
27
28
  const op = ch.op;
28
29
  for (; p < list.length; p++) {
@@ -37,6 +38,11 @@ export function bareRows(lanes, c, p, id) {
37
38
  if (probed &&
38
39
  (per[q + 3 /* Per.LANE_PROBE */] > from || per[q + 4 /* Per.GENERAL_PROBE */] > from))
39
40
  break;
41
+ // Left to the general path, as `rows` leaves it.
42
+ if (lately >= 0 &&
43
+ !(per[q + 3 /* Per.LANE_PROBE */] > lately ||
44
+ per[q + 4 /* Per.GENERAL_PROBE */] > lately))
45
+ break;
40
46
  const elapsed = clock(hot, h, now) - data[o + 0 /* Row.DELAY */];
41
47
  if (!(elapsed >= 0))
42
48
  break;
@@ -48,9 +54,9 @@ export function bareRows(lanes, c, p, id) {
48
54
  data[o + 5 /* Row.SAMPLED */] = -2 /* Sampled.BARE */;
49
55
  c.deltas[p] = null;
50
56
  }
51
- const share = left(c.eases[p], progress(elapsed, hot[h + 8 /* Hot.AT */], hot[h + 9 /* Hot.MS */]));
52
57
  if (w <= 0)
53
58
  continue;
59
+ const share = left(c.eases[p], progress(elapsed, hot[h + 8 /* Hot.AT */], hot[h + 9 /* Hot.MS */]));
54
60
  const values = ch.values;
55
61
  const base = slot * n;
56
62
  const x = h + 10 /* Hot.X0 */;
package/dist/book.d.ts CHANGED
@@ -35,6 +35,8 @@ export declare class Book<I, O> implements Booker {
35
35
  private prev;
36
36
  private round;
37
37
  private stopped;
38
+ /** The host time the mix last sought at: a mark at or before it was taken before the seek. */
39
+ private floor;
38
40
  /** Bookings of marks, by `keyOf`, kept while listed so one already taken is not taken late. */
39
41
  private readonly marked;
40
42
  private readonly tracks;
@@ -43,6 +45,11 @@ export declare class Book<I, O> implements Booker {
43
45
  /** Books what comes within `ahead` of this sync, and retracts what moved. */
44
46
  sync(): void;
45
47
  stop(): void;
48
+ /**
49
+ * The mix was sought to `host.now`, back or ahead: what is booked ahead is stopped, and
50
+ * everything after that moment is booked as the mix reaches it.
51
+ */
52
+ sought(): void;
46
53
  /** Whether a standing booking now falls at host time `time` within `RETIME_MS`. */
47
54
  private stands;
48
55
  private fits;
package/dist/book.js CHANGED
@@ -24,6 +24,8 @@ export class Book {
24
24
  prev = Number.NaN;
25
25
  round = 0;
26
26
  stopped = false;
27
+ /** The host time the mix last sought at: a mark at or before it was taken before the seek. */
28
+ floor = Number.NEGATIVE_INFINITY;
27
29
  /** Bookings of marks, by `keyOf`, kept while listed so one already taken is not taken late. */
28
30
  marked = new Map();
29
31
  tracks = new Map();
@@ -85,6 +87,35 @@ export class Book {
85
87
  this.tracks.clear();
86
88
  this.host.unhook(this);
87
89
  }
90
+ /**
91
+ * The mix was sought to `host.now`, back or ahead: what is booked ahead is stopped, and
92
+ * everything after that moment is booked as the mix reaches it.
93
+ */
94
+ sought() {
95
+ if (this.stopped)
96
+ return;
97
+ const last = this.last;
98
+ for (const b of this.marked.values())
99
+ if (b.time > last)
100
+ b.taken?.stop();
101
+ for (const t of this.tracks.values())
102
+ for (const b of t.booked.values())
103
+ if (b.time > last)
104
+ b.taken?.stop();
105
+ this.marked.clear();
106
+ this.tracks.clear();
107
+ const host = this.host;
108
+ this.floor = host.timestamp;
109
+ this.prev = host.now;
110
+ for (const voice of host.voices)
111
+ if (voice.spec.hits !== undefined && voice.state !== 'done')
112
+ this.tracks.set(voice, {
113
+ seenTo: voice.elapsedAt(host.now),
114
+ seeks: voice.seeks,
115
+ booked: new Map(),
116
+ met: this.round,
117
+ });
118
+ }
88
119
  /** Whether a standing booking now falls at host time `time` within `RETIME_MS`. */
89
120
  stands(b, time) {
90
121
  if (time === undefined || !Number.isFinite(time))
@@ -100,7 +131,7 @@ export class Book {
100
131
  const marked = this.marked;
101
132
  const seen = new Set();
102
133
  for (const m of listed) {
103
- if (!this.fits(m.score, m.tags))
134
+ if (!this.fits(m.score, m.tags) || m.timestamp <= this.floor)
104
135
  continue;
105
136
  const key = keyOf(m);
106
137
  seen.add(key);
@@ -211,8 +242,8 @@ export class Book {
211
242
  return this.host.hostOf(t);
212
243
  }
213
244
  }
214
- const markIndex = { start: 1, in: 2, out: 3, end: 4 };
215
- /** A mark's identity: its voice and which of the four, or the announcement. */
245
+ const markIndex = { start: 1, in: 2, coast: 3, out: 4, end: 5 };
246
+ /** A mark's identity: its voice and which of its marks, or the announcement. */
216
247
  function keyOf(m) {
217
- return m.order * 5 + (m.mark === undefined ? 0 : markIndex[m.mark]);
248
+ return m.order * 6 + (m.mark === undefined ? 0 : markIndex[m.mark]);
218
249
  }
package/dist/chain.d.ts CHANGED
@@ -6,7 +6,8 @@ import type { Subject, Voice } from './voice.js';
6
6
  * it goes live, since `target` is asked on first sight, and sight only comes once a voice plays
7
7
  * or freezes before.
8
8
  */
9
- export declare function chain<I, O>(this: Mixer<I, O>, subject: I, now: number): Subject<unknown>;
9
+ export declare function chain<I, O>(this: Mixer<I, O>, subject: I): Subject<unknown>;
10
+ export declare function relink<I, O>(this: Mixer<I, O>, subject: I, was: Subject<unknown> | undefined): Subject<unknown>;
10
11
  /** A voice's record for a subject where its chain links it: the voice plays or freezes, and reaches it. */
11
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
13
  /** Whether a voice reaches a subject by what its spec says: the subjects it names, or its `target`. */
package/dist/chain.js CHANGED
@@ -29,10 +29,14 @@ function stub() {
29
29
  * it goes live, since `target` is asked on first sight, and sight only comes once a voice plays
30
30
  * or freezes before.
31
31
  */
32
- export function chain(subject, now) {
32
+ export function chain(subject) {
33
33
  const was = this.chains.get(subject);
34
- if (was !== undefined && was.version === this.version)
35
- return was;
34
+ return was !== undefined && was.version === this.version ? was : this.relink(subject, was);
35
+ }
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.
38
+ export function relink(subject, was) {
39
+ const now = this.now;
36
40
  const slot = was !== undefined ? was.slot : this.lanes !== null ? this.lanes.number(subject) : -1;
37
41
  // Where only voices over every subject changed since, those alone are taken off or put on.
38
42
  let first = was === undefined
@@ -11,9 +11,12 @@ export type LerpInto = (out: unknown[] | undefined, a: unknown, b: unknown, u: n
11
11
  * since only the stock fold and merge are known to copy the array rather than keep it.
12
12
  */
13
13
  export declare function lerpInto(channel: Channel<unknown>): LerpInto | undefined;
14
- /** What a lane needs of a stock numeric channel beyond the channel: its arithmetic by name, its axes. */
14
+ /**
15
+ * What a lane needs of a stock numeric channel beyond the channel: its arithmetic by name, its axes.
16
+ * `'last'` has no rest: the last contribution to pass the band replaces, and a weight only gates it.
17
+ */
15
18
  export interface Numeric {
16
- op: 'sum' | 'mul' | 'max';
19
+ op: 'sum' | 'mul' | 'max' | 'last';
17
20
  axes: number;
18
21
  }
19
22
  /**
@@ -21,6 +24,12 @@ export interface Numeric {
21
24
  * so a custom channel that claims a stock `kind` is not trusted with a lane.
22
25
  */
23
26
  export declare function numericOf(channel: Channel<unknown>): Numeric | undefined;
27
+ /** Vouches for a stock channel made outside this module, as `color` makes its own. */
28
+ export declare function vouch(channel: Channel<unknown>, numeric: Numeric): void;
29
+ /** Records how many numbers a rest-less array channel holds a subject, which its rest cannot say. */
30
+ export declare function widen(channel: Channel<unknown>, axes: number): void;
31
+ /** How many numbers a channel holds a subject: its rest's length, a recorded width, or one. */
32
+ export declare function axesOf(channel: Channel<unknown>): number;
24
33
  /** `merge(acc, scale(v, w))` for a stock numeric channel, in exactly its arithmetic. */
25
34
  export declare function foldNumber(op: Numeric['op'], acc: number, v: number, w: number): number;
26
35
  /**
@@ -51,7 +60,7 @@ export declare function mul(opts?: NumberOptions): Channel<number>;
51
60
  */
52
61
  export declare function max(opts?: NumberOptions): Channel<number>;
53
62
  /**
54
- * A channel with no rest: the last influence to pass wins, and a weight can only gate it. The
63
+ * A channel with no rest: the last contribution to pass wins, and a weight can only gate it. The
55
64
  * default `lerp` steps at the midpoint, which is all a value with no arithmetic can promise; a
56
65
  * consumer with a real interpolation passes its own, as `hex` does.
57
66
  *
@@ -66,22 +75,6 @@ export declare function last<V>(opts?: {
66
75
  * @category channel
67
76
  */
68
77
  export declare function vec(n: number, of: Channel<number>): Channel<number[]>;
69
- /**
70
- * OKLCH, hue the short way round, so a crossfade between hues keeps its lightness and saturation
71
- * instead of dipping through a muddy middle. A mix that leaves sRGB's gamut is clipped per channel.
72
- *
73
- * @category channel
74
- */
75
- export declare function mixHex(a: number, b: number, u: number): number;
76
- /**
77
- * Color, as 0xrrggbb, blended in OKLCH as `mixHex` does, or with `space: 'srgb'` channel by
78
- * channel in sRGB, which keeps a port's arithmetic identical to a host that blended that way.
79
- *
80
- * @category channel
81
- */
82
- export declare function hex(opts?: {
83
- space?: 'oklch' | 'srgb';
84
- }): Channel<number>;
85
78
  /**
86
79
  * A kit is plain data; this only fixes the type.
87
80
  *
package/dist/channels.js CHANGED
@@ -23,13 +23,29 @@ const numerics = new WeakMap();
23
23
  export function numericOf(channel) {
24
24
  return numerics.get(channel);
25
25
  }
26
+ /** Vouches for a stock channel made outside this module, as `color` makes its own. */
27
+ export function vouch(channel, numeric) {
28
+ numerics.set(channel, numeric);
29
+ }
30
+ const widths = new WeakMap();
31
+ /** Records how many numbers a rest-less array channel holds a subject, which its rest cannot say. */
32
+ export function widen(channel, axes) {
33
+ widths.set(channel, axes);
34
+ }
35
+ /** How many numbers a channel holds a subject: its rest's length, a recorded width, or one. */
36
+ export function axesOf(channel) {
37
+ const rest = channel.rest;
38
+ return Array.isArray(rest) ? rest.length : (widths.get(channel) ?? 1);
39
+ }
26
40
  /** `merge(acc, scale(v, w))` for a stock numeric channel, in exactly its arithmetic. */
27
41
  export function foldNumber(op, acc, v, w) {
28
42
  if (op === 'sum')
29
43
  return add(acc, times(v, w));
30
44
  if (op === 'mul')
31
45
  return product(acc, toward(v, w));
32
- return larger(acc, times(v, w));
46
+ if (op === 'max')
47
+ return larger(acc, times(v, w));
48
+ return v;
33
49
  }
34
50
  /** Bounds change how a value folds at its edges, so they are part of the kind. */
35
51
  const kindOf = (name, bounds) => bounds === undefined ? name : `${name}[${bounds[0]}, ${bounds[1]}]`;
@@ -85,7 +101,7 @@ export function max(opts) {
85
101
  return channel;
86
102
  }
87
103
  /**
88
- * A channel with no rest: the last influence to pass wins, and a weight can only gate it. The
104
+ * A channel with no rest: the last contribution to pass wins, and a weight can only gate it. The
89
105
  * default `lerp` steps at the midpoint, which is all a value with no arithmetic can promise; a
90
106
  * consumer with a real interpolation passes its own, as `hex` does.
91
107
  *
@@ -162,122 +178,6 @@ export function vec(n, of) {
162
178
  numerics.set(channel, { ...inner, axes: n });
163
179
  return channel;
164
180
  }
165
- const LINEAR = Array.from({ length: 256 }, (_, c) => {
166
- const v = c / 255;
167
- return v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
168
- });
169
- /** The linear value halfway between byte c - 1 and c: at or above it, a value rounds to c. */
170
- const ROUNDS_UP = LINEAR.map((_, c) => {
171
- const v = (c - 0.5) / 255;
172
- return v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
173
- });
174
- /** The byte each 1/4096 of linear light starts at; a bin holds at most one rounding point. */
175
- const BINS = 4096;
176
- const FIRST = Array.from({ length: BINS + 1 }, (_, i) => {
177
- let c = 0;
178
- while (c < 255 && i / BINS >= ROUNDS_UP[c + 1])
179
- c++;
180
- return c;
181
- });
182
- const toByte = (v) => {
183
- if (v <= 0)
184
- return 0;
185
- if (v >= 1)
186
- return 255;
187
- const c = FIRST[(v * BINS) | 0];
188
- return c < 255 && v >= ROUNDS_UP[c + 1] ? c + 1 : c;
189
- };
190
- /** Below this chroma a color is gray and its hue is noise, so the lerp takes the other end's. */
191
- const GRAY = 1e-4;
192
- /**
193
- * Colors already turned into OKLCH, direct-mapped on a byte of their hash: a mix reads the same
194
- * few keyframe colors every frame, and the table stays 256 colors however many it meets.
195
- */
196
- const SLOTS = 256;
197
- const slotColor = new Int32Array(SLOTS).fill(-1);
198
- const slotL = new Float64Array(SLOTS);
199
- const slotC = new Float64Array(SLOTS);
200
- const slotH = new Float64Array(SLOTS);
201
- /** 0xrrggbb to its slot, filled with [L, C, h], h in radians; Björn Ottosson's OKLab matrices. */
202
- function oklch(rgb) {
203
- const slot = Math.imul(rgb, 0x9e3779b1) >>> 24;
204
- if (slotColor[slot] === rgb)
205
- return slot;
206
- const r = LINEAR[(rgb >> 16) & 0xff];
207
- const g = LINEAR[(rgb >> 8) & 0xff];
208
- const b = LINEAR[rgb & 0xff];
209
- const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
210
- const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
211
- const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
212
- const A = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s;
213
- const B = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s;
214
- slotColor[slot] = rgb;
215
- slotL[slot] = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s;
216
- slotC[slot] = Math.sqrt(A * A + B * B);
217
- slotH[slot] = Math.atan2(B, A);
218
- return slot;
219
- }
220
- /** [L, C, h] to 0xrrggbb, clipping each channel to sRGB's gamut. */
221
- function fromOklch(L, C, h) {
222
- const A = C * Math.cos(h);
223
- const B = C * Math.sin(h);
224
- const l_ = L + 0.3963377774 * A + 0.2158037573 * B;
225
- const m_ = L - 0.1055613458 * A - 0.0638541728 * B;
226
- const s_ = L - 0.0894841775 * A - 1.291485548 * B;
227
- const l = l_ * l_ * l_;
228
- const m = m_ * m_ * m_;
229
- const s = s_ * s_ * s_;
230
- const r = 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s;
231
- const g = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s;
232
- const b = -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s;
233
- return (toByte(r) << 16) | (toByte(g) << 8) | toByte(b);
234
- }
235
- /**
236
- * OKLCH, hue the short way round, so a crossfade between hues keeps its lightness and saturation
237
- * instead of dipping through a muddy middle. A mix that leaves sRGB's gamut is clipped per channel.
238
- *
239
- * @category channel
240
- */
241
- export function mixHex(a, b, u) {
242
- if (u === 0 || a === b)
243
- return a;
244
- if (u === 1)
245
- return b;
246
- const sa = oklch(a);
247
- const al = slotL[sa];
248
- const ac = slotC[sa];
249
- const ah = slotH[sa];
250
- const sb = oklch(b);
251
- const bc = slotC[sb];
252
- const from = ac < GRAY ? slotH[sb] : ah;
253
- const to = bc < GRAY ? ah : slotH[sb];
254
- let turn = to - from;
255
- if (turn > Math.PI)
256
- turn -= 2 * Math.PI;
257
- else if (turn < -Math.PI)
258
- turn += 2 * Math.PI;
259
- return fromOklch(mix(al, slotL[sb], u), mix(ac, bc, u), from + turn * u);
260
- }
261
- const clamp255 = (v) => (v < 0 ? 0 : v > 255 ? 255 : Math.round(v));
262
- /** Each of red, green and blue on its own, as klieg's light channel blends. */
263
- function mixSrgb(a, b, u) {
264
- const ar = (a >> 16) & 0xff;
265
- const ag = (a >> 8) & 0xff;
266
- const ab = a & 0xff;
267
- const br = (b >> 16) & 0xff;
268
- const bg = (b >> 8) & 0xff;
269
- const bb = b & 0xff;
270
- return ((clamp255(mix(ar, br, u)) << 16) | (clamp255(mix(ag, bg, u)) << 8) | clamp255(mix(ab, bb, u)));
271
- }
272
- /**
273
- * Color, as 0xrrggbb, blended in OKLCH as `mixHex` does, or with `space: 'srgb'` channel by
274
- * channel in sRGB, which keeps a port's arithmetic identical to a host that blended that way.
275
- *
276
- * @category channel
277
- */
278
- export function hex(opts) {
279
- return { ...last({ lerp: opts?.space === 'srgb' ? mixSrgb : mixHex }), kind: 'hex' };
280
- }
281
181
  /**
282
182
  * A kit is plain data; this only fixes the type.
283
183
  *
package/dist/clock.d.ts CHANGED
@@ -9,12 +9,19 @@ export declare function passesOf(loop: boolean | number | undefined): number;
9
9
  export declare function phaseAt(elapsed: number, duration: number, passes: number): number;
10
10
  /** The pass `elapsed` voice ms falls in, held at the last once they are done. */
11
11
  export declare function passAt(elapsed: number, duration: number, passes: number): number;
12
- /** A voice's own fade this frame, 0..1: in from `since` over `fadeIn`, and out along its ramp. */
12
+ /**
13
+ * A voice's own fade this frame, 0..1: in from `since` over `fadeIn`, out along its ramp, and back
14
+ * up the same curve where a fade out was turned around.
15
+ */
13
16
  export declare function envelope(fadeIn: number, out: {
14
17
  at: number;
15
18
  over: number;
16
19
  rest: boolean;
17
- } | null, ease: Curve | undefined, reduced: boolean, now: number, since: number): number;
20
+ } | null, ease: Curve | undefined, reduced: boolean, now: number, since: number, back?: {
21
+ from: number;
22
+ at: number;
23
+ over: number;
24
+ } | null): number;
18
25
  /**
19
26
  * A subject's voice time `raw` once the voice's freezes apply: 0 before it starts if it freezes
20
27
  * before, else NaN, where it shows nothing; `span` once its passes end if it freezes after.
package/dist/clock.js CHANGED
@@ -21,14 +21,22 @@ export function passAt(elapsed, duration, passes) {
21
21
  return 0;
22
22
  return over(elapsed, duration, passes) ? passes - 1 : Math.floor(elapsed / duration);
23
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) {
24
+ /**
25
+ * A voice's own fade this frame, 0..1: in from `since` over `fadeIn`, out along its ramp, and back
26
+ * up the same curve where a fade out was turned around.
27
+ */
28
+ export function envelope(fadeIn, out, ease, reduced, now, since, back = null) {
26
29
  let w = 1;
27
30
  if (fadeIn > 0 && !reduced) {
28
31
  const u = (now - since) / fadeIn;
29
32
  if (u < 1)
30
33
  w *= ease ? ease(Math.max(0, u)) : Math.max(0, u);
31
34
  }
35
+ if (back && !reduced) {
36
+ const u = back.from + (now - back.at) / back.over;
37
+ if (u < 1)
38
+ w *= ease ? ease(Math.max(0, u)) : Math.max(0, u);
39
+ }
32
40
  if (out && !out.rest) {
33
41
  if (reduced || out.over === 0)
34
42
  return 0;