entangled-worlds-orbiters-shared 2.11.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/dist/notes/index.d.ts +72 -0
- package/dist/notes/index.js +94 -0
- package/package.json +7 -2
- package/src/clock/curves.ts +185 -14
- package/src/notes/index.ts +146 -0
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
|
/**
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
export type NoteEventType = 'on' | 'off';
|
|
2
|
+
export interface NoteEvent {
|
|
3
|
+
type: NoteEventType;
|
|
4
|
+
/** Unique per sounding note; an `off` carries the id of the `on` it ends. */
|
|
5
|
+
id: string;
|
|
6
|
+
/** MIDI note number, 0 … 127 (integers today; the field allows fractions later). */
|
|
7
|
+
pitch: number;
|
|
8
|
+
/** 0 … 1. An `off` may carry a release velocity; nothing reads it yet. */
|
|
9
|
+
velocity: number;
|
|
10
|
+
/** Audio-context seconds when it should sound; at or before now means now. */
|
|
11
|
+
time: number;
|
|
12
|
+
/** Who sent it: a device, the keyboard, a MIDI effect's slot, a room client. */
|
|
13
|
+
source: string;
|
|
14
|
+
}
|
|
15
|
+
export declare const NOTE_PITCH_MIN = 0;
|
|
16
|
+
export declare const NOTE_PITCH_MAX = 127;
|
|
17
|
+
/** Whether `value` is a well-formed note event (the shape a room peer or an agent must send). */
|
|
18
|
+
export declare function isNoteEvent(value: unknown): value is NoteEvent;
|
|
19
|
+
/** What a channel message means to a note player. Channels are 0 … 15, as on the wire. */
|
|
20
|
+
export type MidiNoteMessage = {
|
|
21
|
+
kind: 'on';
|
|
22
|
+
channel: number;
|
|
23
|
+
pitch: number;
|
|
24
|
+
velocity: number;
|
|
25
|
+
} | {
|
|
26
|
+
kind: 'off';
|
|
27
|
+
channel: number;
|
|
28
|
+
pitch: number;
|
|
29
|
+
velocity: number;
|
|
30
|
+
}
|
|
31
|
+
/** Pitch bend, −1 … 1 (0 is the centre). */
|
|
32
|
+
| {
|
|
33
|
+
kind: 'bend';
|
|
34
|
+
channel: number;
|
|
35
|
+
value: number;
|
|
36
|
+
}
|
|
37
|
+
/** A control change, its value 0 … 1. */
|
|
38
|
+
| {
|
|
39
|
+
kind: 'cc';
|
|
40
|
+
channel: number;
|
|
41
|
+
controller: number;
|
|
42
|
+
value: number;
|
|
43
|
+
};
|
|
44
|
+
/** The mod wheel. */
|
|
45
|
+
export declare const MIDI_CC_MOD_WHEEL = 1;
|
|
46
|
+
/**
|
|
47
|
+
* Decode a channel message's bytes. A note-on with velocity 0 is a note-off (running status sends
|
|
48
|
+
* them that way). Anything a note player has no use for (aftertouch, program change, system) is null.
|
|
49
|
+
*/
|
|
50
|
+
export declare function decodeMidiMessage(status: number, data1?: number, data2?: number): MidiNoteMessage | null;
|
|
51
|
+
/**
|
|
52
|
+
* A MIDI note-off names only a channel and a pitch. This gives each held key its note id, so the
|
|
53
|
+
* off pairs with its on by id like every other source.
|
|
54
|
+
*
|
|
55
|
+
* - `on` mints a fresh id. If that key was already held (a device that sends two ons without an off)
|
|
56
|
+
* the old note's id comes back as `replaced`, so the caller can end it first: nothing sticks.
|
|
57
|
+
* - `off` returns the id the key was playing, or null when it was never down (an off arriving after
|
|
58
|
+
* a panic, or from before the page listened).
|
|
59
|
+
* - `releaseDevice` hands back every id a device holds: it went away, and they all end.
|
|
60
|
+
*/
|
|
61
|
+
export interface MidiNoteIds {
|
|
62
|
+
on(device: string, channel: number, pitch: number): {
|
|
63
|
+
id: string;
|
|
64
|
+
replaced: string | null;
|
|
65
|
+
};
|
|
66
|
+
off(device: string, channel: number, pitch: number): string | null;
|
|
67
|
+
releaseDevice(device: string): string[];
|
|
68
|
+
releaseAll(): string[];
|
|
69
|
+
/** How many keys are down, across devices. */
|
|
70
|
+
readonly size: number;
|
|
71
|
+
}
|
|
72
|
+
export declare function createMidiNoteIds(prefix?: string): MidiNoteIds;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Notes as events, shared by every note source (MIDI in, the on-screen keyboard, a capability call, a
|
|
2
|
+
// MIDI effect, a room peer) and everything that routes or sounds them.
|
|
3
|
+
//
|
|
4
|
+
// On and off are separate events with no duration: a live key has no known length when it goes
|
|
5
|
+
// down. They pair by `id`, never by pitch, since two sources can hold the same pitch at once and a
|
|
6
|
+
// chord or an echo plays the same pitch more than once. Pairing by pitch alone gives stuck notes.
|
|
7
|
+
//
|
|
8
|
+
// App-agnostic: nothing here knows about Orbiters, dimensions or the audio graph.
|
|
9
|
+
export const NOTE_PITCH_MIN = 0;
|
|
10
|
+
export const NOTE_PITCH_MAX = 127;
|
|
11
|
+
const isFiniteNumber = (value) => typeof value === 'number' && Number.isFinite(value);
|
|
12
|
+
/** Whether `value` is a well-formed note event (the shape a room peer or an agent must send). */
|
|
13
|
+
export function isNoteEvent(value) {
|
|
14
|
+
if (!value || typeof value !== 'object')
|
|
15
|
+
return false;
|
|
16
|
+
const event = value;
|
|
17
|
+
return ((event.type === 'on' || event.type === 'off') &&
|
|
18
|
+
typeof event.id === 'string' &&
|
|
19
|
+
event.id.length > 0 &&
|
|
20
|
+
isFiniteNumber(event.pitch) &&
|
|
21
|
+
event.pitch >= NOTE_PITCH_MIN &&
|
|
22
|
+
event.pitch <= NOTE_PITCH_MAX &&
|
|
23
|
+
isFiniteNumber(event.velocity) &&
|
|
24
|
+
event.velocity >= 0 &&
|
|
25
|
+
event.velocity <= 1 &&
|
|
26
|
+
isFiniteNumber(event.time) &&
|
|
27
|
+
typeof event.source === 'string');
|
|
28
|
+
}
|
|
29
|
+
/** The mod wheel. */
|
|
30
|
+
export const MIDI_CC_MOD_WHEEL = 1;
|
|
31
|
+
const BEND_CENTRE = 8192;
|
|
32
|
+
/**
|
|
33
|
+
* Decode a channel message's bytes. A note-on with velocity 0 is a note-off (running status sends
|
|
34
|
+
* them that way). Anything a note player has no use for (aftertouch, program change, system) is null.
|
|
35
|
+
*/
|
|
36
|
+
export function decodeMidiMessage(status, data1 = 0, data2 = 0) {
|
|
37
|
+
const channel = status & 0x0f;
|
|
38
|
+
switch (status & 0xf0) {
|
|
39
|
+
case 0x90:
|
|
40
|
+
return data2 > 0
|
|
41
|
+
? { kind: 'on', channel, pitch: data1 & 0x7f, velocity: (data2 & 0x7f) / 127 }
|
|
42
|
+
: { kind: 'off', channel, pitch: data1 & 0x7f, velocity: 0 };
|
|
43
|
+
case 0x80:
|
|
44
|
+
return { kind: 'off', channel, pitch: data1 & 0x7f, velocity: (data2 & 0x7f) / 127 };
|
|
45
|
+
case 0xe0: {
|
|
46
|
+
// 14 bits, least significant first. The top is 8191 above the centre and the bottom 8192 below
|
|
47
|
+
// it, so each side is scaled on its own and both ends reach ±1.
|
|
48
|
+
const raw = ((data2 & 0x7f) << 7) | (data1 & 0x7f);
|
|
49
|
+
const offset = raw - BEND_CENTRE;
|
|
50
|
+
return { kind: 'bend', channel, value: offset >= 0 ? offset / (BEND_CENTRE - 1) : offset / BEND_CENTRE };
|
|
51
|
+
}
|
|
52
|
+
case 0xb0:
|
|
53
|
+
return { kind: 'cc', channel, controller: data1 & 0x7f, value: (data2 & 0x7f) / 127 };
|
|
54
|
+
default:
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
export function createMidiNoteIds(prefix = 'midi') {
|
|
59
|
+
const held = new Map();
|
|
60
|
+
let next = 0;
|
|
61
|
+
const keyOf = (device, channel, pitch) => `${device}\u0000${channel}\u0000${pitch}`;
|
|
62
|
+
const deviceOf = (key) => key.slice(0, key.indexOf('\u0000'));
|
|
63
|
+
const take = (predicate) => {
|
|
64
|
+
const ids = [];
|
|
65
|
+
for (const [key, id] of held) {
|
|
66
|
+
if (!predicate(key))
|
|
67
|
+
continue;
|
|
68
|
+
ids.push(id);
|
|
69
|
+
held.delete(key);
|
|
70
|
+
}
|
|
71
|
+
return ids;
|
|
72
|
+
};
|
|
73
|
+
return {
|
|
74
|
+
on(device, channel, pitch) {
|
|
75
|
+
const key = keyOf(device, channel, pitch);
|
|
76
|
+
const replaced = held.get(key) ?? null;
|
|
77
|
+
next += 1;
|
|
78
|
+
const id = `${prefix}:${next}`;
|
|
79
|
+
held.set(key, id);
|
|
80
|
+
return { id, replaced };
|
|
81
|
+
},
|
|
82
|
+
off(device, channel, pitch) {
|
|
83
|
+
const key = keyOf(device, channel, pitch);
|
|
84
|
+
const id = held.get(key) ?? null;
|
|
85
|
+
held.delete(key);
|
|
86
|
+
return id;
|
|
87
|
+
},
|
|
88
|
+
releaseDevice: (device) => take((key) => deviceOf(key) === device),
|
|
89
|
+
releaseAll: () => take(() => true),
|
|
90
|
+
get size() {
|
|
91
|
+
return held.size;
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
}
|
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",
|
|
@@ -42,6 +42,10 @@
|
|
|
42
42
|
"types": "./dist/capabilities/index.d.ts",
|
|
43
43
|
"import": "./dist/capabilities/index.js"
|
|
44
44
|
},
|
|
45
|
+
"./notes": {
|
|
46
|
+
"types": "./dist/notes/index.d.ts",
|
|
47
|
+
"import": "./dist/notes/index.js"
|
|
48
|
+
},
|
|
45
49
|
"./studio": {
|
|
46
50
|
"types": "./dist/studio/index.d.ts",
|
|
47
51
|
"import": "./dist/studio/index.js"
|
|
@@ -109,10 +113,11 @@
|
|
|
109
113
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
110
114
|
"test:clock": "node --test --import tsx src/clock/*.test.ts",
|
|
111
115
|
"test:capabilities": "node --test --import tsx src/capabilities/*.test.js",
|
|
116
|
+
"test:notes": "node --test --import tsx src/notes/*.test.ts",
|
|
112
117
|
"test:studio": "node --test --import tsx src/studio/*.test.js",
|
|
113
118
|
"test:uploads": "node --test --import tsx src/uploads/*.test.js",
|
|
114
119
|
"test:render": "node --test src/render/*.test.js",
|
|
115
|
-
"test": "npm run test:clock && npm run test:capabilities && npm run test:studio && npm run test:uploads && npm run test:render && npm run test:moons && npm run test:star",
|
|
120
|
+
"test": "npm run test:clock && npm run test:capabilities && npm run test:notes && npm run test:studio && npm run test:uploads && npm run test:render && npm run test:moons && npm run test:star",
|
|
116
121
|
"test:moons": "node --test src/moons/*.test.js",
|
|
117
122
|
"test:star": "node --test --import tsx src/star/*.test.ts",
|
|
118
123
|
"bench:star:build": "esbuild bench/star/main.ts --bundle --format=esm --target=es2022 --outfile=bench/star/build/main.js --log-level=warning",
|
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
|
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// Notes as events, shared by every note source (MIDI in, the on-screen keyboard, a capability call, a
|
|
2
|
+
// MIDI effect, a room peer) and everything that routes or sounds them.
|
|
3
|
+
//
|
|
4
|
+
// On and off are separate events with no duration: a live key has no known length when it goes
|
|
5
|
+
// down. They pair by `id`, never by pitch, since two sources can hold the same pitch at once and a
|
|
6
|
+
// chord or an echo plays the same pitch more than once. Pairing by pitch alone gives stuck notes.
|
|
7
|
+
//
|
|
8
|
+
// App-agnostic: nothing here knows about Orbiters, dimensions or the audio graph.
|
|
9
|
+
|
|
10
|
+
export type NoteEventType = 'on' | 'off'
|
|
11
|
+
|
|
12
|
+
export interface NoteEvent {
|
|
13
|
+
type: NoteEventType
|
|
14
|
+
/** Unique per sounding note; an `off` carries the id of the `on` it ends. */
|
|
15
|
+
id: string
|
|
16
|
+
/** MIDI note number, 0 … 127 (integers today; the field allows fractions later). */
|
|
17
|
+
pitch: number
|
|
18
|
+
/** 0 … 1. An `off` may carry a release velocity; nothing reads it yet. */
|
|
19
|
+
velocity: number
|
|
20
|
+
/** Audio-context seconds when it should sound; at or before now means now. */
|
|
21
|
+
time: number
|
|
22
|
+
/** Who sent it: a device, the keyboard, a MIDI effect's slot, a room client. */
|
|
23
|
+
source: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const NOTE_PITCH_MIN = 0
|
|
27
|
+
export const NOTE_PITCH_MAX = 127
|
|
28
|
+
|
|
29
|
+
const isFiniteNumber = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value)
|
|
30
|
+
|
|
31
|
+
/** Whether `value` is a well-formed note event (the shape a room peer or an agent must send). */
|
|
32
|
+
export function isNoteEvent(value: unknown): value is NoteEvent {
|
|
33
|
+
if (!value || typeof value !== 'object') return false
|
|
34
|
+
const event = value as Record<string, unknown>
|
|
35
|
+
return (
|
|
36
|
+
(event.type === 'on' || event.type === 'off') &&
|
|
37
|
+
typeof event.id === 'string' &&
|
|
38
|
+
event.id.length > 0 &&
|
|
39
|
+
isFiniteNumber(event.pitch) &&
|
|
40
|
+
event.pitch >= NOTE_PITCH_MIN &&
|
|
41
|
+
event.pitch <= NOTE_PITCH_MAX &&
|
|
42
|
+
isFiniteNumber(event.velocity) &&
|
|
43
|
+
event.velocity >= 0 &&
|
|
44
|
+
event.velocity <= 1 &&
|
|
45
|
+
isFiniteNumber(event.time) &&
|
|
46
|
+
typeof event.source === 'string'
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ── MIDI ────────────────────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
/** What a channel message means to a note player. Channels are 0 … 15, as on the wire. */
|
|
53
|
+
export type MidiNoteMessage =
|
|
54
|
+
| { kind: 'on'; channel: number; pitch: number; velocity: number }
|
|
55
|
+
| { kind: 'off'; channel: number; pitch: number; velocity: number }
|
|
56
|
+
/** Pitch bend, −1 … 1 (0 is the centre). */
|
|
57
|
+
| { kind: 'bend'; channel: number; value: number }
|
|
58
|
+
/** A control change, its value 0 … 1. */
|
|
59
|
+
| { kind: 'cc'; channel: number; controller: number; value: number }
|
|
60
|
+
|
|
61
|
+
/** The mod wheel. */
|
|
62
|
+
export const MIDI_CC_MOD_WHEEL = 1
|
|
63
|
+
const BEND_CENTRE = 8192
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Decode a channel message's bytes. A note-on with velocity 0 is a note-off (running status sends
|
|
67
|
+
* them that way). Anything a note player has no use for (aftertouch, program change, system) is null.
|
|
68
|
+
*/
|
|
69
|
+
export function decodeMidiMessage(status: number, data1 = 0, data2 = 0): MidiNoteMessage | null {
|
|
70
|
+
const channel = status & 0x0f
|
|
71
|
+
switch (status & 0xf0) {
|
|
72
|
+
case 0x90:
|
|
73
|
+
return data2 > 0
|
|
74
|
+
? { kind: 'on', channel, pitch: data1 & 0x7f, velocity: (data2 & 0x7f) / 127 }
|
|
75
|
+
: { kind: 'off', channel, pitch: data1 & 0x7f, velocity: 0 }
|
|
76
|
+
case 0x80:
|
|
77
|
+
return { kind: 'off', channel, pitch: data1 & 0x7f, velocity: (data2 & 0x7f) / 127 }
|
|
78
|
+
case 0xe0: {
|
|
79
|
+
// 14 bits, least significant first. The top is 8191 above the centre and the bottom 8192 below
|
|
80
|
+
// it, so each side is scaled on its own and both ends reach ±1.
|
|
81
|
+
const raw = ((data2 & 0x7f) << 7) | (data1 & 0x7f)
|
|
82
|
+
const offset = raw - BEND_CENTRE
|
|
83
|
+
return { kind: 'bend', channel, value: offset >= 0 ? offset / (BEND_CENTRE - 1) : offset / BEND_CENTRE }
|
|
84
|
+
}
|
|
85
|
+
case 0xb0:
|
|
86
|
+
return { kind: 'cc', channel, controller: data1 & 0x7f, value: (data2 & 0x7f) / 127 }
|
|
87
|
+
default:
|
|
88
|
+
return null
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* A MIDI note-off names only a channel and a pitch. This gives each held key its note id, so the
|
|
94
|
+
* off pairs with its on by id like every other source.
|
|
95
|
+
*
|
|
96
|
+
* - `on` mints a fresh id. If that key was already held (a device that sends two ons without an off)
|
|
97
|
+
* the old note's id comes back as `replaced`, so the caller can end it first: nothing sticks.
|
|
98
|
+
* - `off` returns the id the key was playing, or null when it was never down (an off arriving after
|
|
99
|
+
* a panic, or from before the page listened).
|
|
100
|
+
* - `releaseDevice` hands back every id a device holds: it went away, and they all end.
|
|
101
|
+
*/
|
|
102
|
+
export interface MidiNoteIds {
|
|
103
|
+
on(device: string, channel: number, pitch: number): { id: string; replaced: string | null }
|
|
104
|
+
off(device: string, channel: number, pitch: number): string | null
|
|
105
|
+
releaseDevice(device: string): string[]
|
|
106
|
+
releaseAll(): string[]
|
|
107
|
+
/** How many keys are down, across devices. */
|
|
108
|
+
readonly size: number
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function createMidiNoteIds(prefix = 'midi'): MidiNoteIds {
|
|
112
|
+
const held = new Map<string, string>()
|
|
113
|
+
let next = 0
|
|
114
|
+
const keyOf = (device: string, channel: number, pitch: number) => `${device}\u0000${channel}\u0000${pitch}`
|
|
115
|
+
const deviceOf = (key: string) => key.slice(0, key.indexOf('\u0000'))
|
|
116
|
+
const take = (predicate: (key: string) => boolean) => {
|
|
117
|
+
const ids: string[] = []
|
|
118
|
+
for (const [key, id] of held) {
|
|
119
|
+
if (!predicate(key)) continue
|
|
120
|
+
ids.push(id)
|
|
121
|
+
held.delete(key)
|
|
122
|
+
}
|
|
123
|
+
return ids
|
|
124
|
+
}
|
|
125
|
+
return {
|
|
126
|
+
on(device, channel, pitch) {
|
|
127
|
+
const key = keyOf(device, channel, pitch)
|
|
128
|
+
const replaced = held.get(key) ?? null
|
|
129
|
+
next += 1
|
|
130
|
+
const id = `${prefix}:${next}`
|
|
131
|
+
held.set(key, id)
|
|
132
|
+
return { id, replaced }
|
|
133
|
+
},
|
|
134
|
+
off(device, channel, pitch) {
|
|
135
|
+
const key = keyOf(device, channel, pitch)
|
|
136
|
+
const id = held.get(key) ?? null
|
|
137
|
+
held.delete(key)
|
|
138
|
+
return id
|
|
139
|
+
},
|
|
140
|
+
releaseDevice: (device) => take((key) => deviceOf(key) === device),
|
|
141
|
+
releaseAll: () => take(() => true),
|
|
142
|
+
get size() {
|
|
143
|
+
return held.size
|
|
144
|
+
},
|
|
145
|
+
}
|
|
146
|
+
}
|