@msb235/blits 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,33 @@ 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.6.0
8
+
9
+ ### Breaking
10
+
11
+ - A voice's `hold` is `freeze`, SMIL's name for it, and a voice frozen after its passes is in state
12
+ `'frozen'`, not `'held'`: a host comparing `handle.state` to `'held'` must compare to `'frozen'`.
13
+ `hold` is still taken on a voice and an owner where `freeze` is not set, but it is deprecated and
14
+ a later release removes it; where both are set, `freeze` wins. `assess` still says `'held'` for a
15
+ channel whose input is not known, which is another thing.
16
+
17
+ ### Added
18
+
19
+ - `ticker()`, one frame loop for any number of mixes: `add(mix)` syncs it every frame, `after(fn)`
20
+ runs once the mixes have synced, and `hold()` keeps the loop awake. It runs on animation frames, or on a
21
+ timer where there are none, as in a worker or Node, or with `grain: 'timer'`; `fps` caps the rate on
22
+ a grid, so a 60 Hz display capped at 30 runs every other frame. It sleeps while every mix is
23
+ `inert` and wakes on a mix's `onWake`. A mix or callback that throws stops neither the loop nor the
24
+ others; the error is thrown again on a microtask.
25
+ - `mix.onWake(fn)`: called when a host call makes the mix need frames again after a sync — a cue,
26
+ a fade, a rate, a seek, a `drop`, a motion retargeted — once until the next sync, and never
27
+ during one. A host's loop sleeping on `inert` wakes on it instead of on every path that cues.
28
+
29
+ ### Fixed
30
+
31
+ - `slew` and `lag` given `from` start a subject on their input when `dt` is infinite, as under
32
+ reduced motion, rather than showing `from` for its first frame and snapping on the next.
33
+
7
34
  ## 0.5.0
8
35
 
9
36
  ### Breaking
package/README.md CHANGED
@@ -76,8 +76,8 @@ so the engine keeps no dependencies; its README says how it works.
76
76
 
77
77
  ## Status
78
78
 
