@hraness/dawg 0.4.1 → 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 +70 -0
- package/DAWG.md +293 -31
- 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/common.ts +30 -1
- 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 +92 -2
- package/src/audio/engine.ts +90 -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 +74 -14
- 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 +301 -26
- 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 +709 -33
- 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 +38 -5
- package/src/tui/fader.ts +409 -0
- package/src/tui/menu-time.ts +401 -0
- package/src/tui/menu.ts +635 -14
- package/src/tui/performance-menu.ts +235 -0
- package/src/tui/play-chords.ts +3 -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 +63 -1
- 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,949 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Arrangement prompt commands (0.5): song sections, the form, and the
|
|
3
|
+
* build, drop and fill generators (core/sections.ts). Bars are numbered
|
|
4
|
+
* from 1 here, as a DAW's ruler shows them; the score stores them from 0.
|
|
5
|
+
*
|
|
6
|
+
* section list sections, the form and the loop
|
|
7
|
+
* section [mark] <name> <a>-<b> | <a> mark bars a..b (re-marks an existing one)
|
|
8
|
+
* section add [<name>] [<n> bars] a new section after the last one
|
|
9
|
+
* section dup <name> [as <new>] copy it (and its bars) right after
|
|
10
|
+
* section move <name> to <bar> | left | right | before <x> | after <x>
|
|
11
|
+
* section rename <name> to <new> (also `section rename <name> <new>`)
|
|
12
|
+
* section delete <name> remove it and its bars (ripple)
|
|
13
|
+
* section unmark <name> remove the marker; the music stays
|
|
14
|
+
* section mute|unmute <name> [<track>…] (the focused track by default)
|
|
15
|
+
* section vary <name> [<track>] [+12|-5] [gain 0.8] | off
|
|
16
|
+
* section reset <name> clear its mutes and variations
|
|
17
|
+
* section loop <name> | off loop it in playback
|
|
18
|
+
* section jump <name> move the playhead to it
|
|
19
|
+
* form | form <a> <b>*2 … | form off | form bake
|
|
20
|
+
* build [into <section> | <section> | <a>-<b>] [<n> bars] [riser] [roll] [sweep] [uplifter]
|
|
21
|
+
* drop [<section> | at <bar>] [cut <beats>] [no impact]
|
|
22
|
+
* fill [<section> | at <bar>] [toms|roll|kick] [<n> beats] [no crash]
|
|
23
|
+
* (into the section: on the beats before it)
|
|
24
|
+
*
|
|
25
|
+
* Commands that do not match this grammar return undefined, so prose such
|
|
26
|
+
* as `build a bigger chorus` or `drop the bass` still reaches the agent.
|
|
27
|
+
*/
|
|
28
|
+
import {
|
|
29
|
+
ScoreValidationError,
|
|
30
|
+
type Section,
|
|
31
|
+
type TrackScore,
|
|
32
|
+
} from "../../core/score.ts";
|
|
33
|
+
import {
|
|
34
|
+
addSection,
|
|
35
|
+
arrangedBars,
|
|
36
|
+
arrangedStartBar,
|
|
37
|
+
deleteSection,
|
|
38
|
+
duplicateSection,
|
|
39
|
+
DROP_CUT_MIN,
|
|
40
|
+
FILL_STYLES,
|
|
41
|
+
findSection,
|
|
42
|
+
flattenForm,
|
|
43
|
+
formatForm,
|
|
44
|
+
generateBuild,
|
|
45
|
+
generateDrop,
|
|
46
|
+
generateFill,
|
|
47
|
+
loopSection,
|
|
48
|
+
moveSection,
|
|
49
|
+
parseForm,
|
|
50
|
+
renameSection,
|
|
51
|
+
resetSection,
|
|
52
|
+
resizeSection,
|
|
53
|
+
setSectionMute,
|
|
54
|
+
setSectionVariation,
|
|
55
|
+
shiftSection,
|
|
56
|
+
unmarkSection,
|
|
57
|
+
withForm,
|
|
58
|
+
type BuildOptions,
|
|
59
|
+
type DropOptions,
|
|
60
|
+
type FillOptions,
|
|
61
|
+
type FillStyle,
|
|
62
|
+
} from "../../core/sections.ts";
|
|
63
|
+
|
|
64
|
+
export type SectionCommand =
|
|
65
|
+
| { type: "section-list" }
|
|
66
|
+
| { type: "section-mark"; name: string; startBar: number; bars: number }
|
|
67
|
+
| { type: "section-add"; name?: string; bars?: number }
|
|
68
|
+
| { type: "section-dup"; name: string; as?: string }
|
|
69
|
+
| {
|
|
70
|
+
type: "section-move";
|
|
71
|
+
name: string;
|
|
72
|
+
to:
|
|
73
|
+
| { bar: number }
|
|
74
|
+
| { step: -1 | 1 }
|
|
75
|
+
| { before: string }
|
|
76
|
+
| { after: string };
|
|
77
|
+
}
|
|
78
|
+
| { type: "section-rename"; name: string; to: string }
|
|
79
|
+
| { type: "section-delete"; name: string }
|
|
80
|
+
| { type: "section-unmark"; name: string }
|
|
81
|
+
| {
|
|
82
|
+
type: "section-mute";
|
|
83
|
+
name: string;
|
|
84
|
+
tracks: readonly string[];
|
|
85
|
+
muted: boolean;
|
|
86
|
+
}
|
|
87
|
+
| {
|
|
88
|
+
type: "section-vary";
|
|
89
|
+
name: string;
|
|
90
|
+
track?: string;
|
|
91
|
+
transpose?: number;
|
|
92
|
+
gain?: number;
|
|
93
|
+
off?: boolean;
|
|
94
|
+
}
|
|
95
|
+
| { type: "section-reset"; name: string }
|
|
96
|
+
| { type: "section-loop"; name?: string }
|
|
97
|
+
| { type: "section-jump"; name: string }
|
|
98
|
+
| { type: "form-show" }
|
|
99
|
+
| { type: "form-set"; text: string }
|
|
100
|
+
| { type: "form-bake" }
|
|
101
|
+
| { type: "build"; options: BuildOptions }
|
|
102
|
+
| { type: "drop"; options: DropOptions }
|
|
103
|
+
| { type: "fill"; options: FillOptions }
|
|
104
|
+
/** A known subcommand naming no section: answered locally, never sent on. */
|
|
105
|
+
| { type: "section-unknown"; sub: string; name: string };
|
|
106
|
+
|
|
107
|
+
export type ArrangeResult = Readonly<{
|
|
108
|
+
ok: boolean;
|
|
109
|
+
message: string;
|
|
110
|
+
next?: TrackScore;
|
|
111
|
+
kind?: string;
|
|
112
|
+
payload?: Record<string, unknown>;
|
|
113
|
+
/** `section jump`: the arranged beat the playhead moves to. */
|
|
114
|
+
seekBeat?: number;
|
|
115
|
+
}>;
|
|
116
|
+
|
|
117
|
+
const VERBS = new Set(["section", "sections", "form", "build", "drop", "fill"]);
|
|
118
|
+
const SUBCOMMANDS = new Set([
|
|
119
|
+
"add",
|
|
120
|
+
"new",
|
|
121
|
+
"dup",
|
|
122
|
+
"duplicate",
|
|
123
|
+
"copy",
|
|
124
|
+
"move",
|
|
125
|
+
"rename",
|
|
126
|
+
"delete",
|
|
127
|
+
"remove",
|
|
128
|
+
"unmark",
|
|
129
|
+
"mute",
|
|
130
|
+
"unmute",
|
|
131
|
+
"vary",
|
|
132
|
+
"reset",
|
|
133
|
+
"loop",
|
|
134
|
+
"jump",
|
|
135
|
+
"go",
|
|
136
|
+
]);
|
|
137
|
+
|
|
138
|
+
/** `3-6` or `3..6` or `3` → 0-based start and length (1-based inclusive in). */
|
|
139
|
+
function parseBars(word: string): { startBar: number; bars: number } | null {
|
|
140
|
+
const match = /^(\d{1,4})(?:(?:-|\.\.|–)(\d{1,4}))?$/u.exec(word);
|
|
141
|
+
if (!match) return null;
|
|
142
|
+
const from = Number(match[1]);
|
|
143
|
+
const to = match[2] === undefined ? from : Number(match[2]);
|
|
144
|
+
if (from < 1 || to < from) return null;
|
|
145
|
+
return { startBar: from - 1, bars: to - from + 1 };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** A section name from `words` (names may contain spaces), case-folded. */
|
|
149
|
+
function sectionName(
|
|
150
|
+
score: TrackScore,
|
|
151
|
+
words: readonly string[],
|
|
152
|
+
): string | undefined {
|
|
153
|
+
if (words.length === 0) return undefined;
|
|
154
|
+
return findSection(score, words.join(" "))?.name;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Split `words` into a known section name prefix and the rest; the longest
|
|
159
|
+
* matching prefix wins (`chorus 2 mute` → `chorus 2`, `mute`).
|
|
160
|
+
*/
|
|
161
|
+
function leadingSection(
|
|
162
|
+
score: TrackScore,
|
|
163
|
+
words: readonly string[],
|
|
164
|
+
): { name: string; rest: readonly string[] } | undefined {
|
|
165
|
+
for (let length = words.length; length >= 1; length -= 1) {
|
|
166
|
+
const name = sectionName(score, words.slice(0, length));
|
|
167
|
+
if (name) return { name, rest: words.slice(length) };
|
|
168
|
+
}
|
|
169
|
+
return undefined;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const NAME_PATTERN = /^[\p{L}\p{N}][\p{L}\p{N} _'.-]{0,31}$/u;
|
|
173
|
+
|
|
174
|
+
/** A usable section name: not a bar range, which would read as one. */
|
|
175
|
+
const NAME = {
|
|
176
|
+
test: (name: string): boolean =>
|
|
177
|
+
NAME_PATTERN.test(name) && parseBars(name.toLowerCase()) === null,
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
export function parseSectionCommand(
|
|
181
|
+
prompt: string,
|
|
182
|
+
score: TrackScore,
|
|
183
|
+
): SectionCommand | undefined {
|
|
184
|
+
if (prompt.length > 512) return undefined;
|
|
185
|
+
const words = prompt.trim().replace(/^\//, "").split(/\s+/u);
|
|
186
|
+
const verb = words[0]?.toLowerCase();
|
|
187
|
+
if (!verb || !VERBS.has(verb)) return undefined;
|
|
188
|
+
const rest = words.slice(1);
|
|
189
|
+
const lower = rest.map((word) => word.toLowerCase());
|
|
190
|
+
if (verb === "form") return parseFormWords(score, rest, lower);
|
|
191
|
+
if (verb === "build") return parseBuild(score, lower);
|
|
192
|
+
if (verb === "drop") return parseDrop(score, lower);
|
|
193
|
+
if (verb === "fill") return parseFill(score, lower);
|
|
194
|
+
if (rest.length === 0) return { type: "section-list" };
|
|
195
|
+
const sub = lower[0]!;
|
|
196
|
+
if (SUBCOMMANDS.has(sub))
|
|
197
|
+
return (
|
|
198
|
+
parseSub(score, sub, rest.slice(1)) ??
|
|
199
|
+
unknownSection(score, sub, rest.slice(1))
|
|
200
|
+
);
|
|
201
|
+
// section [mark] <name> <a>-<b>: mark (or re-mark) bars.
|
|
202
|
+
const marked = sub === "mark" ? rest.slice(1) : rest;
|
|
203
|
+
const range = parseBars(marked[marked.length - 1]!.toLowerCase());
|
|
204
|
+
const name = marked.slice(0, -1).join(" ");
|
|
205
|
+
if (range && marked.length >= 2 && NAME.test(name))
|
|
206
|
+
return { type: "section-mark", name, ...range };
|
|
207
|
+
return undefined;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* `section <sub> <name…>` whose leading words name no section: a typo such
|
|
212
|
+
* as `section loop chrous` gets a local error instead of reaching the agent.
|
|
213
|
+
*/
|
|
214
|
+
function unknownSection(
|
|
215
|
+
score: TrackScore,
|
|
216
|
+
sub: string,
|
|
217
|
+
words: readonly string[],
|
|
218
|
+
): SectionCommand | undefined {
|
|
219
|
+
if (sub === "add" || sub === "new" || words.length === 0) return undefined;
|
|
220
|
+
const named = words[0]?.toLowerCase() === "to" ? words.slice(1) : words;
|
|
221
|
+
if (named.length === 0) return undefined;
|
|
222
|
+
if (leadingSection(score, named)) return undefined;
|
|
223
|
+
// Loop, jump and the one-word edits take the whole rest as the name;
|
|
224
|
+
// the others name the section first, then their own words.
|
|
225
|
+
const whole = new Set([
|
|
226
|
+
"loop",
|
|
227
|
+
"jump",
|
|
228
|
+
"go",
|
|
229
|
+
"delete",
|
|
230
|
+
"remove",
|
|
231
|
+
"unmark",
|
|
232
|
+
"reset",
|
|
233
|
+
]);
|
|
234
|
+
const stop = named.findIndex((word) => /^(to|as)$/iu.test(word));
|
|
235
|
+
const name = whole.has(sub)
|
|
236
|
+
? named.join(" ")
|
|
237
|
+
: sub === "rename" && stop > 0
|
|
238
|
+
? named.slice(0, stop).join(" ")
|
|
239
|
+
: named[0]!;
|
|
240
|
+
return { type: "section-unknown", sub, name };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function editDistance(a: string, b: string): number {
|
|
244
|
+
const row = Array.from({ length: b.length + 1 }, (_, index) => index);
|
|
245
|
+
for (let i = 1; i <= a.length; i += 1) {
|
|
246
|
+
let previous = row[0]!;
|
|
247
|
+
row[0] = i;
|
|
248
|
+
for (let j = 1; j <= b.length; j += 1) {
|
|
249
|
+
const current = row[j]!;
|
|
250
|
+
row[j] = Math.min(
|
|
251
|
+
row[j]! + 1,
|
|
252
|
+
row[j - 1]! + 1,
|
|
253
|
+
previous + (a[i - 1] === b[j - 1] ? 0 : 1),
|
|
254
|
+
);
|
|
255
|
+
previous = current;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
return row[b.length]!;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** `no section named chrous (sections: …) · did you mean chorus?` */
|
|
262
|
+
function unknownSectionMessage(score: TrackScore, name: string): string {
|
|
263
|
+
const wanted = name.toLowerCase();
|
|
264
|
+
let best: { name: string; distance: number } | undefined;
|
|
265
|
+
for (const section of score.sections) {
|
|
266
|
+
const distance = editDistance(wanted, section.name.toLowerCase());
|
|
267
|
+
if (!best || distance < best.distance)
|
|
268
|
+
best = { name: section.name, distance };
|
|
269
|
+
}
|
|
270
|
+
const list =
|
|
271
|
+
score.sections.length > 0
|
|
272
|
+
? ` (sections: ${score.sections.map((s) => s.name).join(", ")})`
|
|
273
|
+
: " (no sections yet: section verse 1-8 marks one)";
|
|
274
|
+
const near =
|
|
275
|
+
best && best.distance <= Math.max(2, Math.floor(wanted.length / 3))
|
|
276
|
+
? ` · did you mean ${best.name}?`
|
|
277
|
+
: "";
|
|
278
|
+
return `section · no section named ${name}${list}${near}`;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function parseSub(
|
|
282
|
+
score: TrackScore,
|
|
283
|
+
sub: string,
|
|
284
|
+
words: readonly string[],
|
|
285
|
+
): SectionCommand | undefined {
|
|
286
|
+
const lower = words.map((word) => word.toLowerCase());
|
|
287
|
+
if (sub === "add" || sub === "new") {
|
|
288
|
+
let bars: number | undefined;
|
|
289
|
+
let nameWords = [...words];
|
|
290
|
+
const barsAt = lower.findIndex((word) => /^bars?$/u.test(word));
|
|
291
|
+
if (barsAt > 0 && /^\d{1,3}$/u.test(lower[barsAt - 1]!)) {
|
|
292
|
+
bars = Number(lower[barsAt - 1]);
|
|
293
|
+
nameWords = words.slice(0, barsAt - 1);
|
|
294
|
+
} else if (/^\d{1,3}$/u.test(lower[lower.length - 1] ?? "")) {
|
|
295
|
+
bars = Number(lower[lower.length - 1]);
|
|
296
|
+
nameWords = words.slice(0, -1);
|
|
297
|
+
}
|
|
298
|
+
if (bars !== undefined && bars < 1) return undefined;
|
|
299
|
+
const name = nameWords.join(" ");
|
|
300
|
+
if (name !== "" && !NAME.test(name)) return undefined;
|
|
301
|
+
return {
|
|
302
|
+
type: "section-add",
|
|
303
|
+
...(name ? { name } : {}),
|
|
304
|
+
...(bars !== undefined ? { bars } : {}),
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
if (sub === "loop") {
|
|
308
|
+
if (words.length === 0) return undefined;
|
|
309
|
+
if (lower.length === 1 && (lower[0] === "off" || lower[0] === "none"))
|
|
310
|
+
return { type: "section-loop" };
|
|
311
|
+
const name = sectionName(score, words);
|
|
312
|
+
return name ? { type: "section-loop", name } : undefined;
|
|
313
|
+
}
|
|
314
|
+
if (sub === "jump" || sub === "go") {
|
|
315
|
+
const name = sectionName(score, words[0] === "to" ? words.slice(1) : words);
|
|
316
|
+
return name ? { type: "section-jump", name } : undefined;
|
|
317
|
+
}
|
|
318
|
+
if (sub === "rename") {
|
|
319
|
+
const toAt = lower.indexOf("to");
|
|
320
|
+
if (toAt > 0) {
|
|
321
|
+
const name = sectionName(score, words.slice(0, toAt));
|
|
322
|
+
const to = words.slice(toAt + 1).join(" ");
|
|
323
|
+
return name && NAME.test(to)
|
|
324
|
+
? { type: "section-rename", name, to }
|
|
325
|
+
: undefined;
|
|
326
|
+
}
|
|
327
|
+
const lead = leadingSection(score, words);
|
|
328
|
+
const to = lead?.rest.join(" ") ?? "";
|
|
329
|
+
return lead && NAME.test(to)
|
|
330
|
+
? { type: "section-rename", name: lead.name, to }
|
|
331
|
+
: undefined;
|
|
332
|
+
}
|
|
333
|
+
const lead = leadingSection(score, words);
|
|
334
|
+
if (!lead) return undefined;
|
|
335
|
+
const tail = lead.rest.map((word) => word.toLowerCase());
|
|
336
|
+
switch (sub) {
|
|
337
|
+
case "dup":
|
|
338
|
+
case "duplicate":
|
|
339
|
+
case "copy": {
|
|
340
|
+
if (tail.length === 0) return { type: "section-dup", name: lead.name };
|
|
341
|
+
const as = lead.rest.slice(1).join(" ");
|
|
342
|
+
return tail[0] === "as" && NAME.test(as)
|
|
343
|
+
? { type: "section-dup", name: lead.name, as }
|
|
344
|
+
: undefined;
|
|
345
|
+
}
|
|
346
|
+
case "move": {
|
|
347
|
+
const where = tail[0] === "to" ? tail.slice(1) : tail;
|
|
348
|
+
if (where.length === 1 && (where[0] === "left" || where[0] === "earlier"))
|
|
349
|
+
return { type: "section-move", name: lead.name, to: { step: -1 } };
|
|
350
|
+
if (where.length === 1 && (where[0] === "right" || where[0] === "later"))
|
|
351
|
+
return { type: "section-move", name: lead.name, to: { step: 1 } };
|
|
352
|
+
if (where.length === 2 && where[0] === "bar") where.shift();
|
|
353
|
+
if (where.length === 1 && /^\d{1,4}$/u.test(where[0]!)) {
|
|
354
|
+
const bar = Number(where[0]);
|
|
355
|
+
return bar >= 1
|
|
356
|
+
? { type: "section-move", name: lead.name, to: { bar: bar - 1 } }
|
|
357
|
+
: undefined;
|
|
358
|
+
}
|
|
359
|
+
if (where[0] === "before" || where[0] === "after") {
|
|
360
|
+
const other = sectionName(
|
|
361
|
+
score,
|
|
362
|
+
lead.rest.slice(tail.length - where.length + 1),
|
|
363
|
+
);
|
|
364
|
+
if (!other) return undefined;
|
|
365
|
+
return {
|
|
366
|
+
type: "section-move",
|
|
367
|
+
name: lead.name,
|
|
368
|
+
to: where[0] === "before" ? { before: other } : { after: other },
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
return undefined;
|
|
372
|
+
}
|
|
373
|
+
case "delete":
|
|
374
|
+
case "remove":
|
|
375
|
+
return tail.length === 0
|
|
376
|
+
? { type: "section-delete", name: lead.name }
|
|
377
|
+
: undefined;
|
|
378
|
+
case "unmark":
|
|
379
|
+
return tail.length === 0
|
|
380
|
+
? { type: "section-unmark", name: lead.name }
|
|
381
|
+
: undefined;
|
|
382
|
+
case "reset":
|
|
383
|
+
return tail.length === 0
|
|
384
|
+
? { type: "section-reset", name: lead.name }
|
|
385
|
+
: undefined;
|
|
386
|
+
case "mute":
|
|
387
|
+
case "unmute":
|
|
388
|
+
return {
|
|
389
|
+
type: "section-mute",
|
|
390
|
+
name: lead.name,
|
|
391
|
+
tracks: lead.rest,
|
|
392
|
+
muted: sub === "mute",
|
|
393
|
+
};
|
|
394
|
+
case "vary":
|
|
395
|
+
return parseVary(lead.name, lead.rest);
|
|
396
|
+
default:
|
|
397
|
+
return undefined;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function parseVary(
|
|
402
|
+
name: string,
|
|
403
|
+
words: readonly string[],
|
|
404
|
+
): SectionCommand | undefined {
|
|
405
|
+
const out: {
|
|
406
|
+
type: "section-vary";
|
|
407
|
+
name: string;
|
|
408
|
+
track?: string;
|
|
409
|
+
transpose?: number;
|
|
410
|
+
gain?: number;
|
|
411
|
+
off?: boolean;
|
|
412
|
+
} = { type: "section-vary", name };
|
|
413
|
+
for (let index = 0; index < words.length; index += 1) {
|
|
414
|
+
const word = words[index]!;
|
|
415
|
+
const lower = word.toLowerCase();
|
|
416
|
+
if (lower === "off" || lower === "reset") out.off = true;
|
|
417
|
+
else if (/^[+-]\d{1,2}$/u.test(lower)) out.transpose = Number(lower);
|
|
418
|
+
else if (lower === "transpose" || lower === "octave") {
|
|
419
|
+
const value = words[index + 1];
|
|
420
|
+
if (
|
|
421
|
+
lower === "octave" &&
|
|
422
|
+
(value === undefined || !/^[+-]?\d$/u.test(value))
|
|
423
|
+
) {
|
|
424
|
+
out.transpose = 12;
|
|
425
|
+
continue;
|
|
426
|
+
}
|
|
427
|
+
if (value === undefined || !/^[+-]?\d{1,2}$/u.test(value))
|
|
428
|
+
return undefined;
|
|
429
|
+
out.transpose = Number(value) * (lower === "octave" ? 12 : 1);
|
|
430
|
+
index += 1;
|
|
431
|
+
} else if (lower === "gain" || lower === "velocity") {
|
|
432
|
+
const value = Number(words[index + 1]);
|
|
433
|
+
if (!Number.isFinite(value)) return undefined;
|
|
434
|
+
out.gain = value;
|
|
435
|
+
index += 1;
|
|
436
|
+
} else if (out.track === undefined) out.track = word;
|
|
437
|
+
else return undefined;
|
|
438
|
+
}
|
|
439
|
+
if (!out.off && out.transpose === undefined && out.gain === undefined)
|
|
440
|
+
return undefined;
|
|
441
|
+
return out;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function parseFormWords(
|
|
445
|
+
score: TrackScore,
|
|
446
|
+
words: readonly string[],
|
|
447
|
+
lower: readonly string[],
|
|
448
|
+
): SectionCommand | undefined {
|
|
449
|
+
if (words.length === 0) return { type: "form-show" };
|
|
450
|
+
if (lower.length === 1 && (lower[0] === "bake" || lower[0] === "flatten"))
|
|
451
|
+
return { type: "form-bake" };
|
|
452
|
+
if (lower.length === 1 && (lower[0] === "off" || lower[0] === "none"))
|
|
453
|
+
return { type: "form-set", text: "" };
|
|
454
|
+
const text = words.join(" ");
|
|
455
|
+
// Prose (`form a catchy hook`) names no section at all: leave it to the
|
|
456
|
+
// agent. A form naming at least one section is a form; a typo in it gets
|
|
457
|
+
// the "no section named" error.
|
|
458
|
+
const items = text.includes(",") ? text.split(",") : words;
|
|
459
|
+
const names = items.some((item) =>
|
|
460
|
+
findSection(score, item.trim().replace(/\s*(?:\*|x|×)\s*\d+$/iu, "")),
|
|
461
|
+
);
|
|
462
|
+
return names ? { type: "form-set", text } : undefined;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** A range: a section name, `a-b` bars, or `at <bar>`; the rest is options. */
|
|
466
|
+
function parseTarget(
|
|
467
|
+
score: TrackScore,
|
|
468
|
+
words: readonly string[],
|
|
469
|
+
): {
|
|
470
|
+
section?: string;
|
|
471
|
+
/** The section came after `into` (or `at`/`in`): lead into it. */
|
|
472
|
+
into?: boolean;
|
|
473
|
+
range?: { startBar: number; bars: number };
|
|
474
|
+
rest: string[];
|
|
475
|
+
} {
|
|
476
|
+
const rest = [...words];
|
|
477
|
+
for (let index = 0; index < rest.length; index += 1) {
|
|
478
|
+
if (
|
|
479
|
+
rest[index] === "at" ||
|
|
480
|
+
rest[index] === "into" ||
|
|
481
|
+
rest[index] === "in"
|
|
482
|
+
) {
|
|
483
|
+
const tail = rest.slice(index + 1);
|
|
484
|
+
const range =
|
|
485
|
+
tail[0] === "bar" ? parseBars(tail[1] ?? "") : parseBars(tail[0] ?? "");
|
|
486
|
+
if (range) {
|
|
487
|
+
rest.splice(index, tail[0] === "bar" ? 3 : 2);
|
|
488
|
+
return { range, rest };
|
|
489
|
+
}
|
|
490
|
+
const lead = leadingSection(score, tail);
|
|
491
|
+
if (lead) {
|
|
492
|
+
rest.splice(index, tail.length - lead.rest.length + 1);
|
|
493
|
+
return { section: lead.name, into: true, rest };
|
|
494
|
+
}
|
|
495
|
+
continue;
|
|
496
|
+
}
|
|
497
|
+
const range = parseBars(rest[index]!);
|
|
498
|
+
if (
|
|
499
|
+
range &&
|
|
500
|
+
rest[index + 1] !== "beats" &&
|
|
501
|
+
rest[index + 1] !== "beat" &&
|
|
502
|
+
rest[index + 1] !== "bars" &&
|
|
503
|
+
rest[index + 1] !== "bar" &&
|
|
504
|
+
rest[index - 1] !== "cut"
|
|
505
|
+
) {
|
|
506
|
+
rest.splice(index, 1);
|
|
507
|
+
return { range, rest };
|
|
508
|
+
}
|
|
509
|
+
const lead = leadingSection(score, rest.slice(index));
|
|
510
|
+
if (lead) {
|
|
511
|
+
rest.splice(index, rest.length - index - lead.rest.length);
|
|
512
|
+
return { section: lead.name, rest };
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
return { rest };
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
const BUILD_PARTS = ["riser", "roll", "sweep", "uplifter"] as const;
|
|
519
|
+
|
|
520
|
+
function parseBuild(
|
|
521
|
+
score: TrackScore,
|
|
522
|
+
words: readonly string[],
|
|
523
|
+
): SectionCommand | undefined {
|
|
524
|
+
const target = parseTarget(score, words);
|
|
525
|
+
const parts = new Set<string>();
|
|
526
|
+
let bars: number | undefined;
|
|
527
|
+
const rest = target.rest;
|
|
528
|
+
for (let index = 0; index < rest.length; index += 1) {
|
|
529
|
+
const word = rest[index]!;
|
|
530
|
+
if (/^\d{1,3}$/u.test(word) && /^bars?$/u.test(rest[index + 1] ?? "")) {
|
|
531
|
+
bars = Number(word);
|
|
532
|
+
if (bars < 1) return undefined;
|
|
533
|
+
index += 1;
|
|
534
|
+
continue;
|
|
535
|
+
}
|
|
536
|
+
const part = word.replace(/s$/u, "");
|
|
537
|
+
if (!(BUILD_PARTS as readonly string[]).includes(part)) return undefined;
|
|
538
|
+
parts.add(part);
|
|
539
|
+
}
|
|
540
|
+
const options: Record<string, unknown> = {};
|
|
541
|
+
if (target.section)
|
|
542
|
+
options[target.into ? "into" : "section"] = target.section;
|
|
543
|
+
if (target.range) Object.assign(options, target.range);
|
|
544
|
+
if (bars !== undefined) options.bars = bars;
|
|
545
|
+
if (parts.size > 0)
|
|
546
|
+
for (const part of BUILD_PARTS) options[part] = parts.has(part);
|
|
547
|
+
return { type: "build", options: options as BuildOptions };
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function parseDrop(
|
|
551
|
+
score: TrackScore,
|
|
552
|
+
words: readonly string[],
|
|
553
|
+
): SectionCommand | undefined {
|
|
554
|
+
const target = parseTarget(score, words);
|
|
555
|
+
const options: {
|
|
556
|
+
section?: string;
|
|
557
|
+
bar?: number;
|
|
558
|
+
cut?: number;
|
|
559
|
+
impact?: boolean;
|
|
560
|
+
} = {};
|
|
561
|
+
if (target.section) options.section = target.section;
|
|
562
|
+
if (target.range) options.bar = target.range.startBar;
|
|
563
|
+
const rest = target.rest;
|
|
564
|
+
for (let index = 0; index < rest.length; index += 1) {
|
|
565
|
+
const word = rest[index]!;
|
|
566
|
+
if (word === "cut") {
|
|
567
|
+
const value = Number(rest[index + 1]);
|
|
568
|
+
if (!Number.isFinite(value) || value < DROP_CUT_MIN) return undefined;
|
|
569
|
+
options.cut = value;
|
|
570
|
+
index += rest[index + 2]?.startsWith("beat") ? 2 : 1;
|
|
571
|
+
} else if (
|
|
572
|
+
word === "nocut" ||
|
|
573
|
+
(word === "no" && rest[index + 1] === "cut")
|
|
574
|
+
) {
|
|
575
|
+
options.cut = 0;
|
|
576
|
+
if (word === "no") index += 1;
|
|
577
|
+
} else if (word === "no" && rest[index + 1] === "impact") {
|
|
578
|
+
options.impact = false;
|
|
579
|
+
index += 1;
|
|
580
|
+
} else if (word === "impact") options.impact = true;
|
|
581
|
+
else return undefined;
|
|
582
|
+
}
|
|
583
|
+
return { type: "drop", options };
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
function parseFill(
|
|
587
|
+
score: TrackScore,
|
|
588
|
+
words: readonly string[],
|
|
589
|
+
): SectionCommand | undefined {
|
|
590
|
+
const target = parseTarget(score, words);
|
|
591
|
+
const options: {
|
|
592
|
+
section?: string;
|
|
593
|
+
bar?: number;
|
|
594
|
+
beats?: number;
|
|
595
|
+
style?: FillStyle;
|
|
596
|
+
crash?: boolean;
|
|
597
|
+
} = {};
|
|
598
|
+
if (target.section) options.section = target.section;
|
|
599
|
+
// `fill at 9`: the fill leads into bar 9.
|
|
600
|
+
if (target.range) options.bar = target.range.startBar;
|
|
601
|
+
const rest = target.rest;
|
|
602
|
+
for (let index = 0; index < rest.length; index += 1) {
|
|
603
|
+
const word = rest[index]!;
|
|
604
|
+
if ((FILL_STYLES as readonly string[]).includes(word))
|
|
605
|
+
options.style = word as FillStyle;
|
|
606
|
+
else if (
|
|
607
|
+
/^\d(\.\d+)?$/u.test(word) &&
|
|
608
|
+
/^beats?$/u.test(rest[index + 1] ?? "")
|
|
609
|
+
) {
|
|
610
|
+
options.beats = Number(word);
|
|
611
|
+
index += 1;
|
|
612
|
+
} else if (word === "no" && rest[index + 1] === "crash") {
|
|
613
|
+
options.crash = false;
|
|
614
|
+
index += 1;
|
|
615
|
+
} else if (word === "crash") options.crash = true;
|
|
616
|
+
else return undefined;
|
|
617
|
+
}
|
|
618
|
+
return { type: "fill", options };
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/** `verse 1–8` (1-based inclusive). */
|
|
622
|
+
export function barsLabel(section: Pick<Section, "startBar" | "bars">): string {
|
|
623
|
+
const from = section.startBar + 1;
|
|
624
|
+
const to = section.startBar + section.bars;
|
|
625
|
+
return from === to ? `bar ${from}` : `bars ${from}–${to}`;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
function describeSection(score: TrackScore, section: Section): string {
|
|
629
|
+
const parts = [`${section.name} ${barsLabel(section)}`];
|
|
630
|
+
if (section.mute && section.mute.length > 0)
|
|
631
|
+
parts.push(`mutes ${section.mute.join(" ")}`);
|
|
632
|
+
for (const [track, vary] of Object.entries(section.vary ?? {})) {
|
|
633
|
+
const bits: string[] = [];
|
|
634
|
+
if (vary.transpose)
|
|
635
|
+
bits.push(`${vary.transpose > 0 ? "+" : ""}${vary.transpose}`);
|
|
636
|
+
if (vary.gain !== undefined && vary.gain !== 1)
|
|
637
|
+
bits.push(`gain ${vary.gain}`);
|
|
638
|
+
if (bits.length > 0) parts.push(`${track} ${bits.join(" ")}`);
|
|
639
|
+
}
|
|
640
|
+
if (score.loopSection === section.name) parts.push("looping");
|
|
641
|
+
return parts.join(" · ");
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/** One line for the activity log: sections, the form and the loop. */
|
|
645
|
+
export function describeArrangement(score: TrackScore): string {
|
|
646
|
+
if (score.sections.length === 0)
|
|
647
|
+
return "no sections · section verse 1-8 marks bars 1–8 · section add chorus 8";
|
|
648
|
+
const sections = score.sections
|
|
649
|
+
.map((section) => describeSection(score, section))
|
|
650
|
+
.join(" | ");
|
|
651
|
+
const form =
|
|
652
|
+
score.form.length > 0
|
|
653
|
+
? ` · form ${formatForm(score.form)} (${arrangedBars(score)} bars)`
|
|
654
|
+
: "";
|
|
655
|
+
return `sections · ${sections}${form}`;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
function trackIdFor(score: TrackScore, word: string): string | undefined {
|
|
659
|
+
const key = word.toLowerCase();
|
|
660
|
+
return (
|
|
661
|
+
score.tracks.find((track) => track.id.toLowerCase() === key)?.id ??
|
|
662
|
+
score.tracks.find((track) => track.name.toLowerCase() === key)?.id
|
|
663
|
+
);
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
function changed(
|
|
667
|
+
score: TrackScore,
|
|
668
|
+
next: TrackScore,
|
|
669
|
+
message: string,
|
|
670
|
+
kind = "score.sections",
|
|
671
|
+
): ArrangeResult {
|
|
672
|
+
return {
|
|
673
|
+
ok: true,
|
|
674
|
+
message,
|
|
675
|
+
next,
|
|
676
|
+
kind,
|
|
677
|
+
payload: {
|
|
678
|
+
sections: next.sections,
|
|
679
|
+
form: next.form,
|
|
680
|
+
...(next.loopSection ? { loopSection: next.loopSection } : {}),
|
|
681
|
+
...(next.bars !== score.bars ? { bars: next.bars } : {}),
|
|
682
|
+
},
|
|
683
|
+
};
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
export function applySectionCommand(
|
|
687
|
+
score: TrackScore,
|
|
688
|
+
trackId: string,
|
|
689
|
+
command: SectionCommand,
|
|
690
|
+
): ArrangeResult {
|
|
691
|
+
try {
|
|
692
|
+
return applyUnchecked(score, trackId, command);
|
|
693
|
+
} catch (error) {
|
|
694
|
+
if (error instanceof ScoreValidationError)
|
|
695
|
+
return {
|
|
696
|
+
ok: false,
|
|
697
|
+
message: `${command.type.split("-")[0]} · ${error.message}`,
|
|
698
|
+
};
|
|
699
|
+
throw error;
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
function applyUnchecked(
|
|
704
|
+
score: TrackScore,
|
|
705
|
+
trackId: string,
|
|
706
|
+
command: SectionCommand,
|
|
707
|
+
): ArrangeResult {
|
|
708
|
+
switch (command.type) {
|
|
709
|
+
case "section-unknown":
|
|
710
|
+
return { ok: false, message: unknownSectionMessage(score, command.name) };
|
|
711
|
+
case "section-list":
|
|
712
|
+
return { ok: true, message: describeArrangement(score) };
|
|
713
|
+
case "section-mark": {
|
|
714
|
+
const existing = findSection(score, command.name);
|
|
715
|
+
const next = existing
|
|
716
|
+
? resizeSection(score, existing.name, command)
|
|
717
|
+
: addSection(score, command);
|
|
718
|
+
const section = findSection(next, command.name)!;
|
|
719
|
+
return changed(
|
|
720
|
+
score,
|
|
721
|
+
next,
|
|
722
|
+
`section · ${section.name} ${barsLabel(section)}`,
|
|
723
|
+
);
|
|
724
|
+
}
|
|
725
|
+
case "section-add": {
|
|
726
|
+
const next = addSection(score, {
|
|
727
|
+
...(command.name ? { name: command.name } : {}),
|
|
728
|
+
...(command.bars ? { bars: command.bars } : {}),
|
|
729
|
+
});
|
|
730
|
+
const section = next.sections.find(
|
|
731
|
+
(candidate) => !findSection(score, candidate.name),
|
|
732
|
+
)!;
|
|
733
|
+
return changed(
|
|
734
|
+
score,
|
|
735
|
+
next,
|
|
736
|
+
`section · added ${section.name} ${barsLabel(section)}${next.bars > score.bars ? ` · song now ${next.bars} bars` : ""}`,
|
|
737
|
+
);
|
|
738
|
+
}
|
|
739
|
+
case "section-dup": {
|
|
740
|
+
const next = duplicateSection(score, command.name, {
|
|
741
|
+
...(command.as ? { as: command.as } : {}),
|
|
742
|
+
});
|
|
743
|
+
const copy = next.sections.find(
|
|
744
|
+
(candidate) => !findSection(score, candidate.name),
|
|
745
|
+
);
|
|
746
|
+
return changed(
|
|
747
|
+
score,
|
|
748
|
+
next,
|
|
749
|
+
`section · ${command.name} copied${copy ? ` as ${copy.name} ${barsLabel(copy)}` : ""}`,
|
|
750
|
+
);
|
|
751
|
+
}
|
|
752
|
+
case "section-move": {
|
|
753
|
+
const to = command.to;
|
|
754
|
+
let next: TrackScore;
|
|
755
|
+
if ("step" in to) next = shiftSection(score, command.name, to.step);
|
|
756
|
+
else if ("bar" in to) next = moveSection(score, command.name, to.bar);
|
|
757
|
+
else {
|
|
758
|
+
const other = findSection(
|
|
759
|
+
score,
|
|
760
|
+
"before" in to ? to.before : to.after,
|
|
761
|
+
)!;
|
|
762
|
+
const section = findSection(score, command.name)!;
|
|
763
|
+
let bar = "before" in to ? other.startBar : other.startBar + other.bars;
|
|
764
|
+
// Removing the moved bars first shifts later targets left.
|
|
765
|
+
if (bar > section.startBar) bar -= section.bars;
|
|
766
|
+
next = moveSection(score, command.name, bar);
|
|
767
|
+
}
|
|
768
|
+
const moved = findSection(next, command.name)!;
|
|
769
|
+
return changed(
|
|
770
|
+
score,
|
|
771
|
+
next,
|
|
772
|
+
`section · ${moved.name} now ${barsLabel(moved)}`,
|
|
773
|
+
);
|
|
774
|
+
}
|
|
775
|
+
case "section-rename": {
|
|
776
|
+
const next = renameSection(score, command.name, command.to);
|
|
777
|
+
return changed(
|
|
778
|
+
score,
|
|
779
|
+
next,
|
|
780
|
+
`section · ${command.name} renamed ${command.to}`,
|
|
781
|
+
);
|
|
782
|
+
}
|
|
783
|
+
case "section-delete": {
|
|
784
|
+
const section = findSection(score, command.name)!;
|
|
785
|
+
const next = deleteSection(score, command.name);
|
|
786
|
+
return changed(
|
|
787
|
+
score,
|
|
788
|
+
next,
|
|
789
|
+
`section · deleted ${section.name} and ${barsLabel(section)} · song now ${next.bars} bars`,
|
|
790
|
+
);
|
|
791
|
+
}
|
|
792
|
+
case "section-unmark": {
|
|
793
|
+
const next = unmarkSection(score, command.name);
|
|
794
|
+
return changed(
|
|
795
|
+
score,
|
|
796
|
+
next,
|
|
797
|
+
`section · unmarked ${command.name}; the music stays`,
|
|
798
|
+
);
|
|
799
|
+
}
|
|
800
|
+
case "section-mute": {
|
|
801
|
+
const words = command.tracks.length > 0 ? command.tracks : [trackId];
|
|
802
|
+
let next = score;
|
|
803
|
+
const names: string[] = [];
|
|
804
|
+
for (const word of words) {
|
|
805
|
+
const id = trackIdFor(score, word);
|
|
806
|
+
if (!id) return { ok: false, message: `section · no track ${word}` };
|
|
807
|
+
next = setSectionMute(next, command.name, id, command.muted);
|
|
808
|
+
names.push(id);
|
|
809
|
+
}
|
|
810
|
+
return changed(
|
|
811
|
+
score,
|
|
812
|
+
next,
|
|
813
|
+
`section · ${command.name} ${command.muted ? "mutes" : "unmutes"} ${names.join(" ")}`,
|
|
814
|
+
);
|
|
815
|
+
}
|
|
816
|
+
case "section-vary": {
|
|
817
|
+
const id = command.track ? trackIdFor(score, command.track) : trackId;
|
|
818
|
+
if (!id)
|
|
819
|
+
return { ok: false, message: `section · no track ${command.track}` };
|
|
820
|
+
const variation = command.off
|
|
821
|
+
? undefined
|
|
822
|
+
: {
|
|
823
|
+
...(command.transpose !== undefined
|
|
824
|
+
? { transpose: command.transpose }
|
|
825
|
+
: {}),
|
|
826
|
+
...(command.gain !== undefined ? { gain: command.gain } : {}),
|
|
827
|
+
};
|
|
828
|
+
const next = setSectionVariation(score, command.name, id, variation);
|
|
829
|
+
return changed(
|
|
830
|
+
score,
|
|
831
|
+
next,
|
|
832
|
+
`section · ${command.name} ${id} ${variation ? describeVary(variation) : "as written"}`,
|
|
833
|
+
);
|
|
834
|
+
}
|
|
835
|
+
case "section-reset": {
|
|
836
|
+
const next = resetSection(score, command.name);
|
|
837
|
+
return changed(score, next, `section · ${command.name} plays as written`);
|
|
838
|
+
}
|
|
839
|
+
case "section-loop": {
|
|
840
|
+
const next = loopSection(score, command.name);
|
|
841
|
+
const section = command.name
|
|
842
|
+
? findSection(next, command.name)
|
|
843
|
+
: undefined;
|
|
844
|
+
return changed(
|
|
845
|
+
score,
|
|
846
|
+
next,
|
|
847
|
+
section
|
|
848
|
+
? `section · looping ${section.name} ${barsLabel(section)}`
|
|
849
|
+
: "section · loop off · playing the song",
|
|
850
|
+
);
|
|
851
|
+
}
|
|
852
|
+
case "section-jump": {
|
|
853
|
+
const section = findSection(score, command.name)!;
|
|
854
|
+
if (score.loopSection !== undefined) {
|
|
855
|
+
// Looping another section: the loop follows the jump.
|
|
856
|
+
const next =
|
|
857
|
+
score.loopSection === section.name
|
|
858
|
+
? score
|
|
859
|
+
: loopSection(score, section.name);
|
|
860
|
+
return {
|
|
861
|
+
...(next === score
|
|
862
|
+
? {
|
|
863
|
+
ok: true,
|
|
864
|
+
message: `section · ${section.name} · bar ${section.startBar + 1}`,
|
|
865
|
+
}
|
|
866
|
+
: changed(
|
|
867
|
+
score,
|
|
868
|
+
next,
|
|
869
|
+
`section · looping ${section.name} ${barsLabel(section)}`,
|
|
870
|
+
)),
|
|
871
|
+
seekBeat: 0,
|
|
872
|
+
};
|
|
873
|
+
}
|
|
874
|
+
const bar = arrangedStartBar(score, section);
|
|
875
|
+
return {
|
|
876
|
+
ok: true,
|
|
877
|
+
message: `section · ${section.name} · bar ${bar + 1}`,
|
|
878
|
+
seekBeat: bar * score.beatsPerBar,
|
|
879
|
+
};
|
|
880
|
+
}
|
|
881
|
+
case "form-show":
|
|
882
|
+
return {
|
|
883
|
+
ok: true,
|
|
884
|
+
message:
|
|
885
|
+
score.form.length > 0
|
|
886
|
+
? `form · ${formatForm(score.form)} · ${arrangedBars(score)} bars`
|
|
887
|
+
: `form · none (plays straight through) · form ${
|
|
888
|
+
score.sections
|
|
889
|
+
.map((s) => s.name)
|
|
890
|
+
.slice(0, 3)
|
|
891
|
+
.join(" ") || "verse chorus verse"
|
|
892
|
+
}`,
|
|
893
|
+
};
|
|
894
|
+
case "form-set": {
|
|
895
|
+
const next = withForm(score, parseForm(score, command.text));
|
|
896
|
+
return changed(
|
|
897
|
+
score,
|
|
898
|
+
next,
|
|
899
|
+
next.form.length > 0
|
|
900
|
+
? `form · ${formatForm(next.form)} · ${arrangedBars(next)} bars`
|
|
901
|
+
: "form · off · plays straight through",
|
|
902
|
+
);
|
|
903
|
+
}
|
|
904
|
+
case "form-bake": {
|
|
905
|
+
if (score.form.length === 0)
|
|
906
|
+
return {
|
|
907
|
+
ok: false,
|
|
908
|
+
message: "form · no form to bake · form verse chorus verse",
|
|
909
|
+
};
|
|
910
|
+
const next = flattenForm(score);
|
|
911
|
+
return changed(score, next, `form · baked into ${next.bars} bars`);
|
|
912
|
+
}
|
|
913
|
+
case "build": {
|
|
914
|
+
const result = generateBuild(score, command.options);
|
|
915
|
+
return changed(
|
|
916
|
+
score,
|
|
917
|
+
result.score,
|
|
918
|
+
`build · ${result.summary}`,
|
|
919
|
+
"score.build",
|
|
920
|
+
);
|
|
921
|
+
}
|
|
922
|
+
case "drop": {
|
|
923
|
+
const result = generateDrop(score, command.options);
|
|
924
|
+
return changed(
|
|
925
|
+
score,
|
|
926
|
+
result.score,
|
|
927
|
+
`drop · ${result.summary}`,
|
|
928
|
+
"score.drop",
|
|
929
|
+
);
|
|
930
|
+
}
|
|
931
|
+
case "fill": {
|
|
932
|
+
const result = generateFill(score, command.options);
|
|
933
|
+
return changed(
|
|
934
|
+
score,
|
|
935
|
+
result.score,
|
|
936
|
+
`fill · ${result.summary}`,
|
|
937
|
+
"score.fill",
|
|
938
|
+
);
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
function describeVary(vary: { transpose?: number; gain?: number }): string {
|
|
944
|
+
const bits: string[] = [];
|
|
945
|
+
if (vary.transpose !== undefined)
|
|
946
|
+
bits.push(`${vary.transpose > 0 ? "+" : ""}${vary.transpose} st`);
|
|
947
|
+
if (vary.gain !== undefined) bits.push(`gain ${vary.gain}`);
|
|
948
|
+
return bits.join(" ");
|
|
949
|
+
}
|