@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 +27 -0
- package/README.md +2 -2
- package/dist/book.d.ts +1 -1
- package/dist/chain.d.ts +2 -2
- package/dist/chain.js +3 -3
- package/dist/clock.d.ts +3 -3
- package/dist/clock.js +3 -3
- package/dist/crowd.d.ts +3 -3
- package/dist/crowd.js +7 -7
- package/dist/due.js +2 -2
- package/dist/fill.js +2 -2
- package/dist/fold.js +7 -7
- package/dist/held.js +3 -3
- package/dist/hosts.js +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/lane.d.ts +3 -3
- package/dist/lane.js +5 -5
- package/dist/marks.js +1 -1
- package/dist/mixer.d.ts +4 -0
- package/dist/mixer.js +31 -3
- package/dist/move.js +4 -4
- package/dist/owner.d.ts +1 -1
- package/dist/owner.js +2 -2
- package/dist/project.js +2 -2
- package/dist/signals.d.ts +3 -2
- package/dist/signals.js +12 -6
- package/dist/ticker.d.ts +48 -0
- package/dist/ticker.js +112 -0
- package/dist/types.d.ts +23 -12
- package/dist/voice.d.ts +5 -5
- package/dist/voice.js +10 -8
- package/docs/schema.html +22 -19
- package/package.json +1 -1
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.
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
20
|
-
* else NaN, where it shows nothing; `span` once its passes end if it
|
|
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
|
|
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
|
|
43
|
-
* else NaN, where it shows nothing; `span` once its passes end if it
|
|
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
|
|
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,
|
|
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
|
|
51
|
-
|
|
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 {
|
|
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.
|
|
218
|
-
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.
|
|
302
|
-
if (v.state === 'live' || v.state === '
|
|
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.
|
|
305
|
-
f |= 256 /* Flag.
|
|
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
|
|
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 === '
|
|
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 {
|
|
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 =
|
|
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 {
|
|
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.
|
|
49
|
+
if (raw < 0 && !voice.freezesBefore)
|
|
50
50
|
return null;
|
|
51
|
-
const elapsed =
|
|
52
|
-
// A
|
|
53
|
-
const still = raw < 0 ? -1 : voice.
|
|
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
|
-
//
|
|
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 =
|
|
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
|
|
89
|
-
if (voice.state === '
|
|
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.
|
|
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.
|
|
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
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
|
|
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
|
|
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 {
|
|
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
|
|
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
|
|
186
|
-
return
|
|
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.
|
|
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 !== '
|
|
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 === '
|
|
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 === '
|
|
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.
|
|
67
|
-
voice.state = '
|
|
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 === '
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
79
|
-
? '
|
|
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
|
-
/**
|
|
34
|
-
|
|
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
|
|
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
|
|
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;
|
package/dist/ticker.d.ts
ADDED
|
@@ -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
|
|
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
|
|
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
|
|
412
|
-
* takes it out, so every one cued must be faded or it stays in the mix for good. Does
|
|
413
|
-
* a motion patch, which
|
|
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 `
|
|
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
|
|
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
|
-
/** `
|
|
514
|
-
readonly state: 'pending' | 'live' | '
|
|
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
|
|
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,
|
|
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,
|
|
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
|
|
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' | '
|
|
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 `
|
|
163
|
-
readonly
|
|
164
|
-
readonly
|
|
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 `
|
|
133
|
-
|
|
134
|
-
|
|
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
|
|
247
|
-
// A voice with no
|
|
248
|
-
const inherits =
|
|
249
|
-
this.
|
|
250
|
-
|
|
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.
|
|
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<S = void, H = unknown> {
|
|
|
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) => 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
|
|
397
|
-
|
|
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<S = void, H = unknown> {
|
|
|
428
428
|
|
|
429
429
|
interface Handle<I> {
|
|
430
430
|
readonly id: number;
|
|
431
|
-
<span class="cm">/** <code>
|
|
432
|
-
readonly state: 'pending' | 'live' | '
|
|
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<I> | undefined;
|
|
435
435
|
<span class="cm">/** Live. Writes land on the next sync. */</span>
|
|
@@ -456,14 +456,14 @@ interface Handle<I> {
|
|
|
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 — its record, its lane position and a motion patch’s state for it — and no longer reaches it, so nothing it held stays stacked under whatever takes the subject over. A motion patch’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>
|
|
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’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’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
|
|
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
|
|
463
|
-
<li><b>A voice
|
|
464
|
-
<li><b>The host owns every
|
|
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
|
|
466
|
-
<li><b>What it costs.</b> A voice
|
|
461
|
+
<li><b>A frozen subject’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’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’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<I, O, H = unknown> {
|
|
|
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,
|
|
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<I>[];
|
|
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
|
|
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,
|
|
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: () => void): () => 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<I>(of: Signal<I>, at: number): Signal<I>;
|
|
|
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>
|
|
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 — that weights are scalars resolved for the subject before any channel is touched — 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
|
|
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, “covers” depends on weights, loci and each channel’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