entangled-worlds-orbiters-shared 2.12.0 → 2.13.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/dist/clock/curves.d.ts +87 -2
- package/dist/clock/curves.js +140 -16
- package/package.json +1 -1
- package/src/clock/curves.ts +185 -14
package/dist/clock/curves.d.ts
CHANGED
|
@@ -4,11 +4,87 @@ export declare const Ease: {
|
|
|
4
4
|
linear: EaseFn;
|
|
5
5
|
smooth: EaseFn;
|
|
6
6
|
};
|
|
7
|
-
/**
|
|
7
|
+
/**
|
|
8
|
+
* A single curve keyframe: a time key `t` (seconds or beats) and its value. `curve` bends the segment
|
|
9
|
+
* to the next keyframe (see {@link bend}); absent or 0 falls back to the track's `ease`.
|
|
10
|
+
*/
|
|
8
11
|
export interface Keyframe {
|
|
9
12
|
t: number;
|
|
10
13
|
value: number;
|
|
14
|
+
curve?: number;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* How a curve moves between keyframes: `line` ramps (bent by each keyframe's `curve`), `step` holds
|
|
18
|
+
* each value until the next keyframe.
|
|
19
|
+
*/
|
|
20
|
+
export type CurveShape = 'line' | 'step';
|
|
21
|
+
/** The range of a keyframe's `curve`: −2 … 2, 0 linear. */
|
|
22
|
+
export declare const CURVE_BEND_LIMIT = 2;
|
|
23
|
+
/**
|
|
24
|
+
* The bend of one segment: maps the fraction `u` in [0, 1] through `curve` c. 0 is linear; c > 0 starts
|
|
25
|
+
* slow and ends fast (exponential), c < 0 starts fast (logarithmic). The automation editor and every
|
|
26
|
+
* player read the same function, so what is drawn is what plays.
|
|
27
|
+
*/
|
|
28
|
+
export declare function bend(u: number, curve?: number): number;
|
|
29
|
+
/**
|
|
30
|
+
* The `curve` that puts a segment's midpoint at `mid`, a share of the way from its first value to its
|
|
31
|
+
* last (0 … 1, exclusive): the inverse of {@link bend} at u = ½, for a bend handle dragged up or down.
|
|
32
|
+
*/
|
|
33
|
+
export declare function curveForMidpoint(mid: number): number;
|
|
34
|
+
/** What {@link sampleKeyframes} needs besides the keyframes. */
|
|
35
|
+
export interface SampleOptions {
|
|
36
|
+
shape?: CurveShape;
|
|
37
|
+
/** For a segment whose keyframe has no `curve` (default linear). */
|
|
38
|
+
ease?: EaseFn;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* The value of sorted keyframes at time key `key`: held before the first and after the last, ramped
|
|
42
|
+
* (or stepped) between. NaN when there are none: an empty lane writes nothing.
|
|
43
|
+
*/
|
|
44
|
+
export declare function sampleKeyframes(keyframes: readonly Keyframe[], key: number, { shape, ease }?: SampleOptions): number;
|
|
45
|
+
/**
|
|
46
|
+
* A loop over a curve's time key: the curve plays from 0 up to `start + length`, then repeats
|
|
47
|
+
* `start` … `start + length` (a clip's loop). `start` 0 is a curve that loops on its own length.
|
|
48
|
+
*/
|
|
49
|
+
export interface CurveLoop {
|
|
50
|
+
start: number;
|
|
51
|
+
length: number;
|
|
52
|
+
}
|
|
53
|
+
/** The curve's own time key for a running key: before the loop's end as is, then wrapped into the loop. */
|
|
54
|
+
export declare function loopKey(key: number, loop: CurveLoop | null | undefined): number;
|
|
55
|
+
/** One of the shapes {@link insertShape} draws: points as [u, value, curve?] over one cycle, u in [0, 1). */
|
|
56
|
+
export interface CurveShapeDef {
|
|
57
|
+
id: string;
|
|
58
|
+
label: string;
|
|
59
|
+
points: ReadonlyArray<readonly [number, number, number?]>;
|
|
60
|
+
/** A closing point at the range's end, for shapes that come back to where they started. */
|
|
61
|
+
end?: number;
|
|
11
62
|
}
|
|
63
|
+
/** The ten shapes of the automation editor's Insert shape menu. */
|
|
64
|
+
export declare const CURVE_SHAPES: readonly CurveShapeDef[];
|
|
65
|
+
/** The keyframes with every one inside [from, to] replaced by `insert` (sorted). */
|
|
66
|
+
export declare function replaceRange(keyframes: readonly Keyframe[], from: number, to: number, insert: readonly Keyframe[]): Keyframe[];
|
|
67
|
+
/**
|
|
68
|
+
* Draws a shape over [from, to], `cycles` times, replacing only the keyframes inside the range. Null
|
|
69
|
+
* when the shape is unknown or the range is too short to draw (under 1/16 of a key unit).
|
|
70
|
+
*/
|
|
71
|
+
export declare function insertShape(keyframes: readonly Keyframe[], shapeId: string, from: number, to: number, cycles?: number): Keyframe[] | null;
|
|
72
|
+
/** Freehand draw: one keyframe at the start of the cell [cellStart, cellStart + cellLength), replacing the cell. */
|
|
73
|
+
export declare function paintCell(keyframes: readonly Keyframe[], cellStart: number, cellLength: number, value: number): Keyframe[];
|
|
74
|
+
/**
|
|
75
|
+
* Recording: the keyframes after the time key moved from `prevKey` to `key` while `value` was live.
|
|
76
|
+
* What was recorded over in that span is replaced (a wrap from a loop end back to its start clears both
|
|
77
|
+
* ends of the loop). A new keyframe is written only once `spacing` has passed since `lastWritten` (null: write now),
|
|
78
|
+
* so a recording is one point per grid step, not one per frame.
|
|
79
|
+
*/
|
|
80
|
+
export declare function recordSpan(keyframes: readonly Keyframe[], prevKey: number, key: number, value: number, { spacing, lastWritten, loop }: {
|
|
81
|
+
spacing: number;
|
|
82
|
+
lastWritten: number | null;
|
|
83
|
+
loop?: CurveLoop | null;
|
|
84
|
+
}): {
|
|
85
|
+
keyframes: Keyframe[];
|
|
86
|
+
written: number | null;
|
|
87
|
+
};
|
|
12
88
|
export type TimeMode = 'absolute-seconds' | 'beat-locked';
|
|
13
89
|
/**
|
|
14
90
|
* Structural shape of a musical projection — typed here so `./clock/curves` never imports
|
|
@@ -22,6 +98,10 @@ export interface CurveTrackSpec {
|
|
|
22
98
|
timeMode?: TimeMode;
|
|
23
99
|
projection?: ProjectionLike | null;
|
|
24
100
|
ease?: EaseFn;
|
|
101
|
+
/** Line (default) or step. */
|
|
102
|
+
shape?: CurveShape;
|
|
103
|
+
/** Loops the curve's time key (a clip loop, or its own length). Absent: plays once, then holds. */
|
|
104
|
+
loop?: CurveLoop | null;
|
|
25
105
|
outputIntent?: unknown;
|
|
26
106
|
}
|
|
27
107
|
export declare class CurveTrack {
|
|
@@ -29,11 +109,16 @@ export declare class CurveTrack {
|
|
|
29
109
|
timeMode: TimeMode;
|
|
30
110
|
projection: ProjectionLike | null;
|
|
31
111
|
ease: EaseFn;
|
|
112
|
+
shape: CurveShape;
|
|
113
|
+
loop: CurveLoop | null;
|
|
32
114
|
outputIntent: unknown;
|
|
33
115
|
constructor(spec: CurveTrackSpec);
|
|
34
|
-
/** The track's own time key for a given clock position. */
|
|
116
|
+
/** The track's own time key for a given clock position (looped when the track loops). */
|
|
35
117
|
_key(positionSec: number): number;
|
|
118
|
+
/** The value at a clock position; NaN for a track with no keyframes. */
|
|
36
119
|
sample(positionSec: number): number;
|
|
120
|
+
/** The value at the track's own time key (beats for a beat-locked track), with no loop applied. */
|
|
121
|
+
sampleKey(key: number): number;
|
|
37
122
|
}
|
|
38
123
|
/**
|
|
39
124
|
* Structural shape of the transport a GestureRecorder/Orchestrator subscribes to — typed here so
|
package/dist/clock/curves.js
CHANGED
|
@@ -13,40 +13,164 @@ export const Ease = {
|
|
|
13
13
|
function lerp(a, b, t) {
|
|
14
14
|
return a + (b - a) * t;
|
|
15
15
|
}
|
|
16
|
+
/** The range of a keyframe's `curve`: −2 … 2, 0 linear. */
|
|
17
|
+
export const CURVE_BEND_LIMIT = 2;
|
|
18
|
+
/**
|
|
19
|
+
* The bend of one segment: maps the fraction `u` in [0, 1] through `curve` c. 0 is linear; c > 0 starts
|
|
20
|
+
* slow and ends fast (exponential), c < 0 starts fast (logarithmic). The automation editor and every
|
|
21
|
+
* player read the same function, so what is drawn is what plays.
|
|
22
|
+
*/
|
|
23
|
+
export function bend(u, curve = 0) {
|
|
24
|
+
if (!curve)
|
|
25
|
+
return u;
|
|
26
|
+
return curve > 0 ? Math.pow(u, 1 + curve * 3) : 1 - Math.pow(1 - u, 1 - curve * 3);
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* The `curve` that puts a segment's midpoint at `mid`, a share of the way from its first value to its
|
|
30
|
+
* last (0 … 1, exclusive): the inverse of {@link bend} at u = ½, for a bend handle dragged up or down.
|
|
31
|
+
*/
|
|
32
|
+
export function curveForMidpoint(mid) {
|
|
33
|
+
const m = Math.max(0.03, Math.min(0.97, mid));
|
|
34
|
+
const c = m <= 0.5 ? (Math.log(m) / Math.log(0.5) - 1) / 3 : -(Math.log(1 - m) / Math.log(0.5) - 1) / 3;
|
|
35
|
+
return Math.max(-CURVE_BEND_LIMIT, Math.min(CURVE_BEND_LIMIT, c));
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* The value of sorted keyframes at time key `key`: held before the first and after the last, ramped
|
|
39
|
+
* (or stepped) between. NaN when there are none: an empty lane writes nothing.
|
|
40
|
+
*/
|
|
41
|
+
export function sampleKeyframes(keyframes, key, { shape = 'line', ease = Ease.linear } = {}) {
|
|
42
|
+
const kf = keyframes;
|
|
43
|
+
if (!kf.length)
|
|
44
|
+
return NaN;
|
|
45
|
+
if (key <= kf[0].t)
|
|
46
|
+
return kf[0].value;
|
|
47
|
+
const last = kf[kf.length - 1];
|
|
48
|
+
if (key >= last.t)
|
|
49
|
+
return last.value;
|
|
50
|
+
// Binary search: the last keyframe at or before key.
|
|
51
|
+
let lo = 0;
|
|
52
|
+
let hi = kf.length - 1;
|
|
53
|
+
while (hi - lo > 1) {
|
|
54
|
+
const mid = (lo + hi) >> 1;
|
|
55
|
+
if (kf[mid].t <= key)
|
|
56
|
+
lo = mid;
|
|
57
|
+
else
|
|
58
|
+
hi = mid;
|
|
59
|
+
}
|
|
60
|
+
const a = kf[lo];
|
|
61
|
+
const b = kf[lo + 1];
|
|
62
|
+
if (shape === 'step')
|
|
63
|
+
return a.value;
|
|
64
|
+
const frac = b.t > a.t ? (key - a.t) / (b.t - a.t) : 1;
|
|
65
|
+
return lerp(a.value, b.value, a.curve ? bend(frac, a.curve) : ease(frac));
|
|
66
|
+
}
|
|
67
|
+
/** The curve's own time key for a running key: before the loop's end as is, then wrapped into the loop. */
|
|
68
|
+
export function loopKey(key, loop) {
|
|
69
|
+
if (!loop || !(loop.length > 0))
|
|
70
|
+
return key;
|
|
71
|
+
const end = loop.start + loop.length;
|
|
72
|
+
if (key < end)
|
|
73
|
+
return key;
|
|
74
|
+
return loop.start + ((key - end) % loop.length);
|
|
75
|
+
}
|
|
76
|
+
/** The ten shapes of the automation editor's Insert shape menu. */
|
|
77
|
+
export const CURVE_SHAPES = [
|
|
78
|
+
{ id: 'sine', label: 'Sine', points: [[0, 0.5, -0.2], [0.25, 1, 0.2], [0.5, 0.5, -0.2], [0.75, 0, 0.2]], end: 0.5 },
|
|
79
|
+
{ id: 'triangle', label: 'Triangle', points: [[0, 0], [0.5, 1]], end: 0 },
|
|
80
|
+
{ id: 'sawDown', label: 'Saw down', points: [[0, 1], [0.999, 0]] },
|
|
81
|
+
{ id: 'sawUp', label: 'Saw up', points: [[0, 0], [0.999, 1]] },
|
|
82
|
+
{ id: 'square', label: 'Square', points: [[0, 1], [0.5 - 1e-3, 1], [0.5, 0], [0.999, 0]] },
|
|
83
|
+
{ id: 'curveUp', label: 'Curve up', points: [[0, 0, 1.2], [0.999, 1]] },
|
|
84
|
+
{ id: 'curveDown', label: 'Curve down', points: [[0, 1, -1.2], [0.999, 0]] },
|
|
85
|
+
{ id: 'sCurveUp', label: 'S-curve up', points: [[0, 0, 0.8], [0.5, 0.5, -0.8], [0.999, 1]] },
|
|
86
|
+
{ id: 'sCurveDown', label: 'S-curve down', points: [[0, 1, 0.8], [0.5, 0.5, -0.8], [0.999, 0]] },
|
|
87
|
+
{ id: 'attackDecay', label: 'Attack / decay', points: [[0, 0, -0.6], [0.15, 1, -1], [0.999, 0]] },
|
|
88
|
+
];
|
|
89
|
+
const byKey = (a, b) => a.t - b.t;
|
|
90
|
+
/** The keyframes with every one inside [from, to] replaced by `insert` (sorted). */
|
|
91
|
+
export function replaceRange(keyframes, from, to, insert) {
|
|
92
|
+
const eps = 1e-6;
|
|
93
|
+
return keyframes.filter((k) => k.t < from - eps || k.t > to + eps).concat(insert).sort(byKey);
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Draws a shape over [from, to], `cycles` times, replacing only the keyframes inside the range. Null
|
|
97
|
+
* when the shape is unknown or the range is too short to draw (under 1/16 of a key unit).
|
|
98
|
+
*/
|
|
99
|
+
export function insertShape(keyframes, shapeId, from, to, cycles = 1) {
|
|
100
|
+
const def = CURVE_SHAPES.find((s) => s.id === shapeId);
|
|
101
|
+
if (!def || to - from < 0.0625)
|
|
102
|
+
return null;
|
|
103
|
+
const n = Math.max(1, Math.floor(cycles));
|
|
104
|
+
const len = (to - from) / n;
|
|
105
|
+
const eps = Math.min(1 / 64, len / 8);
|
|
106
|
+
const made = [];
|
|
107
|
+
for (let k = 0; k < n; k++) {
|
|
108
|
+
for (const [u, value, curve] of def.points) {
|
|
109
|
+
const t = u >= 0.999 ? from + (k + 1) * len - eps : from + (k + u) * len;
|
|
110
|
+
made.push(curve ? { t, value, curve } : { t, value });
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (def.end != null)
|
|
114
|
+
made.push({ t: to, value: def.end });
|
|
115
|
+
return replaceRange(keyframes, from, to, made);
|
|
116
|
+
}
|
|
117
|
+
/** Freehand draw: one keyframe at the start of the cell [cellStart, cellStart + cellLength), replacing the cell. */
|
|
118
|
+
export function paintCell(keyframes, cellStart, cellLength, value) {
|
|
119
|
+
const eps = 1e-6;
|
|
120
|
+
return keyframes
|
|
121
|
+
.filter((k) => k.t < cellStart - eps || k.t >= cellStart + cellLength - eps)
|
|
122
|
+
.concat({ t: cellStart, value })
|
|
123
|
+
.sort(byKey);
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Recording: the keyframes after the time key moved from `prevKey` to `key` while `value` was live.
|
|
127
|
+
* What was recorded over in that span is replaced (a wrap from a loop end back to its start clears both
|
|
128
|
+
* ends of the loop). A new keyframe is written only once `spacing` has passed since `lastWritten` (null: write now),
|
|
129
|
+
* so a recording is one point per grid step, not one per frame.
|
|
130
|
+
*/
|
|
131
|
+
export function recordSpan(keyframes, prevKey, key, value, { spacing, lastWritten, loop }) {
|
|
132
|
+
const wrapped = key < prevKey;
|
|
133
|
+
const start = loop ? loop.start : 0;
|
|
134
|
+
const end = loop ? loop.start + loop.length : Infinity;
|
|
135
|
+
const over = (t) => (wrapped ? (t > prevKey && t < end) || (t >= start && t <= key) : t > prevKey && t <= key);
|
|
136
|
+
const kept = keyframes.filter((k) => !over(k.t));
|
|
137
|
+
const last = lastWritten == null || wrapped ? -Infinity : lastWritten;
|
|
138
|
+
if (key - last < spacing)
|
|
139
|
+
return { keyframes: kept, written: lastWritten };
|
|
140
|
+
return { keyframes: kept.concat({ t: key, value }).sort(byKey), written: key };
|
|
141
|
+
}
|
|
16
142
|
export class CurveTrack {
|
|
17
143
|
keyframes;
|
|
18
144
|
timeMode;
|
|
19
145
|
projection;
|
|
20
146
|
ease;
|
|
147
|
+
shape;
|
|
148
|
+
loop;
|
|
21
149
|
outputIntent;
|
|
22
150
|
constructor(spec) {
|
|
23
|
-
this.keyframes = spec.keyframes.slice().sort(
|
|
151
|
+
this.keyframes = spec.keyframes.slice().sort(byKey);
|
|
24
152
|
this.timeMode = spec.timeMode ?? 'absolute-seconds';
|
|
25
153
|
this.projection = spec.projection ?? null;
|
|
26
154
|
this.ease = spec.ease ?? Ease.linear;
|
|
155
|
+
this.shape = spec.shape ?? 'line';
|
|
156
|
+
this.loop = spec.loop ?? null;
|
|
27
157
|
this.outputIntent = spec.outputIntent ?? null;
|
|
28
158
|
if (this.timeMode === 'beat-locked' && !this.projection) {
|
|
29
159
|
throw new Error('beat-locked CurveTrack needs a MusicalProjection');
|
|
30
160
|
}
|
|
31
161
|
}
|
|
32
|
-
/** The track's own time key for a given clock position. */
|
|
162
|
+
/** The track's own time key for a given clock position (looped when the track loops). */
|
|
33
163
|
_key(positionSec) {
|
|
34
|
-
|
|
164
|
+
const key = this.timeMode === 'beat-locked' ? this.projection.secondsToBeats(positionSec) : positionSec;
|
|
165
|
+
return loopKey(key, this.loop);
|
|
35
166
|
}
|
|
167
|
+
/** The value at a clock position; NaN for a track with no keyframes. */
|
|
36
168
|
sample(positionSec) {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
return kf[kf.length - 1].value;
|
|
43
|
-
let i = 0;
|
|
44
|
-
while (i < kf.length - 1 && kf[i + 1].t <= k)
|
|
45
|
-
i++;
|
|
46
|
-
const a = kf[i];
|
|
47
|
-
const b = kf[i + 1];
|
|
48
|
-
const frac = (k - a.t) / (b.t - a.t);
|
|
49
|
-
return lerp(a.value, b.value, this.ease(frac));
|
|
169
|
+
return this.sampleKey(this._key(positionSec));
|
|
170
|
+
}
|
|
171
|
+
/** The value at the track's own time key (beats for a beat-locked track), with no loop applied. */
|
|
172
|
+
sampleKey(key) {
|
|
173
|
+
return sampleKeyframes(this.keyframes, key, { shape: this.shape, ease: this.ease });
|
|
50
174
|
}
|
|
51
175
|
}
|
|
52
176
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "entangled-worlds-orbiters-shared",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.13.0",
|
|
4
4
|
"description": "Shared library for entangled worlds orbiters - contains core world rendering, moons system, and UI components",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
package/src/clock/curves.ts
CHANGED
|
@@ -19,10 +19,175 @@ function lerp(a: number, b: number, t: number): number {
|
|
|
19
19
|
return a + (b - a) * t;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
/**
|
|
22
|
+
/**
|
|
23
|
+
* A single curve keyframe: a time key `t` (seconds or beats) and its value. `curve` bends the segment
|
|
24
|
+
* to the next keyframe (see {@link bend}); absent or 0 falls back to the track's `ease`.
|
|
25
|
+
*/
|
|
23
26
|
export interface Keyframe {
|
|
24
27
|
t: number;
|
|
25
28
|
value: number;
|
|
29
|
+
curve?: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* How a curve moves between keyframes: `line` ramps (bent by each keyframe's `curve`), `step` holds
|
|
34
|
+
* each value until the next keyframe.
|
|
35
|
+
*/
|
|
36
|
+
export type CurveShape = 'line' | 'step';
|
|
37
|
+
|
|
38
|
+
/** The range of a keyframe's `curve`: −2 … 2, 0 linear. */
|
|
39
|
+
export const CURVE_BEND_LIMIT = 2;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The bend of one segment: maps the fraction `u` in [0, 1] through `curve` c. 0 is linear; c > 0 starts
|
|
43
|
+
* slow and ends fast (exponential), c < 0 starts fast (logarithmic). The automation editor and every
|
|
44
|
+
* player read the same function, so what is drawn is what plays.
|
|
45
|
+
*/
|
|
46
|
+
export function bend(u: number, curve = 0): number {
|
|
47
|
+
if (!curve) return u;
|
|
48
|
+
return curve > 0 ? Math.pow(u, 1 + curve * 3) : 1 - Math.pow(1 - u, 1 - curve * 3);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The `curve` that puts a segment's midpoint at `mid`, a share of the way from its first value to its
|
|
53
|
+
* last (0 … 1, exclusive): the inverse of {@link bend} at u = ½, for a bend handle dragged up or down.
|
|
54
|
+
*/
|
|
55
|
+
export function curveForMidpoint(mid: number): number {
|
|
56
|
+
const m = Math.max(0.03, Math.min(0.97, mid));
|
|
57
|
+
const c = m <= 0.5 ? (Math.log(m) / Math.log(0.5) - 1) / 3 : -(Math.log(1 - m) / Math.log(0.5) - 1) / 3;
|
|
58
|
+
return Math.max(-CURVE_BEND_LIMIT, Math.min(CURVE_BEND_LIMIT, c));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** What {@link sampleKeyframes} needs besides the keyframes. */
|
|
62
|
+
export interface SampleOptions {
|
|
63
|
+
shape?: CurveShape;
|
|
64
|
+
/** For a segment whose keyframe has no `curve` (default linear). */
|
|
65
|
+
ease?: EaseFn;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The value of sorted keyframes at time key `key`: held before the first and after the last, ramped
|
|
70
|
+
* (or stepped) between. NaN when there are none: an empty lane writes nothing.
|
|
71
|
+
*/
|
|
72
|
+
export function sampleKeyframes(keyframes: readonly Keyframe[], key: number, { shape = 'line', ease = Ease.linear }: SampleOptions = {}): number {
|
|
73
|
+
const kf = keyframes;
|
|
74
|
+
if (!kf.length) return NaN;
|
|
75
|
+
if (key <= kf[0].t) return kf[0].value;
|
|
76
|
+
const last = kf[kf.length - 1];
|
|
77
|
+
if (key >= last.t) return last.value;
|
|
78
|
+
// Binary search: the last keyframe at or before key.
|
|
79
|
+
let lo = 0;
|
|
80
|
+
let hi = kf.length - 1;
|
|
81
|
+
while (hi - lo > 1) {
|
|
82
|
+
const mid = (lo + hi) >> 1;
|
|
83
|
+
if (kf[mid].t <= key) lo = mid; else hi = mid;
|
|
84
|
+
}
|
|
85
|
+
const a = kf[lo];
|
|
86
|
+
const b = kf[lo + 1];
|
|
87
|
+
if (shape === 'step') return a.value;
|
|
88
|
+
const frac = b.t > a.t ? (key - a.t) / (b.t - a.t) : 1;
|
|
89
|
+
return lerp(a.value, b.value, a.curve ? bend(frac, a.curve) : ease(frac));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* A loop over a curve's time key: the curve plays from 0 up to `start + length`, then repeats
|
|
94
|
+
* `start` … `start + length` (a clip's loop). `start` 0 is a curve that loops on its own length.
|
|
95
|
+
*/
|
|
96
|
+
export interface CurveLoop {
|
|
97
|
+
start: number;
|
|
98
|
+
length: number;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The curve's own time key for a running key: before the loop's end as is, then wrapped into the loop. */
|
|
102
|
+
export function loopKey(key: number, loop: CurveLoop | null | undefined): number {
|
|
103
|
+
if (!loop || !(loop.length > 0)) return key;
|
|
104
|
+
const end = loop.start + loop.length;
|
|
105
|
+
if (key < end) return key;
|
|
106
|
+
return loop.start + ((key - end) % loop.length);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** One of the shapes {@link insertShape} draws: points as [u, value, curve?] over one cycle, u in [0, 1). */
|
|
110
|
+
export interface CurveShapeDef {
|
|
111
|
+
id: string;
|
|
112
|
+
label: string;
|
|
113
|
+
points: ReadonlyArray<readonly [number, number, number?]>;
|
|
114
|
+
/** A closing point at the range's end, for shapes that come back to where they started. */
|
|
115
|
+
end?: number;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** The ten shapes of the automation editor's Insert shape menu. */
|
|
119
|
+
export const CURVE_SHAPES: readonly CurveShapeDef[] = [
|
|
120
|
+
{ id: 'sine', label: 'Sine', points: [[0, 0.5, -0.2], [0.25, 1, 0.2], [0.5, 0.5, -0.2], [0.75, 0, 0.2]], end: 0.5 },
|
|
121
|
+
{ id: 'triangle', label: 'Triangle', points: [[0, 0], [0.5, 1]], end: 0 },
|
|
122
|
+
{ id: 'sawDown', label: 'Saw down', points: [[0, 1], [0.999, 0]] },
|
|
123
|
+
{ id: 'sawUp', label: 'Saw up', points: [[0, 0], [0.999, 1]] },
|
|
124
|
+
{ id: 'square', label: 'Square', points: [[0, 1], [0.5 - 1e-3, 1], [0.5, 0], [0.999, 0]] },
|
|
125
|
+
{ id: 'curveUp', label: 'Curve up', points: [[0, 0, 1.2], [0.999, 1]] },
|
|
126
|
+
{ id: 'curveDown', label: 'Curve down', points: [[0, 1, -1.2], [0.999, 0]] },
|
|
127
|
+
{ id: 'sCurveUp', label: 'S-curve up', points: [[0, 0, 0.8], [0.5, 0.5, -0.8], [0.999, 1]] },
|
|
128
|
+
{ id: 'sCurveDown', label: 'S-curve down', points: [[0, 1, 0.8], [0.5, 0.5, -0.8], [0.999, 0]] },
|
|
129
|
+
{ id: 'attackDecay', label: 'Attack / decay', points: [[0, 0, -0.6], [0.15, 1, -1], [0.999, 0]] },
|
|
130
|
+
];
|
|
131
|
+
|
|
132
|
+
const byKey = (a: Keyframe, b: Keyframe) => a.t - b.t;
|
|
133
|
+
|
|
134
|
+
/** The keyframes with every one inside [from, to] replaced by `insert` (sorted). */
|
|
135
|
+
export function replaceRange(keyframes: readonly Keyframe[], from: number, to: number, insert: readonly Keyframe[]): Keyframe[] {
|
|
136
|
+
const eps = 1e-6;
|
|
137
|
+
return keyframes.filter((k) => k.t < from - eps || k.t > to + eps).concat(insert).sort(byKey);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Draws a shape over [from, to], `cycles` times, replacing only the keyframes inside the range. Null
|
|
142
|
+
* when the shape is unknown or the range is too short to draw (under 1/16 of a key unit).
|
|
143
|
+
*/
|
|
144
|
+
export function insertShape(keyframes: readonly Keyframe[], shapeId: string, from: number, to: number, cycles = 1): Keyframe[] | null {
|
|
145
|
+
const def = CURVE_SHAPES.find((s) => s.id === shapeId);
|
|
146
|
+
if (!def || to - from < 0.0625) return null;
|
|
147
|
+
const n = Math.max(1, Math.floor(cycles));
|
|
148
|
+
const len = (to - from) / n;
|
|
149
|
+
const eps = Math.min(1 / 64, len / 8);
|
|
150
|
+
const made: Keyframe[] = [];
|
|
151
|
+
for (let k = 0; k < n; k++) {
|
|
152
|
+
for (const [u, value, curve] of def.points) {
|
|
153
|
+
const t = u >= 0.999 ? from + (k + 1) * len - eps : from + (k + u) * len;
|
|
154
|
+
made.push(curve ? { t, value, curve } : { t, value });
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (def.end != null) made.push({ t: to, value: def.end });
|
|
158
|
+
return replaceRange(keyframes, from, to, made);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Freehand draw: one keyframe at the start of the cell [cellStart, cellStart + cellLength), replacing the cell. */
|
|
162
|
+
export function paintCell(keyframes: readonly Keyframe[], cellStart: number, cellLength: number, value: number): Keyframe[] {
|
|
163
|
+
const eps = 1e-6;
|
|
164
|
+
return keyframes
|
|
165
|
+
.filter((k) => k.t < cellStart - eps || k.t >= cellStart + cellLength - eps)
|
|
166
|
+
.concat({ t: cellStart, value })
|
|
167
|
+
.sort(byKey);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Recording: the keyframes after the time key moved from `prevKey` to `key` while `value` was live.
|
|
172
|
+
* What was recorded over in that span is replaced (a wrap from a loop end back to its start clears both
|
|
173
|
+
* ends of the loop). A new keyframe is written only once `spacing` has passed since `lastWritten` (null: write now),
|
|
174
|
+
* so a recording is one point per grid step, not one per frame.
|
|
175
|
+
*/
|
|
176
|
+
export function recordSpan(
|
|
177
|
+
keyframes: readonly Keyframe[],
|
|
178
|
+
prevKey: number,
|
|
179
|
+
key: number,
|
|
180
|
+
value: number,
|
|
181
|
+
{ spacing, lastWritten, loop }: { spacing: number; lastWritten: number | null; loop?: CurveLoop | null },
|
|
182
|
+
): { keyframes: Keyframe[]; written: number | null } {
|
|
183
|
+
const wrapped = key < prevKey;
|
|
184
|
+
const start = loop ? loop.start : 0;
|
|
185
|
+
const end = loop ? loop.start + loop.length : Infinity;
|
|
186
|
+
const over = (t: number) => (wrapped ? (t > prevKey && t < end) || (t >= start && t <= key) : t > prevKey && t <= key);
|
|
187
|
+
const kept = keyframes.filter((k) => !over(k.t));
|
|
188
|
+
const last = lastWritten == null || wrapped ? -Infinity : lastWritten;
|
|
189
|
+
if (key - last < spacing) return { keyframes: kept, written: lastWritten };
|
|
190
|
+
return { keyframes: kept.concat({ t: key, value }).sort(byKey), written: key };
|
|
26
191
|
}
|
|
27
192
|
|
|
28
193
|
export type TimeMode = 'absolute-seconds' | 'beat-locked';
|
|
@@ -40,6 +205,10 @@ export interface CurveTrackSpec {
|
|
|
40
205
|
timeMode?: TimeMode;
|
|
41
206
|
projection?: ProjectionLike | null;
|
|
42
207
|
ease?: EaseFn;
|
|
208
|
+
/** Line (default) or step. */
|
|
209
|
+
shape?: CurveShape;
|
|
210
|
+
/** Loops the curve's time key (a clip loop, or its own length). Absent: plays once, then holds. */
|
|
211
|
+
loop?: CurveLoop | null;
|
|
43
212
|
outputIntent?: unknown;
|
|
44
213
|
}
|
|
45
214
|
|
|
@@ -48,35 +217,37 @@ export class CurveTrack {
|
|
|
48
217
|
timeMode: TimeMode;
|
|
49
218
|
projection: ProjectionLike | null;
|
|
50
219
|
ease: EaseFn;
|
|
220
|
+
shape: CurveShape;
|
|
221
|
+
loop: CurveLoop | null;
|
|
51
222
|
outputIntent: unknown;
|
|
52
223
|
|
|
53
224
|
constructor(spec: CurveTrackSpec) {
|
|
54
|
-
this.keyframes = spec.keyframes.slice().sort(
|
|
225
|
+
this.keyframes = spec.keyframes.slice().sort(byKey);
|
|
55
226
|
this.timeMode = spec.timeMode ?? 'absolute-seconds';
|
|
56
227
|
this.projection = spec.projection ?? null;
|
|
57
228
|
this.ease = spec.ease ?? Ease.linear;
|
|
229
|
+
this.shape = spec.shape ?? 'line';
|
|
230
|
+
this.loop = spec.loop ?? null;
|
|
58
231
|
this.outputIntent = spec.outputIntent ?? null;
|
|
59
232
|
if (this.timeMode === 'beat-locked' && !this.projection) {
|
|
60
233
|
throw new Error('beat-locked CurveTrack needs a MusicalProjection');
|
|
61
234
|
}
|
|
62
235
|
}
|
|
63
236
|
|
|
64
|
-
/** The track's own time key for a given clock position. */
|
|
237
|
+
/** The track's own time key for a given clock position (looped when the track loops). */
|
|
65
238
|
_key(positionSec: number): number {
|
|
66
|
-
|
|
239
|
+
const key = this.timeMode === 'beat-locked' ? this.projection!.secondsToBeats(positionSec) : positionSec;
|
|
240
|
+
return loopKey(key, this.loop);
|
|
67
241
|
}
|
|
68
242
|
|
|
243
|
+
/** The value at a clock position; NaN for a track with no keyframes. */
|
|
69
244
|
sample(positionSec: number): number {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
const a = kf[i];
|
|
77
|
-
const b = kf[i + 1];
|
|
78
|
-
const frac = (k - a.t) / (b.t - a.t);
|
|
79
|
-
return lerp(a.value, b.value, this.ease(frac));
|
|
245
|
+
return this.sampleKey(this._key(positionSec));
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** The value at the track's own time key (beats for a beat-locked track), with no loop applied. */
|
|
249
|
+
sampleKey(key: number): number {
|
|
250
|
+
return sampleKeyframes(this.keyframes, key, { shape: this.shape, ease: this.ease });
|
|
80
251
|
}
|
|
81
252
|
}
|
|
82
253
|
|