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.
@@ -4,11 +4,87 @@ export declare const Ease: {
4
4
  linear: EaseFn;
5
5
  smooth: EaseFn;
6
6
  };
7
- /** A single curve keyframe: a time key `t` (seconds or beats) and its value. */
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
@@ -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((a, b) => a.t - b.t);
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
- return this.timeMode === 'beat-locked' ? this.projection.secondsToBeats(positionSec) : positionSec;
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
- const k = this._key(positionSec);
38
- const kf = this.keyframes;
39
- if (k <= kf[0].t)
40
- return kf[0].value;
41
- if (k >= kf[kf.length - 1].t)
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.12.0",
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",
@@ -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
- /** A single curve keyframe: a time key `t` (seconds or beats) and its value. */
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((a, b) => a.t - b.t);
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
- return this.timeMode === 'beat-locked' ? this.projection!.secondsToBeats(positionSec) : positionSec;
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
- const k = this._key(positionSec);
71
- const kf = this.keyframes;
72
- if (k <= kf[0].t) return kf[0].value;
73
- if (k >= kf[kf.length - 1].t) return kf[kf.length - 1].value;
74
- let i = 0;
75
- while (i < kf.length - 1 && kf[i + 1].t <= k) i++;
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