@msb235/blits 0.7.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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,22 @@ 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
+
7
23
  ## 0.7.0
8
24
 
9
25
  ### Breaking
package/README.md CHANGED
@@ -100,7 +100,7 @@ so the engine keeps no dependencies; its README says how it works.
100
100
 
101
101
  ## Status
102
102
 
103
- v0.7.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
104
104
  transitions, weasel its animator's tweens, sherpa its seams, and
105
105
  [klieg](https://github.com/orochi235/klieg) composes all three of its systems — letter motion,
106
106
  part effects, and the environment a sign is lit by — on a mix each.
package/dist/cue.js CHANGED
@@ -8,6 +8,7 @@ import { motionOf } from './motion.js';
8
8
  import { adopt, ownerPatch, ownerReading } from './owner.js';
9
9
  import { durationOf } from './patch.js';
10
10
  import { checkPlacement, localNow, mixAt, pin, place } from './place.js';
11
+ import { scoredAdd } from './scored.js';
11
12
  import { checkChild, refit } from './spans.js';
12
13
  import { motionOwner } from './strays.js';
13
14
  import { record } from './tape.js';
@@ -127,6 +128,7 @@ function enter(mix, voice) {
127
128
  while (i > 0 && cued[i - 1].id > voice.id)
128
129
  i--;
129
130
  cued.splice(i, 0, voice);
131
+ scoredAdd(mix, voice);
130
132
  index(mix, voice);
131
133
  changed(mix, voice);
132
134
  mix.stir();
package/dist/fade.js CHANGED
@@ -4,6 +4,7 @@ import { schedule } from './due.js';
4
4
  import { leave, noted, releasing } from './history.js';
5
5
  import { orphan } from './owner.js';
6
6
  import { startOf } from './place.js';
7
+ import { scoreTouched } from './scored.js';
7
8
  import { lingers, refit } from './spans.js';
8
9
  import { unreach } from './unreached.js';
9
10
  /** Starts one subject's ramp out of a voice, or takes it out at once for a ramp of 0. */
@@ -162,6 +163,7 @@ export function retire(mix, voice, at) {
162
163
  if (motion !== undefined && asks(mix, motion, voice))
163
164
  motion.owner = null;
164
165
  voice.state = 'done';
166
+ scoreTouched(mix, voice);
165
167
  // The record its setting last wrote to, which a retired voice no longer calls for.
166
168
  voice.keepOn = null;
167
169
  mix.retired.push(voice);
package/dist/gone.d.ts ADDED
@@ -0,0 +1,32 @@
1
+ import type { Mixer } from './mixer.js';
2
+ import { Store } from './store.js';
3
+ import type { Voice } from './voice.js';
4
+ /**
5
+ * A mix's gone voices as `drop` and a freed slot look for them: those over every subject, and the
6
+ * rest by each subject they name. Kept in step with `gone`, which only grows by push and is
7
+ * otherwise replaced whole, so it catches up lazily and rebuilds only on a replacement it did not
8
+ * make itself. Under history a churning mix holds thousands, too many to walk per subject dropped.
9
+ */
10
+ export declare class GoneIndex<I, O> {
11
+ /** The `gone` list it was built over, and how much of it. */
12
+ of: readonly Voice<I, O>[] | null;
13
+ count: number;
14
+ general: Voice<I, O>[];
15
+ named: Store<I, Voice<I, O>[]>;
16
+ /** The latest `doneAt` in `of`, and how far any voice finished before one listed ahead of it. */
17
+ latest: number;
18
+ slack: number;
19
+ }
20
+ /** The index caught up with `mix.gone`. */
21
+ export declare function goneIndex<I, O>(mix: Mixer<I, O>): GoneIndex<I, O>;
22
+ /**
23
+ * Keeps the gone voices `keep` says yes to, asking it once each, and takes the rest out of the
24
+ * index one by one. Leaves `gone` as it was when every one stays.
25
+ */
26
+ export declare function keepGone<I, O>(mix: Mixer<I, O>, keep: (v: Voice<I, O>) => boolean): void;
27
+ /**
28
+ * Lets go of the gone voices that finished before `reach`. Voices go in about the order they
29
+ * finish, so only the front can hold one: past the point where a voice finished `slack` after
30
+ * `reach`, none behind it finished before `reach`.
31
+ */
32
+ export declare function expireGone<I, O>(mix: Mixer<I, O>, reach: number): void;
package/dist/gone.js ADDED
@@ -0,0 +1,134 @@
1
+ import { scoredCut } from './scored.js';
2
+ import { Store } from './store.js';
3
+ /**
4
+ * A mix's gone voices as `drop` and a freed slot look for them: those over every subject, and the
5
+ * rest by each subject they name. Kept in step with `gone`, which only grows by push and is
6
+ * otherwise replaced whole, so it catches up lazily and rebuilds only on a replacement it did not
7
+ * make itself. Under history a churning mix holds thousands, too many to walk per subject dropped.
8
+ */
9
+ export class GoneIndex {
10
+ /** The `gone` list it was built over, and how much of it. */
11
+ of = null;
12
+ count = 0;
13
+ general = [];
14
+ named = new Store();
15
+ /** The latest `doneAt` in `of`, and how far any voice finished before one listed ahead of it. */
16
+ latest = Number.NEGATIVE_INFINITY;
17
+ slack = 0;
18
+ }
19
+ function add(ix, voice) {
20
+ if (voice.doneAt < ix.latest)
21
+ ix.slack = Math.max(ix.slack, ix.latest - voice.doneAt);
22
+ else
23
+ ix.latest = voice.doneAt;
24
+ if (voice.named === null) {
25
+ ix.general.push(voice);
26
+ return;
27
+ }
28
+ for (const subject of voice.named) {
29
+ const list = ix.named.get(subject);
30
+ if (list === undefined)
31
+ ix.named.set(subject, [voice]);
32
+ else
33
+ list.push(voice);
34
+ }
35
+ }
36
+ function remove(ix, out) {
37
+ let general = false;
38
+ for (const v of out) {
39
+ if (v.named === null) {
40
+ general = true;
41
+ continue;
42
+ }
43
+ for (const subject of v.named) {
44
+ const named = ix.named.get(subject);
45
+ if (named === undefined)
46
+ continue;
47
+ const at = named.indexOf(v);
48
+ if (at >= 0)
49
+ named.splice(at, 1);
50
+ if (named.length === 0)
51
+ ix.named.delete(subject);
52
+ }
53
+ }
54
+ if (general) {
55
+ const leaving = new Set(out);
56
+ ix.general = ix.general.filter((v) => !leaving.has(v));
57
+ }
58
+ }
59
+ /** The index caught up with `mix.gone`. */
60
+ export function goneIndex(mix) {
61
+ const ix = mix.goneIx;
62
+ const list = mix.gone;
63
+ if (ix.of !== list || ix.count > list.length) {
64
+ ix.general = [];
65
+ ix.named = new Store();
66
+ ix.count = 0;
67
+ ix.of = list;
68
+ ix.latest = Number.NEGATIVE_INFINITY;
69
+ ix.slack = 0;
70
+ }
71
+ for (let i = ix.count; i < list.length; i++)
72
+ add(ix, list[i]);
73
+ ix.count = list.length;
74
+ return ix;
75
+ }
76
+ /**
77
+ * Keeps the gone voices `keep` says yes to, asking it once each, and takes the rest out of the
78
+ * index one by one. Leaves `gone` as it was when every one stays.
79
+ */
80
+ export function keepGone(mix, keep) {
81
+ const ix = goneIndex(mix);
82
+ const list = mix.gone;
83
+ let i = 0;
84
+ while (i < list.length && keep(list[i]))
85
+ i++;
86
+ if (i === list.length)
87
+ return;
88
+ const kept = list.slice(0, i);
89
+ const out = [list[i]];
90
+ for (i++; i < list.length; i++) {
91
+ const v = list[i];
92
+ (keep(v) ? kept : out).push(v);
93
+ }
94
+ remove(ix, out);
95
+ mix.gone = kept;
96
+ scoredCut(mix, list, out);
97
+ ix.of = kept;
98
+ ix.count = kept.length;
99
+ }
100
+ /**
101
+ * Lets go of the gone voices that finished before `reach`. Voices go in about the order they
102
+ * finish, so only the front can hold one: past the point where a voice finished `slack` after
103
+ * `reach`, none behind it finished before `reach`.
104
+ */
105
+ export function expireGone(mix, reach) {
106
+ const ix = goneIndex(mix);
107
+ if (!Number.isFinite(ix.slack)) {
108
+ keepGone(mix, (v) => v.doneAt >= reach);
109
+ return;
110
+ }
111
+ const list = mix.gone;
112
+ const edge = reach + ix.slack;
113
+ let end = 0;
114
+ let any = false;
115
+ for (let seen = Number.NEGATIVE_INFINITY; end < list.length && seen < edge; end++) {
116
+ const done = list[end].doneAt;
117
+ if (done < reach)
118
+ any = true;
119
+ if (done > seen)
120
+ seen = done;
121
+ }
122
+ if (!any)
123
+ return;
124
+ const kept = [];
125
+ const out = [];
126
+ for (let i = 0; i < end; i++) {
127
+ const v = list[i];
128
+ (v.doneAt < reach ? out : kept).push(v);
129
+ }
130
+ list.splice(0, end, ...kept);
131
+ remove(ix, out);
132
+ scoredCut(mix, list, out);
133
+ ix.count = list.length;
134
+ }
package/dist/history.js CHANGED
@@ -3,6 +3,7 @@ import { clone } from './clone.js';
3
3
  import { schedule } from './due.js';
4
4
  import { packHeld } from './pack.js';
5
5
  import { pageOut } from './paging.js';
6
+ import { scoreTouched } from './scored.js';
6
7
  import { none } from './voice.js';
7
8
  /**
8
9
  * The last entry taken before `t`, or at it where `inclusive` says so, as it does for a change a
@@ -52,6 +53,7 @@ function ownerThen(mix, owner, t) {
52
53
  }
53
54
  /** Records a change to a voice's controls under `history`, and lets go of what it no longer reaches. */
54
55
  export function noted(mix, voice) {
56
+ scoreTouched(mix, voice, true);
55
57
  mix.stir();
56
58
  schedule(mix, voice);
57
59
  const log = voice.log;
package/dist/hosts.js CHANGED
@@ -3,6 +3,7 @@ import { numericOf } from './channels.js';
3
3
  import { crowdable } from './crowd.js';
4
4
  import { schedule } from './due.js';
5
5
  import { beginFade, beginRise, fadeAt, fadeSubject } from './fade.js';
6
+ import { goneIndex } from './gone.js';
6
7
  import { VoiceHandle } from './handle.js';
7
8
  import { noted, remember } from './history.js';
8
9
  import { hostOf, listed, markOf, readingAt } from './marks.js';
@@ -57,7 +58,7 @@ export function laneHost(mix) {
57
58
  // Only a voice over every subject keeps bits, and one gone may still be read back.
58
59
  for (const voice of mix.general)
59
60
  unreach(voice, slot, false);
60
- for (const voice of mix.gone)
61
+ for (const voice of goneIndex(mix).general)
61
62
  unreach(voice, slot, false);
62
63
  },
63
64
  passes: (was, w) => mix.passes(was, w),
package/dist/mixer.d.ts CHANGED
@@ -5,6 +5,7 @@ import { type LerpInto } from './channels.js';
5
5
  import type { Due } from './due.js';
6
6
  import { parting } from './fade.js';
7
7
  import { apply, baseFor, clamp, contribution, fold, folded, foldLoci, foldWith, isRest, type Key, keyed, linked, owedBy, passes, read, slopeFor, tick } from './fold.js';
8
+ import { GoneIndex } from './gone.js';
8
9
  import type { HandleHost } from './handle.js';
9
10
  import { held, shownOf, sinceOf, unreachedOf } from './held.js';
10
11
  import { Lanes } from './lanes.js';
@@ -15,6 +16,7 @@ import { Keys } from './paging.js';
15
16
  import { keep } from './pull.js';
16
17
  import { Steps } from './relink.js';
17
18
  import { Outs } from './revive.js';
19
+ import { ScoreIndex } from './scored.js';
18
20
  import { Fitting } from './spans.js';
19
21
  import { Store } from './store.js';
20
22
  import { type Announced, Transport } from './transport.js';
@@ -27,6 +29,8 @@ export declare class Mixer<I, O> implements Mix<I, O> {
27
29
  cued: Voice<I, O>[];
28
30
  /** Under `history`, voices that have left but that a read back may still reach. */
29
31
  gone: Voice<I, O>[];
32
+ goneIx: GoneIndex<I, O>;
33
+ scored: ScoreIndex<I, O>;
30
34
  /** Set on a projection's own mixer: it sends nothing, keeps no history, and reads without committing. */
31
35
  projecting: boolean;
32
36
  /** Set on a projection reading back: a subject it has nothing on starts from its voice's start. */
package/dist/mixer.js CHANGED
@@ -4,6 +4,7 @@ import { lerpInto } from './channels.js';
4
4
  import { cue } from './cue.js';
5
5
  import { beginFade, parting } from './fade.js';
6
6
  import { apply, baseFor, clamp, contribution, fold, folded, foldLoci, foldWith, isRest, keyed, linked, near, owedBy, passes, read, slopeFor, tick, } from './fold.js';
7
+ import { GoneIndex, goneIndex } from './gone.js';
7
8
  import { held, shownOf, sinceOf, unreachedOf } from './held.js';
8
9
  import { leave, releasing } from './history.js';
9
10
  import { book, laneHost } from './hosts.js';
@@ -17,6 +18,7 @@ import { projectAll } from './project.js';
17
18
  import { keep, pull } from './pull.js';
18
19
  import { Steps } from './relink.js';
19
20
  import { Outs } from './revive.js';
21
+ import { ScoreIndex } from './scored.js';
20
22
  import { seek } from './seek.js';
21
23
  import { Fitting, refit } from './spans.js';
22
24
  import { Store } from './store.js';
@@ -31,6 +33,8 @@ export class Mixer {
31
33
  cued = [];
32
34
  /** Under `history`, voices that have left but that a read back may still reach. */
33
35
  gone = [];
36
+ goneIx = new GoneIndex();
37
+ scored = new ScoreIndex();
34
38
  /** Set on a projection's own mixer: it sends nothing, keeps no history, and reads without committing. */
35
39
  projecting = false;
36
40
  /** Set on a projection reading back: a subject it has nothing on starts from its voice's start. */
@@ -462,8 +466,13 @@ export class Mixer {
462
466
  if (named !== undefined)
463
467
  for (const voice of [...named])
464
468
  forget(voice);
465
- for (const voice of this.gone)
469
+ const gone = goneIndex(this);
470
+ for (const voice of gone.general)
466
471
  forget(voice);
472
+ const goneNaming = gone.named.get(subject);
473
+ if (goneNaming !== undefined)
474
+ for (const voice of goneNaming)
475
+ forget(voice);
467
476
  if (this.owners !== null)
468
477
  for (const voice of this.owners)
469
478
  forget(voice);
package/dist/move.js CHANGED
@@ -1,10 +1,12 @@
1
1
  import { changed, unindex } from './chain.js';
2
2
  import { popDue, schedule } from './due.js';
3
3
  import { beginFade, part, retire } from './fade.js';
4
+ import { expireGone, keepGone } from './gone.js';
4
5
  import { started } from './held.js';
5
6
  import { ownerReading } from './owner.js';
6
7
  import { mixAt, place, repin, startOf } from './place.js';
7
8
  import { pageVoice } from './revive.js';
9
+ import { scoredLeft, scoredRemove } from './scored.js';
8
10
  import { lapse } from './spans.js';
9
11
  import { unplay } from './strays.js';
10
12
  /** Numbers every mix's frames apart, so a blend's read is never taken for another mix's or frame's. */
@@ -173,8 +175,11 @@ function indexOf(voices, voice) {
173
175
  }
174
176
  /** Lets go of a retired voice's place in the mix; whether it was among the voices naming no subject. */
175
177
  function leave(mix, voice) {
176
- if (mix.opts.history)
178
+ scoredRemove(mix, [voice]);
179
+ if (mix.opts.history) {
177
180
  mix.gone.push(voice);
181
+ scoredLeft(mix, voice);
182
+ }
178
183
  else
179
184
  mix.parters.delete(voice);
180
185
  unplay(mix, voice);
@@ -196,11 +201,10 @@ export function forget(mix) {
196
201
  // With a store a voice that has left pages out where it can, and stays where it cannot.
197
202
  if (mix.transport.pager !== null) {
198
203
  const reach = mix.now - history.ms;
199
- mix.gone = mix.gone.filter((v) => v.doneAt >= reach || !pageVoice(mix, v, reach));
204
+ keepGone(mix, (v) => v.doneAt >= reach || !pageVoice(mix, v, reach));
200
205
  }
201
206
  else {
202
- const reach = mix.transport.keepsFrom();
203
- mix.gone = mix.gone.filter((v) => v.doneAt >= reach);
207
+ expireGone(mix, mix.transport.keepsFrom());
204
208
  }
205
209
  }
206
210
  }
package/dist/place.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { noted } from './history.js';
2
2
  import { markOf } from './marks.js';
3
3
  import { mixTime, ownerReading } from './owner.js';
4
+ import { holders, scored, scoredGone, scoreVersion, unhold } from './scored.js';
4
5
  import { none } from './voice.js';
5
6
  /** What a voice's own owner's clock reads now: the mix clock for a voice no owner holds. */
6
7
  export function localNow(mix, voice) {
@@ -178,7 +179,7 @@ function resolve(mix, a, self) {
178
179
  mark = 'start';
179
180
  by = -by;
180
181
  }
181
- const t = timeOf(mix, typeof query === 'string' ? { name: query } : query, mark, self);
182
+ const t = timeOf(mix, query, mark, self);
182
183
  if (t === undefined)
183
184
  return undefined;
184
185
  if (o === null)
@@ -200,46 +201,188 @@ function member(mix, a, self) {
200
201
  }
201
202
  return t;
202
203
  }
204
+ /** Picks among the times a query meets, as its resolver says, in the order they are met. */
205
+ class Pick {
206
+ resolver;
207
+ now;
208
+ any = false;
209
+ firstOrder = Number.POSITIVE_INFINITY;
210
+ lastOrder = Number.NEGATIVE_INFINITY;
211
+ first;
212
+ last;
213
+ pick;
214
+ constructor(resolver, now) {
215
+ this.resolver = resolver;
216
+ this.now = now;
217
+ }
218
+ take(order, t) {
219
+ this.any = true;
220
+ if (order < this.firstOrder) {
221
+ this.firstOrder = order;
222
+ this.first = t;
223
+ }
224
+ if (order > this.lastOrder) {
225
+ this.lastOrder = order;
226
+ this.last = t;
227
+ }
228
+ if (t !== undefined)
229
+ this.consider(t);
230
+ }
231
+ /** Takes what `o` picked as though its times had been met here. */
232
+ merge(o) {
233
+ if (!o.any)
234
+ return;
235
+ this.any = true;
236
+ if (o.firstOrder < this.firstOrder) {
237
+ this.firstOrder = o.firstOrder;
238
+ this.first = o.first;
239
+ }
240
+ if (o.lastOrder > this.lastOrder) {
241
+ this.lastOrder = o.lastOrder;
242
+ this.last = o.last;
243
+ }
244
+ if (o.pick !== undefined)
245
+ this.consider(o.pick);
246
+ }
247
+ consider(t) {
248
+ const p = this.pick;
249
+ if (this.resolver === 'earliest'
250
+ ? p === undefined || t < p
251
+ : this.resolver === 'latest'
252
+ ? p === undefined || t > p
253
+ : this.resolver === 'next' && t >= this.now && (p === undefined || t < p))
254
+ this.pick = t;
255
+ }
256
+ answer() {
257
+ if (!this.any)
258
+ return undefined;
259
+ if (this.resolver === 'first')
260
+ return this.first;
261
+ if (this.resolver === 'last')
262
+ return this.last;
263
+ return this.pick;
264
+ }
265
+ }
203
266
  /**
204
267
  * The mix time a query answers: `mark` of the voice it picks, among those cued and those that have
205
268
  * left, never the asker; or a mark the host announced on the score, which is the same time for
206
269
  * any of the four. Undefined while what it picks has no such time. A query naming no score looks
207
270
  * among the asker's siblings, the voices its owner holds.
208
271
  */
209
- function timeOf(mix, q, mark, self) {
272
+ function timeOf(mix, key, mark, self) {
273
+ const q = typeof key === 'string' ? { name: key } : key;
210
274
  const score = q.score ?? self.spec.score;
211
275
  const anywhere = q.score !== undefined;
212
- const found = [];
213
- // A query naming a score looks in every mix on the transport; one naming none, in its own.
214
- const mixes = anywhere ? mix.transport.members : [mix];
215
- for (const m of mixes)
216
- for (const v of [...m.gone, ...m.cued])
217
- if (v !== self &&
218
- v.spec.score === score &&
219
- (anywhere || v.owner === self.owner) &&
220
- (q.name === undefined || v.spec.name === q.name) &&
221
- (q.tag === undefined || (v.spec.tags ?? none).includes(q.tag)) &&
222
- (q.writes === undefined || v.patch.writes.includes(q.writes)))
223
- found.push({ order: v.id, t: markOf(m, v, mark) });
276
+ const resolver = q.resolver ?? 'last';
277
+ const now = Number.isNaN(mix.now) ? Number.NEGATIVE_INFINITY : mix.now;
278
+ const picked = new Pick(resolver, now);
279
+ const matches = (v) => v !== self &&
280
+ v.spec.score === score &&
281
+ (anywhere || v.owner === self.owner) &&
282
+ (q.name === undefined || v.spec.name === q.name) &&
283
+ (q.tag === undefined || (v.spec.tags ?? none).includes(q.tag)) &&
284
+ (q.writes === undefined || v.patch.writes.includes(q.writes));
285
+ // A query naming a score looks in every mix on the transport that holds a voice on it; one
286
+ // naming none, in its own.
287
+ const listed = anywhere && score !== undefined && !mix.projecting;
288
+ const mixes = !anywhere
289
+ ? [mix]
290
+ : listed
291
+ ? holders(mix, score)
292
+ : mix.transport.members;
293
+ for (let i = 0; i < mixes.length; i++) {
294
+ const m = mixes[i];
295
+ if (score === undefined || m.projecting || mix.projecting) {
296
+ for (const v of [...m.gone, ...m.cued])
297
+ if (matches(v))
298
+ picked.take(v.id, markOf(m, v, mark));
299
+ continue;
300
+ }
301
+ const cued = scored(m, score);
302
+ const list = scoredGone(m, score);
303
+ if (cued.size === 0 && list.length === 0) {
304
+ if (listed) {
305
+ unhold(m, score);
306
+ i--;
307
+ }
308
+ continue;
309
+ }
310
+ // `next` asks what is still ahead of now, which a running pick cannot keep.
311
+ if (resolver === 'next') {
312
+ for (const v of cued)
313
+ if (matches(v))
314
+ picked.take(v.id, markOf(m, v, mark));
315
+ for (const v of list)
316
+ if (matches(v))
317
+ picked.take(v.id, markOf(m, v, mark));
318
+ continue;
319
+ }
320
+ const fold = foldOf(m, list, key, mark, self, resolver);
321
+ const version = scoreVersion(m, score);
322
+ const reduced = m.reduced;
323
+ if (fold.live === null ||
324
+ fold.liveOf !== m.cued ||
325
+ fold.liveVersion !== version ||
326
+ fold.liveReduced !== reduced) {
327
+ const live = new Pick(resolver, Number.NEGATIVE_INFINITY);
328
+ let keeps = true;
329
+ for (const v of cued) {
330
+ if (!matches(v))
331
+ continue;
332
+ if (v.owner !== null || v.holding !== null || v.fitting !== null)
333
+ keeps = false;
334
+ live.take(v.id, markOf(m, v, mark));
335
+ }
336
+ fold.live = live;
337
+ fold.liveOf = keeps ? m.cued : null;
338
+ fold.liveVersion = version;
339
+ fold.liveReduced = reduced;
340
+ }
341
+ picked.merge(fold.live);
342
+ for (; fold.seen < list.length; fold.seen++) {
343
+ const v = list[fold.seen];
344
+ if (!matches(v))
345
+ continue;
346
+ if (v.owner === null)
347
+ fold.pick.take(v.id, markOf(m, v, mark));
348
+ else
349
+ fold.owned.push(v);
350
+ }
351
+ picked.merge(fold.pick);
352
+ for (const v of fold.owned)
353
+ picked.take(v.id, markOf(m, v, mark));
354
+ }
224
355
  if (q.writes === undefined && (anywhere || self.owner === null))
225
356
  for (const a of mix.announced)
226
357
  if (a.score === score &&
227
358
  (score !== undefined || a.slot === mix.slot) &&
228
359
  (q.name === undefined || a.name === q.name) &&
229
360
  (q.tag === undefined || a.tags.includes(q.tag)))
230
- found.push({ order: a.order, t: Number.isNaN(a.at) ? undefined : mixAt(mix, a.at) });
231
- if (found.length === 0)
232
- return undefined;
233
- found.sort((x, y) => x.order - y.order);
234
- const resolver = q.resolver ?? 'last';
235
- if (resolver === 'first')
236
- return found[0]?.t;
237
- if (resolver === 'last')
238
- return found[found.length - 1]?.t;
239
- const times = found.flatMap((e) => (e.t === undefined ? [] : [e.t])).sort((x, y) => x - y);
240
- if (resolver === 'next') {
241
- const now = Number.isNaN(mix.now) ? Number.NEGATIVE_INFINITY : mix.now;
242
- return times.find((t) => t >= now);
243
- }
244
- return resolver === 'earliest' ? times[0] : times[times.length - 1];
361
+ picked.take(a.order, Number.isNaN(a.at) ? undefined : mixAt(mix, a.at));
362
+ return picked.answer();
363
+ }
364
+ /** `self`'s read of `m`'s gone voices for one query, started over where the list has changed under it. */
365
+ function foldOf(m, list, key, mark, self, resolver) {
366
+ self.folds ??= new Map();
367
+ let byMix = self.folds.get(key);
368
+ if (byMix === undefined) {
369
+ byMix = new Map();
370
+ self.folds.set(key, byMix);
371
+ }
372
+ let fold = byMix.get(m);
373
+ if (fold === undefined || fold.list !== list || fold.mark !== mark) {
374
+ fold = {
375
+ list,
376
+ mark,
377
+ seen: 0,
378
+ pick: new Pick(resolver, Number.NEGATIVE_INFINITY),
379
+ owned: [],
380
+ live: null,
381
+ liveOf: null,
382
+ liveVersion: -1,
383
+ liveReduced: false,
384
+ };
385
+ byMix.set(m, fold);
386
+ }
387
+ return fold;
245
388
  }
@@ -0,0 +1,57 @@
1
+ import type { Mixer } from './mixer.js';
2
+ import type { Voice } from './voice.js';
3
+ /**
4
+ * A mix's voices by the score they were cued on, for an anchor that asks for a score's marks: the
5
+ * cued ones as a set, the gone ones in the order they left, which an anchor reads once each. Built
6
+ * over the lists it saw: a seek or a copy that replaces either rebuilds that half on the next read.
7
+ * A score whose gone voices history lets go of gets a fresh list, so a read of the old one starts
8
+ * over.
9
+ */
10
+ export declare class ScoreIndex<I, O> {
11
+ cued: readonly Voice<I, O>[] | null;
12
+ by: Map<string, Set<Voice<I, O>>>;
13
+ gone: readonly Voice<I, O>[] | null;
14
+ goneBy: Map<string, Voice<I, O>[]>;
15
+ /** Bumped for a score each time a voice on it is cued, retimed, retired or leaves. */
16
+ versions: Map<string, number>;
17
+ /** The scores whose list on the transport names this mix. */
18
+ held: Set<string>;
19
+ }
20
+ type Member = Mixer<unknown, unknown>;
21
+ /**
22
+ * Per score, the mixes on a transport that may hold a voice on it, cued or gone, in the
23
+ * transport's order. Never short of one that does; one found holding none is let go on that read.
24
+ */
25
+ export type ScoreHolders = Map<string, Member[]>;
26
+ /** The mixes on `mix`'s transport that may hold a voice on `score`; the list is the transport's own. */
27
+ export declare function holders<I, O>(mix: Mixer<I, O>, score: string): readonly Mixer<I, O>[];
28
+ /** Takes `mix` off `score`'s list, having found it holds nothing there. */
29
+ export declare function unhold<I, O>(mix: Mixer<I, O>, score: string): void;
30
+ /** A mix joining its transport, with whatever voices it already has. */
31
+ export declare function scoredJoin<I, O>(mix: Mixer<I, O>): void;
32
+ /** A mix leaving its transport. */
33
+ export declare function scoredDrop<I, O>(mix: Mixer<I, O>): void;
34
+ /**
35
+ * A voice on its score was cued, retimed, retired or left: what was read of the score is stale. A
36
+ * host can still retime a gone voice through a handle it kept, so `retimed` on a done voice
37
+ * replaces its score's gone list too, which starts every read of it over.
38
+ */
39
+ export declare function scoreTouched<I, O>(mix: Mixer<I, O>, voice: Voice<I, O>, retimed?: boolean): void;
40
+ /** How many times `score` has been touched in `mix`. */
41
+ export declare function scoreVersion<I, O>(mix: Mixer<I, O>, score: string): number;
42
+ /** The voices of `mix` cued on `score` and not yet gone, in no order. */
43
+ export declare function scored<I, O>(mix: Mixer<I, O>, score: string): ReadonlySet<Voice<I, O>>;
44
+ /** The voices of `mix` cued on `score` that have gone, in the order they left. */
45
+ export declare function scoredGone<I, O>(mix: Mixer<I, O>, score: string): readonly Voice<I, O>[];
46
+ /** A voice just pushed onto `gone`. */
47
+ export declare function scoredLeft<I, O>(mix: Mixer<I, O>, voice: Voice<I, O>): void;
48
+ /**
49
+ * Gone voices history let go of, out of `was`; `mix.gone` is already what is left, whether that
50
+ * is `was` cut in place or a new list.
51
+ */
52
+ export declare function scoredCut<I, O>(mix: Mixer<I, O>, was: readonly Voice<I, O>[], out: readonly Voice<I, O>[]): void;
53
+ /** A voice just cued. */
54
+ export declare function scoredAdd<I, O>(mix: Mixer<I, O>, voice: Voice<I, O>): void;
55
+ /** Voices no longer cued. */
56
+ export declare function scoredRemove<I, O>(mix: Mixer<I, O>, voices: Iterable<Voice<I, O>>): void;
57
+ export {};
package/dist/scored.js ADDED
@@ -0,0 +1,196 @@
1
+ /**
2
+ * A mix's voices by the score they were cued on, for an anchor that asks for a score's marks: the
3
+ * cued ones as a set, the gone ones in the order they left, which an anchor reads once each. Built
4
+ * over the lists it saw: a seek or a copy that replaces either rebuilds that half on the next read.
5
+ * A score whose gone voices history lets go of gets a fresh list, so a read of the old one starts
6
+ * over.
7
+ */
8
+ export class ScoreIndex {
9
+ cued = null;
10
+ by = new Map();
11
+ gone = null;
12
+ goneBy = new Map();
13
+ /** Bumped for a score each time a voice on it is cued, retimed, retired or leaves. */
14
+ versions = new Map();
15
+ /** The scores whose list on the transport names this mix. */
16
+ held = new Set();
17
+ }
18
+ function hold(mix, score) {
19
+ const held = mix.scored.held;
20
+ // A dropped mix is off the transport's lists until a seek puts it back, and `scoredJoin` then.
21
+ if (held.has(score) || mix.dropped)
22
+ return;
23
+ held.add(score);
24
+ const by = mix.transport.holders;
25
+ const m = mix;
26
+ const list = by.get(score);
27
+ if (list === undefined) {
28
+ by.set(score, [m]);
29
+ return;
30
+ }
31
+ const i = list.findIndex((o) => o.slot > m.slot);
32
+ if (i < 0)
33
+ list.push(m);
34
+ else
35
+ list.splice(i, 0, m);
36
+ }
37
+ /** The mixes on `mix`'s transport that may hold a voice on `score`; the list is the transport's own. */
38
+ export function holders(mix, score) {
39
+ return (mix.transport.holders.get(score) ?? none);
40
+ }
41
+ /** Takes `mix` off `score`'s list, having found it holds nothing there. */
42
+ export function unhold(mix, score) {
43
+ mix.scored.held.delete(score);
44
+ const by = mix.transport.holders;
45
+ const list = by.get(score);
46
+ if (list === undefined)
47
+ return;
48
+ const i = list.indexOf(mix);
49
+ if (i >= 0)
50
+ list.splice(i, 1);
51
+ if (list.length === 0)
52
+ by.delete(score);
53
+ }
54
+ /** A mix joining its transport, with whatever voices it already has. */
55
+ export function scoredJoin(mix) {
56
+ for (const v of mix.cued)
57
+ if (v.spec.score !== undefined)
58
+ hold(mix, v.spec.score);
59
+ for (const v of mix.gone)
60
+ if (v.spec.score !== undefined)
61
+ hold(mix, v.spec.score);
62
+ }
63
+ /** A mix leaving its transport. */
64
+ export function scoredDrop(mix) {
65
+ for (const score of [...mix.scored.held])
66
+ unhold(mix, score);
67
+ }
68
+ /**
69
+ * A voice on its score was cued, retimed, retired or left: what was read of the score is stale. A
70
+ * host can still retime a gone voice through a handle it kept, so `retimed` on a done voice
71
+ * replaces its score's gone list too, which starts every read of it over.
72
+ */
73
+ export function scoreTouched(mix, voice, retimed = false) {
74
+ const score = voice.spec.score;
75
+ if (score === undefined)
76
+ return;
77
+ const ix = mix.scored;
78
+ ix.versions.set(score, (ix.versions.get(score) ?? 0) + 1);
79
+ if (!retimed || voice.state !== 'done')
80
+ return;
81
+ const gone = ix.goneBy.get(score);
82
+ if (gone !== undefined)
83
+ ix.goneBy.set(score, [...gone]);
84
+ }
85
+ /** How many times `score` has been touched in `mix`. */
86
+ export function scoreVersion(mix, score) {
87
+ return mix.scored.versions.get(score) ?? 0;
88
+ }
89
+ function put(by, voice) {
90
+ const score = voice.spec.score;
91
+ if (score === undefined)
92
+ return;
93
+ const set = by.get(score);
94
+ if (set === undefined)
95
+ by.set(score, new Set([voice]));
96
+ else
97
+ set.add(voice);
98
+ }
99
+ /** The voices of `mix` cued on `score` and not yet gone, in no order. */
100
+ export function scored(mix, score) {
101
+ const ix = mix.scored;
102
+ if (ix.cued !== mix.cued) {
103
+ ix.by = new Map();
104
+ for (const v of mix.cued)
105
+ put(ix.by, v);
106
+ ix.cued = mix.cued;
107
+ }
108
+ return ix.by.get(score) ?? noVoices;
109
+ }
110
+ const noVoices = new Set();
111
+ /** The voices of `mix` cued on `score` that have gone, in the order they left. */
112
+ export function scoredGone(mix, score) {
113
+ const ix = mix.scored;
114
+ if (ix.gone !== mix.gone) {
115
+ ix.goneBy = new Map();
116
+ for (const v of mix.gone)
117
+ goneAdd(ix.goneBy, v);
118
+ ix.gone = mix.gone;
119
+ }
120
+ return ix.goneBy.get(score) ?? none;
121
+ }
122
+ const none = [];
123
+ function goneAdd(by, voice) {
124
+ const score = voice.spec.score;
125
+ if (score === undefined)
126
+ return;
127
+ const list = by.get(score);
128
+ if (list === undefined)
129
+ by.set(score, [voice]);
130
+ else
131
+ list.push(voice);
132
+ }
133
+ /** A voice just pushed onto `gone`. */
134
+ export function scoredLeft(mix, voice) {
135
+ if (voice.spec.score !== undefined)
136
+ hold(mix, voice.spec.score);
137
+ const ix = mix.scored;
138
+ if (ix.gone === mix.gone)
139
+ goneAdd(ix.goneBy, voice);
140
+ }
141
+ /**
142
+ * Gone voices history let go of, out of `was`; `mix.gone` is already what is left, whether that
143
+ * is `was` cut in place or a new list.
144
+ */
145
+ export function scoredCut(mix, was, out) {
146
+ const ix = mix.scored;
147
+ if (ix.gone !== was)
148
+ return;
149
+ ix.gone = mix.gone;
150
+ const by = new Map();
151
+ for (const v of out) {
152
+ const score = v.spec.score;
153
+ if (score === undefined)
154
+ continue;
155
+ const set = by.get(score);
156
+ if (set === undefined)
157
+ by.set(score, new Set([v]));
158
+ else
159
+ set.add(v);
160
+ }
161
+ for (const [score, gone] of by) {
162
+ const left = (ix.goneBy.get(score) ?? []).filter((v) => !gone.has(v));
163
+ if (left.length === 0)
164
+ ix.goneBy.delete(score);
165
+ else
166
+ ix.goneBy.set(score, left);
167
+ }
168
+ }
169
+ /** A voice just cued. */
170
+ export function scoredAdd(mix, voice) {
171
+ scoreTouched(mix, voice);
172
+ if (voice.spec.score !== undefined)
173
+ hold(mix, voice.spec.score);
174
+ const ix = mix.scored;
175
+ if (ix.cued === mix.cued)
176
+ put(ix.by, voice);
177
+ }
178
+ /** Voices no longer cued. */
179
+ export function scoredRemove(mix, voices) {
180
+ for (const v of voices)
181
+ scoreTouched(mix, v);
182
+ const ix = mix.scored;
183
+ if (ix.cued !== mix.cued)
184
+ return;
185
+ for (const v of voices) {
186
+ const score = v.spec.score;
187
+ if (score === undefined)
188
+ continue;
189
+ const set = ix.by.get(score);
190
+ if (set === undefined)
191
+ continue;
192
+ set.delete(v);
193
+ if (set.size === 0)
194
+ ix.by.delete(score);
195
+ }
196
+ }
@@ -1,6 +1,7 @@
1
1
  import type { Mixer } from './mixer.js';
2
2
  import { Pace } from './pace.js';
3
3
  import { Pager } from './paging.js';
4
+ import { type ScoreHolders } from './scored.js';
4
5
  import type { Marked, Mix, MixOptions, Tape, Transport as TransportApi, TransportOptions, TransportProjection } from './types.js';
5
6
  /**
6
7
  * A mark the host announced on a score: `at` in host time, so the rate never moves one, and NaN
@@ -37,6 +38,7 @@ export declare class Transport implements TransportApi {
37
38
  }[];
38
39
  /** Numbers the mixes as they join, for their order and for whose unnamed score a mark is on. */
39
40
  slots: number;
41
+ holders: ScoreHolders;
40
42
  private wakeFns;
41
43
  /** Whether it has woken since the last sync, so it wakes once. */
42
44
  private woken;
@@ -51,6 +53,8 @@ export declare class Transport implements TransportApi {
51
53
  floor: number;
52
54
  last: number;
53
55
  rebasing: boolean;
56
+ /** The reach the tape was last pruned to. */
57
+ taped: number;
54
58
  /** The rate; null while it has never been set, when mix time is host time. */
55
59
  pace: Pace | null;
56
60
  /** True while the tape makes a recorded call again, so it is not recorded twice. */
package/dist/transport.js CHANGED
@@ -4,8 +4,11 @@ import { Pace } from './pace.js';
4
4
  import { Pager, prepare } from './paging.js';
5
5
  import { pin } from './place.js';
6
6
  import { projectAll } from './project.js';
7
+ import { scoredDrop, scoredJoin } from './scored.js';
7
8
  import { seek } from './seek.js';
8
9
  import { record, replay, tapeOf } from './tape.js';
10
+ /** How far the tape's reach moves before it is pruned again, ms of mix time. */
11
+ const TAPE_STEP_MS = 1000;
9
12
  /**
10
13
  * The clock its mixes play on: host time less what `rebase` took out, at the rate its `pace`
11
14
  * sets, moved by `sync` and `seek`, and the tape of calls a seek plays again. A mix made alone
@@ -20,6 +23,7 @@ export class Transport {
20
23
  dropped = [];
21
24
  /** Numbers the mixes as they join, for their order and for whose unnamed score a mark is on. */
22
25
  slots = 0;
26
+ holders = new Map();
23
27
  wakeFns = [];
24
28
  /** Whether it has woken since the last sync, so it wakes once. */
25
29
  woken = false;
@@ -34,6 +38,8 @@ export class Transport {
34
38
  floor = Number.NEGATIVE_INFINITY;
35
39
  last = Number.NaN;
36
40
  rebasing = false;
41
+ /** The reach the tape was last pruned to. */
42
+ taped = Number.NEGATIVE_INFINITY;
37
43
  /** The rate; null while it has never been set, when mix time is host time. */
38
44
  pace = null;
39
45
  /** True while the tape makes a recorded call again, so it is not recorded twice. */
@@ -66,6 +72,7 @@ export class Transport {
66
72
  this.members.push(mix);
67
73
  else
68
74
  this.members.splice(i, 0, mix);
75
+ scoredJoin(mix);
69
76
  }
70
77
  drop(mix) {
71
78
  const m = mix;
@@ -73,6 +80,7 @@ export class Transport {
73
80
  if (i < 0)
74
81
  throw new Error(`blits: ${m.name ?? 'that mix'} is not on this transport`);
75
82
  this.members.splice(i, 1);
83
+ scoredDrop(m);
76
84
  m.dropped = true;
77
85
  if (this.history !== undefined) {
78
86
  this.dropped.push({ mix: m, at: this.now });
@@ -173,8 +181,12 @@ export class Transport {
173
181
  const history = this.history;
174
182
  this.kept();
175
183
  const reach = this.keepsFrom();
176
- if (this.tape !== undefined && history !== undefined)
184
+ // In steps: the tape shifts every entry it keeps to let go of the oldest, and one a second
185
+ // behind reach is never played again.
186
+ if (this.tape !== undefined && history !== undefined && !(reach - this.taped < TAPE_STEP_MS)) {
177
187
  this.tape.prune(reach);
188
+ this.taped = reach;
189
+ }
178
190
  if (history !== undefined && this.dropped.length > 0)
179
191
  this.dropped = this.dropped.filter((d) => d.at >= reach);
180
192
  this.woken = false;
package/dist/unpage.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { unpackHeld } from './pack.js';
2
2
  import { HistoryMiss } from './paging.js';
3
3
  import { reviveVoice } from './revive.js';
4
+ import { scoredLeft } from './scored.js';
4
5
  /**
5
6
  * Makes memory reach mix time `t` for a seek or read there, from what `prepare` loaded; true where
6
7
  * it took records from the store. Throws before anything moves where it cannot.
@@ -66,6 +67,7 @@ function unpageMix(mix, records) {
66
67
  const v = reviveVoice(mix, r.voice, r.data);
67
68
  voices.set(v.id, v);
68
69
  mix.gone.push(v);
70
+ scoredLeft(mix, v);
69
71
  }
70
72
  const host = records.filter((r) => r.stream === 'host');
71
73
  mix.hostLog = before(host.map((r) => ({ at: r.at, fields: r.data })), mix.hostLog);
package/dist/voice.d.ts CHANGED
@@ -222,6 +222,8 @@ export declare class Voice<I, O> {
222
222
  placing: boolean;
223
223
  /** What each member of a join last answered, kept for once its target has left. */
224
224
  answers: Map<Anchor, number> | null;
225
+ /** What each of its anchors' queries has read of each mix's gone voices (`place.ts`'s `Fold`). */
226
+ folds: Map<unknown, Map<unknown, unknown>> | null;
225
227
  /** The record `setting.keep` writes to: the one its patch or signal is being called for. */
226
228
  keepOn: Subject<unknown> | null;
227
229
  /** What `setting.keep` holds when called before any record is. */
package/dist/voice.js CHANGED
@@ -175,6 +175,8 @@ export class Voice {
175
175
  placing = false;
176
176
  /** What each member of a join last answered, kept for once its target has left. */
177
177
  answers = null;
178
+ /** What each of its anchors' queries has read of each mix's gone voices (`place.ts`'s `Fold`). */
179
+ folds = null;
178
180
  /** The record `setting.keep` writes to: the one its patch or signal is being called for. */
179
181
  keepOn = null;
180
182
  /** What `setting.keep` holds when called before any record is. */
@@ -317,6 +319,7 @@ export class Voice {
317
319
  this.doneAt = Number.POSITIVE_INFINITY;
318
320
  this.placing = false;
319
321
  this.answers = null;
322
+ this.folds = null;
320
323
  this.keepOn = null;
321
324
  this.ownKept = null;
322
325
  this.unreached = null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@msb235/blits",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
4
4
  "description": "Concurrent effects: each on its own clock with its own state and weight, folded into one value per subject per frame",
5
5
  "keywords": [
6
6
  "animation",
@@ -53,7 +53,7 @@
53
53
  "devDependencies": {
54
54
  "@biomejs/biome": "^2.5.8",
55
55
  "@types/node": "^26.2.0",
56
- "@weasel-js/history": "^1.9.0",
56
+ "@weasel-js/history": "^1.9.1",
57
57
  "typescript": "^7.0.2",
58
58
  "vitest": "^4.1.10"
59
59
  }