@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
|
@@ -0,0 +1,964 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tempo map, meter, fermata and track-time commands (`core/tempo.ts`). Each
|
|
3
|
+
* is one score revision, so one undo step.
|
|
4
|
+
*
|
|
5
|
+
* tempo <bpm> at <beat>|bar <n> [ramp|exp] tempo change; ramp glides in
|
|
6
|
+
* tempo remove <beat>|bar <n> · tempo clear · tempo map
|
|
7
|
+
* rit|accel [<n> [bars|beats]] [to <bpm>] [at bar <n>|<beat>] [exp]
|
|
8
|
+
* gradual change; 75% / 133% by
|
|
9
|
+
* default, over the last bars
|
|
10
|
+
* fermata [at <beat>|[at] bar <n>|[at] end] [<extra beats>] hold a beat
|
|
11
|
+
* fermata remove <beat>|bar <n> · fermata clear
|
|
12
|
+
* meter <n>/<d> [at bar <n>] meter change at a bar line
|
|
13
|
+
* meter remove bar <n> · meter clear
|
|
14
|
+
* track rate <0.125..8>|<a>/<b>|off polytempo: the track's tempo ratio
|
|
15
|
+
* track phase <beats>|off start the track later
|
|
16
|
+
* track cycle <beats>|off polymeter: loop the first beats
|
|
17
|
+
* track phasing <cycle> [over <beats>] [cycles <n>] Reich-style drift
|
|
18
|
+
* track phasing <cycle> [hold <n>] [drift <n>] [shift <beats>]
|
|
19
|
+
* stepped, as in Piano Phase
|
|
20
|
+
* track time off follow the song again
|
|
21
|
+
*
|
|
22
|
+
* `tempo <bpm>` (the start tempo) and `meter <n>` (beats per bar) keep their
|
|
23
|
+
* own parsers. Beats count from 0, bars from 1, as in the rest of the grammar.
|
|
24
|
+
*/
|
|
25
|
+
import {
|
|
26
|
+
applyScoreOperation,
|
|
27
|
+
ScoreValidationError,
|
|
28
|
+
type ScoreOperation,
|
|
29
|
+
type TrackScore,
|
|
30
|
+
} from "../../core/score.ts";
|
|
31
|
+
import {
|
|
32
|
+
barAt,
|
|
33
|
+
barStartTick,
|
|
34
|
+
bpmAtTick,
|
|
35
|
+
describeSongTime,
|
|
36
|
+
driftRate,
|
|
37
|
+
fermataSpan,
|
|
38
|
+
loopSecondsOf,
|
|
39
|
+
loopTicksOf,
|
|
40
|
+
phaseStepsTicks,
|
|
41
|
+
type PhaseSteps,
|
|
42
|
+
TIME_LIMITS,
|
|
43
|
+
TimeValidationError,
|
|
44
|
+
withFermata,
|
|
45
|
+
withMeterChange,
|
|
46
|
+
withoutFermatas,
|
|
47
|
+
withoutTempoEvents,
|
|
48
|
+
withTempoEvent,
|
|
49
|
+
withTempoRamp,
|
|
50
|
+
type SongTime,
|
|
51
|
+
type TempoRamp,
|
|
52
|
+
type TrackTime,
|
|
53
|
+
} from "../../core/tempo.ts";
|
|
54
|
+
|
|
55
|
+
/** A place in the song: a 0-based beat or a 1-based bar's downbeat. */
|
|
56
|
+
export type TimePosition =
|
|
57
|
+
Readonly<{ beat: number }> | Readonly<{ bar: number }>;
|
|
58
|
+
|
|
59
|
+
export type TimeCommand =
|
|
60
|
+
| Readonly<{
|
|
61
|
+
type: "tempo-at";
|
|
62
|
+
bpm: number;
|
|
63
|
+
at: TimePosition;
|
|
64
|
+
ramp?: TempoRamp;
|
|
65
|
+
}>
|
|
66
|
+
| Readonly<{
|
|
67
|
+
/** `a tempo` (back to before the last rit/accel) or `tempo primo`. */
|
|
68
|
+
type: "tempo-return";
|
|
69
|
+
primo: boolean;
|
|
70
|
+
/** Absent: the first barline after the last rit/accel ends. */
|
|
71
|
+
at?: TimePosition;
|
|
72
|
+
}>
|
|
73
|
+
| Readonly<{ type: "tempo-remove"; at: TimePosition }>
|
|
74
|
+
| Readonly<{ type: "tempo-clear" }>
|
|
75
|
+
| Readonly<{ type: "tempo-map" }>
|
|
76
|
+
| Readonly<{
|
|
77
|
+
type: "gradual";
|
|
78
|
+
direction: "rit" | "accel";
|
|
79
|
+
length: number;
|
|
80
|
+
unit: "bars" | "beats";
|
|
81
|
+
bpm?: number;
|
|
82
|
+
at?: TimePosition;
|
|
83
|
+
curve: TempoRamp;
|
|
84
|
+
}>
|
|
85
|
+
| Readonly<{ type: "fermata"; at?: TimePosition; beats: number }>
|
|
86
|
+
| Readonly<{ type: "fermata-remove"; at: TimePosition }>
|
|
87
|
+
| Readonly<{ type: "fermata-clear" }>
|
|
88
|
+
| Readonly<{
|
|
89
|
+
type: "meter-at";
|
|
90
|
+
beatsPerBar: number;
|
|
91
|
+
beatUnit: number;
|
|
92
|
+
/** 1-based; absent sets the meter for the whole song. */
|
|
93
|
+
bar?: number;
|
|
94
|
+
}>
|
|
95
|
+
| Readonly<{ type: "meter-remove"; bar: number }>
|
|
96
|
+
| Readonly<{ type: "meter-clear" }>
|
|
97
|
+
| Readonly<{
|
|
98
|
+
type: "track-time";
|
|
99
|
+
field: "rate" | "phase" | "cycle";
|
|
100
|
+
/** Rate, or beats for phase and cycle; null resets the field. */
|
|
101
|
+
value: number | null;
|
|
102
|
+
}>
|
|
103
|
+
| Readonly<{
|
|
104
|
+
type: "track-phasing";
|
|
105
|
+
cycle: number;
|
|
106
|
+
over?: number;
|
|
107
|
+
cycles: number;
|
|
108
|
+
/** Stepped (Piano Phase): any of these switches to shift and hold. */
|
|
109
|
+
hold?: number;
|
|
110
|
+
drift?: number;
|
|
111
|
+
/** Beats gained per step. */
|
|
112
|
+
shift?: number;
|
|
113
|
+
}>
|
|
114
|
+
| Readonly<{ type: "track-time-off" }>;
|
|
115
|
+
|
|
116
|
+
export type TimeResult = Readonly<{
|
|
117
|
+
ok: boolean;
|
|
118
|
+
message: string;
|
|
119
|
+
next?: TrackScore;
|
|
120
|
+
kind?: string;
|
|
121
|
+
payload?: Record<string, unknown>;
|
|
122
|
+
}>;
|
|
123
|
+
|
|
124
|
+
/** MuseScore's default rit. and accel. amounts (75% and 133%). */
|
|
125
|
+
export const GRADUAL_DEFAULTS = Object.freeze({
|
|
126
|
+
ritFactor: 0.75,
|
|
127
|
+
accelFactor: 1.33,
|
|
128
|
+
bars: 2,
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
/** Extra beats a bare `fermata` holds for (the SDK's `fermata()` default). */
|
|
132
|
+
export const FERMATA_DEFAULT_BEATS = 2;
|
|
133
|
+
|
|
134
|
+
const NUMBER = /^-?\d+(?:\.\d+)?$/;
|
|
135
|
+
const UNSIGNED = /^\d+(?:\.\d+)?$/;
|
|
136
|
+
|
|
137
|
+
/** Parses one time command, or undefined when `prompt` is not one. */
|
|
138
|
+
export function parseTimeCommand(prompt: string): TimeCommand | undefined {
|
|
139
|
+
const text = prompt.trim().replace(/\s+/g, " ").toLowerCase();
|
|
140
|
+
if (text.length > 256) return undefined;
|
|
141
|
+
const words = text.split(" ");
|
|
142
|
+
const verb = words[0]!;
|
|
143
|
+
const rest = words.slice(1);
|
|
144
|
+
if (verb === "a" && rest[0] === "tempo")
|
|
145
|
+
return tempoReturnCommand(false, rest.slice(1));
|
|
146
|
+
if (verb === "tempo" && rest[0] === "primo")
|
|
147
|
+
return tempoReturnCommand(true, rest.slice(1));
|
|
148
|
+
if (verb === "tempo" || verb === "bpm") return tempoCommand(rest);
|
|
149
|
+
if (RIT.has(verb) || ACCEL.has(verb))
|
|
150
|
+
return gradualCommand(RIT.has(verb) ? "rit" : "accel", rest);
|
|
151
|
+
if (verb === "fermata") return fermataCommand(rest);
|
|
152
|
+
if (verb === "meter") return meterCommand(rest);
|
|
153
|
+
if (verb === "track") return trackTimeCommand(rest);
|
|
154
|
+
return undefined;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const RIT = new Set(["rit", "rit.", "ritardando", "rall", "rallentando"]);
|
|
158
|
+
const ACCEL = new Set(["accel", "accel.", "accelerando"]);
|
|
159
|
+
|
|
160
|
+
function tempoCommand(rest: readonly string[]): TimeCommand | undefined {
|
|
161
|
+
if (rest.length === 1 && (rest[0] === "clear" || rest[0] === "reset"))
|
|
162
|
+
return { type: "tempo-clear" };
|
|
163
|
+
if (rest.length === 1 && rest[0] === "map") return { type: "tempo-map" };
|
|
164
|
+
if (rest[0] === "remove" || rest[0] === "delete") {
|
|
165
|
+
const at = position(rest.slice(1));
|
|
166
|
+
return at && at.used === rest.length - 1
|
|
167
|
+
? { type: "tempo-remove", at: at.position }
|
|
168
|
+
: undefined;
|
|
169
|
+
}
|
|
170
|
+
// `tempo <bpm>` alone is the start tempo, parsed elsewhere.
|
|
171
|
+
if (rest.length < 3 || !UNSIGNED.test(rest[0]!) || rest[1] !== "at")
|
|
172
|
+
return undefined;
|
|
173
|
+
const bpm = Number(rest[0]);
|
|
174
|
+
const at = position(rest.slice(2));
|
|
175
|
+
if (!at || !validBpm(bpm)) return undefined;
|
|
176
|
+
const tail = rest.slice(2 + at.used);
|
|
177
|
+
if (tail.length === 0) return { type: "tempo-at", bpm, at: at.position };
|
|
178
|
+
if (tail.length !== 1) return undefined;
|
|
179
|
+
const ramp = rampWord(tail[0]!);
|
|
180
|
+
return ramp ? { type: "tempo-at", bpm, at: at.position, ramp } : undefined;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function gradualCommand(
|
|
184
|
+
direction: "rit" | "accel",
|
|
185
|
+
rest: readonly string[],
|
|
186
|
+
): TimeCommand | undefined {
|
|
187
|
+
let index = 0;
|
|
188
|
+
let length: number = GRADUAL_DEFAULTS.bars;
|
|
189
|
+
let unit: "bars" | "beats" = "bars";
|
|
190
|
+
let bpm: number | undefined;
|
|
191
|
+
let at: TimePosition | undefined;
|
|
192
|
+
let curve: TempoRamp = "linear";
|
|
193
|
+
if (rest[0] !== undefined && UNSIGNED.test(rest[0])) {
|
|
194
|
+
length = Number(rest[0]);
|
|
195
|
+
index = 1;
|
|
196
|
+
const word = rest[1];
|
|
197
|
+
if (word === "bar" || word === "bars") index = 2;
|
|
198
|
+
else if (word === "beat" || word === "beats") {
|
|
199
|
+
unit = "beats";
|
|
200
|
+
index = 2;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
if (!(length > 0)) return undefined;
|
|
204
|
+
if (unit === "bars" && !Number.isInteger(length)) return undefined;
|
|
205
|
+
while (index < rest.length) {
|
|
206
|
+
const word = rest[index]!;
|
|
207
|
+
if (word === "to" && rest[index + 1] && UNSIGNED.test(rest[index + 1]!)) {
|
|
208
|
+
bpm = Number(rest[index + 1]);
|
|
209
|
+
if (!validBpm(bpm)) return undefined;
|
|
210
|
+
index += 2;
|
|
211
|
+
} else if (word === "at" || word === "from") {
|
|
212
|
+
const found = position(rest.slice(index + 1));
|
|
213
|
+
if (!found) return undefined;
|
|
214
|
+
at = found.position;
|
|
215
|
+
index += 1 + found.used;
|
|
216
|
+
} else if (rampWord(word)) {
|
|
217
|
+
curve = rampWord(word)!;
|
|
218
|
+
index += 1;
|
|
219
|
+
} else return undefined;
|
|
220
|
+
}
|
|
221
|
+
return {
|
|
222
|
+
type: "gradual",
|
|
223
|
+
direction,
|
|
224
|
+
length,
|
|
225
|
+
unit,
|
|
226
|
+
...(bpm !== undefined ? { bpm } : {}),
|
|
227
|
+
...(at ? { at } : {}),
|
|
228
|
+
curve,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function tempoReturnCommand(
|
|
233
|
+
primo: boolean,
|
|
234
|
+
rest: readonly string[],
|
|
235
|
+
): TimeCommand | undefined {
|
|
236
|
+
if (rest.length === 0) return { type: "tempo-return", primo };
|
|
237
|
+
const words = rest[0] === "at" ? rest.slice(1) : rest;
|
|
238
|
+
const at = position(words);
|
|
239
|
+
return at && at.used === words.length
|
|
240
|
+
? { type: "tempo-return", primo, at: at.position }
|
|
241
|
+
: undefined;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function fermataCommand(rest: readonly string[]): TimeCommand | undefined {
|
|
245
|
+
if (rest.length === 1 && rest[0] === "clear")
|
|
246
|
+
return { type: "fermata-clear" };
|
|
247
|
+
if (rest[0] === "remove" || rest[0] === "delete") {
|
|
248
|
+
const words = rest[1] === "at" ? rest.slice(2) : rest.slice(1);
|
|
249
|
+
const at = position(words);
|
|
250
|
+
return at && at.used === words.length
|
|
251
|
+
? { type: "fermata-remove", at: at.position }
|
|
252
|
+
: undefined;
|
|
253
|
+
}
|
|
254
|
+
let words = rest;
|
|
255
|
+
let at: TimePosition | undefined;
|
|
256
|
+
// `at` is optional before `end` and `bar <n>`; a bare number stays the
|
|
257
|
+
// extra beats, so a beat position needs `at`.
|
|
258
|
+
if (words[0] === "end") words = words.slice(1);
|
|
259
|
+
else if (words[0] === "bar") {
|
|
260
|
+
const found = position(words);
|
|
261
|
+
if (!found) return undefined;
|
|
262
|
+
at = found.position;
|
|
263
|
+
words = words.slice(found.used);
|
|
264
|
+
} else if (words[0] === "at") {
|
|
265
|
+
if (words[1] === "end") words = words.slice(2);
|
|
266
|
+
else {
|
|
267
|
+
const found = position(words.slice(1));
|
|
268
|
+
if (!found) return undefined;
|
|
269
|
+
at = found.position;
|
|
270
|
+
words = words.slice(1 + found.used);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
if (words[0] === "for") words = words.slice(1);
|
|
274
|
+
let beats = FERMATA_DEFAULT_BEATS;
|
|
275
|
+
if (words.length > 0) {
|
|
276
|
+
if (!UNSIGNED.test(words[0]!)) return undefined;
|
|
277
|
+
beats = Number(words[0]);
|
|
278
|
+
words = words.slice(
|
|
279
|
+
words[1] === "beat" || words[1] === "beats" || words[1] === "extra"
|
|
280
|
+
? 2
|
|
281
|
+
: 1,
|
|
282
|
+
);
|
|
283
|
+
if (words[0] === "beat" || words[0] === "beats") words = words.slice(1);
|
|
284
|
+
}
|
|
285
|
+
if (words.length > 0) return undefined;
|
|
286
|
+
if (!(beats > 0 && beats <= TIME_LIMITS.maxFermataBeats)) return undefined;
|
|
287
|
+
return { type: "fermata", ...(at ? { at } : {}), beats };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function meterCommand(rest: readonly string[]): TimeCommand | undefined {
|
|
291
|
+
if (rest.length === 1 && rest[0] === "clear") return { type: "meter-clear" };
|
|
292
|
+
if (rest[0] === "remove" || rest[0] === "delete") {
|
|
293
|
+
let words = rest.slice(1);
|
|
294
|
+
if (words[0] === "at") words = words.slice(1);
|
|
295
|
+
if (words[0] === "bar") words = words.slice(1);
|
|
296
|
+
if (words.length !== 1 || !/^\d+$/.test(words[0]!)) return undefined;
|
|
297
|
+
const bar = Number(words[0]);
|
|
298
|
+
return bar >= 1 ? { type: "meter-remove", bar } : undefined;
|
|
299
|
+
}
|
|
300
|
+
const signature = rest[0]?.match(/^(\d+)(?:\/(\d+))?$/);
|
|
301
|
+
if (!signature) return undefined;
|
|
302
|
+
const beatsPerBar = Number(signature[1]);
|
|
303
|
+
const beatUnit = signature[2] === undefined ? 4 : Number(signature[2]);
|
|
304
|
+
if (
|
|
305
|
+
beatsPerBar < 1 ||
|
|
306
|
+
beatsPerBar > TIME_LIMITS.maxBeatsPerBar ||
|
|
307
|
+
!TIME_LIMITS.beatUnits.includes(beatUnit)
|
|
308
|
+
)
|
|
309
|
+
return undefined;
|
|
310
|
+
if (rest.length === 1)
|
|
311
|
+
// `meter 3` stays the edit command; `meter 6/8` covers the whole song.
|
|
312
|
+
return signature[2] === undefined
|
|
313
|
+
? undefined
|
|
314
|
+
: { type: "meter-at", beatsPerBar, beatUnit };
|
|
315
|
+
let words = rest.slice(1);
|
|
316
|
+
if (words[0] !== "at" && words[0] !== "from") return undefined;
|
|
317
|
+
words = words.slice(1);
|
|
318
|
+
if (words[0] === "bar") words = words.slice(1);
|
|
319
|
+
if (words.length !== 1 || !/^\d+$/.test(words[0]!)) return undefined;
|
|
320
|
+
const bar = Number(words[0]);
|
|
321
|
+
if (bar < 1) return undefined;
|
|
322
|
+
return { type: "meter-at", beatsPerBar, beatUnit, bar };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function trackTimeCommand(rest: readonly string[]): TimeCommand | undefined {
|
|
326
|
+
const [field, value, ...tail] = rest;
|
|
327
|
+
if (field === "time")
|
|
328
|
+
return rest.length === 2 && (value === "off" || value === "reset")
|
|
329
|
+
? { type: "track-time-off" }
|
|
330
|
+
: undefined;
|
|
331
|
+
if (field === "rate" || field === "phase" || field === "cycle") {
|
|
332
|
+
if (value === undefined || tail.length > 0) return undefined;
|
|
333
|
+
if (value === "off" || value === "reset")
|
|
334
|
+
return { type: "track-time", field, value: null };
|
|
335
|
+
const ratio = value.match(/^(\d+(?:\.\d+)?)[/:](\d+(?:\.\d+)?)$/);
|
|
336
|
+
const number = value.replace(/x$/, "");
|
|
337
|
+
if (!ratio && !NUMBER.test(number)) return undefined;
|
|
338
|
+
if (ratio && field !== "rate") return undefined;
|
|
339
|
+
const parsed = ratio ? Number(ratio[1]) / Number(ratio[2]) : Number(number);
|
|
340
|
+
if (!Number.isFinite(parsed)) return undefined;
|
|
341
|
+
// An out-of-range rate parses, so applying it can name the range.
|
|
342
|
+
if (field === "rate" && !(parsed > 0)) return undefined;
|
|
343
|
+
if (field === "cycle" && !(parsed > 0)) return undefined;
|
|
344
|
+
return { type: "track-time", field, value: parsed };
|
|
345
|
+
}
|
|
346
|
+
if (field === "phasing") {
|
|
347
|
+
if (value === undefined || !UNSIGNED.test(value)) return undefined;
|
|
348
|
+
const cycle = Number(value);
|
|
349
|
+
if (!(cycle > 0)) return undefined;
|
|
350
|
+
let over: number | undefined;
|
|
351
|
+
let cycles = 1;
|
|
352
|
+
const stepped: { hold?: number; drift?: number; shift?: number } = {};
|
|
353
|
+
let index = 0;
|
|
354
|
+
while (index < tail.length) {
|
|
355
|
+
const word = tail[index]!;
|
|
356
|
+
const next = tail[index + 1];
|
|
357
|
+
if (next === undefined) return undefined;
|
|
358
|
+
if (word === "over" && UNSIGNED.test(next)) over = Number(next);
|
|
359
|
+
else if (word === "cycles" && NUMBER.test(next)) cycles = Number(next);
|
|
360
|
+
else if (
|
|
361
|
+
(word === "hold" || word === "drift" || word === "shift") &&
|
|
362
|
+
UNSIGNED.test(next)
|
|
363
|
+
)
|
|
364
|
+
stepped[word] = Number(next);
|
|
365
|
+
else return undefined;
|
|
366
|
+
index += 2;
|
|
367
|
+
}
|
|
368
|
+
if (over !== undefined && !(over > 0)) return undefined;
|
|
369
|
+
if (cycles === 0) return undefined;
|
|
370
|
+
if (Object.keys(stepped).length > 0 && (over !== undefined || cycles !== 1))
|
|
371
|
+
return undefined;
|
|
372
|
+
return {
|
|
373
|
+
type: "track-phasing",
|
|
374
|
+
cycle,
|
|
375
|
+
...(over !== undefined ? { over } : {}),
|
|
376
|
+
cycles,
|
|
377
|
+
...stepped,
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
return undefined;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** `12`, `bar 3`, `beat 12`: the position and the words it used. */
|
|
384
|
+
function position(
|
|
385
|
+
words: readonly string[],
|
|
386
|
+
): { position: TimePosition; used: number } | undefined {
|
|
387
|
+
if (words[0] === "bar") {
|
|
388
|
+
const value = words[1];
|
|
389
|
+
if (value === undefined || !/^\d+$/.test(value)) return undefined;
|
|
390
|
+
const bar = Number(value);
|
|
391
|
+
return bar >= 1 ? { position: { bar }, used: 2 } : undefined;
|
|
392
|
+
}
|
|
393
|
+
const skip = words[0] === "beat" ? 1 : 0;
|
|
394
|
+
const value = words[skip];
|
|
395
|
+
if (value === undefined || !UNSIGNED.test(value)) return undefined;
|
|
396
|
+
return { position: { beat: Number(value) }, used: skip + 1 };
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function rampWord(word: string): TempoRamp | undefined {
|
|
400
|
+
if (word === "ramp" || word === "linear" || word === "glide") return "linear";
|
|
401
|
+
if (word === "exp" || word === "exponential") return "exp";
|
|
402
|
+
return undefined;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function validBpm(bpm: number): boolean {
|
|
406
|
+
return (
|
|
407
|
+
Number.isFinite(bpm) &&
|
|
408
|
+
bpm >= TIME_LIMITS.minBpm &&
|
|
409
|
+
bpm <= TIME_LIMITS.maxBpm
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// ---------------------------------------------------------------------------
|
|
414
|
+
// Applying
|
|
415
|
+
|
|
416
|
+
/** Applies `command` (track commands to `trackId`) as one revision. */
|
|
417
|
+
export function applyTimeCommand(
|
|
418
|
+
score: TrackScore,
|
|
419
|
+
trackId: string,
|
|
420
|
+
command: TimeCommand,
|
|
421
|
+
): TimeResult {
|
|
422
|
+
try {
|
|
423
|
+
return applyOrThrow(score, trackId, command);
|
|
424
|
+
} catch (error) {
|
|
425
|
+
if (
|
|
426
|
+
error instanceof TimeValidationError ||
|
|
427
|
+
error instanceof ScoreValidationError
|
|
428
|
+
)
|
|
429
|
+
return { ok: false, message: error.message };
|
|
430
|
+
throw error;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function applyOrThrow(
|
|
435
|
+
score: TrackScore,
|
|
436
|
+
trackId: string,
|
|
437
|
+
command: TimeCommand,
|
|
438
|
+
): TimeResult {
|
|
439
|
+
switch (command.type) {
|
|
440
|
+
case "tempo-map":
|
|
441
|
+
return {
|
|
442
|
+
ok: true,
|
|
443
|
+
message: `tempo map · ${num(score.tempoBpm)} BPM${score.time ? ` · ${describeSongTime(score, { bars: true })}` : " throughout"} · ${num(loopSecondsOf(score))} s`,
|
|
444
|
+
};
|
|
445
|
+
case "tempo-at": {
|
|
446
|
+
const tick = tickOf(score, command.at);
|
|
447
|
+
const where = placeLabel(command.at);
|
|
448
|
+
if (tick >= loopTicksOf(score))
|
|
449
|
+
return { ok: false, message: `tempo · ${where} is past the song end` };
|
|
450
|
+
if (tick === 0) {
|
|
451
|
+
const next = applyScoreOperation(score, {
|
|
452
|
+
type: "setTempo",
|
|
453
|
+
tempoBpm: command.bpm,
|
|
454
|
+
});
|
|
455
|
+
return songResult(
|
|
456
|
+
next,
|
|
457
|
+
`tempo · ${fmt(command.bpm)} BPM`,
|
|
458
|
+
"score.tempo",
|
|
459
|
+
{ tempoBpm: command.bpm },
|
|
460
|
+
);
|
|
461
|
+
}
|
|
462
|
+
const time = withTempoEvent(score.time, {
|
|
463
|
+
tick,
|
|
464
|
+
bpm: command.bpm,
|
|
465
|
+
...(command.ramp ? { ramp: command.ramp } : {}),
|
|
466
|
+
});
|
|
467
|
+
const glide = command.ramp
|
|
468
|
+
? ` (${command.ramp === "exp" ? "exp ramp" : "ramp"})`
|
|
469
|
+
: "";
|
|
470
|
+
return timeResult(
|
|
471
|
+
score,
|
|
472
|
+
time,
|
|
473
|
+
`tempo · ${fmt(command.bpm)} BPM at ${where}${glide}`,
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
case "tempo-return":
|
|
477
|
+
return tempoReturn(score, command);
|
|
478
|
+
case "tempo-remove": {
|
|
479
|
+
const [from, to] = spanOf(score, command.at);
|
|
480
|
+
const before = score.time?.tempo?.length ?? 0;
|
|
481
|
+
const time = withoutTempoEvents(score.time, from, to);
|
|
482
|
+
const removed = before - (time?.tempo?.length ?? 0);
|
|
483
|
+
if (removed === 0)
|
|
484
|
+
return {
|
|
485
|
+
ok: false,
|
|
486
|
+
message: `tempo · no change at ${placeLabel(command.at)}`,
|
|
487
|
+
};
|
|
488
|
+
return timeResult(
|
|
489
|
+
score,
|
|
490
|
+
time,
|
|
491
|
+
`tempo · removed ${removed} change${removed === 1 ? "" : "s"} at ${placeLabel(command.at)}`,
|
|
492
|
+
);
|
|
493
|
+
}
|
|
494
|
+
case "tempo-clear": {
|
|
495
|
+
if (!score.time?.tempo)
|
|
496
|
+
return { ok: true, message: `tempo · ${fmt(score.tempoBpm)} BPM` };
|
|
497
|
+
const time = score.time ? { ...score.time, tempo: [] } : undefined;
|
|
498
|
+
return timeResult(
|
|
499
|
+
score,
|
|
500
|
+
time,
|
|
501
|
+
`tempo · ${fmt(score.tempoBpm)} BPM throughout`,
|
|
502
|
+
);
|
|
503
|
+
}
|
|
504
|
+
case "gradual":
|
|
505
|
+
return gradual(score, command);
|
|
506
|
+
case "fermata": {
|
|
507
|
+
// `at end`: the start of the last felt beat (a dotted quarter in 6/8).
|
|
508
|
+
const end = loopTicksOf(score);
|
|
509
|
+
const tick = command.at
|
|
510
|
+
? tickOf(score, command.at)
|
|
511
|
+
: Math.max(0, end - fermataSpan(score, Math.max(0, end - 1)));
|
|
512
|
+
if (tick >= loopTicksOf(score))
|
|
513
|
+
return {
|
|
514
|
+
ok: false,
|
|
515
|
+
message: `fermata · ${placeLabel(command.at!)} is past the song end`,
|
|
516
|
+
};
|
|
517
|
+
const time = withFermata(score.time, { tick, beats: command.beats });
|
|
518
|
+
return timeResult(
|
|
519
|
+
score,
|
|
520
|
+
time,
|
|
521
|
+
`fermata · +${fmt(command.beats)} beat${command.beats === 1 ? "" : "s"} at beat ${fmt(tick / score.ticksPerBeat)}`,
|
|
522
|
+
);
|
|
523
|
+
}
|
|
524
|
+
case "fermata-remove": {
|
|
525
|
+
const [from, to] = spanOf(score, command.at);
|
|
526
|
+
const before = score.time?.fermatas?.length ?? 0;
|
|
527
|
+
const time = withoutFermatas(score.time, from, to);
|
|
528
|
+
if ((time?.fermatas?.length ?? 0) === before)
|
|
529
|
+
return {
|
|
530
|
+
ok: false,
|
|
531
|
+
message: `fermata · none at ${placeLabel(command.at)}`,
|
|
532
|
+
};
|
|
533
|
+
return timeResult(
|
|
534
|
+
score,
|
|
535
|
+
time,
|
|
536
|
+
`fermata · removed at ${placeLabel(command.at)}`,
|
|
537
|
+
);
|
|
538
|
+
}
|
|
539
|
+
case "fermata-clear": {
|
|
540
|
+
const time = score.time ? { ...score.time, fermatas: [] } : undefined;
|
|
541
|
+
return timeResult(score, time, "fermata · none");
|
|
542
|
+
}
|
|
543
|
+
case "meter-at":
|
|
544
|
+
return meterAt(score, command);
|
|
545
|
+
case "meter-remove": {
|
|
546
|
+
const bar = command.bar - 1;
|
|
547
|
+
if (!score.time?.meter?.some((change) => change.bar === bar))
|
|
548
|
+
return {
|
|
549
|
+
ok: false,
|
|
550
|
+
message: `meter · no change at bar ${command.bar}`,
|
|
551
|
+
};
|
|
552
|
+
const time = withMeterChange(score.time, bar, null);
|
|
553
|
+
return timeResult(
|
|
554
|
+
score,
|
|
555
|
+
time,
|
|
556
|
+
`meter · bar ${command.bar} follows the bar before`,
|
|
557
|
+
);
|
|
558
|
+
}
|
|
559
|
+
case "meter-clear": {
|
|
560
|
+
const time = score.time ? { ...score.time, meter: [] } : undefined;
|
|
561
|
+
return timeResult(
|
|
562
|
+
score,
|
|
563
|
+
time,
|
|
564
|
+
`meter · ${score.beatsPerBar}/4 throughout`,
|
|
565
|
+
);
|
|
566
|
+
}
|
|
567
|
+
case "track-time":
|
|
568
|
+
case "track-phasing":
|
|
569
|
+
case "track-time-off":
|
|
570
|
+
return trackTime(score, trackId, command);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function gradual(
|
|
575
|
+
score: TrackScore,
|
|
576
|
+
command: Extract<TimeCommand, { type: "gradual" }>,
|
|
577
|
+
): TimeResult {
|
|
578
|
+
const loopTicks = loopTicksOf(score);
|
|
579
|
+
const tpb = score.ticksPerBeat;
|
|
580
|
+
let from: number;
|
|
581
|
+
let to: number;
|
|
582
|
+
if (command.at) {
|
|
583
|
+
from = tickOf(score, command.at);
|
|
584
|
+
if (command.unit === "beats") to = from + Math.round(command.length * tpb);
|
|
585
|
+
else {
|
|
586
|
+
const bar = barAt(score, from);
|
|
587
|
+
to = barStartTick(score, bar.bar + command.length) + bar.offset;
|
|
588
|
+
}
|
|
589
|
+
} else {
|
|
590
|
+
// No start: the last bars (or beats) of the song, as a closing rit.
|
|
591
|
+
to = loopTicks;
|
|
592
|
+
from =
|
|
593
|
+
command.unit === "beats"
|
|
594
|
+
? to - Math.round(command.length * tpb)
|
|
595
|
+
: barStartTick(score, Math.max(0, score.bars - command.length));
|
|
596
|
+
from = Math.max(0, from);
|
|
597
|
+
}
|
|
598
|
+
if (from >= loopTicks)
|
|
599
|
+
return {
|
|
600
|
+
ok: false,
|
|
601
|
+
message: `${command.direction} · ${placeLabel(command.at!)} is past the song end`,
|
|
602
|
+
};
|
|
603
|
+
if (!(to > from))
|
|
604
|
+
return { ok: false, message: `${command.direction} · needs a length` };
|
|
605
|
+
if (to > TIME_LIMITS.maxTick)
|
|
606
|
+
return { ok: false, message: `${command.direction} · runs too long` };
|
|
607
|
+
const start = bpmAtTick(score, from);
|
|
608
|
+
const factor =
|
|
609
|
+
command.direction === "rit"
|
|
610
|
+
? GRADUAL_DEFAULTS.ritFactor
|
|
611
|
+
: GRADUAL_DEFAULTS.accelFactor;
|
|
612
|
+
const target =
|
|
613
|
+
command.bpm ??
|
|
614
|
+
Math.min(
|
|
615
|
+
TIME_LIMITS.maxBpm,
|
|
616
|
+
Math.max(TIME_LIMITS.minBpm, Math.round(start * factor * 100) / 100),
|
|
617
|
+
);
|
|
618
|
+
if (command.direction === "rit" && target > start)
|
|
619
|
+
return {
|
|
620
|
+
ok: false,
|
|
621
|
+
message: `rit · ${fmt(target)} BPM is faster than ${fmt(start)}; use accel`,
|
|
622
|
+
};
|
|
623
|
+
if (command.direction === "accel" && target < start)
|
|
624
|
+
return {
|
|
625
|
+
ok: false,
|
|
626
|
+
message: `accel · ${fmt(target)} BPM is slower than ${fmt(start)}; use rit`,
|
|
627
|
+
};
|
|
628
|
+
const time = withTempoRamp(score, from, to, target, command.curve);
|
|
629
|
+
const span = spanLabel(score, from, to);
|
|
630
|
+
return timeResult(
|
|
631
|
+
score,
|
|
632
|
+
time,
|
|
633
|
+
`${command.direction} · ${fmt(start)} → ${fmt(target)} BPM ${span}${command.curve === "exp" ? " (exp)" : ""}`,
|
|
634
|
+
);
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function meterAt(
|
|
638
|
+
score: TrackScore,
|
|
639
|
+
command: Extract<TimeCommand, { type: "meter-at" }>,
|
|
640
|
+
): TimeResult {
|
|
641
|
+
const label = `${command.beatsPerBar}/${command.beatUnit}`;
|
|
642
|
+
if (command.bar === undefined) {
|
|
643
|
+
// The whole song: beats per bar is the one source of the song meter
|
|
644
|
+
// (`meter <n>` sets it too); a note value other than a quarter adds a
|
|
645
|
+
// matching change at bar 1, which `meter <n>` drops again.
|
|
646
|
+
const metered = applyScoreOperation(score, {
|
|
647
|
+
type: "setMeter",
|
|
648
|
+
beatsPerBar: command.beatsPerBar,
|
|
649
|
+
});
|
|
650
|
+
if (command.beatUnit === 4)
|
|
651
|
+
return songResult(metered, `meter · ${label}`, "score.meter", {
|
|
652
|
+
beatsPerBar: command.beatsPerBar,
|
|
653
|
+
});
|
|
654
|
+
return timeResult(
|
|
655
|
+
metered,
|
|
656
|
+
withMeterChange(metered.time, 0, command),
|
|
657
|
+
`meter · ${label}`,
|
|
658
|
+
);
|
|
659
|
+
}
|
|
660
|
+
if (command.bar > score.bars)
|
|
661
|
+
return {
|
|
662
|
+
ok: false,
|
|
663
|
+
message: `meter · bar ${command.bar} is past the song end (${score.bars} bars)`,
|
|
664
|
+
};
|
|
665
|
+
const time = withMeterChange(score.time, command.bar - 1, command);
|
|
666
|
+
const result = timeResult(
|
|
667
|
+
score,
|
|
668
|
+
time,
|
|
669
|
+
`meter · ${label} from bar ${command.bar}`,
|
|
670
|
+
);
|
|
671
|
+
return result.next ? withPastEnd(result, result.next) : result;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/** Name the notes and fermatas a shorter song left past its end. */
|
|
675
|
+
function withPastEnd(result: TimeResult, next: TrackScore): TimeResult {
|
|
676
|
+
const end = loopTicksOf(next);
|
|
677
|
+
const notes = next.notes.filter((note) => note.startTick >= end).length;
|
|
678
|
+
const fermatas = (next.time?.fermatas ?? []).filter(
|
|
679
|
+
(fermata) => fermata.tick >= end,
|
|
680
|
+
).length;
|
|
681
|
+
if (notes === 0 && fermatas === 0) return result;
|
|
682
|
+
const parts = [
|
|
683
|
+
notes > 0 ? `${notes} note${notes === 1 ? "" : "s"}` : "",
|
|
684
|
+
fermatas > 0 ? `${fermatas} fermata${fermatas === 1 ? "" : "s"}` : "",
|
|
685
|
+
].filter(Boolean);
|
|
686
|
+
const beats = end / next.ticksPerBeat;
|
|
687
|
+
return {
|
|
688
|
+
...result,
|
|
689
|
+
message: `${result.message} · ${parts.join(" and ")} now past the song end (beat ${fmt(beats)}); add bars to keep them`,
|
|
690
|
+
};
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
function trackTime(
|
|
694
|
+
score: TrackScore,
|
|
695
|
+
trackId: string,
|
|
696
|
+
command: Extract<
|
|
697
|
+
TimeCommand,
|
|
698
|
+
{ type: "track-time" | "track-phasing" | "track-time-off" }
|
|
699
|
+
>,
|
|
700
|
+
): TimeResult {
|
|
701
|
+
const track = score.tracks.find((candidate) => candidate.id === trackId);
|
|
702
|
+
if (!track) return { ok: false, message: `no track ${trackId}` };
|
|
703
|
+
const tpb = score.ticksPerBeat;
|
|
704
|
+
const current: {
|
|
705
|
+
rate?: number;
|
|
706
|
+
phase?: number;
|
|
707
|
+
cycle?: number;
|
|
708
|
+
steps?: PhaseSteps;
|
|
709
|
+
} = {
|
|
710
|
+
...track.time,
|
|
711
|
+
};
|
|
712
|
+
let message: string;
|
|
713
|
+
if (command.type === "track-time-off") {
|
|
714
|
+
for (const key of Object.keys(current)) delete current[key as "rate"];
|
|
715
|
+
message = `${trackId} · follows the song`;
|
|
716
|
+
} else if (
|
|
717
|
+
command.type === "track-phasing" &&
|
|
718
|
+
(command.hold !== undefined ||
|
|
719
|
+
command.drift !== undefined ||
|
|
720
|
+
command.shift !== undefined)
|
|
721
|
+
) {
|
|
722
|
+
const cycle = Math.round(command.cycle * tpb);
|
|
723
|
+
const shiftBeats = command.shift ?? 0.25;
|
|
724
|
+
const shift = Math.round(shiftBeats * tpb);
|
|
725
|
+
const hold = command.hold ?? 8;
|
|
726
|
+
const drift = command.drift ?? 2;
|
|
727
|
+
if (cycle < 1)
|
|
728
|
+
return { ok: false, message: "track phasing · cycle is too short" };
|
|
729
|
+
if (shift < 1 || shift > cycle)
|
|
730
|
+
return {
|
|
731
|
+
ok: false,
|
|
732
|
+
message: `track phasing · shift must be more than 0 and at most the ${fmt(command.cycle)}-beat cycle`,
|
|
733
|
+
};
|
|
734
|
+
if (!Number.isInteger(hold) || hold > 64)
|
|
735
|
+
return {
|
|
736
|
+
ok: false,
|
|
737
|
+
message: "track phasing · hold is 0..64 whole cycles",
|
|
738
|
+
};
|
|
739
|
+
if (!Number.isInteger(drift) || drift < 1 || drift > 64)
|
|
740
|
+
return {
|
|
741
|
+
ok: false,
|
|
742
|
+
message: "track phasing · drift is 1..64 whole cycles",
|
|
743
|
+
};
|
|
744
|
+
const steps = { shift, hold, drift };
|
|
745
|
+
delete current.rate;
|
|
746
|
+
current.cycle = cycle;
|
|
747
|
+
(current as { steps?: PhaseSteps }).steps = steps;
|
|
748
|
+
const turn = phaseStepsTicks(cycle, steps);
|
|
749
|
+
const loop = loopTicksOf(score);
|
|
750
|
+
const bars = turn > loop ? barsForWhole(score, turn) : undefined;
|
|
751
|
+
message = `${trackId} · phasing ${fmt(command.cycle)}-beat cycle in steps of ${fmt(shiftBeats)}: hold ${hold}, drift ${drift} (a full turn is ${fmt(turn / tpb)} beats${turn > loop ? `, longer than the loop; restarts at its end${bars === undefined ? "" : ` · bars ${bars}`}` : ""})`;
|
|
752
|
+
} else if (command.type === "track-phasing") {
|
|
753
|
+
const cycle = Math.round(command.cycle * tpb);
|
|
754
|
+
if (cycle < 1)
|
|
755
|
+
return { ok: false, message: "track phasing · cycle is too short" };
|
|
756
|
+
delete (current as { steps?: PhaseSteps }).steps;
|
|
757
|
+
const over =
|
|
758
|
+
command.over !== undefined
|
|
759
|
+
? Math.round(command.over * tpb)
|
|
760
|
+
: loopTicksOf(score);
|
|
761
|
+
// Placement restarts every loop pass, so the pair only realigns when
|
|
762
|
+
// the loop holds whole spans and the span whole cycles.
|
|
763
|
+
const loop = loopTicksOf(score);
|
|
764
|
+
if (over > loop || loop % over !== 0 || over % cycle !== 0) {
|
|
765
|
+
const bars = barsForWhole(
|
|
766
|
+
score,
|
|
767
|
+
command.over === undefined ? cycle : lcm(over, cycle),
|
|
768
|
+
);
|
|
769
|
+
const span = `phasing ${fmt(command.cycle)}${command.over !== undefined ? ` over ${fmt(command.over)}` : ""}`;
|
|
770
|
+
return {
|
|
771
|
+
ok: false,
|
|
772
|
+
message:
|
|
773
|
+
over % cycle !== 0 && command.over !== undefined
|
|
774
|
+
? `track phasing · over ${fmt(over / tpb)} beats is not a whole number of ${fmt(command.cycle)}-beat cycles`
|
|
775
|
+
: bars === undefined
|
|
776
|
+
? `track phasing · ${span} does not fit a loop of whole cycles`
|
|
777
|
+
: `track phasing · ${span} needs a ${bars}-bar loop · bars ${bars}`,
|
|
778
|
+
};
|
|
779
|
+
}
|
|
780
|
+
const rate = driftRate(over, cycle, command.cycles);
|
|
781
|
+
if (!(rate >= TIME_LIMITS.minRate && rate <= TIME_LIMITS.maxRate))
|
|
782
|
+
return {
|
|
783
|
+
ok: false,
|
|
784
|
+
message: `track phasing · needs a rate between ${TIME_LIMITS.minRate} and ${TIME_LIMITS.maxRate}`,
|
|
785
|
+
};
|
|
786
|
+
current.cycle = cycle;
|
|
787
|
+
current.rate = rate;
|
|
788
|
+
message = `${trackId} · phasing ${fmt(command.cycle)}-beat cycle, rate ${fmt(rate, 4)} (realigns every ${fmt(over / tpb)} beats)`;
|
|
789
|
+
} else if (command.value === null) {
|
|
790
|
+
delete current[command.field];
|
|
791
|
+
message = `${trackId} · ${command.field} off`;
|
|
792
|
+
} else if (command.field === "rate") {
|
|
793
|
+
if (
|
|
794
|
+
command.value < TIME_LIMITS.minRate ||
|
|
795
|
+
command.value > TIME_LIMITS.maxRate
|
|
796
|
+
)
|
|
797
|
+
return {
|
|
798
|
+
ok: false,
|
|
799
|
+
message: `track rate · ${fmt(command.value, 4)} is out of range; use ${TIME_LIMITS.minRate}..${TIME_LIMITS.maxRate} or a ratio like 3/2`,
|
|
800
|
+
};
|
|
801
|
+
current.rate = command.value;
|
|
802
|
+
message = `${trackId} · rate ${fmt(command.value, 4)}×`;
|
|
803
|
+
} else {
|
|
804
|
+
const ticks = Math.round(command.value * tpb);
|
|
805
|
+
if (command.field === "cycle" && ticks < 1)
|
|
806
|
+
return { ok: false, message: "track cycle · too short" };
|
|
807
|
+
current[command.field] = ticks;
|
|
808
|
+
message = `${trackId} · ${command.field} ${fmt(command.value)} beat${command.value === 1 ? "" : "s"}`;
|
|
809
|
+
if (command.field === "cycle" && loopTicksOf(score) % ticks !== 0) {
|
|
810
|
+
const bars = barsForWhole(score, ticks);
|
|
811
|
+
message += ` · restarts at the loop end${bars === undefined ? "" : `; bars ${bars} for whole cycles`}`;
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
const time: TrackTime | null = Object.keys(current).length
|
|
815
|
+
? (current as TrackTime)
|
|
816
|
+
: null;
|
|
817
|
+
const operation: ScoreOperation = {
|
|
818
|
+
type: "updateTrack",
|
|
819
|
+
trackId,
|
|
820
|
+
patch: { time },
|
|
821
|
+
};
|
|
822
|
+
const next = applyScoreOperation(score, operation);
|
|
823
|
+
return {
|
|
824
|
+
ok: true,
|
|
825
|
+
message,
|
|
826
|
+
next,
|
|
827
|
+
kind: "track.time",
|
|
828
|
+
payload: { trackId, time },
|
|
829
|
+
};
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
function gcd(a: number, b: number): number {
|
|
833
|
+
while (b) [a, b] = [b, a % b];
|
|
834
|
+
return a;
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
function lcm(a: number, b: number): number {
|
|
838
|
+
return (a / gcd(a, b)) * b;
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
/**
|
|
842
|
+
* Bars whose loop is a whole multiple of `ticks`: the fewest at or above
|
|
843
|
+
* the song's length, else the fewest overall; undefined when none fits.
|
|
844
|
+
*/
|
|
845
|
+
function barsForWhole(score: TrackScore, ticks: number): number | undefined {
|
|
846
|
+
let fewest: number | undefined;
|
|
847
|
+
for (let bars = 1; bars <= TIME_LIMITS.maxBars; bars += 1) {
|
|
848
|
+
if (barStartTick(score, bars) % ticks !== 0) continue;
|
|
849
|
+
if (bars >= score.bars) return bars;
|
|
850
|
+
fewest ??= bars;
|
|
851
|
+
}
|
|
852
|
+
return fewest;
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
function timeResult(
|
|
856
|
+
score: TrackScore,
|
|
857
|
+
time: SongTime | undefined,
|
|
858
|
+
message: string,
|
|
859
|
+
): TimeResult {
|
|
860
|
+
const operation: ScoreOperation = { type: "setTime", time: time ?? null };
|
|
861
|
+
const next = applyScoreOperation(score, operation);
|
|
862
|
+
return songResult(next, message, "score.time", {
|
|
863
|
+
time: next.time ?? null,
|
|
864
|
+
});
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
function songResult(
|
|
868
|
+
next: TrackScore,
|
|
869
|
+
message: string,
|
|
870
|
+
kind: string,
|
|
871
|
+
payload: Record<string, unknown>,
|
|
872
|
+
): TimeResult {
|
|
873
|
+
return { ok: true, message, next, kind, payload };
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
/** The tick a position names. */
|
|
877
|
+
export function tickOf(score: TrackScore, at: TimePosition): number {
|
|
878
|
+
return "bar" in at
|
|
879
|
+
? barStartTick(score, at.bar - 1)
|
|
880
|
+
: Math.round(at.beat * score.ticksPerBeat);
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
/** A beat names one tick; a bar names its whole span. */
|
|
884
|
+
function spanOf(score: TrackScore, at: TimePosition): [number, number] {
|
|
885
|
+
if ("beat" in at) {
|
|
886
|
+
const tick = tickOf(score, at);
|
|
887
|
+
return [tick, tick];
|
|
888
|
+
}
|
|
889
|
+
return [barStartTick(score, at.bar - 1), barStartTick(score, at.bar) - 1];
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
/**
|
|
893
|
+
* `a tempo` steps back to the tempo before the last rit/accel; `tempo
|
|
894
|
+
* primo` steps to the song's start tempo. Both are plain steps.
|
|
895
|
+
*/
|
|
896
|
+
function tempoReturn(
|
|
897
|
+
score: TrackScore,
|
|
898
|
+
command: Extract<TimeCommand, { type: "tempo-return" }>,
|
|
899
|
+
): TimeResult {
|
|
900
|
+
const name = command.primo ? "tempo primo" : "a tempo";
|
|
901
|
+
const events = [...(score.time?.tempo ?? [])].sort((a, b) => a.tick - b.tick);
|
|
902
|
+
const limit = command.at === undefined ? Infinity : tickOf(score, command.at);
|
|
903
|
+
let last = -1;
|
|
904
|
+
events.forEach((event, index) => {
|
|
905
|
+
if (event.ramp && event.tick < limit) last = index;
|
|
906
|
+
});
|
|
907
|
+
let tick: number;
|
|
908
|
+
if (command.at !== undefined) tick = limit;
|
|
909
|
+
else {
|
|
910
|
+
if (last < 0)
|
|
911
|
+
return {
|
|
912
|
+
ok: false,
|
|
913
|
+
message: `${name} · no rit or accel to come back from; say where: ${name} at bar <n>`,
|
|
914
|
+
};
|
|
915
|
+
// The ramp's own tick holds its target, so the step lands on the next
|
|
916
|
+
// barline after it.
|
|
917
|
+
tick = barStartTick(score, barAt(score, events[last]!.tick).bar + 1);
|
|
918
|
+
}
|
|
919
|
+
const where = command.at
|
|
920
|
+
? placeLabel(command.at)
|
|
921
|
+
: `bar ${barAt(score, tick).bar + 1}`;
|
|
922
|
+
if (tick >= loopTicksOf(score))
|
|
923
|
+
return { ok: false, message: `${name} · ${where} is past the song end` };
|
|
924
|
+
if (tick === 0)
|
|
925
|
+
return {
|
|
926
|
+
ok: false,
|
|
927
|
+
message: `${name} · bar 1 already plays the start tempo`,
|
|
928
|
+
};
|
|
929
|
+
let bpm = score.tempoBpm;
|
|
930
|
+
if (!command.primo) {
|
|
931
|
+
if (last < 0)
|
|
932
|
+
return {
|
|
933
|
+
ok: false,
|
|
934
|
+
message: `a tempo · no rit or accel before ${where}; use tempo <bpm> at ${where}`,
|
|
935
|
+
};
|
|
936
|
+
bpm = last > 0 ? events[last - 1]!.bpm : score.tempoBpm;
|
|
937
|
+
}
|
|
938
|
+
const time = withTempoEvent(score.time, { tick, bpm });
|
|
939
|
+
return timeResult(score, time, `${name} · ${fmt(bpm)} BPM at ${where}`);
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
function placeLabel(at: TimePosition): string {
|
|
943
|
+
return "bar" in at ? `bar ${at.bar}` : `beat ${fmt(at.beat)}`;
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
function spanLabel(score: TrackScore, from: number, to: number): string {
|
|
947
|
+
const start = barAt(score, from);
|
|
948
|
+
const end = barAt(score, to);
|
|
949
|
+
if (start.offset === 0 && end.offset === 0) {
|
|
950
|
+
const bars = end.bar - start.bar;
|
|
951
|
+
return `over ${bars} bar${bars === 1 ? "" : "s"} from bar ${start.bar + 1}`;
|
|
952
|
+
}
|
|
953
|
+
const beats = (to - from) / score.ticksPerBeat;
|
|
954
|
+
return `over ${fmt(beats)} beat${beats === 1 ? "" : "s"} from beat ${fmt(from / score.ticksPerBeat)}`;
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
function num(value: number): string {
|
|
958
|
+
return fmt(value);
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
function fmt(value: number, digits = 2): string {
|
|
962
|
+
const scale = 10 ** digits;
|
|
963
|
+
return String(Math.round(value * scale) / scale);
|
|
964
|
+
}
|