79
- v0.5.0, with four consumers. magicsmoke runs every fault on it, wod its wheel, tricks and
80
- transitions, weasel its animator's tweens, and
79
+ v0.6.0, with five consumers. magicsmoke runs every fault on it, wod its wheel, tricks and
80
+ transitions, weasel its animator's tweens, sherpa its seams, and
81
81
  [klieg](https://github.com/orochi235/klieg) composes all three of its systems — letter motion,
82
82
  part effects, and the environment a sign is lit by — on a mix each.
83
83
  That port was an extraction rather than a rewrite, so it is also the evidence that the arithmetic
package/dist/book.d.ts CHANGED
@@ -6,7 +6,7 @@ export type Listed = Marked & {
6
6
  };
7
7
  /** What a booker reads of the mix that made it. */
8
8
  export interface BookHost<I, O> {
9
- /** The voices in the mix, pending, live, held or fading. */
9
+ /** The voices in the mix, pending, live, frozen or fading. */
10
10
  readonly voices: readonly Voice<I, O>[];
11
11
  /** The host's timestamp at the last sync. */
12
12
  readonly timestamp: number;
package/dist/chain.d.ts CHANGED
@@ -4,10 +4,10 @@ import type { Subject, Voice } from './voice.js';
4
4
  * The first of this subject's records, linked through every live voice that reaches it. A voice
5
5
  * is asked once, through the record it keeps for the subject anyway; a pending one is linked when
6
6
  * it goes live, since `target` is asked on first sight, and sight only comes once a voice plays
7
- * or holds before.
7
+ * or freezes before.
8
8
  */
9
9
  export declare function chain<I, O>(this: Mixer<I, O>, subject: I, now: number): Subject<unknown>;
10
- /** A voice's record for a subject where its chain links it: the voice plays or holds, and reaches it. */
10
+ /** A voice's record for a subject where its chain links it: the voice plays or freezes, and reaches it. */
11
11
  export declare function linkable<I, O>(this: Mixer<I, O>, voice: Voice<I, O>, subject: I, now: number, slot: number): Subject<unknown> | null;
12
12
  /** Whether a voice reaches a subject by what its spec says: the subjects it names, or its `target`. */
13
13
  export declare function aims<I, O>(this: Mixer<I, O>, voice: Voice<I, O>, subject: I): boolean;
package/dist/chain.js CHANGED
@@ -27,7 +27,7 @@ function stub() {
27
27
  * The first of this subject's records, linked through every live voice that reaches it. A voice
28
28
  * is asked once, through the record it keeps for the subject anyway; a pending one is linked when
29
29
  * it goes live, since `target` is asked on first sight, and sight only comes once a voice plays
30
- * or holds before.
30
+ * or freezes before.
31
31
  */
32
32
  export function chain(subject, now) {
33
33
  const was = this.chains.get(subject);
@@ -77,10 +77,10 @@ export function chain(subject, now) {
77
77
  this.chains.set(subject, head);
78
78
  return head;
79
79
  }
80
- /** A voice's record for a subject where its chain links it: the voice plays or holds, and reaches it. */
80
+ /** A voice's record for a subject where its chain links it: the voice plays or freezes, and reaches it. */
81
81
  export function linkable(voice, subject, now, slot) {
82
82
  const state = voice.state;
83
- if (state === 'done' || (state === 'pending' && !voice.holdsBefore))
83
+ if (state === 'done' || (state === 'pending' && !voice.freezesBefore))
84
84
  return null;
85
85
  const held = this.held(voice, subject, now, slot);
86
86
  return held.reaches ? held : null;
package/dist/clock.d.ts CHANGED
@@ -16,10 +16,10 @@ export declare function envelope(fadeIn: number, out: {
16
16
  rest: boolean;
17
17
  } | null, ease: Curve | undefined, reduced: boolean, now: number, since: number): number;
18
18
  /**
19
- * A subject's voice time `raw` once the voice's holds apply: 0 before it starts if it holds before,
20
- * else NaN, where it shows nothing; `span` once its passes end if it holds after.
19
+ * A subject's voice time `raw` once the voice's freezes apply: 0 before it starts if it freezes
20
+ * before, else NaN, where it shows nothing; `span` once its passes end if it freezes after.
21
21
  */
22
- export declare function heldTime(raw: number, before: boolean, after: boolean, span: number): number;
22
+ export declare function frozenTime(raw: number, before: boolean, after: boolean, span: number): number;
23
23
  /** A weight held to 0..1. */
24
24
  export declare function clampWeight(raw: number): number;
25
25
  /**
package/dist/clock.js CHANGED
@@ -39,10 +39,10 @@ export function envelope(fadeIn, out, ease, reduced, now, since) {
39
39
  return w;
40
40
  }
41
41
  /**
42
- * A subject's voice time `raw` once the voice's holds apply: 0 before it starts if it holds before,
43
- * else NaN, where it shows nothing; `span` once its passes end if it holds after.
42
+ * A subject's voice time `raw` once the voice's freezes apply: 0 before it starts if it freezes
43
+ * before, else NaN, where it shows nothing; `span` once its passes end if it freezes after.
44
44
  */
45
- export function heldTime(raw, before, after, span) {
45
+ export function frozenTime(raw, before, after, span) {
46
46
  if (raw < 0)
47
47
  return before ? 0 : Number.NaN;
48
48
  return after && raw > span ? span : raw;
package/dist/crowd.d.ts CHANGED
@@ -31,7 +31,7 @@ export declare const enum Hot {
31
31
  }
32
32
  /** A crowd row's flags, at `Hot.FLAGS`. */
33
33
  export declare const enum Flag {
34
- /** The voice is playing: live, held or fading. */
34
+ /** The voice is playing: live, frozen or fading. */
35
35
  PLAYING = 1,
36
36
  /** Its weight and clock are `hot`'s: no fade in or out, rate ramp, subject ramp or kept state. */
37
37
  FAST = 2,
@@ -47,8 +47,8 @@ export declare const enum Flag {
47
47
  VOICE = 64,
48
48
  /** The row's voice is a motion voice; otherwise its patch is keys or a stateless fn. */
49
49
  MOTION = 128,
50
- /** The voice holds before or after, so its clock is read from the voice. */
51
- HOLDS = 256,
50
+ /** The voice freezes before or after, so its clock is read from the voice. */
51
+ FREEZES = 256,
52
52
  /** A keys row whose stops `keys` holds, at `Hot.AT`, with its duration at `Hot.X0` and passes at `Hot.MS`. */
53
53
  FLAT = 512,
54
54
  /** The row's patch is a tween. */
package/dist/crowd.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { bareRows, clock, unbare } from './bare.js';
2
2
  import { clampWeight, passAt, phaseAt, weighed } from './clock.js';
3
3
  import { KeyRows } from './keyrows.js';
4
- import { held, SPARSE } from './lane.js';
4
+ import { frozenAt, SPARSE } from './lane.js';
5
5
  import { closed } from './motion.js';
6
6
  import { move } from './sample.js';
7
7
  /**
@@ -214,8 +214,8 @@ function rows(lanes, c, first, id) {
214
214
  const voice = c.voices[p];
215
215
  const elapsedNow = fast ? clock(hot, h, now) : voice.elapsedAt(now);
216
216
  let elapsed = elapsedNow - delay;
217
- if ((f & 256 /* Flag.HOLDS */) !== 0)
218
- elapsed = held(voice, elapsed);
217
+ if ((f & 256 /* Flag.FREEZES */) !== 0)
218
+ elapsed = frozenAt(voice, elapsed);
219
219
  if (!(elapsed >= 0)) {
220
220
  data[o + 2 /* Row.WEIGHT */] = 0;
221
221
  continue;
@@ -298,11 +298,11 @@ export function row(c, p, voice, slot, elapsed, delay, w) {
298
298
  function copyVoice(c, p) {
299
299
  const v = c.voices[p];
300
300
  const h = p * c.stride;
301
- let f = c.hot[h + 0 /* Hot.FLAGS */] & ~(64 /* Flag.VOICE */ | 1 /* Flag.PLAYING */ | 2 /* Flag.FAST */ | 256 /* Flag.HOLDS */);
302
- if (v.state === 'live' || v.state === 'held' || v.state === 'fading')
301
+ let f = c.hot[h + 0 /* Hot.FLAGS */] & ~(64 /* Flag.VOICE */ | 1 /* Flag.PLAYING */ | 2 /* Flag.FAST */ | 256 /* Flag.FREEZES */);
302
+ if (v.state === 'live' || v.state === 'frozen' || v.state === 'fading')
303
303
  f |= 1 /* Flag.PLAYING */;
304
- if (v.holdsBefore || v.holdsAfter)
305
- f |= 256 /* Flag.HOLDS */;
304
+ if (v.freezesBefore || v.freezesAfter)
305
+ f |= 256 /* Flag.FREEZES */;
306
306
  if (v.ramp === null &&
307
307
  v.owner === null &&
308
308
  !((v.fade.in ?? 0) > 0) &&
package/dist/due.js CHANGED
@@ -26,10 +26,10 @@ function dueOf(mix, voice) {
26
26
  due = Math.min(due, voice.outAt);
27
27
  if (Number.isFinite(voice.span)) {
28
28
  const end = voice.span + voice.latest;
29
- // A held voice goes live again once its clock is back before its end: by a seek, by a
29
+ // A frozen voice goes live again once its clock is back before its end: by a seek, by a
30
30
  // subject staggered later than any before, or by running backwards, which is checked each
31
31
  // frame while it can.
32
- if (voice.state === 'held') {
32
+ if (voice.state === 'frozen') {
33
33
  if (voice.rate <= 0 || voice.ramp !== null)
34
34
  return Number.NEGATIVE_INFINITY;
35
35
  if (!Number.isNaN(mix.now) && voice.elapsedAt(mix.now) < end)
package/dist/fill.js CHANGED
@@ -3,7 +3,7 @@ import { passAt, phaseAt, silent, weighed } from './clock.js';
3
3
  import { flush } from './columns.js';
4
4
  import { crowdsUpTo, freshen } from './crowd.js';
5
5
  import { foldLocus, gatherLocus } from './gather.js';
6
- import { held } from './lane.js';
6
+ import { frozenAt } from './lane.js';
7
7
  import { reach } from './meet.js';
8
8
  import { absent } from './numbers.js';
9
9
  import { AT, NOTHING, readKeys, seg, segment, shifted } from './patch.js';
@@ -250,7 +250,7 @@ export function one(lane, p, slot, elapsedNow, duration, passes) {
250
250
  if (this.per[slot * 6 /* Per.SLOT */ + 5 /* Per.LANE_FILL */] === this.fills - 1)
251
251
  data[o + 3 /* Row.PROBED */] = data[o + 2 /* Row.WEIGHT */];
252
252
  const delay = data[o + 0 /* Row.DELAY */];
253
- const elapsed = held(voice, elapsedNow - delay);
253
+ const elapsed = frozenAt(voice, elapsedNow - delay);
254
254
  if (!(elapsed >= 0)) {
255
255
  data[o + 2 /* Row.WEIGHT */] = 0;
256
256
  return false;
package/dist/fold.js CHANGED
@@ -1,4 +1,4 @@
1
- import { heldTime, passAt, phaseAt, silent } from './clock.js';
1
+ import { frozenTime, passAt, phaseAt, silent } from './clock.js';
2
2
  import { copy as copyValue } from './clone.js';
3
3
  import { recordHost, remember } from './history.js';
4
4
  import { stateful } from './hosts.js';
@@ -46,11 +46,11 @@ export function influence(voice, subject, now, held) {
46
46
  if (voice.out?.rest && held.rested)
47
47
  return null;
48
48
  const raw = voice.elapsedAt(now) - held.delay;
49
- if (raw < 0 && !voice.holdsBefore)
49
+ if (raw < 0 && !voice.freezesBefore)
50
50
  return null;
51
- const elapsed = heldTime(raw, voice.holdsBefore, voice.holdsAfter, voice.span);
52
- // A held subject's clock stands still at the edge it holds: -1 before, 1 after, 0 playing.
53
- const still = raw < 0 ? -1 : voice.holdsAfter && raw > voice.span ? 1 : 0;
51
+ const elapsed = frozenTime(raw, voice.freezesBefore, voice.freezesAfter, voice.span);
52
+ // A frozen subject's clock stands still at the edge it freezes at: -1 before, 1 after, 0 playing.
53
+ const still = raw < 0 ? -1 : voice.freezesAfter && raw > voice.span ? 1 : 0;
54
54
  const duration = voice.duration;
55
55
  const phase = phaseAt(elapsed, duration, voice.passes);
56
56
  const pass = passAt(elapsed, duration, voice.passes);
@@ -77,7 +77,7 @@ export function influence(voice, subject, now, held) {
77
77
  reading.horizon = this.horizonFor(voice, held.delay, now);
78
78
  const tick = this.opts.stepMs;
79
79
  if (voice.patch.step && held.probed !== now && still >= 0) {
80
- // Held after, it steps once more to where its last pass ended, and no further.
80
+ // Frozen after, it steps once more to where its last pass ended, and no further.
81
81
  const to = still === 1 ? voice.timeAt(held.delay + voice.span) : now;
82
82
  if (tick !== undefined && tick > 0 && !this.reducedNow)
83
83
  this.tick(voice, subject, held, tick, to);
@@ -456,7 +456,7 @@ export function read(voice, subject, now, dry, held) {
456
456
  if (held.reaches && held.probed === now && held.delta && held.seeks === voice.seeks) {
457
457
  const setting = voice.setting;
458
458
  // The setting the probe that read the delta had, but for `dt`: a dry read advances nothing.
459
- const elapsed = heldTime(voice.elapsedAt(now) - held.delay, voice.holdsBefore, voice.holdsAfter, voice.span);
459
+ const elapsed = frozenTime(voice.elapsedAt(now) - held.delay, voice.freezesBefore, voice.freezesAfter, voice.span);
460
460
  setting.timestamp = now;
461
461
  setting.dt = 0;
462
462
  setting.elapsed = elapsed;
package/dist/held.js CHANGED
@@ -85,8 +85,8 @@ export function held(voice, subject, now, slot) {
85
85
  voice.seen++;
86
86
  if (delay > voice.latest) {
87
87
  voice.latest = delay;
88
- // A later end can take a held voice live again.
89
- if (voice.state === 'held')
88
+ // A later end can take a frozen voice live again.
89
+ if (voice.state === 'frozen')
90
90
  schedule(this, voice);
91
91
  }
92
92
  return held;
@@ -102,7 +102,7 @@ export function sinceOf(voice, delay) {
102
102
  return voice.owner === null ? t : mixTime(voice.owner, t);
103
103
  }
104
104
  export function shownOf(voice, since) {
105
- return voice.holdsBefore && voice.opened < since ? voice.opened : since;
105
+ return voice.freezesBefore && voice.opened < since ? voice.opened : since;
106
106
  }
107
107
  /** A voice has gone live: the records it made while pending count from where it started. */
108
108
  export function started(mix, voice) {
package/dist/hosts.js CHANGED
@@ -99,7 +99,7 @@ export function fits(mix, voice) {
99
99
  const patch = voice.patch;
100
100
  if (voice.keeping)
101
101
  return false;
102
- if (voice.state === 'pending' && voice.holdsBefore)
102
+ if (voice.state === 'pending' && voice.freezesBefore)
103
103
  return false;
104
104
  // A fill weighs a voice's owners once for every subject, which a signal on one would not be.
105
105
  if (voice.owner !== null && signalled(voice))
package/dist/index.d.ts CHANGED
@@ -6,4 +6,6 @@ export { glide, spring, tween } from './motion.js';
6
6
  export type { KeysOptions, PatchOptions } from './patch.js';
7
7
  export { keys, patch } from './patch.js';
8
8
  export { gate, lag, level, peak, slew } from './signals.js';
9
+ export type { Ticked, Ticker, TickerOptions } from './ticker.js';
10
+ export { ticker } from './ticker.js';
9
11
  export type { Anchor, BookedHit, Booker, BookOptions, Channel, Columns, Doubt, Easing, Engine, FadeOptions, FadeSpec, Handle, Hit, Keyframe, Kit, Mark, Marked, Mix, MixOptions, MotionSpec, OwnerSpec, Patch, Placement, Projection, Query, Sent, Setting, Signal, VoiceSpec, } from './types.js';
package/dist/index.js CHANGED
@@ -3,3 +3,4 @@ export { mix, mixer } from './mixer.js';
3
3
  export { glide, spring, tween } from './motion.js';
4
4
  export { keys, patch } from './patch.js';
5
5
  export { gate, lag, level, peak, slew } from './signals.js';
6
+ export { ticker } from './ticker.js';
package/dist/lane.d.ts CHANGED
@@ -204,7 +204,7 @@ export declare class Lane<I, O> implements Positions<I, O> {
204
204
  remove(slot: number): void;
205
205
  }
206
206
  /**
207
- * A subject's voice time once its voice's holds apply: held at 0 before it starts and at the end of
208
- * its passes after, and NaN where it shows nothing.
207
+ * A subject's voice time once its voice's freezes apply: frozen at 0 before it starts and at the end
208
+ * of its passes after, and NaN where it shows nothing.
209
209
  */
210
- export declare function held<I, O>(voice: Voice<I, O>, elapsed: number): number;
210
+ export declare function frozenAt<I, O>(voice: Voice<I, O>, elapsed: number): number;
package/dist/lane.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { unbareLane } from './bare.js';
2
- import { heldTime } from './clock.js';
2
+ import { frozenTime } from './clock.js';
3
3
  import { motionOf } from './motion.js';
4
4
  /**
5
5
  * Below what share of its subjects probed last frame a lane stops filling, and above what share it
@@ -179,9 +179,9 @@ export class Lane {
179
179
  }
180
180
  }
181
181
  /**
182
- * A subject's voice time once its voice's holds apply: held at 0 before it starts and at the end of
183
- * its passes after, and NaN where it shows nothing.
182
+ * A subject's voice time once its voice's freezes apply: frozen at 0 before it starts and at the end
183
+ * of its passes after, and NaN where it shows nothing.
184
184
  */
185
- export function held(voice, elapsed) {
186
- return heldTime(elapsed, voice.holdsBefore, voice.holdsAfter, voice.span);
185
+ export function frozenAt(voice, elapsed) {
186
+ return frozenTime(elapsed, voice.freezesBefore, voice.freezesAfter, voice.span);
187
187
  }
package/dist/marks.js CHANGED
@@ -62,7 +62,7 @@ export function markOf(mix, voice, mark) {
62
62
  outAt = out.at;
63
63
  else if (Number.isFinite(voice.outAt))
64
64
  outAt = voice.outAt;
65
- else if (!voice.holdsAfter && Number.isFinite(voice.span)) {
65
+ else if (!voice.freezesAfter && Number.isFinite(voice.span)) {
66
66
  const t = voice.timeAt(voice.span + voice.latest);
67
67
  if (Number.isFinite(t))
68
68
  outAt = Math.max(start, t);
package/dist/mixer.d.ts CHANGED
@@ -100,8 +100,11 @@ export declare class Mixer<I, O> implements Mix<I, O> {
100
100
  stirred: boolean;
101
101
  /** Counts the changes `stir` records, which a frame's record of a probe is current only before. */
102
102
  stirs: number;
103
+ syncing: boolean;
104
+ stirFns: (() => void)[] | null;
103
105
  /** Records a change to the mix's voices or controls. */
104
106
  stir(): void;
107
+ onWake(fn: () => void): () => void;
105
108
  /**
106
109
  * Per subject, the first record of the chain through every live voice that reaches it, or a stub
107
110
  * where none does. Relinked when `version` moves, which is whenever the list or a voice's pending
@@ -168,6 +171,7 @@ export declare class Mixer<I, O> implements Mix<I, O> {
168
171
  blend(patches: readonly Patch<I, O, unknown>[], by: Signal<I>, spec?: Omit<VoiceSpec<I, O>, 'patch' | 'weight' | 'locus'>): Handle<I>[];
169
172
  owns(spec: OwnerSpec<I>): Handle<I>;
170
173
  sync(timestamp: number): void;
174
+ private syncAt;
171
175
  book(opts: BookOptions): Booker;
172
176
  probe(subject: I, out?: O): O;
173
177
  pull(subjects: Iterable<I>, into: Columns<O>): void;
package/dist/mixer.js CHANGED
@@ -86,10 +86,29 @@ export class Mixer {
86
86
  stirred = false;
87
87
  /** Counts the changes `stir` records, which a frame's record of a probe is current only before. */
88
88
  stirs = 0;
89
+ syncing = false;
90
+ stirFns = null;
89
91
  /** Records a change to the mix's voices or controls. */
90
92
  stir() {
91
- this.stirred = true;
92
93
  this.stirs++;
94
+ if (this.stirred)
95
+ return;
96
+ this.stirred = true;
97
+ const fns = this.stirFns;
98
+ if (fns !== null && !this.syncing)
99
+ for (const fn of [...fns])
100
+ fn();
101
+ }
102
+ onWake(fn) {
103
+ if (this.stirFns === null)
104
+ this.stirFns = [];
105
+ const fns = this.stirFns;
106
+ fns.push(fn);
107
+ return () => {
108
+ const i = fns.indexOf(fn);
109
+ if (i >= 0)
110
+ fns.splice(i, 1);
111
+ };
93
112
  }
94
113
  /**
95
114
  * Per subject, the first record of the chain through every live voice that reaches it, or a stub
@@ -200,6 +219,15 @@ export class Mixer {
200
219
  return this.cue({ ...spec, patch: ownerPatch });
201
220
  }
202
221
  sync(timestamp) {
222
+ this.syncing = true;
223
+ try {
224
+ this.syncAt(timestamp);
225
+ }
226
+ finally {
227
+ this.syncing = false;
228
+ }
229
+ }
230
+ syncAt(timestamp) {
203
231
  if (this.rebasing && !Number.isNaN(this.last))
204
232
  this.offset += timestamp - this.last;
205
233
  this.rebasing = false;
@@ -337,14 +365,14 @@ export class Mixer {
337
365
  still(voice, now) {
338
366
  if (voice.state === 'done')
339
367
  return true;
340
- if (voice.state !== 'held' && voice.state !== 'live')
368
+ if (voice.state !== 'frozen' && voice.state !== 'live')
341
369
  return false;
342
370
  if (typeof voice.spec.weight === 'function' || voice.parts !== null)
343
371
  return false;
344
372
  const fadeIn = this.reducedNow ? 0 : (voice.fade.in ?? 0);
345
373
  if (fadeIn > 0 && now - this.sinceOf(voice, voice.latest) < fadeIn)
346
374
  return false;
347
- if (voice.state === 'held' || voice.holding !== null)
375
+ if (voice.state === 'frozen' || voice.holding !== null)
348
376
  return true;
349
377
  const motion = voice.motion;
350
378
  return motion !== undefined && voice.elapsedAt(now) >= voice.latest && motion.landed();
package/dist/move.js CHANGED
@@ -48,7 +48,7 @@ function moveTo(mix, now) {
48
48
  continue;
49
49
  if (Number.isNaN(voice.opened))
50
50
  voice.opened = now;
51
- if ((voice.state === 'live' || voice.state === 'held') && voice.outAt <= now)
51
+ if ((voice.state === 'live' || voice.state === 'frozen') && voice.outAt <= now)
52
52
  beginFade(mix, voice, {}, Math.max(startOf(voice), voice.outAt));
53
53
  if (voice.state === 'pending' &&
54
54
  (voice.owner === null ? now : ownerReading(voice.owner, now)) >= voice.start) {
@@ -63,13 +63,13 @@ function moveTo(mix, now) {
63
63
  // The fade starts when the last pass ended, not at the frame that noticed, so it plays
64
64
  // the same at any frame rate and a read at another time can find it.
65
65
  if (voice.state === 'live') {
66
- if (voice.holdsAfter)
67
- voice.state = 'held';
66
+ if (voice.freezesAfter)
67
+ voice.state = 'frozen';
68
68
  else
69
69
  beginFade(mix, voice, {}, Math.max(startOf(voice), Math.min(now, voice.timeAt(end))));
70
70
  }
71
71
  }
72
- else if (voice.state === 'held')
72
+ else if (voice.state === 'frozen')
73
73
  voice.state = 'live';
74
74
  }
75
75
  // A projection reads a finished ramp as weight 0, and leaves the live voice's subjects be.
package/dist/owner.d.ts CHANGED
@@ -34,7 +34,7 @@ export declare function heldByInput<I, O>(voice: Voice<I, O>): boolean;
34
34
  /**
35
35
  * What one owner multiplies into its children's weight for a subject at mix time `now`: its weight,
36
36
  * or its signal's read by `base`, times its own fade, held to 0..1. Its fade in counts from when it
37
- * started, or from when it first showed where it holds before.
37
+ * started, or from when it first showed where it freezes before.
38
38
  */
39
39
  export declare function ownWeight<I, O>(owner: Voice<I, O>, now: number, reduced: boolean, base: (owner: Voice<I, O>) => number): number;
40
40
  /** What every owner above a voice multiplies into its weight. */
package/dist/owner.js CHANGED
@@ -80,12 +80,12 @@ export function heldByInput(voice) {
80
80
  /**
81
81
  * What one owner multiplies into its children's weight for a subject at mix time `now`: its weight,
82
82
  * or its signal's read by `base`, times its own fade, held to 0..1. Its fade in counts from when it
83
- * started, or from when it first showed where it holds before.
83
+ * started, or from when it first showed where it freezes before.
84
84
  */
85
85
  export function ownWeight(owner, now, reduced, base) {
86
86
  const b = typeof owner.spec.weight === 'function' ? base(owner) : owner.weight;
87
87
  const start = owner.owner === null ? owner.start : mixTime(owner.owner, owner.start);
88
- const since = owner.holdsBefore && owner.opened < start ? owner.opened : start;
88
+ const since = owner.freezesBefore && owner.opened < start ? owner.opened : start;
89
89
  return clampWeight(b * envelope(owner.fade.in ?? 0, owner.out, owner.ease, reduced, now, since));
90
90
  }
91
91
  /** What every owner above a voice multiplies into its weight. */
package/dist/project.js CHANGED
@@ -75,8 +75,8 @@ export function project(mix, c, timestamp) {
75
75
  ? 'pending'
76
76
  : copy.out && copy.out.at <= t
77
77
  ? 'fading'
78
- : copy.holdsAfter && copy.elapsedAt(t) >= copy.span + copy.latest
79
- ? 'held'
78
+ : copy.freezesAfter && copy.elapsedAt(t) >= copy.span + copy.latest
79
+ ? 'frozen'
80
80
  : 'live';
81
81
  count(c);
82
82
  }
package/dist/signals.d.ts CHANGED
@@ -16,7 +16,8 @@ export declare function level<I, H = unknown>(initial?: number): Signal<I, H> &
16
16
  /**
17
17
  * The package's one decay primitive: follows its input at a rate limit, no faster than `riseMs` per
18
18
  * unit climbing or `fallMs` draining. The mix keeps its state per voice and subject, so two voices
19
- * handed the same slew each follow on their own. A subject starts on its input, or at `from`.
19
+ * handed the same slew each follow on their own. A subject starts on its input, or at `from`
20
+ * unless `dt` is infinite.
20
21
  *
21
22
  * @category signal
22
23
  */
@@ -31,7 +32,7 @@ export declare function slew<I, H = unknown>(of: Signal<I, H>, { riseMs, fallMs,
31
32
  * sample it are spaced while the input holds still. `riseMs` and `fallMs` are time constants: after
32
33
  * one, 63% of the way is covered. 0 follows at once. Within `floor` of its input it lands on it, so a
33
34
  * subject can reach rest; pass 0 to keep the curve exact. The mix keeps its state per voice and subject.
34
- * A subject starts on its input, or at `from`.
35
+ * A subject starts on its input, or at `from` unless `dt` is infinite.
35
36
  *
36
37
  * @category signal
37
38
  */
package/dist/signals.js CHANGED
@@ -30,19 +30,25 @@ export function level(initial = 0) {
30
30
  },
31
31
  });
32
32
  }
33
- /** Where a follower starts for a subject: on its input, or at `from`, seen now so the next frame moves. */
34
- const firstSight = (target, from, now) => from === undefined ? { value: target, seen: Number.NaN } : { value: from, seen: now };
33
+ /**
34
+ * Where a follower starts for a subject: on its input, or at `from`, seen now so the next frame
35
+ * moves. Under reduced motion it starts on its input, since every later frame would snap there.
36
+ */
37
+ const firstSight = (target, from, setting) => from === undefined || !Number.isFinite(setting.dt)
38
+ ? { value: target, seen: Number.NaN }
39
+ : { value: from, seen: setting.timestamp };
35
40
  /**
36
41
  * The package's one decay primitive: follows its input at a rate limit, no faster than `riseMs` per
37
42
  * unit climbing or `fallMs` draining. The mix keeps its state per voice and subject, so two voices
38
- * handed the same slew each follow on their own. A subject starts on its input, or at `from`.
43
+ * handed the same slew each follow on their own. A subject starts on its input, or at `from`
44
+ * unless `dt` is infinite.
39
45
  *
40
46
  * @category signal
41
47
  */
42
48
  export function slew(of, { riseMs = 0, fallMs = 0, from }) {
43
49
  const read = (subject, setting) => {
44
50
  const target = of(subject, setting);
45
- const held = setting.keep(read, () => firstSight(target, from, setting.timestamp));
51
+ const held = setting.keep(read, () => firstSight(target, from, setting));
46
52
  if (setting.timestamp === held.seen)
47
53
  return held.value;
48
54
  let next = target;
@@ -66,14 +72,14 @@ export function slew(of, { riseMs = 0, fallMs = 0, from }) {
66
72
  * sample it are spaced while the input holds still. `riseMs` and `fallMs` are time constants: after
67
73
  * one, 63% of the way is covered. 0 follows at once. Within `floor` of its input it lands on it, so a
68
74
  * subject can reach rest; pass 0 to keep the curve exact. The mix keeps its state per voice and subject.
69
- * A subject starts on its input, or at `from`.
75
+ * A subject starts on its input, or at `from` unless `dt` is infinite.
70
76
  *
71
77
  * @category signal
72
78
  */
73
79
  export function lag(of, { riseMs = 0, fallMs = 0, floor = 1e-6, from, }) {
74
80
  const read = (subject, setting) => {
75
81
  const target = of(subject, setting);
76
- const held = setting.keep(read, () => firstSight(target, from, setting.timestamp));
82
+ const held = setting.keep(read, () => firstSight(target, from, setting));
77
83
  if (setting.timestamp === held.seen)
78
84
  return held.value;
79
85
  let next = target;
@@ -0,0 +1,48 @@
1
+ /** What a ticker runs: a mix, or anything that syncs, sleeps on `inert` and wakes by `onWake`. */
2
+ export interface Ticked {
3
+ sync(timestamp: number): void;
4
+ readonly inert: boolean;
5
+ onWake(fn: () => void): () => void;
6
+ }
7
+ export interface TickerOptions {
8
+ /**
9
+ * The most frames a second it syncs. Under animation frames it skips a frame that comes sooner
10
+ * than that, on a grid, so a 60 Hz display capped at 30 runs every other frame. Under a timer it
11
+ * is the timer's rate, 60 unless given.
12
+ */
13
+ fps?: number;
14
+ /**
15
+ * Where frames come from: `'frame'`, `requestAnimationFrame`, or `'timer'`, `setTimeout`. By
16
+ * default animation frames where they exist, and a timer where they don't, as in a worker
17
+ * without them or in Node.
18
+ */
19
+ grain?: 'frame' | 'timer';
20
+ /**
21
+ * The clock every mix is synced by. By default an animation frame's own timestamp, or
22
+ * `performance.now()` under a timer. A worker's `performance.now()` counts from its own origin,
23
+ * not the page's.
24
+ */
25
+ now?: () => number;
26
+ /** Stands in for `requestAnimationFrame`, as in a test. */
27
+ raf?: (callback: (timestamp: number) => void) => number;
28
+ /** Stands in for `cancelAnimationFrame`. */
29
+ caf?: (id: number) => void;
30
+ }
31
+ export interface Ticker {
32
+ /** Syncs `mix` every frame from now on, and wakes the loop on its `onWake`. Returns a remove. */
33
+ add(mix: Ticked): () => void;
34
+ /** Calls `fn` each frame, after every mix has synced, with the frame's timestamp. Returns an unsubscribe. */
35
+ after(fn: (timestamp: number) => void): () => void;
36
+ /** Keeps frames coming whatever the mixes say, until the returned release is called. */
37
+ hold(): () => void;
38
+ /** Cancels the waiting frame; a mix waking, `hold` or `add` starts the loop again. */
39
+ stop(): void;
40
+ /** What the ticker's clock reads now. */
41
+ now(): number;
42
+ }
43
+ /**
44
+ * One frame loop for any number of mixes. It runs while a mix would still change or something
45
+ * holds it, and sleeps otherwise, until a mix wakes it. A mix or `after` callback that throws stops
46
+ * neither the loop nor the others: its error is thrown again on a microtask.
47
+ */
48
+ export declare function ticker(opts?: TickerOptions): Ticker;
package/dist/ticker.js ADDED
@@ -0,0 +1,112 @@
1
+ /** Slack under the interval that still counts as due, for a display whose frames jitter. */
2
+ const SLACK = 1;
3
+ /**
4
+ * One frame loop for any number of mixes. It runs while a mix would still change or something
5
+ * holds it, and sleeps otherwise, until a mix wakes it. A mix or `after` callback that throws stops
6
+ * neither the loop nor the others: its error is thrown again on a microtask.
7
+ */
8
+ export function ticker(opts = {}) {
9
+ const g = globalThis;
10
+ const perfNow = () => (g.performance === undefined ? Date.now() : g.performance.now());
11
+ const byFrame = opts.raf !== undefined ||
12
+ (opts.grain !== 'timer' && typeof g.requestAnimationFrame === 'function');
13
+ const fps = opts.fps ?? (byFrame ? undefined : 60);
14
+ if (fps !== undefined && !(fps > 0 && fps < Number.POSITIVE_INFINITY))
15
+ throw new RangeError(`blits: a ticker's fps is a finite number above 0, not ${fps}`);
16
+ const interval = fps === undefined ? 0 : 1000 / fps;
17
+ const mixes = [];
18
+ const unwakes = new Map();
19
+ const fns = [];
20
+ let holds = 0;
21
+ let pending = null;
22
+ let last = Number.NaN;
23
+ const now = opts.now ?? perfNow;
24
+ const later = (err) => g.queueMicrotask(() => {
25
+ throw err;
26
+ });
27
+ const request = byFrame
28
+ ? (opts.raf ??
29
+ ((cb) => g.requestAnimationFrame(cb)))
30
+ : (cb) => g.setTimeout(() => cb(now()), Number.isNaN(last) ? 0 : Math.max(0, last + interval - now()));
31
+ const cancel = byFrame
32
+ ? (opts.caf ?? ((id) => g.cancelAnimationFrame?.(id)))
33
+ : (id) => g.clearTimeout(id);
34
+ const schedule = () => {
35
+ if (pending === null)
36
+ pending = request(frame);
37
+ };
38
+ function frame(stamp) {
39
+ pending = null;
40
+ const t = opts.now === undefined ? stamp : opts.now();
41
+ // A capped loop skips a frame that comes early, and steps a grid so its rate does not drift
42
+ // with the display's or with a timer's rounding. A long gap, as after sleeping, starts it again.
43
+ if (interval > 0 && !Number.isNaN(last)) {
44
+ const gap = t - last;
45
+ if (gap < interval - SLACK) {
46
+ pending = request(frame);
47
+ return;
48
+ }
49
+ last = gap >= 2 * interval ? t : last + interval;
50
+ }
51
+ else
52
+ last = t;
53
+ for (const mix of [...mixes])
54
+ try {
55
+ mix.sync(t);
56
+ }
57
+ catch (err) {
58
+ later(err);
59
+ }
60
+ for (const fn of [...fns])
61
+ try {
62
+ fn(t);
63
+ }
64
+ catch (err) {
65
+ later(err);
66
+ }
67
+ if (holds > 0 || mixes.some((mix) => !mix.inert))
68
+ schedule();
69
+ }
70
+ return {
71
+ add(mix) {
72
+ if (!unwakes.has(mix)) {
73
+ mixes.push(mix);
74
+ unwakes.set(mix, mix.onWake(schedule));
75
+ }
76
+ schedule();
77
+ return () => {
78
+ const unwake = unwakes.get(mix);
79
+ if (unwake === undefined)
80
+ return;
81
+ unwake();
82
+ unwakes.delete(mix);
83
+ mixes.splice(mixes.indexOf(mix), 1);
84
+ };
85
+ },
86
+ after(fn) {
87
+ fns.push(fn);
88
+ return () => {
89
+ const i = fns.indexOf(fn);
90
+ if (i >= 0)
91
+ fns.splice(i, 1);
92
+ };
93
+ },
94
+ hold() {
95
+ holds++;
96
+ schedule();
97
+ let released = false;
98
+ return () => {
99
+ if (released)
100
+ return;
101
+ released = true;
102
+ holds--;
103
+ };
104
+ },
105
+ stop() {
106
+ if (pending !== null)
107
+ cancel(pending);
108
+ pending = null;
109
+ },
110
+ now,
111
+ };
112
+ }
package/dist/types.d.ts CHANGED
@@ -395,7 +395,7 @@ export interface VoiceSpec<I, O, H = unknown> {
395
395
  /**
396
396
  * true loops for good, false plays one pass, n plays n passes. A finite loop leaves when its
397
397
  * passes are done for the latest-staggered subject it has seen — over `fade.out` where one is
398
- * set, at once where none is — unless it holds after.
398
+ * set, at once where none is — unless it freezes after.
399
399
  */
400
400
  loop?: boolean | number;
401
401
  /**
@@ -406,12 +406,14 @@ export interface VoiceSpec<I, O, H = unknown> {
406
406
  /**
407
407
  * Show a subject the first frame before it starts — while the voice is pending and while the
408
408
  * subject waits out its `stagger` — the last frame of the last pass after a finite loop plays
409
- * out, or both. A held subject's clock stands still: `at` is asked for the edge frame and `step`
409
+ * out, or both. A frozen subject's clock stands still: `at` is asked for the edge frame and `step`
410
410
  * does not run, past one last step to the end of the last pass. `fade.in` counts from the first
411
- * frame the voice shows. A voice holding after stays, `held`, until `fade()` or an anchored `out`
412
- * takes it out, so every one cued must be faded or it stays in the mix for good. Does nothing to
413
- * a motion patch, which holds its target already. Default: neither.
411
+ * frame the voice shows. A voice freezing after stays, `frozen`, until `fade()` or an anchored
412
+ * `out` takes it out, so every one cued must be faded or it stays in the mix for good. Does
413
+ * nothing to a motion patch, which keeps its target already. Default: neither.
414
414
  */
415
+ freeze?: 'before' | 'after' | 'both';
416
+ /** @deprecated Use `freeze`. A voice that sets only `hold` still freezes; `freeze` wins over it. */
415
417
  hold?: 'before' | 'after' | 'both';
416
418
  /** Steady weight, or a signal read per subject per frame. Default 1. */
417
419
  weight?: number | Signal<I, H>;
@@ -443,7 +445,7 @@ export interface VoiceSpec<I, O, H = unknown> {
443
445
  * the owner's clock, in ms from when the owner starts, and default to where that clock is now; a
444
446
  * bare name in an anchor means a sibling, another voice the owner holds. Its clock runs on the
445
447
  * owner's, so the owner's rate, ramp and seeks move it, and its weight is multiplied by the
446
- * owner's weight and fade. Without a `hold` of its own it takes the owner's.
448
+ * owner's weight and fade. Without a `freeze` of its own it takes the owner's.
447
449
  */
448
450
  owner?: Handle<I>;
449
451
  }
@@ -467,7 +469,9 @@ export interface OwnerSpec<I, H = unknown> {
467
469
  weight?: number | Signal<I, H>;
468
470
  /** One ramp in and out over every child, multiplied into their weights as `weight` is. */
469
471
  fade?: FadeSpec;
470
- /** The hold every child without one of its own takes. */
472
+ /** The freeze every child without one of its own takes. */
473
+ freeze?: 'before' | 'after' | 'both';
474
+ /** @deprecated Use `freeze`. An owner that sets only `hold` still freezes; `freeze` wins over it. */
471
475
  hold?: 'before' | 'after' | 'both';
472
476
  /** Words a source attaches to it, as a voice's. */
473
477
  tags?: readonly string[];
@@ -510,8 +514,8 @@ export type FadeOptions<I = unknown> = {
510
514
  */
511
515
  export interface Handle<I = unknown> {
512
516
  readonly id: number;
513
- /** `held`: a voice holding after has played every pass and shows its last frame until faded. */
514
- readonly state: 'pending' | 'live' | 'held' | 'fading' | 'done';
517
+ /** `frozen`: a voice freezing after has played every pass and shows its last frame until faded. */
518
+ readonly state: 'pending' | 'live' | 'frozen' | 'fading' | 'done';
515
519
  /** The handle of the owner this voice plays under; undefined for one on the mix clock. */
516
520
  readonly owner: Handle<I> | undefined;
517
521
  /** Live. Writes land on the next sync. */
@@ -533,7 +537,7 @@ export interface Handle<I = unknown> {
533
537
  /**
534
538
  * The weight this voice gave `subject` the last frame that subject was probed: after its fades
535
539
  * and its weight signal, before a locus folds it with its alternatives. 0 for a subject it does
536
- * not reach, has not started on and does not hold before, has left at rest, or has never been
540
+ * not reach, has not started on and does not freeze before, has left at rest, or has never been
537
541
  * probed for; 0 once done.
538
542
  * Costs nothing until it is asked.
539
543
  */
@@ -714,7 +718,7 @@ export interface Mix<I, O, H = unknown> {
714
718
  */
715
719
  atRest(subject: I): boolean;
716
720
  /**
717
- * A handle on every voice still in the mix, pending, live, held or fading, in the order they were
721
+ * A handle on every voice still in the mix, pending, live, frozen or fading, in the order they were
718
722
  * cued; given a tag, only the voices whose `tags` carry it. Each is the handle `cue` returned for
719
723
  * that voice, so a listed handle is `===` the cued one.
720
724
  */
@@ -722,12 +726,19 @@ export interface Mix<I, O, H = unknown> {
722
726
  /** Anything still contributing, fading, or pending. */
723
727
  readonly live: boolean;
724
728
  /**
725
- * Another frame would change no pose, so a host's frame loop may sleep: every voice is done, held
729
+ * Another frame would change no pose, so a host's frame loop may sleep: every voice is done, frozen
726
730
  * at a plain weight, or a motion whose every subject has landed on its target. At rate 0 it is
727
731
  * enough that every voice not done has started, at a plain weight, with no anchor. A change made
728
732
  * since the last sync, a retarget or a rate included, makes it false until the next.
729
733
  */
730
734
  readonly inert: boolean;
735
+ /**
736
+ * Calls `fn` when a host call makes the mix need frames again after a sync: a cue, a fade, a
737
+ * handle's or the mix's rate, a seek, a `drop`, a motion retargeted. It fires once until the next
738
+ * sync clears the change, and never during `sync`. Returns a function that unsubscribes `fn`.
739
+ * A frame loop sleeping on `inert` subscribes here to wake.
740
+ */
741
+ onWake(fn: () => void): () => void;
731
742
  /** Fades every voice out: over `over` when given, over each voice's own `fade.out` otherwise. */
732
743
  mute(opts?: {
733
744
  over?: number;
package/dist/voice.d.ts CHANGED
@@ -16,7 +16,7 @@ export interface Subject<S> {
16
16
  delay: number;
17
17
  /** The mix timestamp this subject's delay ran out at, which its step grid counts from. */
18
18
  since: number;
19
- /** The mix timestamp its fade in counts from: `since`, or earlier for a voice holding before. */
19
+ /** The mix timestamp its fade in counts from: `since`, or earlier for a voice freezing before. */
20
20
  shown: number;
21
21
  /** The weight this voice gave this subject the last frame it was probed, 0 where it gave none. */
22
22
  weight: number;
@@ -104,7 +104,7 @@ export declare class Voice<I, O> {
104
104
  start: number;
105
105
  /** The owner whose clock it runs on, its start included; null for one on the mix clock. */
106
106
  owner: Voice<I, O> | null;
107
- state: 'pending' | 'live' | 'held' | 'fading' | 'done';
107
+ state: 'pending' | 'live' | 'frozen' | 'fading' | 'done';
108
108
  rate: number;
109
109
  weight: number;
110
110
  /**
@@ -159,9 +159,9 @@ export declare class Voice<I, O> {
159
159
  readonly span: number;
160
160
  /** The state behind a motion patch, undefined for any other. */
161
161
  readonly motion: Motions<I> | undefined;
162
- /** Its `hold`, for a patch it applies to: motion holds its target already. */
163
- readonly holdsBefore: boolean;
164
- readonly holdsAfter: boolean;
162
+ /** Its `freeze`, for a patch it applies to: motion keeps its target already. */
163
+ readonly freezesBefore: boolean;
164
+ readonly freezesAfter: boolean;
165
165
  /** The mix time it first showed: when it was cued, or the first sync after. */
166
166
  opened: number;
167
167
  /** Which of its entries in the mix's due queue is current; older ones are skipped when popped. */
package/dist/voice.js CHANGED
@@ -129,9 +129,9 @@ export class Voice {
129
129
  span;
130
130
  /** The state behind a motion patch, undefined for any other. */
131
131
  motion;
132
- /** Its `hold`, for a patch it applies to: motion holds its target already. */
133
- holdsBefore;
134
- holdsAfter;
132
+ /** Its `freeze`, for a patch it applies to: motion keeps its target already. */
133
+ freezesBefore;
134
+ freezesAfter;
135
135
  /** The mix time it first showed: when it was cued, or the first sync after. */
136
136
  opened = Number.NaN;
137
137
  /** Which of its entries in the mix's due queue is current; older ones are skipped when popped. */
@@ -243,11 +243,13 @@ export class Voice {
243
243
  ? this.duration * this.passes
244
244
  : Number.POSITIVE_INFINITY;
245
245
  this.motion = motionOf(patch);
246
- const holds = this.motion === undefined ? spec.hold : undefined;
247
- // A voice with no hold of its own takes its owner's.
248
- const inherits = holds === undefined && this.motion === undefined && owner !== null;
249
- this.holdsBefore = inherits ? owner.holdsBefore : holds === 'before' || holds === 'both';
250
- this.holdsAfter = inherits ? owner.holdsAfter : holds === 'after' || holds === 'both';
246
+ const freezes = this.motion === undefined ? (spec.freeze ?? spec.hold) : undefined;
247
+ // A voice with no freeze of its own takes its owner's.
248
+ const inherits = freezes === undefined && this.motion === undefined && owner !== null;
249
+ this.freezesBefore = inherits
250
+ ? owner.freezesBefore
251
+ : freezes === 'before' || freezes === 'both';
252
+ this.freezesAfter = inherits ? owner.freezesAfter : freezes === 'after' || freezes === 'both';
251
253
  this.setting = new VoiceSetting(host, send, this);
252
254
  this.rate = spec.rate ?? 1;
253
255
  this.weight = typeof spec.weight === 'number' ? spec.weight : 1;
package/docs/schema.html CHANGED
@@ -160,7 +160,7 @@
160
160
  <h1>An effects schema, and the API around it</h1>
161
161
  <p class="lede">A small runtime package for concurrent effects. Each effect runs on its own clock with its own state and weight, and a mix folds them into one value per subject per frame by rules that belong to the channel, not the effect.</p>
162
162
  <p class="meta"><b>For:</b> whoever builds <code>blits</code>, and whoever ports klieg, wod, sherpa or magicsmoke onto it. <b>Answers:</b> what the vocabulary is, what an author writes, what a host calls, and where a second engine would plug in. The package implements this page; where the two differ, the code in <code>src/</code> is the one that runs.</p>
163
- <span class="status">Built · v0.5.0 · design 2026-09-15, package 2026-09-28</span>
163
+ <span class="status">Built · v0.6.0 · design 2026-09-15, package 2026-09-28</span>
164
164
  </header>
165
165
 
166
166
  <h2 id="picture">In one picture</h2>
@@ -393,8 +393,8 @@ interface Setting&lt;S = void, H = unknown&gt; {
393
393
  kept, like <code>target</code>; the subject's fade in starts when the voice clock reaches it. */</span>
394
394
  stagger?: (subject: I) =&gt; number;
395
395
  <span class="cm">/** Show the first frame before a subject starts, the last after its passes run out, or both. Default: neither,
396
- so the voice shows nothing outside its passes. See Holding. */</span>
397
- hold?: 'before' | 'after' | 'both';
396
+ so the voice shows nothing outside its passes. See Freezing. <code>hold</code>, its name before 0.6.0, still works and is deprecated. */</span>
397
+ freeze?: 'before' | 'after' | 'both';
398
398
 
399
399
  <span class="cm">// weight</span>
400
400
  <span class="cm">/** Steady weight, or a signal read per subject per frame. Default 1. */</span>
@@ -428,8 +428,8 @@ interface Setting&lt;S = void, H = unknown&gt; {
428
428
 
429
429
  interface Handle&lt;I&gt; {
430
430
  readonly id: number;
431
- <span class="cm">/** <code>held</code>: a voice holding after has played every pass and shows its last frame until faded. */</span>
432
- readonly state: 'pending' | 'live' | 'held' | 'fading' | 'done';
431
+ <span class="cm">/** <code>frozen</code>: a voice freezing after has played every pass and shows its last frame until faded. */</span>
432
+ readonly state: 'pending' | 'live' | 'frozen' | 'fading' | 'done';
433
433
  <span class="cm">/** The owner's handle, for a voice an owner holds. */</span>
434
434
  readonly owner: Handle&lt;I&gt; | undefined;
435
435
  <span class="cm">/** Live. Writes land on the next sync. */</span>
@@ -456,14 +456,14 @@ interface Handle&lt;I&gt; {
456
456
 
457
457
  <p><b>Fading one subject.</b> <code>fade({ subject, over })</code> ramps one subject out of a voice and leaves the voice playing for every other subject; <code>over: 0</code> takes it out at once. When the ramp ends the voice forgets the subject &mdash; its record, its lane position and a motion patch&rsquo;s state for it &mdash; and no longer reaches it, so nothing it held stays stacked under whatever takes the subject over. A motion patch&rsquo;s <code>to</code> brings it back, met afresh: <code>from</code>, <code>target</code> and <code>stagger</code> are asked again. Any other voice reaches it again only once <code>drop</code> forgets the subject. This is what lets a host that starts many short animations, one per node, run them as one voice on one lane instead of a voice per call, while still cancelling a node or handing it to a different patch.</p>
458
458
 
459
- <p><b>Holding.</b> A voice shows nothing outside its passes unless it holds. <code>hold: 'before'</code> shows a subject the voice&rsquo;s first frame from the cue until the subject starts: while the voice is pending, and while the subject waits out its <code>stagger</code>. <code>hold: 'after'</code> shows the last frame of the last pass once a finite loop has played out, and the voice stays until <code>fade()</code> takes it out, ramping from that frame. <code>'both'</code> does both. wod asked for it to replace WAAPI&rsquo;s <code>fill</code>: a spin lands and holds its angle, and staggered wedges wait on their first frame rather than popping in.</p>
459
+ <p><b>Freezing.</b> A voice shows nothing outside its passes unless it freezes, the name SMIL gives the same thing (<code>fill="freeze"</code>). <code>freeze: 'before'</code> shows a subject the voice&rsquo;s first frame from the cue until the subject starts: while the voice is pending, and while the subject waits out its <code>stagger</code>. <code>freeze: 'after'</code> shows the last frame of the last pass once a finite loop has played out, and the voice stays until <code>fade()</code> takes it out, ramping from that frame. <code>'both'</code> does both. wod asked for it to replace WAAPI&rsquo;s <code>fill</code>: a spin lands and freezes on its angle, and staggered wedges wait on their first frame rather than popping in.</p>
460
460
  <ul>
461
- <li><b>A held subject&rsquo;s clock stands still.</b> It reads phase 0 of pass 0 before, and phase 1 of the last pass after, with <code>elapsed</code> pinned to that edge. <code>at</code> is asked for that frame. <code>step</code> does not run while a subject waits, so a stateful patch starts playing from the state it was cued with; after, it runs once more to where the last pass ended and then no more, so the held frame does not depend on when the last frame before it fell. A voice holding before stays <code>pending</code> until it starts, and <code>weightOf</code> reports the weight it gives a waiting subject.</li>
462
- <li><b><code>fade.in</code> counts from the first frame the voice shows</b>, so with <code>'before'</code> it ramps in on the held frame rather than at full weight, and does not dip when play begins.</li>
463
- <li><b>A voice holding after reaches <code>held</code>, not <code>fading</code>,</b> at the moment a voice without a hold would begin its fade out. <code>played</code> resolves true then. Its <code>out</code> and <code>end</code> marks are unfixed until it is faded, like those of a voice that loops for good, so <code>{ after: 'spin' }</code> waits for the fade. A host that wants to start something when the passes run out awaits <code>played</code>.</li>
464
- <li><b>The host owns every held voice.</b> One cued per spin and never faded stays in the mix for good, one more voice folded every frame. The next voice&rsquo;s cue is the time to fade the last, at once or <code>at: 'rest'</code>.</li>
465
- <li><b>Where it changes nothing.</b> A voice that loops for good never runs out of passes, so only <code>'before'</code> applies to it. A motion patch (<code>spring</code>, <code>glide</code>, <code>tween</code>) already holds its target and has no passes, so <code>hold</code> does nothing to it. Lanes give the same pose with it as without them, as for everything else.</li>
466
- <li><b>What it costs.</b> A voice holding before runs on the general path while it is pending, which takes the channels it writes off their lanes until it starts; one that is already live, waiting only on a subject&rsquo;s stagger, stays on its lane. A mix with no hold reads no slower (<code>keys</code> and <code>fn</code> rows of <code>bench/frame.mjs</code>, three passes alternated against <code>main</code> on a laptop, 2026-10-03).</li>
461
+ <li><b>A frozen subject&rsquo;s clock stands still.</b> It reads phase 0 of pass 0 before, and phase 1 of the last pass after, with <code>elapsed</code> pinned to that edge. <code>at</code> is asked for that frame. <code>step</code> does not run while a subject waits, so a stateful patch starts playing from the state it was cued with; after, it runs once more to where the last pass ended and then no more, so the frozen frame does not depend on when the last frame before it fell. A voice freezing before stays <code>pending</code> until it starts, and <code>weightOf</code> reports the weight it gives a waiting subject.</li>
462
+ <li><b><code>fade.in</code> counts from the first frame the voice shows</b>, so with <code>'before'</code> it ramps in on the frozen frame rather than at full weight, and does not dip when play begins.</li>
463
+ <li><b>A voice freezing after reaches <code>frozen</code>, not <code>fading</code>,</b> at the moment a voice without a freeze would begin its fade out. <code>played</code> resolves true then. Its <code>out</code> and <code>end</code> marks are unfixed until it is faded, like those of a voice that loops for good, so <code>{ after: 'spin' }</code> waits for the fade. A host that wants to start something when the passes run out awaits <code>played</code>.</li>
464
+ <li><b>The host owns every frozen voice.</b> One cued per spin and never faded stays in the mix for good, one more voice folded every frame. The next voice&rsquo;s cue is the time to fade the last, at once or <code>at: 'rest'</code>.</li>
465
+ <li><b>Where it changes nothing.</b> A voice that loops for good never runs out of passes, so only <code>'before'</code> applies to it. A motion patch (<code>spring</code>, <code>glide</code>, <code>tween</code>) already keeps its target and has no passes, so <code>freeze</code> does nothing to it. Lanes give the same pose with it as without them, as for everything else.</li>
466
+ <li><b>What it costs.</b> A voice freezing before runs on the general path while it is pending, which takes the channels it writes off their lanes until it starts; one that is already live, waiting only on a subject&rsquo;s stagger, stays on its lane. A mix with no freeze reads no slower (<code>keys</code> and <code>fn</code> rows of <code>bench/frame.mjs</code>, three passes alternated against <code>main</code> on a laptop, 2026-10-03).</li>
467
467
  </ul>
468
468
 
469
469
  <p>Weights are the only knob the mix owns. Fade, crossfade, handover, alternatives, and hinge's continuous mode are all weights over time; the section on blending shows each one as such. A patch never sees a weight applied to its output, it only sees the number in <code>setting.weight</code> if it cares.</p>
@@ -526,17 +526,20 @@ interface Mix&lt;I, O, H = unknown&gt; {
526
526
  <span class="cm">/** Every mark the plan knows between two timestamps, earliest first. */</span>
527
527
  marks(from: number, to: number): Marked[];
528
528
 
529
- <span class="cm">/** A handle on every voice still in the mix, pending, live, held or fading, in cue order; given a tag, only
529
+ <span class="cm">/** A handle on every voice still in the mix, pending, live, frozen or fading, in cue order; given a tag, only
530
530
  the voices carrying it. Each is the handle `cue` returned for that voice. */</span>
531
531
  voices(tag?: string): Handle&lt;I&gt;[];
532
532
 
533
533
  <span class="cm">/** Anything still contributing, fading, or pending. A host stops its loop when this is false. `cue` and
534
- `blend` make it true again at once; the mix never calls back into the host, so restarting the loop is
535
- the caller's to do on the same path that cued. */</span>
534
+ `blend` make it true again at once, and `onWake` says so. */</span>
536
535
  readonly live: boolean;
537
- <span class="cm">/** Another frame would change no pose: every voice is done, held at a plain weight, or a motion whose every
536
+ <span class="cm">/** Another frame would change no pose: every voice is done, frozen at a plain weight, or a motion whose every
538
537
  subject has landed, or, at rate 0, every voice has started at a plain weight with no anchor. A host may sleep its loop while this is true and `live` still is. */</span>
539
538
  readonly inert: boolean;
539
+ <span class="cm">/** Calls `fn` when a host call makes the mix need frames again after a sync: a cue, a fade, a rate, a seek, a
540
+ `drop`, a motion retargeted. Once until the next sync, and never during one: the only time the mix calls
541
+ back into the host, and always from inside a call the host made. A sleeping loop wakes on it. */</span>
542
+ onWake(fn: () =&gt; void): () =&gt; void;
540
543
  <span class="cm">/** Fades every voice out: over `over` when given, over each voice's own `fade.out` otherwise. */</span>
541
544
  mute(opts?: { over?: number }): void;
542
545
  <span class="cm">/** Forgets per-subject state, a motion patch's included. Call when a subject is retired, or let the WeakMap do it. */</span>
@@ -571,7 +574,7 @@ function gate&lt;I&gt;(of: Signal&lt;I&gt;, at: number): Signal&lt;I&gt;;
571
574
 
572
575
  <h2 id="time">Time</h2>
573
576
  <ul>
574
- <li><b>The mix is pull-based on a host clock.</b> The host owns the frame loop and calls <code>sync(timestamp)</code> with milliseconds on any monotonic clock it likes. klieg passes its <code>Clock.now()</code>; wod passes <code>performance.now()</code>; a test passes integers.</li>
577
+ <li><b>The mix is pull-based on a host clock.</b> The host owns the frame loop and calls <code>sync(timestamp)</code> with milliseconds on any monotonic clock it likes. klieg passes its <code>Clock.now()</code>; wod passes <code>performance.now()</code>; a test passes integers. A host with no loop of its own can take <code>ticker()</code>'s: it syncs every mix it was given on each animation frame, or on a timer where there are none, as in a worker, sleeps while every mix is <code>inert</code>, wakes on <code>onWake</code>, and caps the rate at <code>fps</code> when asked.</li>
575
578
  <li><b>Everything in a frame reads one timestamp.</b> A second <code>sync</code> with the same value is a no-op, and every <code>probe</code> between syncs answers from the same frame. This is what makes state advance once however many probes a wrapper makes.</li>
576
579
  <li><b>A voice's clock is <code>(timestamp − start) · rate</code>.</b> That reading is <code>setting.elapsed</code>, and its position within the current pass, <code>(elapsed mod duration) / duration</code>, is the <code>phase</code> the patch is handed first. Both reach the patch. A rate change rebases so <code>elapsed</code> is continuous.</li>
577
580
  <li><b>The mix has a rate of its own,</b> <code>mix.rate</code> and <code>mix.ramp(rate, over)</code>, shaped like a handle's and multiplied into every voice's: 0 pauses the whole mix, and a negative rate throws, since the mix only goes forward. It runs the mix clock: mix time is the host's timestamp, less what <code>rebase</code> took out, integrated at the mix's rate, and each voice integrates its own rate over mix time, so a ramp on both multiplies and no position jumps. Everything a voice owns runs on mix time and slows with it, which is what a pause has to mean for a fade caught halfway. A timestamp the host gives or reads stays on the host's clock, so a beat announced from an audio clock stays on the beat when the mix slows, and a start ahead of a paused mix waits for the host's clock to reach it. The mix's own ramp runs over host ms, since a ramp over its own slowing time would never arrive. A change made while paused lands at the next sync as always, and a weight signal is still asked at every probe, so one reading input follows it. At rate 0 the mix is <code>inert</code> once every voice not done has started, at a plain weight, with no anchor. Under <code>history</code> every rate change is kept, and a read back finds mix time through them as it finds a voice's clock through its handle's changes; without it a timestamp from before the last change converts at the rate in force, as one from before a <code>rebase</code> converts at the offset in force. weasel also scales a key and a handle; a mix leaves those levels to tags and to the host.
@@ -690,7 +693,7 @@ intro.fade(); <span class="cm">// one envelope over both</span></code></pr
690
693
  <tr><td><code>rate</code>, <code>ramp</code></td><td>Multiply into each child's own rate: a child's clock reads its owner's clock where a voice's reads the mix's.</td></tr>
691
694
  <tr><td><code>seek</code></td><td>Moves the owner's clock, and every child's phase with it. State stays where it is, as for a voice's own seek.</td></tr>
692
695
  <tr><td><code>weight</code> (a number or a signal), <code>fade</code></td><td>Multiply into each child's weight per subject, as the child's own fade does.</td></tr>
693
- <tr><td><code>hold</code></td><td>Taken by every child with no hold of its own.</td></tr>
696
+ <tr><td><code>freeze</code></td><td>Taken by every child with no freeze of its own.</td></tr>
694
697
  <tr><td><code>played</code>, <code>done</code></td><td><code>played</code> once every child has finished its passes. The owner leaves once it has held a voice and has none left; a fade that ends takes its children with it.</td></tr>
695
698
  <tr><td><code>loop</code></td><td>Refused at <code>owns</code>: a pass would have to restart stateful children (decided 2026-10-04).</td></tr>
696
699
  </table></div>
@@ -803,7 +806,7 @@ export const mixer: Engine; <span class="cm">// the one that ships</span></cod
803
806
  <li><b>Subject registration.</b> A <code>mix.subjects(list)</code> call would let the mix step every state eagerly. It puts a second source of truth about the pool in the host. Lazy state keyed by identity, stepped by the gap in the timestamp, needs nothing from the host and is the rule klieg already proved.</li>
804
807
  <li><b>A slew in place of the band.</b> Smoothing a gate's input instead of giving it two edges. It delays the crossing without deciding it, so an input that rests on the threshold chatters anyway, and the rate needed to help at all costs visible lag.</li>
805
808
  <li><b>Normalizing a locus's weights per channel.</b> The other way to make a crossfade hold its value on <code>mul</code>: keep the scale-and-merge fold and pick a per-channel divisor. It costs the property the mixer's fold order rests on &mdash; that weights are scalars resolved for the subject before any channel is touched &mdash; and it has no answer for <code>last</code>, which has no arithmetic to normalize. The <code>lerp</code> fold gets the same result from the rule the package already has.</li>
806
- <li><b>Removing a held voice once another covers it.</b> WAAPI drops a finished, filling animation when a later one replaces every property it sets. In a mix, &ldquo;covers&rdquo; depends on weights, loci and each channel&rsquo;s fold, and a voice leaving on its own would change the pose with no call to point at. The host fades it.</li>
809
+ <li><b>Removing a frozen voice once another covers it.</b> WAAPI drops a finished, filling animation when a later one replaces every property it sets. In a mix, &ldquo;covers&rdquo; depends on weights, loci and each channel&rsquo;s fold, and a voice leaving on its own would change the pose with no call to point at. The host fades it.</li>
807
810
  <li><b>A mix option for the lurch guard.</b> A global "normalize when over 1" would apply to effects that legitimately stack. The cap belongs to the voices that crossfade, so it is a <code>locus</code>.</li>
808
811
  </ul>
809
812
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@msb235/blits",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
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",