@hraness/dawg 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +86 -0
- package/DAWG.md +300 -35
- package/README.md +4 -4
- package/core/chords.ts +288 -7
- package/core/diff.ts +37 -12
- package/core/expression.ts +1241 -0
- package/core/loop.ts +23 -0
- package/core/master.ts +455 -0
- package/core/midi.ts +452 -0
- package/core/rhythm.ts +7 -2
- package/core/score.ts +536 -56
- package/core/sdk/eval-child.ts +38 -3
- package/core/sdk/print.ts +445 -14
- package/core/sdk/v1.ts +1709 -23
- package/core/sections.ts +2046 -0
- package/core/synth.ts +11 -1
- package/core/tempo.ts +1318 -0
- package/core/tuning.ts +1180 -0
- package/guides/audition.md +26 -0
- package/guides/automation.md +26 -0
- package/guides/chords.md +28 -0
- package/guides/effects.md +27 -0
- package/guides/faders.md +29 -0
- package/guides/files.md +28 -0
- package/guides/getting-started.md +26 -0
- package/guides/index.ts +75 -0
- package/guides/keys.md +27 -0
- package/guides/media.md +27 -0
- package/guides/mix.md +23 -0
- package/guides/music.md +14 -0
- package/guides/notes.md +26 -0
- package/guides/performance.md +26 -0
- package/guides/play.md +24 -0
- package/guides/project.md +13 -0
- package/guides/providers.md +25 -0
- package/guides/rhythm.md +26 -0
- package/guides/sessions.md +21 -0
- package/guides/sound.md +15 -0
- package/guides/sounds.md +25 -0
- package/guides/tempo.md +26 -0
- package/guides/tracks.md +25 -0
- package/guides/web-search.md +21 -0
- package/package.json +3 -1
- package/src/agent/agent.ts +2 -0
- package/src/agent/brief.ts +77 -3
- package/src/agent/chord-tools.ts +9 -1
- package/src/agent/expression-tools.ts +336 -0
- package/src/agent/master-tools.ts +299 -0
- package/src/agent/models.ts +4 -4
- package/src/agent/ops.ts +29 -4
- package/src/agent/planner.ts +45 -2
- package/src/agent/preview-tool.ts +21 -3
- package/src/agent/section-tools.ts +411 -0
- package/src/agent/time-tools.ts +290 -0
- package/src/agent/tools.ts +36 -2
- package/src/agent/tuning-tools.ts +301 -0
- package/src/agent/xcb-agent.ts +2 -0
- package/src/audio/arrange.ts +471 -0
- package/src/audio/audition.ts +16 -3
- package/src/audio/click.ts +113 -1
- package/src/audio/clock.ts +71 -5
- package/src/audio/effects/bus.ts +5 -4
- package/src/audio/effects/chain.ts +12 -2
- package/src/audio/effects/common.ts +30 -1
- package/src/audio/effects/convolution.ts +43 -5
- package/src/audio/effects/dynamics.ts +2 -1
- package/src/audio/effects/filter.ts +5 -3
- package/src/audio/effects/modulation.ts +5 -1
- package/src/audio/effects/space.ts +197 -50
- package/src/audio/engine.ts +175 -20
- package/src/audio/live.ts +33 -6
- package/src/audio/loudness.ts +551 -0
- package/src/audio/master.ts +660 -0
- package/src/audio/measure-worker.ts +45 -0
- package/src/audio/measure.ts +110 -0
- package/src/audio/player.ts +11 -6
- package/src/audio/preview.ts +87 -16
- package/src/audio/render-worker.ts +6 -1
- package/src/audio/renderer.ts +7 -1
- package/src/audio/sampler.ts +108 -19
- package/src/audio/synth/voice.ts +60 -13
- package/src/audio/synth/zzfx.ts +10 -4
- package/src/audio/warp.ts +86 -0
- package/src/audio/wav.ts +448 -59
- package/src/commands/arrange.ts +949 -0
- package/src/commands/expression.ts +934 -0
- package/src/commands/help.ts +281 -24
- package/src/commands/master.ts +361 -0
- package/src/commands/music.ts +6 -1
- package/src/commands/synth.ts +11 -1
- package/src/commands/time.ts +964 -0
- package/src/commands/tuning.ts +490 -0
- package/src/main.ts +826 -47
- package/src/render.ts +109 -7
- package/src/session/daemon.ts +18 -8
- package/src/session/naming.ts +8 -1
- package/src/session/rebase.ts +21 -0
- package/src/tui/arrange-menu.ts +390 -0
- package/src/tui/audition.ts +58 -5
- package/src/tui/euclid.ts +50 -6
- package/src/tui/fader.ts +409 -0
- package/src/tui/menu-time.ts +401 -0
- package/src/tui/menu.ts +656 -16
- package/src/tui/performance-menu.ts +235 -0
- package/src/tui/play-chords.ts +95 -1
- package/src/tui/play-mode.ts +150 -6
- package/src/tui/play-session.ts +414 -41
- package/tui/app.ts +262 -13
- package/tui/arrange-strip.ts +174 -0
- package/tui/drawer.ts +478 -0
- package/tui/grammar.ts +79 -12
- package/tui/guide.ts +351 -0
- package/tui/highway.ts +87 -4
- package/tui/hits.ts +68 -0
- package/tui/input.ts +7 -0
- package/tui/keys.ts +72 -0
package/core/tempo.ts
ADDED
|
@@ -0,0 +1,1318 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tempo maps and score time.
|
|
3
|
+
*
|
|
4
|
+
* Score time stays in integer ticks (`ticksPerBeat` per beat). A beat is a
|
|
5
|
+
* quarter note and BPM counts quarter notes, as in Standard MIDI Files, so a
|
|
6
|
+
* 7/8 bar lasts 3.5 beats. The song's optional `time` field turns ticks into
|
|
7
|
+
* seconds:
|
|
8
|
+
*
|
|
9
|
+
* - `tempo`: tempo events after the start tempo (`tempoBpm` holds at tick 0).
|
|
10
|
+
* An event without `ramp` is a step. `ramp: "linear"` glides from the
|
|
11
|
+
* previous tempo into the event with an equal BPM change per beat;
|
|
12
|
+
* `ramp: "exp"` with an equal ratio per beat (even to the ear).
|
|
13
|
+
* - `fermatas`: holds. The beat starting at `tick` (the meter's felt beat,
|
|
14
|
+
* see `fermataSpan`) lasts `1 + beats` times as long (it slows evenly, as a notation player or a MIDI file plays a
|
|
15
|
+
* fermata), and everything later moves back.
|
|
16
|
+
* - `meter`: meter changes at bar boundaries (`bar` is 0-based). A bar lasts
|
|
17
|
+
* `beatsPerBar * 4 / beatUnit` beats. Without changes `beatsPerBar` of the
|
|
18
|
+
* score is the meter, over 4.
|
|
19
|
+
*
|
|
20
|
+
* A track's own `time` (`rate`, `phase`, `cycle`) places its notes on the
|
|
21
|
+
* song timeline for polytempo, polymeter and Reich-style phasing:
|
|
22
|
+
* `performedNotes` repeats the track's first `cycle` ticks every
|
|
23
|
+
* `cycle / rate` song ticks, shifted `phase` song ticks later. Automation
|
|
24
|
+
* stays in song time.
|
|
25
|
+
*
|
|
26
|
+
* Without these fields every function reduces to the 0.4 arithmetic, and
|
|
27
|
+
* `timeMapFor` returns undefined so callers keep their original formulas
|
|
28
|
+
* (0.4 projects render byte-identically).
|
|
29
|
+
*
|
|
30
|
+
* Pure and dependency-free: `core/score.ts` imports the types and the
|
|
31
|
+
* validators from here.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
export const TEMPO_RAMPS = ["linear", "exp"] as const;
|
|
35
|
+
export type TempoRamp = (typeof TEMPO_RAMPS)[number];
|
|
36
|
+
|
|
37
|
+
/** A tempo change. The start tempo is the score's `tempoBpm` at tick 0. */
|
|
38
|
+
export type TempoEvent = Readonly<{
|
|
39
|
+
/** Score tick, 1 or later. */
|
|
40
|
+
tick: number;
|
|
41
|
+
/** Quarter notes per minute from this tick on. */
|
|
42
|
+
bpm: number;
|
|
43
|
+
/** Glide into `bpm` from the previous tempo instead of stepping. */
|
|
44
|
+
ramp?: TempoRamp;
|
|
45
|
+
}>;
|
|
46
|
+
|
|
47
|
+
/** A meter from bar `bar` (0-based) on. */
|
|
48
|
+
export type MeterChange = Readonly<{
|
|
49
|
+
bar: number;
|
|
50
|
+
/** Numerator: beats of `beatUnit` per bar. */
|
|
51
|
+
beatsPerBar: number;
|
|
52
|
+
/** Denominator: 1, 2, 4, 8, 16 or 32; default 4. */
|
|
53
|
+
beatUnit?: number;
|
|
54
|
+
}>;
|
|
55
|
+
|
|
56
|
+
/** The beat at `tick` lasts `1 + beats` times as long. */
|
|
57
|
+
export type Fermata = Readonly<{
|
|
58
|
+
tick: number;
|
|
59
|
+
beats: number;
|
|
60
|
+
}>;
|
|
61
|
+
|
|
62
|
+
/** Song-level `time`: absent fields keep a constant tempo and one meter. */
|
|
63
|
+
export type SongTime = Readonly<{
|
|
64
|
+
tempo?: readonly TempoEvent[];
|
|
65
|
+
meter?: readonly MeterChange[];
|
|
66
|
+
fermatas?: readonly Fermata[];
|
|
67
|
+
}>;
|
|
68
|
+
|
|
69
|
+
/** Track-level `time`: absent fields follow the song. */
|
|
70
|
+
export type TrackTime = Readonly<{
|
|
71
|
+
/** Tempo ratio against the song: 1.5 plays three beats in two. */
|
|
72
|
+
rate?: number;
|
|
73
|
+
/**
|
|
74
|
+
* Song ticks that shift the track's repeating pattern later (negative:
|
|
75
|
+
* earlier). It wraps, so the loop still starts full.
|
|
76
|
+
*/
|
|
77
|
+
phase?: number;
|
|
78
|
+
/** Track ticks per repetition; default the song loop. */
|
|
79
|
+
cycle?: number;
|
|
80
|
+
/**
|
|
81
|
+
* Stepped phasing, as in Reich's Piano Phase: hold `hold` cycles in
|
|
82
|
+
* step, then move `shift` ticks ahead over `drift` cycles, and repeat.
|
|
83
|
+
* Replaces `rate`; absent is the constant drift `rate` gives.
|
|
84
|
+
*/
|
|
85
|
+
steps?: PhaseSteps;
|
|
86
|
+
}>;
|
|
87
|
+
|
|
88
|
+
export type PhaseSteps = Readonly<{
|
|
89
|
+
/** Track ticks gained per step (120 = one sixteenth at 480 tpb). */
|
|
90
|
+
shift: number;
|
|
91
|
+
/** Whole cycles held locked between shifts, 0..64. */
|
|
92
|
+
hold: number;
|
|
93
|
+
/** Whole cycles over which each shift happens, 1..64. */
|
|
94
|
+
drift: number;
|
|
95
|
+
}>;
|
|
96
|
+
|
|
97
|
+
export const TIME_LIMITS = Object.freeze({
|
|
98
|
+
maxTempoEvents: 256,
|
|
99
|
+
maxMeterChanges: 256,
|
|
100
|
+
maxFermatas: 256,
|
|
101
|
+
maxFermataBeats: 64,
|
|
102
|
+
/**
|
|
103
|
+
* Longest held beat, in seconds: the slowest tempo a 3-byte SMF tempo
|
|
104
|
+
* event can write (0xFFFFFF µs per quarter), so MIDI export keeps time.
|
|
105
|
+
*/
|
|
106
|
+
maxFermataSeconds: 16.777,
|
|
107
|
+
/** Same as `SCORE_LIMITS.minTempoBpm` / `maxTempoBpm`. */
|
|
108
|
+
minBpm: 20,
|
|
109
|
+
maxBpm: 300,
|
|
110
|
+
/** Same as `SCORE_LIMITS.maxTick`. */
|
|
111
|
+
maxTick: 1_000_000,
|
|
112
|
+
/** Same as `SCORE_LIMITS.maxBeatsPerBar`. */
|
|
113
|
+
maxBeatsPerBar: 16,
|
|
114
|
+
/** Same as `SCORE_LIMITS.maxBars`. */
|
|
115
|
+
maxBars: 256,
|
|
116
|
+
beatUnits: Object.freeze([1, 2, 4, 8, 16, 32]) as readonly number[],
|
|
117
|
+
minRate: 0.125,
|
|
118
|
+
maxRate: 8,
|
|
119
|
+
/** Timed-track repetitions `performedNotes` places; later ones drop. */
|
|
120
|
+
maxPerformedNotes: 16_384,
|
|
121
|
+
} as const);
|
|
122
|
+
|
|
123
|
+
export class TimeValidationError extends Error {
|
|
124
|
+
constructor(message: string) {
|
|
125
|
+
super(message);
|
|
126
|
+
this.name = "TimeValidationError";
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
// Validation
|
|
132
|
+
|
|
133
|
+
/** Validates a song `time` field; empty lists and `{}` normalize to undefined. */
|
|
134
|
+
export function normalizeSongTime(
|
|
135
|
+
input: unknown,
|
|
136
|
+
label = "time",
|
|
137
|
+
): SongTime | undefined {
|
|
138
|
+
if (input === undefined || input === null) return undefined;
|
|
139
|
+
const record = recordOf(input, label, ["tempo", "meter", "fermatas"]);
|
|
140
|
+
const tempo = normalizeList(
|
|
141
|
+
record.tempo,
|
|
142
|
+
`${label}.tempo`,
|
|
143
|
+
TIME_LIMITS.maxTempoEvents,
|
|
144
|
+
normalizeTempoEvent,
|
|
145
|
+
(event) => event.tick,
|
|
146
|
+
);
|
|
147
|
+
const meter = normalizeList(
|
|
148
|
+
record.meter,
|
|
149
|
+
`${label}.meter`,
|
|
150
|
+
TIME_LIMITS.maxMeterChanges,
|
|
151
|
+
normalizeMeterChange,
|
|
152
|
+
(change) => change.bar,
|
|
153
|
+
);
|
|
154
|
+
const fermatas = normalizeList(
|
|
155
|
+
record.fermatas,
|
|
156
|
+
`${label}.fermatas`,
|
|
157
|
+
TIME_LIMITS.maxFermatas,
|
|
158
|
+
normalizeFermata,
|
|
159
|
+
(fermata) => fermata.tick,
|
|
160
|
+
);
|
|
161
|
+
if (!tempo && !meter && !fermatas) return undefined;
|
|
162
|
+
return Object.freeze({
|
|
163
|
+
...(tempo ? { tempo } : {}),
|
|
164
|
+
...(meter ? { meter } : {}),
|
|
165
|
+
...(fermatas ? { fermatas } : {}),
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Checks what needs the rest of the score: bars must be whole ticks, and
|
|
171
|
+
* with `score` given, no fermata may hold its beat past `maxFermataSeconds`.
|
|
172
|
+
*/
|
|
173
|
+
export function checkSongTime(
|
|
174
|
+
time: SongTime | undefined,
|
|
175
|
+
ticksPerBeat: number,
|
|
176
|
+
score?: Omit<TimeScore, "time" | "ticksPerBeat">,
|
|
177
|
+
): void {
|
|
178
|
+
if (score && time?.fermatas) {
|
|
179
|
+
const map = new TimeMap({ ...score, ticksPerBeat, time });
|
|
180
|
+
for (const fermata of time.fermatas) {
|
|
181
|
+
const span = fermataSpan({ ...score, ticksPerBeat, time }, fermata.tick);
|
|
182
|
+
const held = map.seconds(fermata.tick + span) - map.seconds(fermata.tick);
|
|
183
|
+
if (held > TIME_LIMITS.maxFermataSeconds + 1e-9)
|
|
184
|
+
throw new TimeValidationError(
|
|
185
|
+
`fermata at beat ${fermata.tick / ticksPerBeat} holds ${held.toFixed(1)} s; at most ${TIME_LIMITS.maxFermataSeconds} s (MIDI tempo limit), so use fewer beats or a faster tempo`,
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
for (const change of time?.meter ?? []) {
|
|
190
|
+
const ticks = barTicksOf(
|
|
191
|
+
change.beatsPerBar,
|
|
192
|
+
change.beatUnit ?? 4,
|
|
193
|
+
ticksPerBeat,
|
|
194
|
+
);
|
|
195
|
+
if (!Number.isInteger(ticks))
|
|
196
|
+
throw new TimeValidationError(
|
|
197
|
+
`time.meter bar ${change.bar}: ${change.beatsPerBar}/${change.beatUnit ?? 4} is not a whole number of ticks at ${ticksPerBeat} ticks per beat`,
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Validates a track `time` field; defaults are dropped, all-default is undefined. */
|
|
203
|
+
export function normalizeTrackTime(
|
|
204
|
+
input: unknown,
|
|
205
|
+
label = "track time",
|
|
206
|
+
): TrackTime | undefined {
|
|
207
|
+
if (input === undefined || input === null) return undefined;
|
|
208
|
+
const record = recordOf(input, label, ["rate", "phase", "cycle", "steps"]);
|
|
209
|
+
const out: {
|
|
210
|
+
rate?: number;
|
|
211
|
+
phase?: number;
|
|
212
|
+
cycle?: number;
|
|
213
|
+
steps?: PhaseSteps;
|
|
214
|
+
} = {};
|
|
215
|
+
if (record.rate !== undefined) {
|
|
216
|
+
const rate = record.rate;
|
|
217
|
+
if (
|
|
218
|
+
typeof rate !== "number" ||
|
|
219
|
+
!Number.isFinite(rate) ||
|
|
220
|
+
rate < TIME_LIMITS.minRate ||
|
|
221
|
+
rate > TIME_LIMITS.maxRate
|
|
222
|
+
)
|
|
223
|
+
throw new TimeValidationError(
|
|
224
|
+
`${label} rate must be between ${TIME_LIMITS.minRate} and ${TIME_LIMITS.maxRate}`,
|
|
225
|
+
);
|
|
226
|
+
if (rate !== 1) out.rate = rate;
|
|
227
|
+
}
|
|
228
|
+
if (record.phase !== undefined) {
|
|
229
|
+
const phase = record.phase;
|
|
230
|
+
if (
|
|
231
|
+
typeof phase !== "number" ||
|
|
232
|
+
!Number.isInteger(phase) ||
|
|
233
|
+
Math.abs(phase) > TIME_LIMITS.maxTick
|
|
234
|
+
)
|
|
235
|
+
throw new TimeValidationError(
|
|
236
|
+
`${label} phase must be an integer tick within ±${TIME_LIMITS.maxTick}`,
|
|
237
|
+
);
|
|
238
|
+
if (phase !== 0) out.phase = phase;
|
|
239
|
+
}
|
|
240
|
+
if (record.cycle !== undefined) {
|
|
241
|
+
const cycle = record.cycle;
|
|
242
|
+
if (
|
|
243
|
+
typeof cycle !== "number" ||
|
|
244
|
+
!Number.isInteger(cycle) ||
|
|
245
|
+
cycle < 1 ||
|
|
246
|
+
cycle > TIME_LIMITS.maxTick
|
|
247
|
+
)
|
|
248
|
+
throw new TimeValidationError(
|
|
249
|
+
`${label} cycle must be an integer between 1 and ${TIME_LIMITS.maxTick} ticks`,
|
|
250
|
+
);
|
|
251
|
+
out.cycle = cycle;
|
|
252
|
+
}
|
|
253
|
+
if (record.steps !== undefined) {
|
|
254
|
+
if (out.rate !== undefined)
|
|
255
|
+
throw new TimeValidationError(`${label} takes rate or steps, not both`);
|
|
256
|
+
if (out.cycle === undefined)
|
|
257
|
+
throw new TimeValidationError(`${label} steps need a cycle`);
|
|
258
|
+
out.steps = normalizePhaseSteps(record.steps, out.cycle, `${label} steps`);
|
|
259
|
+
}
|
|
260
|
+
return Object.keys(out).length > 0 ? Object.freeze(out) : undefined;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function normalizePhaseSteps(
|
|
264
|
+
input: unknown,
|
|
265
|
+
cycle: number,
|
|
266
|
+
label: string,
|
|
267
|
+
): PhaseSteps {
|
|
268
|
+
const record = recordOf(input, label, ["shift", "hold", "drift"]);
|
|
269
|
+
const int = (value: unknown, min: number, max: number) =>
|
|
270
|
+
typeof value === "number" &&
|
|
271
|
+
Number.isInteger(value) &&
|
|
272
|
+
value >= min &&
|
|
273
|
+
value <= max;
|
|
274
|
+
if (!int(record.shift, 1, cycle))
|
|
275
|
+
throw new TimeValidationError(
|
|
276
|
+
`${label} shift must be an integer between 1 and the cycle (${cycle} ticks)`,
|
|
277
|
+
);
|
|
278
|
+
if (!int(record.hold, 0, 64))
|
|
279
|
+
throw new TimeValidationError(`${label} hold must be 0..64 cycles`);
|
|
280
|
+
if (!int(record.drift, 1, 64))
|
|
281
|
+
throw new TimeValidationError(`${label} drift must be 1..64 cycles`);
|
|
282
|
+
return Object.freeze({
|
|
283
|
+
shift: record.shift as number,
|
|
284
|
+
hold: record.hold as number,
|
|
285
|
+
drift: record.drift as number,
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Song ticks for one stepped-phasing turn: every shift until the track is
|
|
291
|
+
* a whole cycle ahead and back in step.
|
|
292
|
+
*/
|
|
293
|
+
export function phaseStepsTicks(cycle: number, steps: PhaseSteps): number {
|
|
294
|
+
return Math.ceil(cycle / steps.shift) * (steps.hold + steps.drift) * cycle;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function normalizeTempoEvent(input: unknown, label: string): TempoEvent {
|
|
298
|
+
const record = recordOf(input, label, ["tick", "bpm", "ramp"]);
|
|
299
|
+
const tick = record.tick;
|
|
300
|
+
if (
|
|
301
|
+
typeof tick !== "number" ||
|
|
302
|
+
!Number.isInteger(tick) ||
|
|
303
|
+
tick < 1 ||
|
|
304
|
+
tick > TIME_LIMITS.maxTick
|
|
305
|
+
)
|
|
306
|
+
throw new TimeValidationError(
|
|
307
|
+
`${label} tick must be an integer between 1 and ${TIME_LIMITS.maxTick} (tick 0 is tempoBpm)`,
|
|
308
|
+
);
|
|
309
|
+
const bpm = record.bpm;
|
|
310
|
+
if (
|
|
311
|
+
typeof bpm !== "number" ||
|
|
312
|
+
!Number.isFinite(bpm) ||
|
|
313
|
+
bpm < TIME_LIMITS.minBpm ||
|
|
314
|
+
bpm > TIME_LIMITS.maxBpm
|
|
315
|
+
)
|
|
316
|
+
throw new TimeValidationError(
|
|
317
|
+
`${label} bpm must be between ${TIME_LIMITS.minBpm} and ${TIME_LIMITS.maxBpm}`,
|
|
318
|
+
);
|
|
319
|
+
const ramp = record.ramp;
|
|
320
|
+
if (
|
|
321
|
+
ramp !== undefined &&
|
|
322
|
+
ramp !== null &&
|
|
323
|
+
!TEMPO_RAMPS.includes(ramp as TempoRamp)
|
|
324
|
+
)
|
|
325
|
+
throw new TimeValidationError(
|
|
326
|
+
`${label} ramp must be one of ${TEMPO_RAMPS.join(", ")}`,
|
|
327
|
+
);
|
|
328
|
+
return Object.freeze({
|
|
329
|
+
tick,
|
|
330
|
+
bpm,
|
|
331
|
+
...(typeof ramp === "string" ? { ramp: ramp as TempoRamp } : {}),
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function normalizeMeterChange(input: unknown, label: string): MeterChange {
|
|
336
|
+
const record = recordOf(input, label, ["bar", "beatsPerBar", "beatUnit"]);
|
|
337
|
+
const bar = record.bar;
|
|
338
|
+
if (
|
|
339
|
+
typeof bar !== "number" ||
|
|
340
|
+
!Number.isInteger(bar) ||
|
|
341
|
+
bar < 0 ||
|
|
342
|
+
bar >= TIME_LIMITS.maxBars
|
|
343
|
+
)
|
|
344
|
+
throw new TimeValidationError(
|
|
345
|
+
`${label} bar must be an integer between 0 and ${TIME_LIMITS.maxBars - 1}`,
|
|
346
|
+
);
|
|
347
|
+
const beatsPerBar = record.beatsPerBar;
|
|
348
|
+
if (
|
|
349
|
+
typeof beatsPerBar !== "number" ||
|
|
350
|
+
!Number.isInteger(beatsPerBar) ||
|
|
351
|
+
beatsPerBar < 1 ||
|
|
352
|
+
beatsPerBar > TIME_LIMITS.maxBeatsPerBar
|
|
353
|
+
)
|
|
354
|
+
throw new TimeValidationError(
|
|
355
|
+
`${label} beatsPerBar must be an integer between 1 and ${TIME_LIMITS.maxBeatsPerBar}`,
|
|
356
|
+
);
|
|
357
|
+
const beatUnit = record.beatUnit ?? 4;
|
|
358
|
+
if (typeof beatUnit !== "number" || !TIME_LIMITS.beatUnits.includes(beatUnit))
|
|
359
|
+
throw new TimeValidationError(
|
|
360
|
+
`${label} beatUnit must be one of ${TIME_LIMITS.beatUnits.join(", ")}`,
|
|
361
|
+
);
|
|
362
|
+
return Object.freeze({
|
|
363
|
+
bar,
|
|
364
|
+
beatsPerBar,
|
|
365
|
+
...(beatUnit !== 4 ? { beatUnit } : {}),
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function normalizeFermata(input: unknown, label: string): Fermata {
|
|
370
|
+
const record = recordOf(input, label, ["tick", "beats"]);
|
|
371
|
+
const tick = record.tick;
|
|
372
|
+
if (
|
|
373
|
+
typeof tick !== "number" ||
|
|
374
|
+
!Number.isInteger(tick) ||
|
|
375
|
+
tick < 0 ||
|
|
376
|
+
tick > TIME_LIMITS.maxTick
|
|
377
|
+
)
|
|
378
|
+
throw new TimeValidationError(
|
|
379
|
+
`${label} tick must be an integer between 0 and ${TIME_LIMITS.maxTick}`,
|
|
380
|
+
);
|
|
381
|
+
const beats = record.beats;
|
|
382
|
+
if (
|
|
383
|
+
typeof beats !== "number" ||
|
|
384
|
+
!Number.isFinite(beats) ||
|
|
385
|
+
beats <= 0 ||
|
|
386
|
+
beats > TIME_LIMITS.maxFermataBeats
|
|
387
|
+
)
|
|
388
|
+
throw new TimeValidationError(
|
|
389
|
+
`${label} beats must be above 0 and at most ${TIME_LIMITS.maxFermataBeats}`,
|
|
390
|
+
);
|
|
391
|
+
return Object.freeze({ tick, beats });
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function normalizeList<T>(
|
|
395
|
+
input: unknown,
|
|
396
|
+
label: string,
|
|
397
|
+
max: number,
|
|
398
|
+
normalize: (value: unknown, label: string) => T,
|
|
399
|
+
keyOf: (value: T) => number,
|
|
400
|
+
): readonly T[] | undefined {
|
|
401
|
+
if (input === undefined || input === null) return undefined;
|
|
402
|
+
if (!Array.isArray(input) || input.length > max)
|
|
403
|
+
throw new TimeValidationError(
|
|
404
|
+
`${label} must be an array of at most ${max} entries`,
|
|
405
|
+
);
|
|
406
|
+
if (input.length === 0) return undefined;
|
|
407
|
+
const items = input
|
|
408
|
+
.map((value: unknown, index) => normalize(value, `${label}[${index}]`))
|
|
409
|
+
.sort((a, b) => keyOf(a) - keyOf(b));
|
|
410
|
+
for (let index = 1; index < items.length; index += 1)
|
|
411
|
+
if (keyOf(items[index]!) === keyOf(items[index - 1]!))
|
|
412
|
+
throw new TimeValidationError(
|
|
413
|
+
`${label} has two entries at ${keyOf(items[index]!)}`,
|
|
414
|
+
);
|
|
415
|
+
return Object.freeze(items);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function recordOf(
|
|
419
|
+
input: unknown,
|
|
420
|
+
label: string,
|
|
421
|
+
keys: readonly string[],
|
|
422
|
+
): Record<string, unknown> {
|
|
423
|
+
if (typeof input !== "object" || input === null || Array.isArray(input))
|
|
424
|
+
throw new TimeValidationError(`${label} must be an object`);
|
|
425
|
+
for (const key of Object.keys(input))
|
|
426
|
+
if (!keys.includes(key))
|
|
427
|
+
throw new TimeValidationError(`${label} has unknown field ${key}`);
|
|
428
|
+
return input as Record<string, unknown>;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// ---------------------------------------------------------------------------
|
|
432
|
+
// Meter: bar lengths and positions
|
|
433
|
+
|
|
434
|
+
/** The fields of a score that time depends on. */
|
|
435
|
+
export type TimeScore = Readonly<{
|
|
436
|
+
tempoBpm: number;
|
|
437
|
+
beatsPerBar: number;
|
|
438
|
+
bars: number;
|
|
439
|
+
ticksPerBeat: number;
|
|
440
|
+
time?: SongTime;
|
|
441
|
+
}>;
|
|
442
|
+
|
|
443
|
+
/** One meter run: bar `bar` starts at `tick`, every bar is `barTicks` long. */
|
|
444
|
+
export type MeterSegment = Readonly<{
|
|
445
|
+
bar: number;
|
|
446
|
+
tick: number;
|
|
447
|
+
beatsPerBar: number;
|
|
448
|
+
beatUnit: number;
|
|
449
|
+
barTicks: number;
|
|
450
|
+
}>;
|
|
451
|
+
|
|
452
|
+
/** Where a tick falls: its bar (0-based), that bar's start and meter. */
|
|
453
|
+
export type BarPosition = MeterSegment &
|
|
454
|
+
Readonly<{
|
|
455
|
+
/** Ticks from the bar's start. */
|
|
456
|
+
offset: number;
|
|
457
|
+
}>;
|
|
458
|
+
|
|
459
|
+
function barTicksOf(
|
|
460
|
+
beatsPerBar: number,
|
|
461
|
+
beatUnit: number,
|
|
462
|
+
ticksPerBeat: number,
|
|
463
|
+
): number {
|
|
464
|
+
return (beatsPerBar * ticksPerBeat * 4) / beatUnit;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
const meterCache = new WeakMap<object, readonly MeterSegment[]>();
|
|
468
|
+
|
|
469
|
+
/** Meter runs in bar order; the first starts at bar 0, tick 0. */
|
|
470
|
+
export function meterSegments(score: TimeScore): readonly MeterSegment[] {
|
|
471
|
+
const cached = meterCache.get(score);
|
|
472
|
+
if (cached) return cached;
|
|
473
|
+
let current: MeterSegment = {
|
|
474
|
+
bar: 0,
|
|
475
|
+
tick: 0,
|
|
476
|
+
beatsPerBar: score.beatsPerBar,
|
|
477
|
+
beatUnit: 4,
|
|
478
|
+
barTicks: score.beatsPerBar * score.ticksPerBeat,
|
|
479
|
+
};
|
|
480
|
+
const segments: MeterSegment[] = [];
|
|
481
|
+
for (const change of score.time?.meter ?? []) {
|
|
482
|
+
const beatUnit = change.beatUnit ?? 4;
|
|
483
|
+
const next: MeterSegment = {
|
|
484
|
+
bar: change.bar,
|
|
485
|
+
tick: current.tick + (change.bar - current.bar) * current.barTicks,
|
|
486
|
+
beatsPerBar: change.beatsPerBar,
|
|
487
|
+
beatUnit,
|
|
488
|
+
barTicks: barTicksOf(change.beatsPerBar, beatUnit, score.ticksPerBeat),
|
|
489
|
+
};
|
|
490
|
+
if (change.bar > 0) segments.push(Object.freeze(current));
|
|
491
|
+
current = next;
|
|
492
|
+
}
|
|
493
|
+
segments.push(Object.freeze(current));
|
|
494
|
+
const frozen = Object.freeze(segments);
|
|
495
|
+
meterCache.set(score, frozen);
|
|
496
|
+
return frozen;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/** Start tick of bar `bar` (0-based; later bars repeat the last meter). */
|
|
500
|
+
export function barStartTick(score: TimeScore, bar: number): number {
|
|
501
|
+
if (!score.time?.meter) return bar * score.beatsPerBar * score.ticksPerBeat;
|
|
502
|
+
const segments = meterSegments(score);
|
|
503
|
+
let segment = segments[0]!;
|
|
504
|
+
for (const candidate of segments) {
|
|
505
|
+
if (candidate.bar > bar) break;
|
|
506
|
+
segment = candidate;
|
|
507
|
+
}
|
|
508
|
+
return segment.tick + (bar - segment.bar) * segment.barTicks;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/** Song loop length in ticks: `bars` bars through the meter changes. */
|
|
512
|
+
export function loopTicksOf(score: TimeScore): number {
|
|
513
|
+
if (!score.time?.meter)
|
|
514
|
+
return score.bars * score.beatsPerBar * score.ticksPerBeat;
|
|
515
|
+
return barStartTick(score, score.bars);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/** The bar a tick falls in (ticks before 0 count back in the first meter). */
|
|
519
|
+
export function barAt(score: TimeScore, tick: number): BarPosition {
|
|
520
|
+
const segments = meterSegments(score);
|
|
521
|
+
let segment = segments[0]!;
|
|
522
|
+
for (const candidate of segments) {
|
|
523
|
+
if (candidate.tick > tick) break;
|
|
524
|
+
segment = candidate;
|
|
525
|
+
}
|
|
526
|
+
const within = Math.floor((tick - segment.tick) / segment.barTicks);
|
|
527
|
+
const start = segment.tick + within * segment.barTicks;
|
|
528
|
+
return {
|
|
529
|
+
...segment,
|
|
530
|
+
bar: segment.bar + within,
|
|
531
|
+
tick: start,
|
|
532
|
+
offset: tick - start,
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// The transport runs in unwrapped score beats: pass k of the loop starts at
|
|
537
|
+
// beat k * loop beats. These keep the 0.4 arithmetic without meter changes.
|
|
538
|
+
|
|
539
|
+
/** Bar index, counting every loop pass, of transport `beat`. */
|
|
540
|
+
export function transportBar(score: TimeScore, beat: number): number {
|
|
541
|
+
if (!score.time?.meter) return Math.floor(beat / score.beatsPerBar);
|
|
542
|
+
const loopTicks = loopTicksOf(score);
|
|
543
|
+
const tick = beat * score.ticksPerBeat;
|
|
544
|
+
const pass = Math.floor(tick / loopTicks);
|
|
545
|
+
return pass * score.bars + barAt(score, tick - pass * loopTicks).bar;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/** Transport beat where transport bar `bar` (any loop pass) starts. */
|
|
549
|
+
export function transportBarStart(score: TimeScore, bar: number): number {
|
|
550
|
+
if (!score.time?.meter) return bar * score.beatsPerBar;
|
|
551
|
+
const pass = Math.floor(bar / score.bars);
|
|
552
|
+
const inner = bar - pass * score.bars;
|
|
553
|
+
return (
|
|
554
|
+
(pass * loopTicksOf(score) + barStartTick(score, inner)) /
|
|
555
|
+
score.ticksPerBeat
|
|
556
|
+
);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/** Score tick inside the loop where transport `beat` falls. */
|
|
560
|
+
export function loopTickAt(score: TimeScore, beat: number): number {
|
|
561
|
+
const loopTicks = loopTicksOf(score);
|
|
562
|
+
const tick = beat * score.ticksPerBeat;
|
|
563
|
+
return ((tick % loopTicks) + loopTicks) % loopTicks;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/** True when the score has a meter change after bar 0 or a non-4 unit. */
|
|
567
|
+
export function hasMeterChanges(score: TimeScore): boolean {
|
|
568
|
+
return score.time?.meter !== undefined;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/** Meter label of a bar, such as `7/8`. */
|
|
572
|
+
export function meterLabel(
|
|
573
|
+
position: Pick<MeterSegment, "beatsPerBar" | "beatUnit">,
|
|
574
|
+
): string {
|
|
575
|
+
return `${position.beatsPerBar}/${position.beatUnit}`;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* Ticks between metronome clicks in a meter: one beat unit, or a dotted
|
|
580
|
+
* unit in compound meters (6/8, 9/8, 12/8, 6/16 … click three units), the
|
|
581
|
+
* way a Standard MIDI File's 6/8 example clicks every dotted quarter.
|
|
582
|
+
*/
|
|
583
|
+
/**
|
|
584
|
+
* Ticks a fermata at `tick` stretches: the felt beat of the meter there,
|
|
585
|
+
* so a dotted quarter in 6/8 or 12/8 and a half in 2/2, never less than a
|
|
586
|
+
* quarter note (the 4/4 beat, and every score without meter changes).
|
|
587
|
+
*/
|
|
588
|
+
export function fermataSpan(score: TimeScore, tick: number): number {
|
|
589
|
+
const tpb = score.ticksPerBeat;
|
|
590
|
+
if (!score.time?.meter) return tpb;
|
|
591
|
+
return Math.max(tpb, clickTicksOf(barAt(score, tick), tpb));
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
export function clickTicksOf(
|
|
595
|
+
segment: Pick<MeterSegment, "beatsPerBar" | "beatUnit">,
|
|
596
|
+
ticksPerBeat: number,
|
|
597
|
+
): number {
|
|
598
|
+
const unit = (ticksPerBeat * 4) / segment.beatUnit;
|
|
599
|
+
const compound =
|
|
600
|
+
segment.beatUnit >= 8 &&
|
|
601
|
+
segment.beatsPerBar > 3 &&
|
|
602
|
+
segment.beatsPerBar % 3 === 0;
|
|
603
|
+
return compound ? unit * 3 : unit;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/** A grid line or click: `bar` marks a downbeat. */
|
|
607
|
+
export type BeatMark = Readonly<{
|
|
608
|
+
tick: number;
|
|
609
|
+
bar: boolean;
|
|
610
|
+
barIndex: number;
|
|
611
|
+
}>;
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* Downbeats and counted beats in `[from, to)`: clicks per `clickTicksOf`,
|
|
615
|
+
* restarting on every barline (a 7/8 bar counts 7 eighths, not 3.5 beats).
|
|
616
|
+
*/
|
|
617
|
+
export function beatMarks(
|
|
618
|
+
score: TimeScore,
|
|
619
|
+
from: number,
|
|
620
|
+
to: number,
|
|
621
|
+
): readonly BeatMark[] {
|
|
622
|
+
const marks: BeatMark[] = [];
|
|
623
|
+
if (!(to > from)) return marks;
|
|
624
|
+
let position = barAt(score, from);
|
|
625
|
+
for (let guard = 0; guard < 100_000; guard += 1) {
|
|
626
|
+
const step = clickTicksOf(position, score.ticksPerBeat);
|
|
627
|
+
for (let offset = 0; offset < position.barTicks; offset += step) {
|
|
628
|
+
const tick = position.tick + offset;
|
|
629
|
+
if (tick >= to) return marks;
|
|
630
|
+
if (tick >= from)
|
|
631
|
+
marks.push({ tick, bar: offset === 0, barIndex: position.bar });
|
|
632
|
+
}
|
|
633
|
+
position = barAt(score, position.tick + position.barTicks);
|
|
634
|
+
}
|
|
635
|
+
return marks;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// ---------------------------------------------------------------------------
|
|
639
|
+
// Tempo map: ticks ⇄ seconds
|
|
640
|
+
|
|
641
|
+
type Segment = Readonly<{
|
|
642
|
+
/** Ramp start tick (the segment's own start for steps). */
|
|
643
|
+
origin: number;
|
|
644
|
+
/** Ramp length in beats; 0 for a constant tempo. */
|
|
645
|
+
beats: number;
|
|
646
|
+
from: number;
|
|
647
|
+
to: number;
|
|
648
|
+
ramp: TempoRamp | undefined;
|
|
649
|
+
}>;
|
|
650
|
+
|
|
651
|
+
type Piece = Readonly<{
|
|
652
|
+
tick: number;
|
|
653
|
+
/** Seconds at `tick`. */
|
|
654
|
+
seconds: number;
|
|
655
|
+
/** Time stretch through this piece: 1 + beats of the fermatas here. */
|
|
656
|
+
stretch: number;
|
|
657
|
+
segment: Segment;
|
|
658
|
+
/** Seconds from the segment's origin to `tick`, unstretched. */
|
|
659
|
+
base: number;
|
|
660
|
+
}>;
|
|
661
|
+
|
|
662
|
+
function constant(segment: Segment): boolean {
|
|
663
|
+
return (
|
|
664
|
+
segment.ramp === undefined ||
|
|
665
|
+
segment.beats <= 0 ||
|
|
666
|
+
Math.abs(segment.to - segment.from) < 1e-9
|
|
667
|
+
);
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/** Seconds from a segment's origin to `beats` beats in. */
|
|
671
|
+
function segmentSeconds(segment: Segment, beats: number): number {
|
|
672
|
+
const { from, to } = segment;
|
|
673
|
+
if (constant(segment)) return (60 * beats) / from;
|
|
674
|
+
const span = segment.beats;
|
|
675
|
+
if (segment.ramp === "linear") {
|
|
676
|
+
const slope = (to - from) / span;
|
|
677
|
+
return (60 / slope) * Math.log1p((slope * beats) / from);
|
|
678
|
+
}
|
|
679
|
+
const k = Math.log(to / from) / span;
|
|
680
|
+
return (60 / (from * k)) * -Math.expm1(-k * beats);
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/** Beats from a segment's origin after `seconds`. */
|
|
684
|
+
function segmentBeats(segment: Segment, seconds: number): number {
|
|
685
|
+
const { from, to } = segment;
|
|
686
|
+
if (constant(segment)) return (seconds * from) / 60;
|
|
687
|
+
const span = segment.beats;
|
|
688
|
+
if (segment.ramp === "linear") {
|
|
689
|
+
const slope = (to - from) / span;
|
|
690
|
+
return (from / slope) * Math.expm1((seconds * slope) / 60);
|
|
691
|
+
}
|
|
692
|
+
const k = Math.log(to / from) / span;
|
|
693
|
+
return -Math.log1p((-seconds * from * k) / 60) / k;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
function segmentBpm(segment: Segment, beats: number): number {
|
|
697
|
+
const { from, to } = segment;
|
|
698
|
+
if (constant(segment)) return from;
|
|
699
|
+
const x = Math.min(1, Math.max(0, beats / segment.beats));
|
|
700
|
+
return segment.ramp === "linear"
|
|
701
|
+
? from + (to - from) * x
|
|
702
|
+
: from * Math.pow(to / from, x);
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/**
|
|
706
|
+
* Converts between score ticks and seconds through tempo events and
|
|
707
|
+
* fermatas. A fermata lengthens the beat that carries it, `[tick, tick +
|
|
708
|
+
* ticksPerBeat)`, to `1 + beats` times its length, as a notation player
|
|
709
|
+
* and a Standard MIDI File would play it: everything inside that beat
|
|
710
|
+
* slows evenly and everything later moves back.
|
|
711
|
+
*/
|
|
712
|
+
export class TimeMap {
|
|
713
|
+
readonly ticksPerBeat: number;
|
|
714
|
+
readonly startBpm: number;
|
|
715
|
+
private readonly pieces: readonly Piece[];
|
|
716
|
+
/** Ticks each fermata stretches, by its tick. */
|
|
717
|
+
private readonly spans = new Map<number, number>();
|
|
718
|
+
|
|
719
|
+
constructor(score: TimeScore) {
|
|
720
|
+
this.ticksPerBeat = score.ticksPerBeat;
|
|
721
|
+
this.startBpm = score.tempoBpm;
|
|
722
|
+
const tpb = score.ticksPerBeat;
|
|
723
|
+
const events = score.time?.tempo ?? [];
|
|
724
|
+
// Tempo segments: [start tick, segment].
|
|
725
|
+
const segments: { start: number; segment: Segment }[] = [];
|
|
726
|
+
let previousTick = 0;
|
|
727
|
+
let previousBpm = score.tempoBpm;
|
|
728
|
+
for (const event of events) {
|
|
729
|
+
segments.push({
|
|
730
|
+
start: previousTick,
|
|
731
|
+
segment: event.ramp
|
|
732
|
+
? {
|
|
733
|
+
origin: previousTick,
|
|
734
|
+
beats: (event.tick - previousTick) / tpb,
|
|
735
|
+
from: previousBpm,
|
|
736
|
+
to: event.bpm,
|
|
737
|
+
ramp: event.ramp,
|
|
738
|
+
}
|
|
739
|
+
: {
|
|
740
|
+
origin: previousTick,
|
|
741
|
+
beats: 0,
|
|
742
|
+
from: previousBpm,
|
|
743
|
+
to: previousBpm,
|
|
744
|
+
ramp: undefined,
|
|
745
|
+
},
|
|
746
|
+
});
|
|
747
|
+
previousTick = event.tick;
|
|
748
|
+
previousBpm = event.bpm;
|
|
749
|
+
}
|
|
750
|
+
segments.push({
|
|
751
|
+
start: previousTick,
|
|
752
|
+
segment: {
|
|
753
|
+
origin: previousTick,
|
|
754
|
+
beats: 0,
|
|
755
|
+
from: previousBpm,
|
|
756
|
+
to: previousBpm,
|
|
757
|
+
ramp: undefined,
|
|
758
|
+
},
|
|
759
|
+
});
|
|
760
|
+
// Pieces split segments where a fermata beat starts or ends.
|
|
761
|
+
const fermatas = score.time?.fermatas ?? [];
|
|
762
|
+
const spans = this.spans;
|
|
763
|
+
for (const fermata of fermatas)
|
|
764
|
+
spans.set(fermata.tick, fermataSpan(score, fermata.tick));
|
|
765
|
+
const starts = new Set<number>(segments.map((entry) => entry.start));
|
|
766
|
+
for (const fermata of fermatas) {
|
|
767
|
+
starts.add(fermata.tick);
|
|
768
|
+
starts.add(fermata.tick + spans.get(fermata.tick)!);
|
|
769
|
+
}
|
|
770
|
+
const ticks = [...starts].sort((a, b) => a - b);
|
|
771
|
+
const stretchAt = (tick: number) => {
|
|
772
|
+
let stretch = 1;
|
|
773
|
+
for (const fermata of fermatas)
|
|
774
|
+
if (
|
|
775
|
+
fermata.tick <= tick &&
|
|
776
|
+
tick < fermata.tick + spans.get(fermata.tick)!
|
|
777
|
+
)
|
|
778
|
+
stretch *= 1 + fermata.beats;
|
|
779
|
+
return stretch;
|
|
780
|
+
};
|
|
781
|
+
const pieces: Piece[] = [];
|
|
782
|
+
let segmentIndex = 0;
|
|
783
|
+
let lastPiece: Piece | undefined;
|
|
784
|
+
for (const tick of ticks) {
|
|
785
|
+
while (
|
|
786
|
+
segmentIndex + 1 < segments.length &&
|
|
787
|
+
segments[segmentIndex + 1]!.start <= tick
|
|
788
|
+
)
|
|
789
|
+
segmentIndex += 1;
|
|
790
|
+
const segment = segments[segmentIndex]!.segment;
|
|
791
|
+
const piece: Piece = {
|
|
792
|
+
tick,
|
|
793
|
+
seconds: lastPiece ? this.secondsInPiece(lastPiece, tick) : 0,
|
|
794
|
+
stretch: stretchAt(tick),
|
|
795
|
+
segment,
|
|
796
|
+
base: segmentSeconds(segment, (tick - segment.origin) / tpb),
|
|
797
|
+
};
|
|
798
|
+
pieces.push(piece);
|
|
799
|
+
lastPiece = piece;
|
|
800
|
+
}
|
|
801
|
+
this.pieces = Object.freeze(pieces);
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
private secondsInPiece(piece: Piece, tick: number): number {
|
|
805
|
+
const segment = piece.segment;
|
|
806
|
+
const plain = constant(segment)
|
|
807
|
+
? ((tick - piece.tick) / this.ticksPerBeat) * (60 / segment.from)
|
|
808
|
+
: segmentSeconds(segment, (tick - segment.origin) / this.ticksPerBeat) -
|
|
809
|
+
piece.base;
|
|
810
|
+
return (
|
|
811
|
+
piece.seconds + (piece.stretch === 1 ? plain : plain * piece.stretch)
|
|
812
|
+
);
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
private pieceAt(tick: number): Piece {
|
|
816
|
+
const pieces = this.pieces;
|
|
817
|
+
let low = 0;
|
|
818
|
+
let high = pieces.length - 1;
|
|
819
|
+
while (low < high) {
|
|
820
|
+
const middle = (low + high + 1) >> 1;
|
|
821
|
+
if (pieces[middle]!.tick <= tick) low = middle;
|
|
822
|
+
else high = middle - 1;
|
|
823
|
+
}
|
|
824
|
+
return pieces[low]!;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
/** Seconds from tick 0 to `tick`. */
|
|
828
|
+
seconds(tick: number): number {
|
|
829
|
+
if (tick <= 0) {
|
|
830
|
+
const first = this.pieces[0]!;
|
|
831
|
+
if (tick === 0) return 0;
|
|
832
|
+
return ((tick / this.ticksPerBeat) * 60) / first.segment.from;
|
|
833
|
+
}
|
|
834
|
+
const piece = this.pieceAt(tick);
|
|
835
|
+
if (tick === piece.tick) return piece.seconds;
|
|
836
|
+
return this.secondsInPiece(piece, tick);
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
/** The score tick sounding at `seconds`. */
|
|
840
|
+
tick(seconds: number): number {
|
|
841
|
+
if (seconds <= 0) {
|
|
842
|
+
if (seconds === 0) return 0;
|
|
843
|
+
return ((seconds * this.startBpm) / 60) * this.ticksPerBeat;
|
|
844
|
+
}
|
|
845
|
+
const pieces = this.pieces;
|
|
846
|
+
let low = 0;
|
|
847
|
+
let high = pieces.length - 1;
|
|
848
|
+
while (low < high) {
|
|
849
|
+
const middle = (low + high + 1) >> 1;
|
|
850
|
+
if (pieces[middle]!.seconds <= seconds) low = middle;
|
|
851
|
+
else high = middle - 1;
|
|
852
|
+
}
|
|
853
|
+
const piece = pieces[low]!;
|
|
854
|
+
const into = (seconds - piece.seconds) / piece.stretch;
|
|
855
|
+
if (into <= 0) return piece.tick;
|
|
856
|
+
const segment = piece.segment;
|
|
857
|
+
if (constant(segment))
|
|
858
|
+
return piece.tick + ((into * segment.from) / 60) * this.ticksPerBeat;
|
|
859
|
+
return (
|
|
860
|
+
segment.origin +
|
|
861
|
+
segmentBeats(segment, into + piece.base) * this.ticksPerBeat
|
|
862
|
+
);
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
/** Tempo at `tick` (after a step there; ramps interpolate; a fermata
|
|
866
|
+
* beat plays slower by its stretch). */
|
|
867
|
+
bpm(tick: number): number {
|
|
868
|
+
if (tick < 0) return this.startBpm;
|
|
869
|
+
const piece = this.pieceAt(tick);
|
|
870
|
+
const segment = piece.segment;
|
|
871
|
+
return (
|
|
872
|
+
segmentBpm(segment, (tick - segment.origin) / this.ticksPerBeat) /
|
|
873
|
+
piece.stretch
|
|
874
|
+
);
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
/** Tempo at `tick` ignoring fermatas: what a metronome mark there reads. */
|
|
878
|
+
tempoAt(tick: number): number {
|
|
879
|
+
if (tick < 0) return this.startBpm;
|
|
880
|
+
const segment = this.pieceAt(tick).segment;
|
|
881
|
+
return segmentBpm(segment, (tick - segment.origin) / this.ticksPerBeat);
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
/** Extra seconds a fermata adds to the beat starting at `tick`, else 0. */
|
|
885
|
+
holdAt(tick: number): number {
|
|
886
|
+
const piece = this.pieceAt(tick);
|
|
887
|
+
if (piece.tick !== tick || piece.stretch === 1) return 0;
|
|
888
|
+
const end = tick + (this.spans.get(tick) ?? this.ticksPerBeat);
|
|
889
|
+
const span = this.seconds(end) - piece.seconds;
|
|
890
|
+
return span - span / piece.stretch;
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
const mapCache = new WeakMap<object, TimeMap | null>();
|
|
895
|
+
|
|
896
|
+
/**
|
|
897
|
+
* The score's tempo map, or undefined when tempo is constant and nothing
|
|
898
|
+
* holds: callers then keep the 0.4 arithmetic exactly.
|
|
899
|
+
*/
|
|
900
|
+
export function timeMapFor(score: TimeScore): TimeMap | undefined {
|
|
901
|
+
if (!score.time?.tempo && !score.time?.fermatas) return undefined;
|
|
902
|
+
const cached = mapCache.get(score);
|
|
903
|
+
if (cached !== undefined) return cached ?? undefined;
|
|
904
|
+
const map = new TimeMap(score);
|
|
905
|
+
mapCache.set(score, map);
|
|
906
|
+
return map;
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
/** Seconds from tick 0 to `tick`. */
|
|
910
|
+
export function secondsAtTick(score: TimeScore, tick: number): number {
|
|
911
|
+
const map = timeMapFor(score);
|
|
912
|
+
return map
|
|
913
|
+
? map.seconds(tick)
|
|
914
|
+
: ((tick / score.ticksPerBeat) * 60) / score.tempoBpm;
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
/** Score tick at `seconds`. */
|
|
918
|
+
export function tickAtSeconds(score: TimeScore, seconds: number): number {
|
|
919
|
+
const map = timeMapFor(score);
|
|
920
|
+
return map
|
|
921
|
+
? map.tick(seconds)
|
|
922
|
+
: ((seconds * score.tempoBpm) / 60) * score.ticksPerBeat;
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
/** Tempo in effect at `tick`. */
|
|
926
|
+
export function bpmAtTick(score: TimeScore, tick: number): number {
|
|
927
|
+
return timeMapFor(score)?.bpm(tick) ?? score.tempoBpm;
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
/** Song loop length in seconds (0.4: `bars * beatsPerBar * 60 / tempoBpm`). */
|
|
931
|
+
export function loopSecondsOf(score: TimeScore): number {
|
|
932
|
+
const map = timeMapFor(score);
|
|
933
|
+
if (map) return map.seconds(loopTicksOf(score));
|
|
934
|
+
if (!score.time?.meter)
|
|
935
|
+
return (score.bars * score.beatsPerBar * 60) / score.tempoBpm;
|
|
936
|
+
return ((loopTicksOf(score) / score.ticksPerBeat) * 60) / score.tempoBpm;
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
/**
|
|
940
|
+
* The song's time from bar `startBar` for `bars` bars, rebased so that bar
|
|
941
|
+
* is tick 0: the start tempo is the tempo there (a ramp running through it
|
|
942
|
+
* keeps ramping to its target), later tempo events and fermatas move back
|
|
943
|
+
* by the start tick, and the meter at `startBar` becomes the opening meter.
|
|
944
|
+
* Audition and preview loops use it so a slice plays as it does in the song.
|
|
945
|
+
*/
|
|
946
|
+
export function songTimeSlice(
|
|
947
|
+
score: TimeScore,
|
|
948
|
+
startBar: number,
|
|
949
|
+
bars: number,
|
|
950
|
+
): Readonly<{ tempoBpm: number; time?: SongTime }> {
|
|
951
|
+
const time = score.time;
|
|
952
|
+
if (!time || startBar <= 0)
|
|
953
|
+
return { tempoBpm: score.tempoBpm, ...(time ? { time } : {}) };
|
|
954
|
+
const start = barStartTick(score, startBar);
|
|
955
|
+
const end = barStartTick(score, startBar + bars);
|
|
956
|
+
const map = timeMapFor(score);
|
|
957
|
+
const tempoBpm = map ? map.tempoAt(start) : score.tempoBpm;
|
|
958
|
+
const tempo: TempoEvent[] = [];
|
|
959
|
+
for (const event of time.tempo ?? []) {
|
|
960
|
+
if (event.tick <= start) continue;
|
|
961
|
+
tempo.push({ ...event, tick: event.tick - start });
|
|
962
|
+
// The first event at or past the end still shapes a ramp through it.
|
|
963
|
+
if (event.tick >= end) break;
|
|
964
|
+
}
|
|
965
|
+
const fermatas = (time.fermatas ?? [])
|
|
966
|
+
.filter((fermata) => fermata.tick >= start && fermata.tick < end)
|
|
967
|
+
.map((fermata) => ({ ...fermata, tick: fermata.tick - start }));
|
|
968
|
+
let meter: MeterChange[] | undefined;
|
|
969
|
+
if (time.meter) {
|
|
970
|
+
const opening = barAt(score, start);
|
|
971
|
+
meter = [
|
|
972
|
+
{
|
|
973
|
+
bar: 0,
|
|
974
|
+
beatsPerBar: opening.beatsPerBar,
|
|
975
|
+
...(opening.beatUnit !== 4 ? { beatUnit: opening.beatUnit } : {}),
|
|
976
|
+
},
|
|
977
|
+
];
|
|
978
|
+
for (const change of time.meter)
|
|
979
|
+
if (change.bar > startBar && change.bar < startBar + bars)
|
|
980
|
+
meter.push({ ...change, bar: change.bar - startBar });
|
|
981
|
+
}
|
|
982
|
+
const sliced = normalizeSongTime({
|
|
983
|
+
...(tempo.length ? { tempo } : {}),
|
|
984
|
+
...(meter ? { meter } : {}),
|
|
985
|
+
...(fermatas.length ? { fermatas } : {}),
|
|
986
|
+
});
|
|
987
|
+
return { tempoBpm, ...(sliced ? { time: sliced } : {}) };
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
/**
|
|
991
|
+
* The slowest the beat ever runs: the lowest tempo mark, slowed by the
|
|
992
|
+
* longest fermata. `tempoBpm` exactly when the song has no tempo map.
|
|
993
|
+
*/
|
|
994
|
+
export function slowestBpmOf(score: TimeScore): number {
|
|
995
|
+
if (!score.time?.tempo && !score.time?.fermatas) return score.tempoBpm;
|
|
996
|
+
let bpm = score.tempoBpm;
|
|
997
|
+
for (const event of score.time.tempo ?? []) bpm = Math.min(bpm, event.bpm);
|
|
998
|
+
let stretch = 1;
|
|
999
|
+
for (const fermata of score.time.fermatas ?? [])
|
|
1000
|
+
stretch = Math.max(stretch, 1 + fermata.beats);
|
|
1001
|
+
return bpm / stretch;
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
/** True when ticks do not map to seconds at one constant rate. */
|
|
1005
|
+
export function hasTempoMap(score: TimeScore): boolean {
|
|
1006
|
+
return timeMapFor(score) !== undefined;
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
// ---------------------------------------------------------------------------
|
|
1010
|
+
// Editing helpers (pure; they return a new `SongTime` or undefined)
|
|
1011
|
+
|
|
1012
|
+
function rebuild(
|
|
1013
|
+
time: SongTime | undefined,
|
|
1014
|
+
patch: Partial<Record<keyof SongTime, readonly unknown[] | undefined>>,
|
|
1015
|
+
): SongTime | undefined {
|
|
1016
|
+
const next: Record<string, unknown> = { ...(time ?? {}) };
|
|
1017
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
1018
|
+
if (value === undefined || value.length === 0) delete next[key];
|
|
1019
|
+
else next[key] = value;
|
|
1020
|
+
}
|
|
1021
|
+
return normalizeSongTime(next);
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
/** Adds or replaces the tempo event at `event.tick`. */
|
|
1025
|
+
export function withTempoEvent(
|
|
1026
|
+
time: SongTime | undefined,
|
|
1027
|
+
event: TempoEvent,
|
|
1028
|
+
): SongTime | undefined {
|
|
1029
|
+
const tempo = (time?.tempo ?? []).filter((e) => e.tick !== event.tick);
|
|
1030
|
+
return rebuild(time, { tempo: [...tempo, event] });
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
/** Removes tempo events in `[from, to]` (a single tick when `to` is omitted). */
|
|
1034
|
+
export function withoutTempoEvents(
|
|
1035
|
+
time: SongTime | undefined,
|
|
1036
|
+
from: number,
|
|
1037
|
+
to = from,
|
|
1038
|
+
): SongTime | undefined {
|
|
1039
|
+
return rebuild(time, {
|
|
1040
|
+
tempo: (time?.tempo ?? []).filter((e) => e.tick < from || e.tick > to),
|
|
1041
|
+
});
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
/**
|
|
1045
|
+
* A gradual change: holds the tempo in effect at `from` there, then ramps to
|
|
1046
|
+
* `bpm` at `to`. Events strictly between are replaced. Used by `rit` and
|
|
1047
|
+
* `accel`.
|
|
1048
|
+
*/
|
|
1049
|
+
export function withTempoRamp(
|
|
1050
|
+
score: TimeScore,
|
|
1051
|
+
from: number,
|
|
1052
|
+
to: number,
|
|
1053
|
+
bpm: number,
|
|
1054
|
+
ramp: TempoRamp = "linear",
|
|
1055
|
+
): SongTime | undefined {
|
|
1056
|
+
if (!(to > from))
|
|
1057
|
+
throw new TimeValidationError("a tempo ramp needs an end after its start");
|
|
1058
|
+
const start = bpmAtTick(score, from);
|
|
1059
|
+
const existing = score.time?.tempo ?? [];
|
|
1060
|
+
const events: TempoEvent[] = existing.filter(
|
|
1061
|
+
(e) => e.tick <= from || e.tick > to,
|
|
1062
|
+
);
|
|
1063
|
+
// Pin the start tempo so the ramp begins where the music is; a ramp
|
|
1064
|
+
// already running through `from` keeps its shape up to the pin.
|
|
1065
|
+
if (from > 0 && !events.some((e) => e.tick === from)) {
|
|
1066
|
+
const through = existing.find((e) => e.tick > from)?.ramp;
|
|
1067
|
+
events.push({
|
|
1068
|
+
tick: from,
|
|
1069
|
+
bpm: roundBpm(start),
|
|
1070
|
+
...(through ? { ramp: through } : {}),
|
|
1071
|
+
});
|
|
1072
|
+
}
|
|
1073
|
+
events.push({ tick: to, bpm, ramp });
|
|
1074
|
+
return rebuild(score.time, { tempo: events });
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
/** Adds or replaces the fermata at `tick`. */
|
|
1078
|
+
export function withFermata(
|
|
1079
|
+
time: SongTime | undefined,
|
|
1080
|
+
fermata: Fermata,
|
|
1081
|
+
): SongTime | undefined {
|
|
1082
|
+
const fermatas = (time?.fermatas ?? []).filter(
|
|
1083
|
+
(f) => f.tick !== fermata.tick,
|
|
1084
|
+
);
|
|
1085
|
+
return rebuild(time, { fermatas: [...fermatas, fermata] });
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
/** Removes the fermatas in `[from, to]`. */
|
|
1089
|
+
export function withoutFermatas(
|
|
1090
|
+
time: SongTime | undefined,
|
|
1091
|
+
from: number,
|
|
1092
|
+
to = from,
|
|
1093
|
+
): SongTime | undefined {
|
|
1094
|
+
return rebuild(time, {
|
|
1095
|
+
fermatas: (time?.fermatas ?? []).filter(
|
|
1096
|
+
(f) => f.tick < from || f.tick > to,
|
|
1097
|
+
),
|
|
1098
|
+
});
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
/** Sets the meter from bar `bar` (0-based) on; null removes that change. */
|
|
1102
|
+
export function withMeterChange(
|
|
1103
|
+
time: SongTime | undefined,
|
|
1104
|
+
bar: number,
|
|
1105
|
+
meter: Readonly<{ beatsPerBar: number; beatUnit?: number }> | null,
|
|
1106
|
+
): SongTime | undefined {
|
|
1107
|
+
const changes = (time?.meter ?? []).filter((m) => m.bar !== bar);
|
|
1108
|
+
return rebuild(time, {
|
|
1109
|
+
meter: meter
|
|
1110
|
+
? [
|
|
1111
|
+
...changes,
|
|
1112
|
+
{
|
|
1113
|
+
bar,
|
|
1114
|
+
beatsPerBar: meter.beatsPerBar,
|
|
1115
|
+
...(meter.beatUnit !== undefined && meter.beatUnit !== 4
|
|
1116
|
+
? { beatUnit: meter.beatUnit }
|
|
1117
|
+
: {}),
|
|
1118
|
+
},
|
|
1119
|
+
]
|
|
1120
|
+
: changes,
|
|
1121
|
+
});
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
function roundBpm(bpm: number): number {
|
|
1125
|
+
return Math.round(bpm * 100) / 100;
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
/**
|
|
1129
|
+
* The rate that makes a track gain `cycles` whole cycles per song loop, so
|
|
1130
|
+
* a phasing pair drifts apart and lines up again exactly at the loop end.
|
|
1131
|
+
* This is the constant tape drift of Reich's It's Gonna Rain and Come Out;
|
|
1132
|
+
* Piano Phase's shift-and-hold process is `TrackTime.steps`.
|
|
1133
|
+
*/
|
|
1134
|
+
export function driftRate(
|
|
1135
|
+
loopTicks: number,
|
|
1136
|
+
cycleTicks: number,
|
|
1137
|
+
cycles: number,
|
|
1138
|
+
): number {
|
|
1139
|
+
const perLoop = loopTicks / cycleTicks;
|
|
1140
|
+
return (perLoop + cycles) / perLoop;
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
// ---------------------------------------------------------------------------
|
|
1144
|
+
// Track time: placing notes on the song timeline
|
|
1145
|
+
|
|
1146
|
+
/** The note fields placement reads; extra fields are kept. */
|
|
1147
|
+
export type PlaceableNote = Readonly<{
|
|
1148
|
+
trackId: string;
|
|
1149
|
+
startTick: number;
|
|
1150
|
+
durationTicks: number;
|
|
1151
|
+
pitch: number;
|
|
1152
|
+
id: string;
|
|
1153
|
+
}>;
|
|
1154
|
+
|
|
1155
|
+
export type PlacementScore<N extends PlaceableNote> = TimeScore &
|
|
1156
|
+
Readonly<{
|
|
1157
|
+
tracks: readonly Readonly<{ id: string; time?: TrackTime }>[];
|
|
1158
|
+
notes: readonly N[];
|
|
1159
|
+
}>;
|
|
1160
|
+
|
|
1161
|
+
const placedCache = new WeakMap<object, readonly PlaceableNote[]>();
|
|
1162
|
+
|
|
1163
|
+
/**
|
|
1164
|
+
* Notes in song ticks. Tracks without `time` keep their notes (the same
|
|
1165
|
+
* objects, and the same array when no track has `time`); a timed track's
|
|
1166
|
+
* first `cycle` ticks repeat every `cycle / rate` song ticks from `phase`,
|
|
1167
|
+
* clipped to the song loop. Placed ticks may be fractional.
|
|
1168
|
+
*/
|
|
1169
|
+
export function performedNotes<N extends PlaceableNote>(
|
|
1170
|
+
score: PlacementScore<N>,
|
|
1171
|
+
): readonly N[] {
|
|
1172
|
+
if (!score.tracks.some((track) => track.time)) return score.notes;
|
|
1173
|
+
const cached = placedCache.get(score);
|
|
1174
|
+
if (cached) return cached as readonly N[];
|
|
1175
|
+
const loop = loopTicksOf(score);
|
|
1176
|
+
const times = new Map<string, TrackTime>();
|
|
1177
|
+
for (const track of score.tracks)
|
|
1178
|
+
if (track.time) times.set(track.id, track.time);
|
|
1179
|
+
const out: N[] = [];
|
|
1180
|
+
// The cap bounds repetitions only: untimed notes always play.
|
|
1181
|
+
let repeats = 0;
|
|
1182
|
+
for (const note of score.notes) {
|
|
1183
|
+
const time = times.get(note.trackId);
|
|
1184
|
+
if (!time) {
|
|
1185
|
+
out.push(note);
|
|
1186
|
+
continue;
|
|
1187
|
+
}
|
|
1188
|
+
const rate = time.rate ?? 1;
|
|
1189
|
+
const phase = time.phase ?? 0;
|
|
1190
|
+
const cycle = time.cycle ?? loop;
|
|
1191
|
+
if (note.startTick >= cycle) continue;
|
|
1192
|
+
if (time.steps) {
|
|
1193
|
+
repeats = placeStepped(
|
|
1194
|
+
note,
|
|
1195
|
+
cycle,
|
|
1196
|
+
phase,
|
|
1197
|
+
time.steps,
|
|
1198
|
+
loop,
|
|
1199
|
+
out,
|
|
1200
|
+
repeats,
|
|
1201
|
+
);
|
|
1202
|
+
continue;
|
|
1203
|
+
}
|
|
1204
|
+
const period = cycle / rate;
|
|
1205
|
+
const offset = phase + note.startTick / rate;
|
|
1206
|
+
const first = Math.ceil((0 - offset) / period - 1e-9);
|
|
1207
|
+
const last = Math.floor((loop - offset) / period - 1e-9);
|
|
1208
|
+
for (let k = first; k <= last; k += 1) {
|
|
1209
|
+
if (repeats >= TIME_LIMITS.maxPerformedNotes) break;
|
|
1210
|
+
const startTick = offset + k * period;
|
|
1211
|
+
if (startTick < 0 || startTick >= loop) continue;
|
|
1212
|
+
out.push({
|
|
1213
|
+
...note,
|
|
1214
|
+
startTick,
|
|
1215
|
+
durationTicks: note.durationTicks / rate,
|
|
1216
|
+
});
|
|
1217
|
+
repeats += 1;
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
out.sort(
|
|
1221
|
+
(a, b) =>
|
|
1222
|
+
a.startTick - b.startTick ||
|
|
1223
|
+
(a.trackId < b.trackId ? -1 : a.trackId > b.trackId ? 1 : 0) ||
|
|
1224
|
+
a.pitch - b.pitch ||
|
|
1225
|
+
(a.id < b.id ? -1 : a.id > b.id ? 1 : 0),
|
|
1226
|
+
);
|
|
1227
|
+
const frozen = Object.freeze(out);
|
|
1228
|
+
placedCache.set(score, frozen);
|
|
1229
|
+
return frozen;
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
/**
|
|
1233
|
+
* Stepped phasing: a step is `hold` cycles in song time at rate 1, then
|
|
1234
|
+
* `drift` cycles in which the track plays `shift` ticks more. Track tick
|
|
1235
|
+
* `tau` maps back to song time through that piecewise-linear clock.
|
|
1236
|
+
*/
|
|
1237
|
+
function placeStepped<N extends PlaceableNote>(
|
|
1238
|
+
note: N,
|
|
1239
|
+
cycle: number,
|
|
1240
|
+
phase: number,
|
|
1241
|
+
steps: PhaseSteps,
|
|
1242
|
+
loop: number,
|
|
1243
|
+
out: N[],
|
|
1244
|
+
repeats: number,
|
|
1245
|
+
): number {
|
|
1246
|
+
const held = steps.hold * cycle;
|
|
1247
|
+
const drifting = steps.drift * cycle;
|
|
1248
|
+
const song = held + drifting;
|
|
1249
|
+
const track = song + steps.shift;
|
|
1250
|
+
const squeeze = drifting / (drifting + steps.shift);
|
|
1251
|
+
const songTick = (tau: number) => {
|
|
1252
|
+
const step = Math.floor(tau / track);
|
|
1253
|
+
const within = tau - step * track;
|
|
1254
|
+
return (
|
|
1255
|
+
step * song + (within < held ? within : held + (within - held) * squeeze)
|
|
1256
|
+
);
|
|
1257
|
+
};
|
|
1258
|
+
// The clock repeats every step, so earlier cycles fill the loop's start
|
|
1259
|
+
// when the phase moves it later (as the plain placement wraps).
|
|
1260
|
+
const first = Math.floor((-phase * track) / song / cycle) - 2;
|
|
1261
|
+
for (let k = first; ; k += 1) {
|
|
1262
|
+
if (repeats >= TIME_LIMITS.maxPerformedNotes) break;
|
|
1263
|
+
const tau = k * cycle + note.startTick;
|
|
1264
|
+
const startTick = songTick(tau) + phase;
|
|
1265
|
+
if (startTick >= loop) break;
|
|
1266
|
+
if (startTick < 0) continue;
|
|
1267
|
+
const within = tau - Math.floor(tau / track) * track;
|
|
1268
|
+
out.push({
|
|
1269
|
+
...note,
|
|
1270
|
+
startTick,
|
|
1271
|
+
durationTicks: note.durationTicks * (within < held ? 1 : squeeze),
|
|
1272
|
+
});
|
|
1273
|
+
repeats += 1;
|
|
1274
|
+
}
|
|
1275
|
+
return repeats;
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
/** True when any track has its own `time`. */
|
|
1279
|
+
export function hasTrackTime(
|
|
1280
|
+
score: Readonly<{ tracks: readonly Readonly<{ time?: TrackTime }>[] }>,
|
|
1281
|
+
): boolean {
|
|
1282
|
+
return score.tracks.some((track) => track.time !== undefined);
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
/** One-line description of a song's time, for status lines and briefs. */
|
|
1286
|
+
export function describeSongTime(
|
|
1287
|
+
score: TimeScore,
|
|
1288
|
+
options: Readonly<{ bars?: boolean }> = {},
|
|
1289
|
+
): string {
|
|
1290
|
+
const parts: string[] = [];
|
|
1291
|
+
const time = score.time;
|
|
1292
|
+
// `@<beat>` for the agent; with `bars`, positions as the commands take
|
|
1293
|
+
// them: `bar 3` on a downbeat, else `beat 13` (as `at 13`).
|
|
1294
|
+
const at = (tick: number) => {
|
|
1295
|
+
if (!options.bars) return `@${formatNumber(tick / score.ticksPerBeat)}`;
|
|
1296
|
+
const position = barAt(score, tick);
|
|
1297
|
+
return position.offset === 0
|
|
1298
|
+
? ` bar ${position.bar + 1}`
|
|
1299
|
+
: ` beat ${formatNumber(tick / score.ticksPerBeat)}`;
|
|
1300
|
+
};
|
|
1301
|
+
for (const event of time?.tempo ?? [])
|
|
1302
|
+
parts.push(
|
|
1303
|
+
`${event.ramp ? (event.ramp === "exp" ? "exp→" : "→") : "="}${formatNumber(event.bpm)}${at(event.tick)}`,
|
|
1304
|
+
);
|
|
1305
|
+
for (const change of time?.meter ?? [])
|
|
1306
|
+
parts.push(
|
|
1307
|
+
`${change.beatsPerBar}/${change.beatUnit ?? 4}${options.bars ? " " : "@"}bar${options.bars ? " " : ""}${change.bar + 1}`,
|
|
1308
|
+
);
|
|
1309
|
+
for (const fermata of time?.fermatas ?? [])
|
|
1310
|
+
parts.push(`𝄐${formatNumber(fermata.beats)}${at(fermata.tick)}`);
|
|
1311
|
+
return parts.join(options.bars ? " · " : " ");
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
function formatNumber(value: number): string {
|
|
1315
|
+
return Number.isInteger(value)
|
|
1316
|
+
? String(value)
|
|
1317
|
+
: String(Math.round(value * 1000) / 1000);
|
|
1318
|
+
}
|