@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
package/core/sections.ts
ADDED
|
@@ -0,0 +1,2046 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Song structure (0.5). Sections are named bar ranges of the score and the
|
|
3
|
+
* form is the order they play in, with repeats (`A A B A`). Everything here
|
|
4
|
+
* is pure: it reads a `TrackScore` and returns a new one, so the renderer,
|
|
5
|
+
* the TUI, prompt commands, agent tools and the SDK share one behaviour.
|
|
6
|
+
*
|
|
7
|
+
* Arrangement semantics follow the arranger tracks of Logic Pro and
|
|
8
|
+
* Studio One: a section owns the music inside its bars, so duplicating,
|
|
9
|
+
* moving or deleting a section moves that music and ripples what follows,
|
|
10
|
+
* while renaming or unmarking only touches the marker. Playback follows
|
|
11
|
+
* the form when one exists; otherwise the score plays straight through and
|
|
12
|
+
* each bar takes the mutes and variations of the section covering it.
|
|
13
|
+
*/
|
|
14
|
+
import { DRUM_VOICES, isDrumInstrument } from "./drums.ts";
|
|
15
|
+
import { FX_LANES } from "./fx.ts";
|
|
16
|
+
import {
|
|
17
|
+
SCORE_LIMITS,
|
|
18
|
+
ScoreValidationError,
|
|
19
|
+
TrackScore,
|
|
20
|
+
isSamplerInstrument,
|
|
21
|
+
wavetableOf,
|
|
22
|
+
type AutomationPoint,
|
|
23
|
+
type FormEntry,
|
|
24
|
+
type Note,
|
|
25
|
+
type Section,
|
|
26
|
+
type SectionVariation,
|
|
27
|
+
type Track,
|
|
28
|
+
} from "./score.ts";
|
|
29
|
+
import {
|
|
30
|
+
barStartTick,
|
|
31
|
+
bpmAtTick,
|
|
32
|
+
hasTrackTime,
|
|
33
|
+
normalizeSongTime,
|
|
34
|
+
performedNotes,
|
|
35
|
+
type Fermata,
|
|
36
|
+
type SongTime,
|
|
37
|
+
type TempoEvent,
|
|
38
|
+
} from "./tempo.ts";
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The song tempo map for `pieces` of the score laid end to end: each piece
|
|
42
|
+
* starts at the tempo sounding at its source tick, keeps the tempo events
|
|
43
|
+
* and fermatas inside it (a ramp cut by the piece glides on from that
|
|
44
|
+
* tempo, and one running past its end glides to the tempo reached there),
|
|
45
|
+
* and the cut score starts at the first piece's tempo. Sections need one
|
|
46
|
+
* meter, so a bar-1 meter is kept as it is. Scores without a tempo map come
|
|
47
|
+
* back unchanged.
|
|
48
|
+
*/
|
|
49
|
+
export function sliceSongTime(
|
|
50
|
+
score: TrackScore,
|
|
51
|
+
pieces: readonly { from: number; to: number; offset: number }[],
|
|
52
|
+
): Readonly<{ tempoBpm: number; time?: SongTime }> {
|
|
53
|
+
if (!score.time?.tempo && !score.time?.fermatas)
|
|
54
|
+
return {
|
|
55
|
+
tempoBpm: score.tempoBpm,
|
|
56
|
+
...(score.time ? { time: score.time } : {}),
|
|
57
|
+
};
|
|
58
|
+
const clampBpm = (bpm: number) =>
|
|
59
|
+
Math.min(SCORE_LIMITS.maxTempoBpm, Math.max(SCORE_LIMITS.minTempoBpm, bpm));
|
|
60
|
+
const sorted = [...pieces].sort((a, b) => a.offset - b.offset);
|
|
61
|
+
const first = sorted[0];
|
|
62
|
+
const tempoBpm = clampBpm(
|
|
63
|
+
first && first.offset === 0 ? bpmAtTick(score, first.from) : score.tempoBpm,
|
|
64
|
+
);
|
|
65
|
+
const tempo: TempoEvent[] = [];
|
|
66
|
+
const fermatas: Fermata[] = [];
|
|
67
|
+
let current = tempoBpm;
|
|
68
|
+
for (const piece of sorted) {
|
|
69
|
+
const shift = piece.offset - piece.from;
|
|
70
|
+
const inner = (score.time.tempo ?? []).filter(
|
|
71
|
+
(event) => event.tick >= piece.from && event.tick < piece.to,
|
|
72
|
+
);
|
|
73
|
+
const startBpm = clampBpm(bpmAtTick(score, piece.from));
|
|
74
|
+
if (
|
|
75
|
+
piece.offset > 0 &&
|
|
76
|
+
Math.abs(startBpm - current) > 1e-9 &&
|
|
77
|
+
!inner.some((event) => event.tick === piece.from)
|
|
78
|
+
)
|
|
79
|
+
tempo.push({ tick: piece.offset, bpm: startBpm });
|
|
80
|
+
current = startBpm;
|
|
81
|
+
for (const event of inner) {
|
|
82
|
+
const tick = event.tick + shift;
|
|
83
|
+
if (tick < 1) {
|
|
84
|
+
current = event.bpm;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
tempo.push({ ...event, tick });
|
|
88
|
+
current = event.bpm;
|
|
89
|
+
}
|
|
90
|
+
// A ramp running on past the piece's end: glide to the tempo it has
|
|
91
|
+
// reached a tick before the end, so the piece follows the curve (a
|
|
92
|
+
// sub-span of a linear or exponential ramp is the same kind of ramp).
|
|
93
|
+
const crossing = (score.time.tempo ?? []).find(
|
|
94
|
+
(event) => event.tick >= piece.to,
|
|
95
|
+
);
|
|
96
|
+
const lastTick = inner.at(-1)?.tick ?? piece.from;
|
|
97
|
+
if (crossing?.ramp && piece.to - 1 > lastTick) {
|
|
98
|
+
const bpm = clampBpm(bpmAtTick(score, piece.to - 1));
|
|
99
|
+
const tick = piece.to - 1 + shift;
|
|
100
|
+
if (tick >= 1 && Math.abs(bpm - current) > 1e-9)
|
|
101
|
+
tempo.push({ tick, bpm, ramp: crossing.ramp });
|
|
102
|
+
}
|
|
103
|
+
// The tempo at the piece's end, for the next piece's step.
|
|
104
|
+
current = clampBpm(bpmAtTick(score, Math.max(piece.from, piece.to - 1)));
|
|
105
|
+
for (const fermata of score.time.fermatas ?? [])
|
|
106
|
+
if (fermata.tick >= piece.from && fermata.tick < piece.to)
|
|
107
|
+
fermatas.push({ ...fermata, tick: fermata.tick + shift });
|
|
108
|
+
}
|
|
109
|
+
const time = normalizeSongTime({
|
|
110
|
+
...(tempo.length > 0 ? { tempo } : {}),
|
|
111
|
+
...(fermatas.length > 0 ? { fermatas } : {}),
|
|
112
|
+
// The one meter sections allow (a compound meter held from bar 1).
|
|
113
|
+
...(score.time.meter ? { meter: score.time.meter } : {}),
|
|
114
|
+
});
|
|
115
|
+
return { tempoBpm, ...(time ? { time } : {}) };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Tracks with their own `time` (rate, phase, cycle) placed into song ticks,
|
|
120
|
+
* so sections and forms cut and repeat what those tracks actually play.
|
|
121
|
+
* Later repetitions get `~p2`, `~p3` ids. Without timed tracks, or without
|
|
122
|
+
* sections, the score comes back unchanged.
|
|
123
|
+
*/
|
|
124
|
+
export function bakeTrackTime(score: TrackScore): TrackScore {
|
|
125
|
+
if (score.sections.length === 0 || !hasTrackTime(score)) return score;
|
|
126
|
+
const seen = new Map<string, number>();
|
|
127
|
+
const notes = performedNotes(score).map((note) => {
|
|
128
|
+
const count = (seen.get(note.id) ?? 0) + 1;
|
|
129
|
+
seen.set(note.id, count);
|
|
130
|
+
return {
|
|
131
|
+
...note,
|
|
132
|
+
id: count === 1 ? note.id : `${note.id}~p${count}`,
|
|
133
|
+
startTick: Math.round(note.startTick),
|
|
134
|
+
durationTicks: Math.max(1, Math.round(note.durationTicks)),
|
|
135
|
+
};
|
|
136
|
+
});
|
|
137
|
+
return new TrackScore({
|
|
138
|
+
...score.toJSON(),
|
|
139
|
+
tracks: score.tracks.map(({ time: _time, ...track }) => track),
|
|
140
|
+
notes,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Conventional section names, offered first by `nextSectionName`. */
|
|
145
|
+
export const SECTION_KINDS = Object.freeze([
|
|
146
|
+
"intro",
|
|
147
|
+
"verse",
|
|
148
|
+
"pre",
|
|
149
|
+
"chorus",
|
|
150
|
+
"build",
|
|
151
|
+
"drop",
|
|
152
|
+
"breakdown",
|
|
153
|
+
"bridge",
|
|
154
|
+
"outro",
|
|
155
|
+
] as const);
|
|
156
|
+
|
|
157
|
+
/** Longest song the arranged renderer produces, in seconds. */
|
|
158
|
+
export const MAX_SONG_SECONDS = 900;
|
|
159
|
+
|
|
160
|
+
/** One played pass of a form entry. */
|
|
161
|
+
export type FormSegment = Readonly<{
|
|
162
|
+
section: Section;
|
|
163
|
+
/** Index of the form entry this pass belongs to. */
|
|
164
|
+
entry: number;
|
|
165
|
+
/** 0-based repeat of that entry. */
|
|
166
|
+
pass: number;
|
|
167
|
+
/** First bar on the arranged (playback) timeline. */
|
|
168
|
+
startBar: number;
|
|
169
|
+
bars: number;
|
|
170
|
+
}>;
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Ticks per bar. Sections need one meter, which may be a compound one held
|
|
174
|
+
* from bar 1 (`time.meter` bar 0, such as 6/8).
|
|
175
|
+
*/
|
|
176
|
+
export function barTicks(score: TrackScore): number {
|
|
177
|
+
return score.time?.meter
|
|
178
|
+
? barStartTick(score, 1)
|
|
179
|
+
: score.beatsPerBar * score.ticksPerBeat;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Beats (quarter notes) per bar in the sections' meter. */
|
|
183
|
+
export function barBeats(score: TrackScore): number {
|
|
184
|
+
return barTicks(score) / score.ticksPerBeat;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** The section called `name`, ignoring case and extra spaces. */
|
|
188
|
+
export function findSection(
|
|
189
|
+
score: TrackScore,
|
|
190
|
+
name: string,
|
|
191
|
+
): Section | undefined {
|
|
192
|
+
const key = foldName(name);
|
|
193
|
+
return score.sections.find((section) => foldName(section.name) === key);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function foldName(name: string): string {
|
|
197
|
+
return name.trim().replace(/\s+/gu, " ").toLowerCase();
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function requireSection(score: TrackScore, name: string): Section {
|
|
201
|
+
const section = findSection(score, name);
|
|
202
|
+
if (!section)
|
|
203
|
+
throw new ScoreValidationError(
|
|
204
|
+
`no section named ${name}${score.sections.length > 0 ? ` (sections: ${score.sections.map((s) => s.name).join(", ")})` : ""}`,
|
|
205
|
+
);
|
|
206
|
+
return section;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Every pass of the form in order; empty when the song has no form. */
|
|
210
|
+
export function formSegments(score: TrackScore): readonly FormSegment[] {
|
|
211
|
+
const segments: FormSegment[] = [];
|
|
212
|
+
let bar = 0;
|
|
213
|
+
score.form.forEach((entry, index) => {
|
|
214
|
+
const section = findSection(score, entry.section);
|
|
215
|
+
if (!section) return;
|
|
216
|
+
for (let pass = 0; pass < (entry.repeat ?? 1); pass += 1) {
|
|
217
|
+
segments.push({
|
|
218
|
+
section,
|
|
219
|
+
entry: index,
|
|
220
|
+
pass,
|
|
221
|
+
startBar: bar,
|
|
222
|
+
bars: section.bars,
|
|
223
|
+
});
|
|
224
|
+
bar += section.bars;
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
return segments;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Bars playback runs through: the form's length, or the score's. */
|
|
231
|
+
export function arrangedBars(score: TrackScore): number {
|
|
232
|
+
const segments = formSegments(score);
|
|
233
|
+
if (segments.length === 0) return score.bars;
|
|
234
|
+
const last = segments[segments.length - 1]!;
|
|
235
|
+
return last.startBar + last.bars;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Seconds at the score tempo. */
|
|
239
|
+
export function barsToSeconds(score: TrackScore, bars: number): number {
|
|
240
|
+
return (bars * barBeats(score) * 60) / score.tempoBpm;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Where arranged (playback) `beat` falls: the form pass and score beat. */
|
|
244
|
+
export function formPositionAt(
|
|
245
|
+
score: TrackScore,
|
|
246
|
+
beat: number,
|
|
247
|
+
): Readonly<{ segment?: FormSegment; index: number; scoreBeat: number }> {
|
|
248
|
+
const segments = formSegments(score);
|
|
249
|
+
const bpb = barBeats(score);
|
|
250
|
+
if (segments.length === 0) {
|
|
251
|
+
const total = score.bars * bpb;
|
|
252
|
+
return { index: -1, scoreBeat: wrap(beat, total) };
|
|
253
|
+
}
|
|
254
|
+
const total = arrangedBars(score) * bpb;
|
|
255
|
+
const at = wrap(beat, total);
|
|
256
|
+
let index = segments.findIndex(
|
|
257
|
+
(segment) => at < (segment.startBar + segment.bars) * bpb,
|
|
258
|
+
);
|
|
259
|
+
if (index < 0) index = segments.length - 1;
|
|
260
|
+
const segment = segments[index]!;
|
|
261
|
+
return {
|
|
262
|
+
segment,
|
|
263
|
+
index,
|
|
264
|
+
scoreBeat: segment.section.startBar * bpb + (at - segment.startBar * bpb),
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function wrap(value: number, length: number): number {
|
|
269
|
+
if (!Number.isFinite(value) || length <= 0) return 0;
|
|
270
|
+
return ((value % length) + length) % length;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** First arranged bar where `section` plays (its score bar without a form). */
|
|
274
|
+
export function arrangedStartBar(score: TrackScore, section: Section): number {
|
|
275
|
+
const segment = formSegments(score).find(
|
|
276
|
+
(candidate) => candidate.section.name === section.name,
|
|
277
|
+
);
|
|
278
|
+
return segment ? segment.startBar : section.startBar;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* The section governing `bar` when the score plays straight through: of the
|
|
283
|
+
* sections covering it, the one that starts last (the first listed on a tie).
|
|
284
|
+
*/
|
|
285
|
+
export function sectionAtBar(
|
|
286
|
+
score: TrackScore,
|
|
287
|
+
bar: number,
|
|
288
|
+
): Section | undefined {
|
|
289
|
+
let found: Section | undefined;
|
|
290
|
+
for (const section of score.sections) {
|
|
291
|
+
if (bar < section.startBar || bar >= section.startBar + section.bars)
|
|
292
|
+
continue;
|
|
293
|
+
if (!found || section.startBar > found.startBar) found = section;
|
|
294
|
+
}
|
|
295
|
+
return found;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** True when playback differs from plain straight-through rendering. */
|
|
299
|
+
export function hasArrangement(score: TrackScore): boolean {
|
|
300
|
+
return (
|
|
301
|
+
score.form.length > 0 ||
|
|
302
|
+
score.sections.some(
|
|
303
|
+
(section) => section.mute !== undefined || section.vary !== undefined,
|
|
304
|
+
)
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// ---------------------------------------------------------------------------
|
|
309
|
+
// Rendering helpers
|
|
310
|
+
|
|
311
|
+
/** Pitched tracks transpose; drum kits and one-shot samplers do not. */
|
|
312
|
+
function transposes(track: Track | undefined): boolean {
|
|
313
|
+
if (!track) return false;
|
|
314
|
+
if (isDrumInstrument(track.instrument)) return false;
|
|
315
|
+
if (isSamplerInstrument(track.instrument))
|
|
316
|
+
return track.sampler?.mode === "keyed";
|
|
317
|
+
return true;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function varyNote(
|
|
321
|
+
note: Note,
|
|
322
|
+
variation: SectionVariation | undefined,
|
|
323
|
+
track: Track | undefined,
|
|
324
|
+
): Note {
|
|
325
|
+
if (!variation) return note;
|
|
326
|
+
let { pitch, velocity } = note;
|
|
327
|
+
if (variation.transpose !== undefined && transposes(track))
|
|
328
|
+
pitch = Math.max(0, Math.min(127, pitch + variation.transpose));
|
|
329
|
+
if (variation.gain !== undefined)
|
|
330
|
+
velocity = Math.max(0, Math.min(1, velocity * variation.gain));
|
|
331
|
+
return pitch === note.pitch && velocity === note.velocity
|
|
332
|
+
? note
|
|
333
|
+
: { ...note, pitch, velocity };
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* The score as it sounds straight through: notes in a section's bars take
|
|
338
|
+
* its mutes and variations. Returns `score` itself when nothing changes, so
|
|
339
|
+
* songs without section mutes or variations render byte-identically.
|
|
340
|
+
*/
|
|
341
|
+
export function applySectionChanges(score: TrackScore): TrackScore {
|
|
342
|
+
if (
|
|
343
|
+
!score.sections.some(
|
|
344
|
+
(section) => section.mute !== undefined || section.vary !== undefined,
|
|
345
|
+
)
|
|
346
|
+
)
|
|
347
|
+
return score;
|
|
348
|
+
const ticks = barTicks(score);
|
|
349
|
+
const tracks = new Map(score.tracks.map((track) => [track.id, track]));
|
|
350
|
+
let changed = false;
|
|
351
|
+
const notes: Note[] = [];
|
|
352
|
+
for (const note of score.notes) {
|
|
353
|
+
const section = sectionAtBar(score, Math.floor(note.startTick / ticks));
|
|
354
|
+
if (section?.mute?.includes(note.trackId)) {
|
|
355
|
+
changed = true;
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
const varied = varyNote(
|
|
359
|
+
note,
|
|
360
|
+
section?.vary?.[note.trackId],
|
|
361
|
+
tracks.get(note.trackId),
|
|
362
|
+
);
|
|
363
|
+
if (varied !== note) changed = true;
|
|
364
|
+
// A held note stops where a later section mutes its track, the way a
|
|
365
|
+
// form pass cuts it at the section edge.
|
|
366
|
+
const startBar = Math.floor(note.startTick / ticks);
|
|
367
|
+
const endTick = note.startTick + varied.durationTicks;
|
|
368
|
+
let cut = varied;
|
|
369
|
+
for (let bar = startBar + 1; bar * ticks < endTick; bar += 1) {
|
|
370
|
+
if (sectionAtBar(score, bar)?.mute?.includes(note.trackId)) {
|
|
371
|
+
cut = { ...varied, durationTicks: bar * ticks - note.startTick };
|
|
372
|
+
changed = true;
|
|
373
|
+
break;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
notes.push(cut);
|
|
377
|
+
}
|
|
378
|
+
if (!changed) return score;
|
|
379
|
+
return new TrackScore({ ...score.toJSON(), notes, sections: [], form: [] });
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* One section as a standalone score: its bars only, notes clipped at its
|
|
384
|
+
* end, automation carried across the cut, with its mutes and variations.
|
|
385
|
+
* This is what a section loop plays and what each form pass renders.
|
|
386
|
+
*/
|
|
387
|
+
export function sectionScore(score: TrackScore, section: Section): TrackScore {
|
|
388
|
+
const ticks = barTicks(score);
|
|
389
|
+
const from = section.startBar * ticks;
|
|
390
|
+
const to = from + section.bars * ticks;
|
|
391
|
+
const tracks = new Map(score.tracks.map((track) => [track.id, track]));
|
|
392
|
+
const notes: Note[] = [];
|
|
393
|
+
for (const note of score.notes) {
|
|
394
|
+
if (note.startTick < from || note.startTick >= to) continue;
|
|
395
|
+
if (section.mute?.includes(note.trackId)) continue;
|
|
396
|
+
const varied = varyNote(
|
|
397
|
+
note,
|
|
398
|
+
section.vary?.[note.trackId],
|
|
399
|
+
tracks.get(note.trackId),
|
|
400
|
+
);
|
|
401
|
+
notes.push({
|
|
402
|
+
...varied,
|
|
403
|
+
startTick: note.startTick - from,
|
|
404
|
+
durationTicks: Math.max(
|
|
405
|
+
1,
|
|
406
|
+
Math.min(note.durationTicks, to - note.startTick),
|
|
407
|
+
),
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
const timed = sliceSongTime(score, [{ from, to, offset: 0 }]);
|
|
411
|
+
return new TrackScore({
|
|
412
|
+
...score.toJSON(),
|
|
413
|
+
tempoBpm: timed.tempoBpm,
|
|
414
|
+
time: timed.time ?? null,
|
|
415
|
+
bars: section.bars,
|
|
416
|
+
tracks: score.tracks.map((track) =>
|
|
417
|
+
mapAutomation(track, (points) => cropPoints(points, from, to)),
|
|
418
|
+
),
|
|
419
|
+
notes,
|
|
420
|
+
sections: [],
|
|
421
|
+
form: [],
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// ---------------------------------------------------------------------------
|
|
426
|
+
// Automation over time edits
|
|
427
|
+
|
|
428
|
+
type Points = readonly AutomationPoint[];
|
|
429
|
+
|
|
430
|
+
const TRACK_LANES = [
|
|
431
|
+
"volumeAutomation",
|
|
432
|
+
"panAutomation",
|
|
433
|
+
"filterAutomation",
|
|
434
|
+
"resonanceAutomation",
|
|
435
|
+
"delayFeedbackAutomation",
|
|
436
|
+
"delayMixAutomation",
|
|
437
|
+
"wtAutomation",
|
|
438
|
+
] as const;
|
|
439
|
+
|
|
440
|
+
/** Applies `edit` to every automation lane of `track`. */
|
|
441
|
+
function mapAutomation(track: Track, edit: (points: Points) => Points): Track {
|
|
442
|
+
const out: Record<string, unknown> = { ...track };
|
|
443
|
+
let changed = false;
|
|
444
|
+
for (const field of TRACK_LANES) {
|
|
445
|
+
const points = track[field];
|
|
446
|
+
if (!points || points.length === 0) continue;
|
|
447
|
+
const next = edit(points);
|
|
448
|
+
if (next !== points) {
|
|
449
|
+
out[field] = next;
|
|
450
|
+
changed = true;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
if (track.fxAutomation) {
|
|
454
|
+
const lanes: Record<string, Points> = {};
|
|
455
|
+
let lanesChanged = false;
|
|
456
|
+
for (const [lane, points] of Object.entries(track.fxAutomation)) {
|
|
457
|
+
if (!points) continue;
|
|
458
|
+
const next = points.length === 0 ? points : edit(points);
|
|
459
|
+
if (next !== points) lanesChanged = true;
|
|
460
|
+
lanes[lane] = next;
|
|
461
|
+
}
|
|
462
|
+
if (lanesChanged) {
|
|
463
|
+
out.fxAutomation = lanes;
|
|
464
|
+
changed = true;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
return changed ? (out as Track) : track;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/** The lane value at `tick` (undefined before the first point). */
|
|
471
|
+
function valueAt(points: Points, tick: number): number | undefined {
|
|
472
|
+
const first = points[0];
|
|
473
|
+
if (!first || tick < first.tick) return undefined;
|
|
474
|
+
for (let index = 1; index < points.length; index += 1) {
|
|
475
|
+
const right = points[index]!;
|
|
476
|
+
if (tick > right.tick) continue;
|
|
477
|
+
const left = points[index - 1]!;
|
|
478
|
+
const span = right.tick - left.tick;
|
|
479
|
+
return span <= 0
|
|
480
|
+
? right.value
|
|
481
|
+
: left.value + ((right.value - left.value) * (tick - left.tick)) / span;
|
|
482
|
+
}
|
|
483
|
+
return points[points.length - 1]!.value;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function sortPoints(points: AutomationPoint[]): Points {
|
|
487
|
+
const byTick = new Map<number, AutomationPoint>();
|
|
488
|
+
for (const point of points) byTick.set(point.tick, point);
|
|
489
|
+
return [...byTick.values()].sort((a, b) => a.tick - b.tick);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/** The curve over `from..to`, re-based to 0 (before the first point stays static). */
|
|
493
|
+
function cropPoints(points: Points, from: number, to: number): Points {
|
|
494
|
+
const out: AutomationPoint[] = [];
|
|
495
|
+
const start = valueAt(points, from);
|
|
496
|
+
if (start !== undefined) out.push({ tick: 0, value: start });
|
|
497
|
+
for (const point of points)
|
|
498
|
+
if (point.tick > from && point.tick < to)
|
|
499
|
+
out.push({ tick: point.tick - from, value: point.value });
|
|
500
|
+
const last = points[points.length - 1];
|
|
501
|
+
if (last && last.tick >= to && points[0]!.tick < to) {
|
|
502
|
+
const end = valueAt(points, to);
|
|
503
|
+
if (end !== undefined) out.push({ tick: to - from, value: end });
|
|
504
|
+
}
|
|
505
|
+
return sortPoints(out);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/** Remove `count` ticks at `at`: f'(t) = f(t) before `at`, f(t + count) after. */
|
|
509
|
+
function deletePoints(points: Points, at: number, count: number): Points {
|
|
510
|
+
const first = points[0];
|
|
511
|
+
const last = points[points.length - 1];
|
|
512
|
+
if (!first || !last || last.tick < at) return points;
|
|
513
|
+
const out: AutomationPoint[] = points.filter((point) => point.tick < at);
|
|
514
|
+
const before = out[out.length - 1];
|
|
515
|
+
if (before && before.tick < at - 1)
|
|
516
|
+
out.push({ tick: at - 1, value: valueAt(points, at - 1)! });
|
|
517
|
+
if (first.tick < at + count) {
|
|
518
|
+
const right = valueAt(points, at + count);
|
|
519
|
+
if (right !== undefined) out.push({ tick: at, value: right });
|
|
520
|
+
}
|
|
521
|
+
for (const point of points)
|
|
522
|
+
if (point.tick > at + count)
|
|
523
|
+
out.push({ tick: point.tick - count, value: point.value });
|
|
524
|
+
return sortPoints(out);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/** Open `count` ticks at `at`; the gap holds the value the curve had at `at`. */
|
|
528
|
+
function insertPoints(points: Points, at: number, count: number): Points {
|
|
529
|
+
const first = points[0];
|
|
530
|
+
const last = points[points.length - 1];
|
|
531
|
+
if (!first || !last || last.tick < at) return points;
|
|
532
|
+
const out: AutomationPoint[] = [];
|
|
533
|
+
const held = valueAt(points, at);
|
|
534
|
+
for (const point of points)
|
|
535
|
+
out.push(
|
|
536
|
+
point.tick < at
|
|
537
|
+
? point
|
|
538
|
+
: { tick: point.tick + count, value: point.value },
|
|
539
|
+
);
|
|
540
|
+
if (held !== undefined) {
|
|
541
|
+
out.push({ tick: at, value: held });
|
|
542
|
+
if (!points.some((point) => point.tick === at))
|
|
543
|
+
out.push({ tick: at + count, value: held });
|
|
544
|
+
}
|
|
545
|
+
return sortPoints(out);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/** Copy the curve over `from..from+count` onto `to..to+count` (a blank gap). */
|
|
549
|
+
function copyPoints(
|
|
550
|
+
points: Points,
|
|
551
|
+
from: number,
|
|
552
|
+
count: number,
|
|
553
|
+
to: number,
|
|
554
|
+
): Points {
|
|
555
|
+
if (points.length === 0) return points;
|
|
556
|
+
const piece: AutomationPoint[] = [];
|
|
557
|
+
const start = valueAt(points, from);
|
|
558
|
+
if (start !== undefined) piece.push({ tick: to, value: start });
|
|
559
|
+
for (const point of points)
|
|
560
|
+
if (point.tick > from && point.tick < from + count)
|
|
561
|
+
piece.push({ tick: point.tick - from + to, value: point.value });
|
|
562
|
+
const end = valueAt(points, from + count - 1);
|
|
563
|
+
if (end !== undefined && count > 1)
|
|
564
|
+
piece.push({ tick: to + count - 1, value: end });
|
|
565
|
+
if (piece.length === 0) return points;
|
|
566
|
+
const kept = points.filter(
|
|
567
|
+
(point) => point.tick < to || point.tick >= to + count,
|
|
568
|
+
);
|
|
569
|
+
return sortPoints([...kept, ...piece]);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// ---------------------------------------------------------------------------
|
|
573
|
+
// Ripple edits
|
|
574
|
+
|
|
575
|
+
function checkBars(bars: number): void {
|
|
576
|
+
if (bars > SCORE_LIMITS.maxBars)
|
|
577
|
+
throw new ScoreValidationError(
|
|
578
|
+
`the song would be ${bars} bars; the limit is ${SCORE_LIMITS.maxBars}`,
|
|
579
|
+
"score-limit",
|
|
580
|
+
);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/** Insert `count` empty bars at `atBar`, shifting later music and sections. */
|
|
584
|
+
export function insertBars(
|
|
585
|
+
score: TrackScore,
|
|
586
|
+
atBar: number,
|
|
587
|
+
count: number,
|
|
588
|
+
): TrackScore {
|
|
589
|
+
if (count <= 0) return score;
|
|
590
|
+
checkBars(score.bars + count);
|
|
591
|
+
const ticks = barTicks(score);
|
|
592
|
+
const at = atBar * ticks;
|
|
593
|
+
const shift = count * ticks;
|
|
594
|
+
return new TrackScore({
|
|
595
|
+
...score.toJSON(),
|
|
596
|
+
bars: score.bars + count,
|
|
597
|
+
tracks: score.tracks.map((track) =>
|
|
598
|
+
mapAutomation(track, (points) => insertPoints(points, at, shift)),
|
|
599
|
+
),
|
|
600
|
+
notes: score.notes.map((note) =>
|
|
601
|
+
note.startTick >= at
|
|
602
|
+
? { ...note, startTick: note.startTick + shift }
|
|
603
|
+
: note,
|
|
604
|
+
),
|
|
605
|
+
sections: score.sections.map((section) =>
|
|
606
|
+
section.startBar >= atBar
|
|
607
|
+
? { ...section, startBar: section.startBar + count }
|
|
608
|
+
: section.startBar + section.bars > atBar
|
|
609
|
+
? { ...section, bars: section.bars + count }
|
|
610
|
+
: section,
|
|
611
|
+
),
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/** Delete bars `atBar..atBar+count`, closing the gap (sections inside go). */
|
|
616
|
+
export function deleteBars(
|
|
617
|
+
score: TrackScore,
|
|
618
|
+
atBar: number,
|
|
619
|
+
count: number,
|
|
620
|
+
): TrackScore {
|
|
621
|
+
if (count <= 0) return score;
|
|
622
|
+
if (count >= score.bars)
|
|
623
|
+
throw new ScoreValidationError("a song keeps at least one bar");
|
|
624
|
+
const ticks = barTicks(score);
|
|
625
|
+
const at = atBar * ticks;
|
|
626
|
+
const end = (atBar + count) * ticks;
|
|
627
|
+
const shift = count * ticks;
|
|
628
|
+
const notes: Note[] = [];
|
|
629
|
+
for (const note of score.notes) {
|
|
630
|
+
if (note.startTick >= end)
|
|
631
|
+
notes.push({ ...note, startTick: note.startTick - shift });
|
|
632
|
+
else if (note.startTick < at)
|
|
633
|
+
notes.push(
|
|
634
|
+
note.startTick + note.durationTicks > at
|
|
635
|
+
? { ...note, durationTicks: at - note.startTick }
|
|
636
|
+
: note,
|
|
637
|
+
);
|
|
638
|
+
}
|
|
639
|
+
const sections: Section[] = [];
|
|
640
|
+
for (const section of score.sections) {
|
|
641
|
+
const from = section.startBar;
|
|
642
|
+
const until = from + section.bars;
|
|
643
|
+
if (until <= atBar) sections.push(section);
|
|
644
|
+
else if (from >= atBar + count)
|
|
645
|
+
sections.push({ ...section, startBar: from - count });
|
|
646
|
+
else {
|
|
647
|
+
const left = Math.max(0, atBar - from);
|
|
648
|
+
const right = Math.max(0, until - (atBar + count));
|
|
649
|
+
if (left + right > 0)
|
|
650
|
+
sections.push({
|
|
651
|
+
...section,
|
|
652
|
+
startBar: Math.min(from, atBar),
|
|
653
|
+
bars: left + right,
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
const names = new Set(sections.map((section) => foldName(section.name)));
|
|
658
|
+
return new TrackScore({
|
|
659
|
+
...score.toJSON(),
|
|
660
|
+
bars: score.bars - count,
|
|
661
|
+
tracks: score.tracks.map((track) =>
|
|
662
|
+
mapAutomation(track, (points) => deletePoints(points, at, shift)),
|
|
663
|
+
),
|
|
664
|
+
notes,
|
|
665
|
+
sections,
|
|
666
|
+
form: score.form.filter((entry) => names.has(foldName(entry.section))),
|
|
667
|
+
});
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/** Unused note ids `prefix1`, `prefix2`, ... */
|
|
671
|
+
function idMaker(score: TrackScore, prefix: string): () => string {
|
|
672
|
+
const used = new Set(score.notes.map((note) => note.id));
|
|
673
|
+
const base = prefix.slice(0, SCORE_LIMITS.maxIdLength - 6);
|
|
674
|
+
let next = 1;
|
|
675
|
+
return () => {
|
|
676
|
+
let id = `${base}${next}`;
|
|
677
|
+
while (used.has(id)) {
|
|
678
|
+
next += 1;
|
|
679
|
+
id = `${base}${next}`;
|
|
680
|
+
}
|
|
681
|
+
used.add(id);
|
|
682
|
+
next += 1;
|
|
683
|
+
return id;
|
|
684
|
+
};
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
function slug(text: string): string {
|
|
688
|
+
return (
|
|
689
|
+
text
|
|
690
|
+
.toLowerCase()
|
|
691
|
+
.replace(/[^a-z0-9]+/gu, "-")
|
|
692
|
+
.replace(/^-|-$/gu, "")
|
|
693
|
+
.slice(0, 24) || "s"
|
|
694
|
+
);
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/** An unused section name: `base`, `base 2`, `base 3`, ... */
|
|
698
|
+
export function uniqueSectionName(score: TrackScore, base: string): string {
|
|
699
|
+
const clean = base.trim().replace(/\s+/gu, " ").slice(0, 28) || "section";
|
|
700
|
+
if (!findSection(score, clean)) return clean;
|
|
701
|
+
const stem = clean.replace(/ \d+$/u, "");
|
|
702
|
+
for (let n = 2; ; n += 1) {
|
|
703
|
+
const name = `${stem} ${n}`.slice(0, SCORE_LIMITS.maxSectionNameLength);
|
|
704
|
+
if (!findSection(score, name)) return name;
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/** The next conventional name the song does not use yet. */
|
|
709
|
+
export function nextSectionName(score: TrackScore): string {
|
|
710
|
+
for (const kind of SECTION_KINDS) if (!findSection(score, kind)) return kind;
|
|
711
|
+
return uniqueSectionName(score, "section");
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
export type SectionInput = Readonly<{
|
|
715
|
+
name?: string;
|
|
716
|
+
startBar?: number;
|
|
717
|
+
bars?: number;
|
|
718
|
+
}>;
|
|
719
|
+
|
|
720
|
+
/**
|
|
721
|
+
* Mark bars as a section. Without a start it begins where the last section
|
|
722
|
+
* ends; the song grows when the section runs past its end.
|
|
723
|
+
*/
|
|
724
|
+
export function addSection(
|
|
725
|
+
score: TrackScore,
|
|
726
|
+
input: SectionInput = {},
|
|
727
|
+
): TrackScore {
|
|
728
|
+
const name = uniqueSectionName(score, input.name ?? nextSectionName(score));
|
|
729
|
+
if (input.name !== undefined && findSection(score, input.name))
|
|
730
|
+
throw new ScoreValidationError(`a section named ${input.name} exists`);
|
|
731
|
+
const lastEnd = Math.max(
|
|
732
|
+
0,
|
|
733
|
+
...score.sections.map((section) => section.startBar + section.bars),
|
|
734
|
+
);
|
|
735
|
+
const startBar = Math.max(0, input.startBar ?? lastEnd);
|
|
736
|
+
// Logic's arrangement markers default to 8 bars; past the end of the
|
|
737
|
+
// song a new section appends that many empty bars.
|
|
738
|
+
const remaining = score.bars - startBar;
|
|
739
|
+
const length = Math.max(
|
|
740
|
+
1,
|
|
741
|
+
input.bars ?? (remaining > 0 ? Math.min(remaining, 8) : 8),
|
|
742
|
+
);
|
|
743
|
+
checkBars(startBar + length);
|
|
744
|
+
const grown =
|
|
745
|
+
startBar + length > score.bars
|
|
746
|
+
? new TrackScore({ ...score.toJSON(), bars: startBar + length })
|
|
747
|
+
: score;
|
|
748
|
+
return grown.withSections(
|
|
749
|
+
[...grown.sections, { name, startBar, bars: length }],
|
|
750
|
+
grown.form,
|
|
751
|
+
);
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
/** Rename a section; the form follows. */
|
|
755
|
+
export function renameSection(
|
|
756
|
+
score: TrackScore,
|
|
757
|
+
from: string,
|
|
758
|
+
to: string,
|
|
759
|
+
): TrackScore {
|
|
760
|
+
const section = requireSection(score, from);
|
|
761
|
+
const clash = findSection(score, to);
|
|
762
|
+
if (clash && clash !== section)
|
|
763
|
+
throw new ScoreValidationError(`a section named ${to} exists`);
|
|
764
|
+
const name = to.trim().replace(/\s+/gu, " ");
|
|
765
|
+
return score.withSections(
|
|
766
|
+
score.sections.map((candidate) =>
|
|
767
|
+
candidate === section ? { ...candidate, name } : candidate,
|
|
768
|
+
),
|
|
769
|
+
score.form.map((entry) =>
|
|
770
|
+
foldName(entry.section) === foldName(section.name)
|
|
771
|
+
? { ...entry, section: name }
|
|
772
|
+
: entry,
|
|
773
|
+
),
|
|
774
|
+
score.loopSection === section.name ? name : score.loopSection,
|
|
775
|
+
);
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
/**
|
|
779
|
+
* Loop `name` in playback (the DAW loop brace over a section), or play the
|
|
780
|
+
* song again with `undefined`. Export ignores it.
|
|
781
|
+
*/
|
|
782
|
+
export function loopSection(
|
|
783
|
+
score: TrackScore,
|
|
784
|
+
name: string | undefined,
|
|
785
|
+
): TrackScore {
|
|
786
|
+
const section = name === undefined ? undefined : requireSection(score, name);
|
|
787
|
+
return score.withSections(score.sections, score.form, section?.name ?? null);
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
/** Change a section's bars (the marker only; the music stays put). */
|
|
791
|
+
export function resizeSection(
|
|
792
|
+
score: TrackScore,
|
|
793
|
+
name: string,
|
|
794
|
+
range: Readonly<{ startBar?: number; bars?: number }>,
|
|
795
|
+
): TrackScore {
|
|
796
|
+
const section = requireSection(score, name);
|
|
797
|
+
const startBar = Math.max(0, range.startBar ?? section.startBar);
|
|
798
|
+
const bars = Math.max(1, range.bars ?? section.bars);
|
|
799
|
+
checkBars(startBar + bars);
|
|
800
|
+
const grown =
|
|
801
|
+
startBar + bars > score.bars
|
|
802
|
+
? new TrackScore({ ...score.toJSON(), bars: startBar + bars })
|
|
803
|
+
: score;
|
|
804
|
+
return grown.withSections(
|
|
805
|
+
grown.sections.map((candidate) =>
|
|
806
|
+
candidate.name === section.name
|
|
807
|
+
? { ...candidate, startBar, bars }
|
|
808
|
+
: candidate,
|
|
809
|
+
),
|
|
810
|
+
grown.form,
|
|
811
|
+
);
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
/** Remove the marker only (and its form entries); the music stays. */
|
|
815
|
+
export function unmarkSection(score: TrackScore, name: string): TrackScore {
|
|
816
|
+
const section = requireSection(score, name);
|
|
817
|
+
return score.withSections(
|
|
818
|
+
score.sections.filter((candidate) => candidate !== section),
|
|
819
|
+
score.form.filter(
|
|
820
|
+
(entry) => foldName(entry.section) !== foldName(section.name),
|
|
821
|
+
),
|
|
822
|
+
);
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
/** Delete a section with its music; later bars move up to close the gap. */
|
|
826
|
+
export function deleteSection(score: TrackScore, name: string): TrackScore {
|
|
827
|
+
const section = requireSection(score, name);
|
|
828
|
+
const marked = unmarkSection(score, section.name);
|
|
829
|
+
return deleteBars(marked, section.startBar, section.bars);
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
/**
|
|
833
|
+
* Duplicate a section with its music right after it (or at `toBar`),
|
|
834
|
+
* shifting later bars; the copy keeps the mutes and variations.
|
|
835
|
+
*/
|
|
836
|
+
export function duplicateSection(
|
|
837
|
+
score: TrackScore,
|
|
838
|
+
name: string,
|
|
839
|
+
options: Readonly<{ as?: string; toBar?: number }> = {},
|
|
840
|
+
): TrackScore {
|
|
841
|
+
const section = requireSection(score, name);
|
|
842
|
+
const copyName = uniqueSectionName(score, options.as ?? section.name);
|
|
843
|
+
if (options.as !== undefined && findSection(score, options.as))
|
|
844
|
+
throw new ScoreValidationError(`a section named ${options.as} exists`);
|
|
845
|
+
const at = options.toBar ?? section.startBar + section.bars;
|
|
846
|
+
const opened = insertBars(score, at, section.bars);
|
|
847
|
+
const source =
|
|
848
|
+
section.startBar >= at ? section.startBar + section.bars : section.startBar;
|
|
849
|
+
const copied = copyBars(
|
|
850
|
+
opened,
|
|
851
|
+
source,
|
|
852
|
+
section.bars,
|
|
853
|
+
at,
|
|
854
|
+
`${slug(copyName)}-`,
|
|
855
|
+
);
|
|
856
|
+
return copied.withSections(
|
|
857
|
+
[...copied.sections, { ...section, name: copyName, startBar: at }],
|
|
858
|
+
copied.form,
|
|
859
|
+
);
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
/** Copy the music of bars `from..from+count` onto bars `to..` (replacing it). */
|
|
863
|
+
export function copyBars(
|
|
864
|
+
score: TrackScore,
|
|
865
|
+
fromBar: number,
|
|
866
|
+
count: number,
|
|
867
|
+
toBar: number,
|
|
868
|
+
idPrefix = "copy-",
|
|
869
|
+
): TrackScore {
|
|
870
|
+
const ticks = barTicks(score);
|
|
871
|
+
const from = fromBar * ticks;
|
|
872
|
+
const length = count * ticks;
|
|
873
|
+
const to = toBar * ticks;
|
|
874
|
+
const nextId = idMaker(score, idPrefix);
|
|
875
|
+
const kept = score.notes.filter(
|
|
876
|
+
(note) => note.startTick < to || note.startTick >= to + length,
|
|
877
|
+
);
|
|
878
|
+
const copies = score.notes
|
|
879
|
+
.filter((note) => note.startTick >= from && note.startTick < from + length)
|
|
880
|
+
.map((note) => ({
|
|
881
|
+
...note,
|
|
882
|
+
id: nextId(),
|
|
883
|
+
startTick: note.startTick - from + to,
|
|
884
|
+
durationTicks: Math.max(
|
|
885
|
+
1,
|
|
886
|
+
Math.min(note.durationTicks, from + length - note.startTick),
|
|
887
|
+
),
|
|
888
|
+
}));
|
|
889
|
+
return new TrackScore({
|
|
890
|
+
...score.toJSON(),
|
|
891
|
+
tracks: score.tracks.map((track) =>
|
|
892
|
+
mapAutomation(track, (points) => copyPoints(points, from, length, to)),
|
|
893
|
+
),
|
|
894
|
+
notes: [...kept, ...copies],
|
|
895
|
+
});
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
/**
|
|
899
|
+
* Move a section and its music so it starts at `toBar` (a bar of the song
|
|
900
|
+
* as it is now), rippling the bars in between.
|
|
901
|
+
*/
|
|
902
|
+
export function moveSection(
|
|
903
|
+
score: TrackScore,
|
|
904
|
+
name: string,
|
|
905
|
+
toBar: number,
|
|
906
|
+
): TrackScore {
|
|
907
|
+
const section = requireSection(score, name);
|
|
908
|
+
const start = section.startBar;
|
|
909
|
+
const length = section.bars;
|
|
910
|
+
if (toBar >= start && toBar <= start + length) return score;
|
|
911
|
+
if (toBar < 0 || toBar > score.bars)
|
|
912
|
+
throw new ScoreValidationError(`bar ${toBar + 1} is outside the song`);
|
|
913
|
+
// Open a gap at the destination, copy the section into it, then delete
|
|
914
|
+
// the original (which sits further right when moving earlier).
|
|
915
|
+
const opened = insertBars(score, toBar, length);
|
|
916
|
+
const source = toBar <= start ? start + length : start;
|
|
917
|
+
const copied = copyBars(
|
|
918
|
+
opened,
|
|
919
|
+
source,
|
|
920
|
+
length,
|
|
921
|
+
toBar,
|
|
922
|
+
`${slug(section.name)}-`,
|
|
923
|
+
);
|
|
924
|
+
const others = copied.sections.filter(
|
|
925
|
+
(candidate) => candidate.name !== section.name,
|
|
926
|
+
);
|
|
927
|
+
const placed = copied.withSections(
|
|
928
|
+
[...others, { ...section, startBar: toBar }],
|
|
929
|
+
copied.form,
|
|
930
|
+
);
|
|
931
|
+
// Remove the original bars: its marker moved, so only the music goes.
|
|
932
|
+
return deleteBars(placed, source, length);
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
/** Move a section one slot earlier (-1) or later (+1) in bar order. */
|
|
936
|
+
export function shiftSection(
|
|
937
|
+
score: TrackScore,
|
|
938
|
+
name: string,
|
|
939
|
+
direction: -1 | 1,
|
|
940
|
+
): TrackScore {
|
|
941
|
+
const section = requireSection(score, name);
|
|
942
|
+
const ordered = score.sections;
|
|
943
|
+
const index = ordered.indexOf(section);
|
|
944
|
+
if (direction < 0) {
|
|
945
|
+
const previous = ordered
|
|
946
|
+
.slice(0, index)
|
|
947
|
+
.reverse()
|
|
948
|
+
.find((candidate) => candidate.startBar < section.startBar);
|
|
949
|
+
if (!previous) return score;
|
|
950
|
+
return moveSection(score, section.name, previous.startBar);
|
|
951
|
+
}
|
|
952
|
+
const next = ordered
|
|
953
|
+
.slice(index + 1)
|
|
954
|
+
.find((candidate) => candidate.startBar >= section.startBar + section.bars);
|
|
955
|
+
if (!next) return score;
|
|
956
|
+
return moveSection(score, section.name, next.startBar + next.bars);
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
/** Mute or unmute `trackId` inside a section. */
|
|
960
|
+
export function setSectionMute(
|
|
961
|
+
score: TrackScore,
|
|
962
|
+
name: string,
|
|
963
|
+
trackId: string,
|
|
964
|
+
muted: boolean,
|
|
965
|
+
): TrackScore {
|
|
966
|
+
const section = requireSection(score, name);
|
|
967
|
+
const current = new Set(section.mute ?? []);
|
|
968
|
+
if (muted) current.add(trackId);
|
|
969
|
+
else current.delete(trackId);
|
|
970
|
+
const mute = [...current];
|
|
971
|
+
return replaceSection(score, section, {
|
|
972
|
+
...omit(section, "mute"),
|
|
973
|
+
...(mute.length > 0 ? { mute } : {}),
|
|
974
|
+
});
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
/** Set (or with `undefined`, clear) a track's variation inside a section. */
|
|
978
|
+
export function setSectionVariation(
|
|
979
|
+
score: TrackScore,
|
|
980
|
+
name: string,
|
|
981
|
+
trackId: string,
|
|
982
|
+
variation: SectionVariation | undefined,
|
|
983
|
+
): TrackScore {
|
|
984
|
+
const section = requireSection(score, name);
|
|
985
|
+
const vary: Record<string, SectionVariation> = { ...section.vary };
|
|
986
|
+
if (
|
|
987
|
+
variation &&
|
|
988
|
+
((variation.transpose ?? 0) !== 0 || (variation.gain ?? 1) !== 1)
|
|
989
|
+
)
|
|
990
|
+
vary[trackId] = variation;
|
|
991
|
+
else delete vary[trackId];
|
|
992
|
+
return replaceSection(score, section, {
|
|
993
|
+
...omit(section, "vary"),
|
|
994
|
+
...(Object.keys(vary).length > 0 ? { vary } : {}),
|
|
995
|
+
});
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
/** Clear a section's mutes and variations. */
|
|
999
|
+
export function resetSection(score: TrackScore, name: string): TrackScore {
|
|
1000
|
+
const section = requireSection(score, name);
|
|
1001
|
+
return replaceSection(score, section, {
|
|
1002
|
+
name: section.name,
|
|
1003
|
+
startBar: section.startBar,
|
|
1004
|
+
bars: section.bars,
|
|
1005
|
+
});
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
function omit<T extends object, K extends keyof T>(
|
|
1009
|
+
value: T,
|
|
1010
|
+
key: K,
|
|
1011
|
+
): Omit<T, K> {
|
|
1012
|
+
const copy = { ...value };
|
|
1013
|
+
delete copy[key];
|
|
1014
|
+
return copy;
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
function replaceSection(
|
|
1018
|
+
score: TrackScore,
|
|
1019
|
+
section: Section,
|
|
1020
|
+
next: Section,
|
|
1021
|
+
): TrackScore {
|
|
1022
|
+
return score.withSections(
|
|
1023
|
+
score.sections.map((candidate) =>
|
|
1024
|
+
candidate === section ? next : candidate,
|
|
1025
|
+
),
|
|
1026
|
+
score.form,
|
|
1027
|
+
);
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
// ---------------------------------------------------------------------------
|
|
1031
|
+
// Form
|
|
1032
|
+
|
|
1033
|
+
/**
|
|
1034
|
+
* Parse a form: `intro verse chorus*2 outro`, `A A B A`, or comma separated
|
|
1035
|
+
* when names hold spaces (`verse, chorus 2 x2`). `*n`/`xn` repeats an entry.
|
|
1036
|
+
*/
|
|
1037
|
+
export function parseForm(
|
|
1038
|
+
score: TrackScore,
|
|
1039
|
+
text: string,
|
|
1040
|
+
): readonly FormEntry[] {
|
|
1041
|
+
const trimmed = text.trim();
|
|
1042
|
+
if (trimmed === "" || trimmed === "none" || trimmed === "clear") return [];
|
|
1043
|
+
const items = trimmed.includes(",")
|
|
1044
|
+
? trimmed.split(",")
|
|
1045
|
+
: trimmed.split(/\s+/u);
|
|
1046
|
+
const entries: FormEntry[] = [];
|
|
1047
|
+
for (const raw of items) {
|
|
1048
|
+
const item = raw.trim();
|
|
1049
|
+
if (item === "") continue;
|
|
1050
|
+
const match = /^(.*?)\s*(?:\*|\bx|×)\s*(\d+)$/iu.exec(item);
|
|
1051
|
+
let name = item;
|
|
1052
|
+
let repeat = 1;
|
|
1053
|
+
if (match && match[1]!.length > 0 && findSection(score, match[1]!)) {
|
|
1054
|
+
name = match[1]!;
|
|
1055
|
+
repeat = Number(match[2]);
|
|
1056
|
+
}
|
|
1057
|
+
const section = findSection(score, name);
|
|
1058
|
+
if (!section)
|
|
1059
|
+
throw new ScoreValidationError(
|
|
1060
|
+
`no section named ${name}${score.sections.length > 0 ? ` (sections: ${score.sections.map((s) => s.name).join(", ")})` : ""}`,
|
|
1061
|
+
);
|
|
1062
|
+
entries.push(
|
|
1063
|
+
repeat === 1
|
|
1064
|
+
? { section: section.name }
|
|
1065
|
+
: { section: section.name, repeat },
|
|
1066
|
+
);
|
|
1067
|
+
}
|
|
1068
|
+
return entries;
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
/** `intro verse chorus×2 outro` (comma separated when a name has a space). */
|
|
1072
|
+
export function formatForm(form: readonly FormEntry[]): string {
|
|
1073
|
+
const spaced = form.some((entry) => entry.section.includes(" "));
|
|
1074
|
+
return form
|
|
1075
|
+
.map(
|
|
1076
|
+
(entry) =>
|
|
1077
|
+
`${entry.section}${(entry.repeat ?? 1) > 1 ? `×${entry.repeat}` : ""}`,
|
|
1078
|
+
)
|
|
1079
|
+
.join(spaced ? ", " : " ");
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
/** Set the form (validated against the sections and the song length cap). */
|
|
1083
|
+
export function withForm(
|
|
1084
|
+
score: TrackScore,
|
|
1085
|
+
form: readonly FormEntry[],
|
|
1086
|
+
): TrackScore {
|
|
1087
|
+
const next = score.withSections(score.sections, form);
|
|
1088
|
+
const seconds = barsToSeconds(next, arrangedBars(next));
|
|
1089
|
+
if (seconds > MAX_SONG_SECONDS)
|
|
1090
|
+
throw new ScoreValidationError(
|
|
1091
|
+
`the form plays ${Math.round(seconds)} s; the limit is ${MAX_SONG_SECONDS} s`,
|
|
1092
|
+
"score-limit",
|
|
1093
|
+
);
|
|
1094
|
+
return next;
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
/** Bake the form into plain bars: the score as it plays, sections in order. */
|
|
1098
|
+
export function flattenForm(score: TrackScore): TrackScore {
|
|
1099
|
+
const segments = formSegments(score);
|
|
1100
|
+
if (segments.length === 0) return applySectionChanges(score);
|
|
1101
|
+
const total = arrangedBars(score);
|
|
1102
|
+
checkBars(total);
|
|
1103
|
+
const sections: Section[] = [];
|
|
1104
|
+
const named = new Set<string>();
|
|
1105
|
+
for (const segment of segments) {
|
|
1106
|
+
let name = segment.section.name;
|
|
1107
|
+
if (named.has(foldName(name)))
|
|
1108
|
+
name = uniqueName(named, segment.section.name);
|
|
1109
|
+
named.add(foldName(name));
|
|
1110
|
+
sections.push({ name, startBar: segment.startBar, bars: segment.bars });
|
|
1111
|
+
}
|
|
1112
|
+
const flat = arrangedSlice(score, 0, total);
|
|
1113
|
+
return new TrackScore({ ...flat.toJSON(), sections });
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
/**
|
|
1117
|
+
* The notes the form plays from arranged bar `fromBar` up to `toBar`, at
|
|
1118
|
+
* arranged ticks. A note keeps its id on its first pass and gets `~2`, `~3`
|
|
1119
|
+
* on later ones, so every pass has a stable id however a render is windowed.
|
|
1120
|
+
* A note held over a section edge keeps sounding when the form plays the
|
|
1121
|
+
* next bars of the score straight on (verse into chorus as written); it is
|
|
1122
|
+
* cut at the edge only when the form jumps elsewhere, or when the next
|
|
1123
|
+
* section mutes its track.
|
|
1124
|
+
*/
|
|
1125
|
+
export function arrangedNotes(
|
|
1126
|
+
score: TrackScore,
|
|
1127
|
+
fromBar: number,
|
|
1128
|
+
toBar: number,
|
|
1129
|
+
): Note[] {
|
|
1130
|
+
const ticks = barTicks(score);
|
|
1131
|
+
const tracks = new Map(score.tracks.map((track) => [track.id, track]));
|
|
1132
|
+
const segments = formSegments(score);
|
|
1133
|
+
const passes = new Map<string, number>();
|
|
1134
|
+
const taken = new Set<string>();
|
|
1135
|
+
const notes: Note[] = [];
|
|
1136
|
+
segments.forEach((segment, index) => {
|
|
1137
|
+
if (segment.startBar >= toBar) return;
|
|
1138
|
+
const section = segment.section;
|
|
1139
|
+
const sectionFrom = section.startBar * ticks;
|
|
1140
|
+
const sectionTo = sectionFrom + section.bars * ticks;
|
|
1141
|
+
const start = Math.max(segment.startBar, fromBar);
|
|
1142
|
+
const stop = Math.min(segment.startBar + segment.bars, toBar);
|
|
1143
|
+
const from = (section.startBar + start - segment.startBar) * ticks;
|
|
1144
|
+
const to = stop > start ? from + (stop - start) * ticks : from;
|
|
1145
|
+
const origin = (segment.startBar - section.startBar) * ticks;
|
|
1146
|
+
for (const note of score.notes) {
|
|
1147
|
+
if (note.startTick < sectionFrom || note.startTick >= sectionTo) continue;
|
|
1148
|
+
if (section.mute?.includes(note.trackId)) continue;
|
|
1149
|
+
const pass = (passes.get(note.id) ?? 0) + 1;
|
|
1150
|
+
passes.set(note.id, pass);
|
|
1151
|
+
if (note.startTick < from || note.startTick >= to) continue;
|
|
1152
|
+
const varied = varyNote(
|
|
1153
|
+
note,
|
|
1154
|
+
section.vary?.[note.trackId],
|
|
1155
|
+
tracks.get(note.trackId),
|
|
1156
|
+
);
|
|
1157
|
+
const hold = holdEnd(segments, index, note.trackId) * ticks;
|
|
1158
|
+
notes.push({
|
|
1159
|
+
...varied,
|
|
1160
|
+
id: passId(note.id, pass, taken),
|
|
1161
|
+
startTick: note.startTick + origin,
|
|
1162
|
+
durationTicks: Math.max(
|
|
1163
|
+
1,
|
|
1164
|
+
Math.min(note.durationTicks, hold - note.startTick),
|
|
1165
|
+
),
|
|
1166
|
+
});
|
|
1167
|
+
}
|
|
1168
|
+
});
|
|
1169
|
+
return notes;
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
/** Score bar where a note from pass `index` must stop: its run's end. */
|
|
1173
|
+
function holdEnd(
|
|
1174
|
+
segments: readonly FormSegment[],
|
|
1175
|
+
index: number,
|
|
1176
|
+
trackId: string,
|
|
1177
|
+
): number {
|
|
1178
|
+
let end = segments[index]!.section.startBar + segments[index]!.bars;
|
|
1179
|
+
for (let next = index + 1; next < segments.length; next += 1) {
|
|
1180
|
+
const section = segments[next]!.section;
|
|
1181
|
+
if (section.startBar !== end || section.mute?.includes(trackId)) break;
|
|
1182
|
+
end = section.startBar + section.bars;
|
|
1183
|
+
}
|
|
1184
|
+
return end;
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
/** `id` on a note's first pass, `id~n` on its nth. */
|
|
1188
|
+
function passId(id: string, pass: number, taken: Set<string>): string {
|
|
1189
|
+
const suffix = pass > 1 ? `~${pass}` : "";
|
|
1190
|
+
let out = `${id.slice(0, SCORE_LIMITS.maxIdLength - suffix.length)}${suffix}`;
|
|
1191
|
+
// Ids near the length limit could collide once shortened.
|
|
1192
|
+
for (let extra = 2; taken.has(out); extra += 1) {
|
|
1193
|
+
const tail = `~${pass}.${extra}`;
|
|
1194
|
+
out = `${id.slice(0, SCORE_LIMITS.maxIdLength - tail.length)}${tail}`;
|
|
1195
|
+
}
|
|
1196
|
+
taken.add(out);
|
|
1197
|
+
return out;
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
/**
|
|
1201
|
+
* Arranged bars `fromBar..fromBar+bars` of the form as a standalone score.
|
|
1202
|
+
* Notes start in the first `noteBars` bars only (a render window's ring-out
|
|
1203
|
+
* keeps the automation that follows but no new notes); see `arrangedNotes`
|
|
1204
|
+
* for ids and held notes. Every pass starts its automation from the value
|
|
1205
|
+
* the lane has at that point of the section (or the track's static value),
|
|
1206
|
+
* so one section's curve never carries into the next.
|
|
1207
|
+
*/
|
|
1208
|
+
export function arrangedSlice(
|
|
1209
|
+
score: TrackScore,
|
|
1210
|
+
fromBar: number,
|
|
1211
|
+
bars: number,
|
|
1212
|
+
noteBars = bars,
|
|
1213
|
+
): TrackScore {
|
|
1214
|
+
const ticks = barTicks(score);
|
|
1215
|
+
const pieces: { from: number; to: number; offset: number }[] = [];
|
|
1216
|
+
const end = fromBar + bars;
|
|
1217
|
+
for (const segment of formSegments(score)) {
|
|
1218
|
+
const segEnd = segment.startBar + segment.bars;
|
|
1219
|
+
if (segEnd <= fromBar || segment.startBar >= end) continue;
|
|
1220
|
+
const start = Math.max(segment.startBar, fromBar);
|
|
1221
|
+
const stop = Math.min(segEnd, end);
|
|
1222
|
+
const from = (segment.section.startBar + start - segment.startBar) * ticks;
|
|
1223
|
+
pieces.push({
|
|
1224
|
+
from,
|
|
1225
|
+
to: from + (stop - start) * ticks,
|
|
1226
|
+
offset: (start - fromBar) * ticks,
|
|
1227
|
+
});
|
|
1228
|
+
}
|
|
1229
|
+
const shift = fromBar * ticks;
|
|
1230
|
+
const timed = sliceSongTime(score, pieces);
|
|
1231
|
+
return new TrackScore({
|
|
1232
|
+
...score.toJSON(),
|
|
1233
|
+
tempoBpm: timed.tempoBpm,
|
|
1234
|
+
time: timed.time ?? null,
|
|
1235
|
+
bars,
|
|
1236
|
+
tracks: score.tracks.map((track) => sliceLanes(track, pieces)),
|
|
1237
|
+
notes: arrangedNotes(score, fromBar, fromBar + noteBars).map((note) => ({
|
|
1238
|
+
...note,
|
|
1239
|
+
startTick: note.startTick - shift,
|
|
1240
|
+
})),
|
|
1241
|
+
sections: [],
|
|
1242
|
+
form: [],
|
|
1243
|
+
});
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
/** Each lane rebuilt from `pieces` of the original, anchored at both ends. */
|
|
1247
|
+
function sliceLanes(
|
|
1248
|
+
track: Track,
|
|
1249
|
+
pieces: readonly { from: number; to: number; offset: number }[],
|
|
1250
|
+
): Track {
|
|
1251
|
+
const slice = (points: Points, fallback: number | undefined): Points => {
|
|
1252
|
+
const out: AutomationPoint[] = [];
|
|
1253
|
+
for (const { from, to, offset } of pieces) {
|
|
1254
|
+
const start = valueAt(points, from) ?? fallback;
|
|
1255
|
+
if (start !== undefined) out.push({ tick: offset, value: start });
|
|
1256
|
+
for (const point of points)
|
|
1257
|
+
if (point.tick > from && point.tick < to)
|
|
1258
|
+
out.push({ tick: point.tick - from + offset, value: point.value });
|
|
1259
|
+
// Hold the pass's last value up to the seam so the next pass does not
|
|
1260
|
+
// ramp from it.
|
|
1261
|
+
const last = valueAt(points, to - 1) ?? fallback;
|
|
1262
|
+
if (last !== undefined && to - from > 1)
|
|
1263
|
+
out.push({ tick: offset + to - from - 1, value: last });
|
|
1264
|
+
}
|
|
1265
|
+
return sortPoints(out);
|
|
1266
|
+
};
|
|
1267
|
+
const out: Record<string, unknown> = { ...track };
|
|
1268
|
+
for (const field of TRACK_LANES) {
|
|
1269
|
+
const points = track[field];
|
|
1270
|
+
if (!points || points.length === 0) continue;
|
|
1271
|
+
out[field] = slice(points, laneFallback(track, field));
|
|
1272
|
+
}
|
|
1273
|
+
if (track.fxAutomation) {
|
|
1274
|
+
const lanes: Record<string, Points> = {};
|
|
1275
|
+
for (const [lane, points] of Object.entries(track.fxAutomation)) {
|
|
1276
|
+
if (!points) continue;
|
|
1277
|
+
lanes[lane] =
|
|
1278
|
+
points.length === 0 ? points : slice(points, laneFallback(track, lane));
|
|
1279
|
+
}
|
|
1280
|
+
out.fxAutomation = lanes;
|
|
1281
|
+
}
|
|
1282
|
+
return out as Track;
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
/** What the renderer plays for a lane where it has no points (its static value). */
|
|
1286
|
+
function laneFallback(track: Track, lane: string): number | undefined {
|
|
1287
|
+
switch (lane) {
|
|
1288
|
+
case "volumeAutomation":
|
|
1289
|
+
return 1;
|
|
1290
|
+
case "panAutomation":
|
|
1291
|
+
return track.pan ?? 0;
|
|
1292
|
+
case "filterAutomation":
|
|
1293
|
+
return track.filter?.cutoff ?? SCORE_LIMITS.maxFilterCutoff;
|
|
1294
|
+
case "resonanceAutomation":
|
|
1295
|
+
return track.filter?.resonance ?? 0;
|
|
1296
|
+
case "delayFeedbackAutomation":
|
|
1297
|
+
return track.delay?.feedback;
|
|
1298
|
+
case "delayMixAutomation":
|
|
1299
|
+
return track.delay?.mix;
|
|
1300
|
+
case "wtAutomation":
|
|
1301
|
+
return wavetableOf(track).wt ?? 0;
|
|
1302
|
+
}
|
|
1303
|
+
const spec = FX_LANES.find((candidate) => candidate.lane === lane);
|
|
1304
|
+
if (!spec) return undefined;
|
|
1305
|
+
const source = (
|
|
1306
|
+
spec.effect === "reverb"
|
|
1307
|
+
? track.reverb
|
|
1308
|
+
: spec.effect === "synth"
|
|
1309
|
+
? track.synth
|
|
1310
|
+
: (track.fx as Record<string, unknown> | undefined)?.[spec.effect]
|
|
1311
|
+
) as Record<string, unknown> | undefined;
|
|
1312
|
+
const stored = source?.[spec.param];
|
|
1313
|
+
return typeof stored === "number" ? stored : spec.spec.default;
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
function uniqueName(taken: ReadonlySet<string>, base: string): string {
|
|
1317
|
+
const stem = base.replace(/ \d+$/u, "").slice(0, 28);
|
|
1318
|
+
for (let n = 2; ; n += 1) {
|
|
1319
|
+
const name = `${stem} ${n}`;
|
|
1320
|
+
if (!taken.has(foldName(name))) return name;
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
// ---------------------------------------------------------------------------
|
|
1325
|
+
// Generators: builds, drops and fills, written as ordinary tracks, notes
|
|
1326
|
+
// and automation so they show in the editor and print to song.ts.
|
|
1327
|
+
|
|
1328
|
+
const SNARE = DRUM_VOICES.find((voice) => voice.voice === "snare")!.pitch;
|
|
1329
|
+
const TOM = DRUM_VOICES.find((voice) => voice.voice === "tom")!.pitch;
|
|
1330
|
+
const TOMS = [50, 47, TOM] as const;
|
|
1331
|
+
const KICK = DRUM_VOICES.find((voice) => voice.voice === "kick")!.pitch;
|
|
1332
|
+
const OPEN_HAT = DRUM_VOICES.find((voice) => voice.voice === "openhat")!.pitch;
|
|
1333
|
+
|
|
1334
|
+
/** Generator tracks, reused by later builds and drops. */
|
|
1335
|
+
export const GENERATOR_TRACKS = Object.freeze({
|
|
1336
|
+
riser: "riser",
|
|
1337
|
+
uplifter: "uplifter",
|
|
1338
|
+
impact: "impact",
|
|
1339
|
+
roll: "roll",
|
|
1340
|
+
fills: "fills",
|
|
1341
|
+
} as const);
|
|
1342
|
+
|
|
1343
|
+
const GENERATOR_IDS: ReadonlySet<string> = new Set(
|
|
1344
|
+
Object.values(GENERATOR_TRACKS),
|
|
1345
|
+
);
|
|
1346
|
+
|
|
1347
|
+
/** Generator tracks, including per-length uplifters (`uplifter-2`, ...). */
|
|
1348
|
+
function isGeneratorTrack(id: string): boolean {
|
|
1349
|
+
return GENERATOR_IDS.has(id) || /^uplifter-\d+$/u.test(id);
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
/** Drop cut limits, in beats: 0 (none) to two bars. */
|
|
1353
|
+
export const DROP_CUT_MIN = 0;
|
|
1354
|
+
export function dropCutMax(score: Pick<TrackScore, "beatsPerBar">): number {
|
|
1355
|
+
return score.beatsPerBar * 2;
|
|
1356
|
+
}
|
|
1357
|
+
/** Fill length limits, in beats: half a beat to two bars. */
|
|
1358
|
+
export const FILL_BEATS_MIN = 0.5;
|
|
1359
|
+
export function fillBeatsMax(score: Pick<TrackScore, "beatsPerBar">): number {
|
|
1360
|
+
return score.beatsPerBar * 2;
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
function clampNote(
|
|
1364
|
+
value: number,
|
|
1365
|
+
min: number,
|
|
1366
|
+
max: number,
|
|
1367
|
+
what: string,
|
|
1368
|
+
notes: string[],
|
|
1369
|
+
): number {
|
|
1370
|
+
const clamped = Math.max(min, Math.min(max, value));
|
|
1371
|
+
if (clamped !== value) notes.push(`${what} clamped to ${clamped}`);
|
|
1372
|
+
return clamped;
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
export type BarRange = Readonly<{ startBar: number; bars: number }>;
|
|
1376
|
+
|
|
1377
|
+
export type RangeInput = Readonly<{
|
|
1378
|
+
/**
|
|
1379
|
+
* A section name; the range is its bars, or its last `bars` bars when
|
|
1380
|
+
* `bars` is given.
|
|
1381
|
+
*/
|
|
1382
|
+
section?: string;
|
|
1383
|
+
/**
|
|
1384
|
+
* A section to lead into: the range is the `bars` bars (default 4) just
|
|
1385
|
+
* before its first bar.
|
|
1386
|
+
*/
|
|
1387
|
+
into?: string;
|
|
1388
|
+
startBar?: number;
|
|
1389
|
+
bars?: number;
|
|
1390
|
+
}>;
|
|
1391
|
+
|
|
1392
|
+
function resolveRange(
|
|
1393
|
+
score: TrackScore,
|
|
1394
|
+
input: RangeInput,
|
|
1395
|
+
fallbackBars: number,
|
|
1396
|
+
): BarRange {
|
|
1397
|
+
if (input.into !== undefined) {
|
|
1398
|
+
const section = requireSection(score, input.into);
|
|
1399
|
+
if (section.startBar === 0)
|
|
1400
|
+
throw new ScoreValidationError(
|
|
1401
|
+
`${section.name} starts at bar 1; nothing comes before it to build over`,
|
|
1402
|
+
);
|
|
1403
|
+
const bars = Math.max(1, Math.min(section.startBar, input.bars ?? 4));
|
|
1404
|
+
return { startBar: section.startBar - bars, bars };
|
|
1405
|
+
}
|
|
1406
|
+
if (input.section !== undefined) {
|
|
1407
|
+
const section = requireSection(score, input.section);
|
|
1408
|
+
if (input.bars === undefined)
|
|
1409
|
+
return { startBar: section.startBar, bars: section.bars };
|
|
1410
|
+
const bars = Math.max(1, Math.min(section.bars, input.bars));
|
|
1411
|
+
return { startBar: section.startBar + section.bars - bars, bars };
|
|
1412
|
+
}
|
|
1413
|
+
const bars = Math.max(1, Math.min(score.bars, input.bars ?? fallbackBars));
|
|
1414
|
+
const startBar = Math.max(
|
|
1415
|
+
0,
|
|
1416
|
+
Math.min(score.bars - bars, input.startBar ?? score.bars - bars),
|
|
1417
|
+
);
|
|
1418
|
+
return { startBar, bars };
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
export type BuildOptions = RangeInput &
|
|
1422
|
+
Readonly<{
|
|
1423
|
+
/** Noise riser: filtered white noise opening up and getting louder. */
|
|
1424
|
+
riser?: boolean;
|
|
1425
|
+
/** Accelerating snare roll on the kit: quarters, 8ths, 16ths, 32nds. */
|
|
1426
|
+
roll?: boolean;
|
|
1427
|
+
/** Filter sweep automation on the playing pitched tracks. */
|
|
1428
|
+
sweep?: boolean;
|
|
1429
|
+
/** Tempo-synced uplifter: a pitch-rising supersaw that lands on the drop. */
|
|
1430
|
+
uplifter?: boolean;
|
|
1431
|
+
}>;
|
|
1432
|
+
|
|
1433
|
+
export type GeneratorResult = Readonly<{
|
|
1434
|
+
score: TrackScore;
|
|
1435
|
+
/** What was added, for the activity line and agent replies. */
|
|
1436
|
+
summary: string;
|
|
1437
|
+
}>;
|
|
1438
|
+
|
|
1439
|
+
function ensureTrack(
|
|
1440
|
+
score: TrackScore,
|
|
1441
|
+
id: string,
|
|
1442
|
+
make: () => Track | Record<string, unknown>,
|
|
1443
|
+
): TrackScore {
|
|
1444
|
+
if (score.tracks.some((track) => track.id === id)) return score;
|
|
1445
|
+
return new TrackScore({
|
|
1446
|
+
...score.toJSON(),
|
|
1447
|
+
tracks: [...score.tracks, make() as Track],
|
|
1448
|
+
});
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
function drumTrackId(score: TrackScore): string | undefined {
|
|
1452
|
+
return score.tracks.find(
|
|
1453
|
+
(track) =>
|
|
1454
|
+
isDrumInstrument(track.instrument) && !isGeneratorTrack(track.id),
|
|
1455
|
+
)?.id;
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
function withNotes(score: TrackScore, notes: readonly Note[]): TrackScore {
|
|
1459
|
+
if (notes.length === 0) return score;
|
|
1460
|
+
return new TrackScore({
|
|
1461
|
+
...score.toJSON(),
|
|
1462
|
+
notes: [...score.notes, ...notes],
|
|
1463
|
+
});
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
function updateTrackFields(
|
|
1467
|
+
score: TrackScore,
|
|
1468
|
+
id: string,
|
|
1469
|
+
edit: (track: Track) => Track,
|
|
1470
|
+
): TrackScore {
|
|
1471
|
+
return new TrackScore({
|
|
1472
|
+
...score.toJSON(),
|
|
1473
|
+
tracks: score.tracks.map((track) =>
|
|
1474
|
+
track.id === id ? edit(track) : track,
|
|
1475
|
+
),
|
|
1476
|
+
});
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
/** Replace the lane points inside `from..to` with `ramp`, keeping the curve after. */
|
|
1480
|
+
function rampPoints(
|
|
1481
|
+
points: Points,
|
|
1482
|
+
from: number,
|
|
1483
|
+
to: number,
|
|
1484
|
+
ramp: readonly AutomationPoint[],
|
|
1485
|
+
after: number,
|
|
1486
|
+
): Points {
|
|
1487
|
+
const resume = valueAt(points, to) ?? after;
|
|
1488
|
+
const kept = points.filter((point) => point.tick < from || point.tick > to);
|
|
1489
|
+
const out = [...kept, ...ramp];
|
|
1490
|
+
if (!ramp.some((point) => point.tick === to))
|
|
1491
|
+
out.push({ tick: to, value: resume });
|
|
1492
|
+
return sortPoints(out);
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
/**
|
|
1496
|
+
* A cutoff ramp on the octave scale (how a producer sweeps a filter):
|
|
1497
|
+
* `a` at `from`, `b` just before `to`, several points per beat so the
|
|
1498
|
+
* linear interpolation between points stays close to the curve.
|
|
1499
|
+
*/
|
|
1500
|
+
function expRamp(
|
|
1501
|
+
score: TrackScore,
|
|
1502
|
+
from: number,
|
|
1503
|
+
to: number,
|
|
1504
|
+
a: number,
|
|
1505
|
+
b: number,
|
|
1506
|
+
): AutomationPoint[] {
|
|
1507
|
+
const last = to - 1;
|
|
1508
|
+
const steps = Math.max(
|
|
1509
|
+
2,
|
|
1510
|
+
Math.min(64, Math.round(((last - from) / score.ticksPerBeat) * 2)),
|
|
1511
|
+
);
|
|
1512
|
+
const out: AutomationPoint[] = [];
|
|
1513
|
+
for (let index = 0; index <= steps; index += 1) {
|
|
1514
|
+
const t = index / steps;
|
|
1515
|
+
out.push({
|
|
1516
|
+
tick: Math.round(from + (last - from) * t),
|
|
1517
|
+
value: Math.round(a * (b / a) ** t * 10) / 10,
|
|
1518
|
+
});
|
|
1519
|
+
}
|
|
1520
|
+
return out.filter(
|
|
1521
|
+
(point, index) => index === 0 || point.tick !== out[index - 1]!.tick,
|
|
1522
|
+
);
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
/** Seconds of `ticks` at the score tempo. */
|
|
1526
|
+
function ticksToSeconds(score: TrackScore, ticks: number): number {
|
|
1527
|
+
return (ticks / score.ticksPerBeat) * (60 / score.tempoBpm);
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
/**
|
|
1531
|
+
* A build over a section (or bars): noise riser, accelerating snare roll,
|
|
1532
|
+
* filter sweep and uplifter, all landing on the bar after the range.
|
|
1533
|
+
*/
|
|
1534
|
+
export function generateBuild(
|
|
1535
|
+
score: TrackScore,
|
|
1536
|
+
options: BuildOptions = {},
|
|
1537
|
+
): GeneratorResult {
|
|
1538
|
+
const range = resolveRange(score, options, Math.min(4, score.bars));
|
|
1539
|
+
const ticks = barTicks(score);
|
|
1540
|
+
const from = range.startBar * ticks;
|
|
1541
|
+
const to = (range.startBar + range.bars) * ticks;
|
|
1542
|
+
const length = to - from;
|
|
1543
|
+
const parts: string[] = [];
|
|
1544
|
+
let next = score;
|
|
1545
|
+
const nextId = idMaker(score, `build${range.startBar + 1}-`);
|
|
1546
|
+
|
|
1547
|
+
if (options.riser ?? true) {
|
|
1548
|
+
next = ensureTrack(next, GENERATOR_TRACKS.riser, () => ({
|
|
1549
|
+
id: GENERATOR_TRACKS.riser,
|
|
1550
|
+
name: "riser",
|
|
1551
|
+
instrument: "white",
|
|
1552
|
+
volume: 0.55,
|
|
1553
|
+
pan: 0,
|
|
1554
|
+
muted: false,
|
|
1555
|
+
volumeAutomation: [],
|
|
1556
|
+
panAutomation: [],
|
|
1557
|
+
filter: { cutoff: 300, resonance: 0.35 },
|
|
1558
|
+
synth: { attack: 0.01, release: 0.25 },
|
|
1559
|
+
}));
|
|
1560
|
+
next = updateTrackFields(next, GENERATOR_TRACKS.riser, (track) => ({
|
|
1561
|
+
...track,
|
|
1562
|
+
filterAutomation: rampPoints(
|
|
1563
|
+
track.filterAutomation ?? [],
|
|
1564
|
+
from,
|
|
1565
|
+
to,
|
|
1566
|
+
expRamp(score, from, to, 300, 12_000),
|
|
1567
|
+
300,
|
|
1568
|
+
),
|
|
1569
|
+
volumeAutomation: rampPoints(
|
|
1570
|
+
track.volumeAutomation,
|
|
1571
|
+
from,
|
|
1572
|
+
to,
|
|
1573
|
+
[
|
|
1574
|
+
{ tick: from, value: 0.08 },
|
|
1575
|
+
{ tick: to - 1, value: 0.8 },
|
|
1576
|
+
],
|
|
1577
|
+
0.08,
|
|
1578
|
+
),
|
|
1579
|
+
}));
|
|
1580
|
+
next = withNotes(next, [
|
|
1581
|
+
{
|
|
1582
|
+
id: nextId(),
|
|
1583
|
+
trackId: GENERATOR_TRACKS.riser,
|
|
1584
|
+
startTick: from,
|
|
1585
|
+
durationTicks: length,
|
|
1586
|
+
pitch: 60,
|
|
1587
|
+
velocity: 0.9,
|
|
1588
|
+
},
|
|
1589
|
+
]);
|
|
1590
|
+
parts.push("noise riser");
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
if (options.roll ?? true) {
|
|
1594
|
+
let drums = drumTrackId(next);
|
|
1595
|
+
if (!drums) {
|
|
1596
|
+
next = ensureTrack(next, GENERATOR_TRACKS.roll, () => ({
|
|
1597
|
+
id: GENERATOR_TRACKS.roll,
|
|
1598
|
+
name: "roll",
|
|
1599
|
+
instrument: "kit",
|
|
1600
|
+
volume: 0.8,
|
|
1601
|
+
pan: 0,
|
|
1602
|
+
muted: false,
|
|
1603
|
+
volumeAutomation: [],
|
|
1604
|
+
panAutomation: [],
|
|
1605
|
+
}));
|
|
1606
|
+
drums = GENERATOR_TRACKS.roll;
|
|
1607
|
+
}
|
|
1608
|
+
const taken = new Set(
|
|
1609
|
+
next.notes
|
|
1610
|
+
.filter((note) => note.trackId === drums && note.pitch === SNARE)
|
|
1611
|
+
.map((note) => note.startTick),
|
|
1612
|
+
);
|
|
1613
|
+
const roll: Note[] = [];
|
|
1614
|
+
const stage = length / 4;
|
|
1615
|
+
for (let index = 0; index < 4; index += 1) {
|
|
1616
|
+
const step = Math.max(1, Math.round(score.ticksPerBeat / 2 ** index));
|
|
1617
|
+
const start = Math.round(from + index * stage);
|
|
1618
|
+
const end = Math.round(from + (index + 1) * stage);
|
|
1619
|
+
for (let tick = start; tick < end; tick += step) {
|
|
1620
|
+
if (taken.has(tick)) continue;
|
|
1621
|
+
taken.add(tick);
|
|
1622
|
+
const progress = (tick - from) / length;
|
|
1623
|
+
roll.push({
|
|
1624
|
+
id: nextId(),
|
|
1625
|
+
trackId: drums,
|
|
1626
|
+
startTick: tick,
|
|
1627
|
+
durationTicks: Math.max(1, Math.floor(step / 2)),
|
|
1628
|
+
pitch: SNARE,
|
|
1629
|
+
velocity: Math.round((0.3 + 0.7 * progress) * 1000) / 1000,
|
|
1630
|
+
});
|
|
1631
|
+
}
|
|
1632
|
+
}
|
|
1633
|
+
next = withNotes(next, roll);
|
|
1634
|
+
parts.push(`snare roll (${roll.length} hits)`);
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
if (options.sweep ?? true) {
|
|
1638
|
+
const playing = new Set(
|
|
1639
|
+
next.notes
|
|
1640
|
+
.filter((note) => note.startTick >= from && note.startTick < to)
|
|
1641
|
+
.map((note) => note.trackId),
|
|
1642
|
+
);
|
|
1643
|
+
const swept: string[] = [];
|
|
1644
|
+
for (const track of next.tracks) {
|
|
1645
|
+
if (isGeneratorTrack(track.id) || !playing.has(track.id)) continue;
|
|
1646
|
+
if (isDrumInstrument(track.instrument)) continue;
|
|
1647
|
+
if (track.filter?.type === "bpf") continue;
|
|
1648
|
+
const highPass = !track.filter || track.filter.type === "hpf";
|
|
1649
|
+
const cutoff = track.filter?.cutoff ?? 20;
|
|
1650
|
+
const ramp = highPass
|
|
1651
|
+
? expRamp(score, from, to, cutoff, Math.max(cutoff, 1_200))
|
|
1652
|
+
: expRamp(
|
|
1653
|
+
score,
|
|
1654
|
+
from,
|
|
1655
|
+
to,
|
|
1656
|
+
Math.max(20, Math.round(cutoff / 10)),
|
|
1657
|
+
cutoff,
|
|
1658
|
+
);
|
|
1659
|
+
next = updateTrackFields(next, track.id, (current) => ({
|
|
1660
|
+
...current,
|
|
1661
|
+
filter: current.filter ?? { cutoff: 20, resonance: 0.1, type: "hpf" },
|
|
1662
|
+
filterAutomation: rampPoints(
|
|
1663
|
+
current.filterAutomation ?? [],
|
|
1664
|
+
from,
|
|
1665
|
+
to,
|
|
1666
|
+
ramp,
|
|
1667
|
+
cutoff,
|
|
1668
|
+
),
|
|
1669
|
+
}));
|
|
1670
|
+
swept.push(track.name);
|
|
1671
|
+
}
|
|
1672
|
+
if (swept.length > 0) parts.push(`filter sweep on ${swept.join(", ")}`);
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1675
|
+
if (options.uplifter ?? true) {
|
|
1676
|
+
// A pitch envelope rises over the uplifter, so its length is real time:
|
|
1677
|
+
// the last whole bars of the range that fit in 10 s (synth pattack max).
|
|
1678
|
+
const barSeconds = ticksToSeconds(score, ticks);
|
|
1679
|
+
const fit = Math.max(1, Math.min(range.bars, Math.floor(10 / barSeconds)));
|
|
1680
|
+
const duration =
|
|
1681
|
+
barSeconds > 10 ? Math.floor((10 / barSeconds) * ticks) : fit * ticks;
|
|
1682
|
+
const seconds = Math.min(10, ticksToSeconds(score, duration));
|
|
1683
|
+
// The rise lives in the track's synth envelope, so each length gets
|
|
1684
|
+
// its own uplifter track; builds of the same length share one.
|
|
1685
|
+
const placed = uplifterTrack(next, seconds);
|
|
1686
|
+
next = placed.score;
|
|
1687
|
+
const uplifterId = placed.id;
|
|
1688
|
+
next = withNotes(next, [
|
|
1689
|
+
{
|
|
1690
|
+
id: nextId(),
|
|
1691
|
+
trackId: uplifterId,
|
|
1692
|
+
startTick: to - duration,
|
|
1693
|
+
durationTicks: duration,
|
|
1694
|
+
pitch: 60,
|
|
1695
|
+
velocity: 0.7,
|
|
1696
|
+
},
|
|
1697
|
+
]);
|
|
1698
|
+
parts.push(`uplifter (${round3(seconds)} s)`);
|
|
1699
|
+
}
|
|
1700
|
+
|
|
1701
|
+
return {
|
|
1702
|
+
score: next,
|
|
1703
|
+
summary: `build over bars ${range.startBar + 1}–${range.startBar + range.bars}: ${parts.join(", ") || "nothing"}`,
|
|
1704
|
+
};
|
|
1705
|
+
}
|
|
1706
|
+
|
|
1707
|
+
function round3(value: number): number {
|
|
1708
|
+
return Math.round(value * 1000) / 1000;
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1711
|
+
/**
|
|
1712
|
+
* The uplifter track whose pitch rise lasts `seconds` (the rise lives in the
|
|
1713
|
+
* track's synth envelope, so each length gets its own track and builds of
|
|
1714
|
+
* the same length share one), created when missing.
|
|
1715
|
+
*/
|
|
1716
|
+
function uplifterTrack(
|
|
1717
|
+
score: TrackScore,
|
|
1718
|
+
seconds: number,
|
|
1719
|
+
): { score: TrackScore; id: string } {
|
|
1720
|
+
const pattack = round3(seconds);
|
|
1721
|
+
const existing = score.tracks.find(
|
|
1722
|
+
(track) =>
|
|
1723
|
+
(track.id === GENERATOR_TRACKS.uplifter ||
|
|
1724
|
+
/^uplifter-\d+$/u.test(track.id)) &&
|
|
1725
|
+
track.synth?.pattack === pattack,
|
|
1726
|
+
);
|
|
1727
|
+
let id = existing?.id ?? GENERATOR_TRACKS.uplifter;
|
|
1728
|
+
if (!existing && score.tracks.some((track) => track.id === id)) {
|
|
1729
|
+
let n = 2;
|
|
1730
|
+
while (score.tracks.some((track) => track.id === `uplifter-${n}`)) n += 1;
|
|
1731
|
+
id = `uplifter-${n}`;
|
|
1732
|
+
}
|
|
1733
|
+
const next = ensureTrack(score, id, () => ({
|
|
1734
|
+
id,
|
|
1735
|
+
name: id === GENERATOR_TRACKS.uplifter ? "uplifter" : id,
|
|
1736
|
+
instrument: "supersaw",
|
|
1737
|
+
volume: 0.3,
|
|
1738
|
+
pan: 0,
|
|
1739
|
+
muted: false,
|
|
1740
|
+
volumeAutomation: [],
|
|
1741
|
+
panAutomation: [],
|
|
1742
|
+
filter: { cutoff: 5_000, resonance: 0.2 },
|
|
1743
|
+
synth: {
|
|
1744
|
+
penv: 24,
|
|
1745
|
+
pattack,
|
|
1746
|
+
psustain: 1,
|
|
1747
|
+
attack: round3(seconds / 2),
|
|
1748
|
+
release: 0.1,
|
|
1749
|
+
},
|
|
1750
|
+
}));
|
|
1751
|
+
return { score: next, id };
|
|
1752
|
+
}
|
|
1753
|
+
|
|
1754
|
+
function isUplifter(id: string): boolean {
|
|
1755
|
+
return id === GENERATOR_TRACKS.uplifter || /^uplifter-\d+$/u.test(id);
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
export type DropOptions = Readonly<{
|
|
1759
|
+
/** The section the drop lands on (its first bar). */
|
|
1760
|
+
section?: string;
|
|
1761
|
+
/** Or the bar it lands on, 0-based. */
|
|
1762
|
+
bar?: number;
|
|
1763
|
+
/** Beats of silence before the drop (0 disables), default 1. */
|
|
1764
|
+
cut?: number;
|
|
1765
|
+
/** Impact hit on the downbeat, default on. */
|
|
1766
|
+
impact?: boolean;
|
|
1767
|
+
}>;
|
|
1768
|
+
|
|
1769
|
+
/** A drop: a pre-drop cut (silence) and an impact hit on the downbeat. */
|
|
1770
|
+
export function generateDrop(
|
|
1771
|
+
score: TrackScore,
|
|
1772
|
+
options: DropOptions = {},
|
|
1773
|
+
): GeneratorResult {
|
|
1774
|
+
const parts: string[] = [];
|
|
1775
|
+
let bar: number;
|
|
1776
|
+
if (options.section !== undefined)
|
|
1777
|
+
bar = requireSection(score, options.section).startBar;
|
|
1778
|
+
else if (options.bar !== undefined)
|
|
1779
|
+
bar = Math.max(0, Math.min(score.bars - 1, options.bar));
|
|
1780
|
+
else {
|
|
1781
|
+
// Default: the section named drop, else chorus, that has bars before it.
|
|
1782
|
+
const target =
|
|
1783
|
+
score.sections.find(
|
|
1784
|
+
(s) => foldName(s.name) === "drop" && s.startBar > 0,
|
|
1785
|
+
) ??
|
|
1786
|
+
score.sections.find(
|
|
1787
|
+
(s) => foldName(s.name) === "chorus" && s.startBar > 0,
|
|
1788
|
+
);
|
|
1789
|
+
// Else the bar after the last build (its layers land on that bar).
|
|
1790
|
+
const built = Math.max(
|
|
1791
|
+
0,
|
|
1792
|
+
...score.notes
|
|
1793
|
+
.filter((note) => /^build\d+-/u.test(note.id))
|
|
1794
|
+
.map((note) => note.startTick + note.durationTicks),
|
|
1795
|
+
);
|
|
1796
|
+
const after = Math.ceil(built / barTicks(score));
|
|
1797
|
+
if (target) bar = target.startBar;
|
|
1798
|
+
else if (built > 0 && after <= SCORE_LIMITS.maxBars - 1) {
|
|
1799
|
+
bar = after;
|
|
1800
|
+
// A build at the song's end: add the bar the drop lands on.
|
|
1801
|
+
if (bar >= score.bars) {
|
|
1802
|
+
score = new TrackScore({ ...score.toJSON(), bars: bar + 1 });
|
|
1803
|
+
parts.push(`added bar ${bar + 1}`);
|
|
1804
|
+
}
|
|
1805
|
+
} else
|
|
1806
|
+
throw new ScoreValidationError(
|
|
1807
|
+
"drop needs a section or a bar: drop chorus, or drop at 17",
|
|
1808
|
+
);
|
|
1809
|
+
}
|
|
1810
|
+
const ticks = barTicks(score);
|
|
1811
|
+
const at = bar * ticks;
|
|
1812
|
+
const cutBeats = clampNote(
|
|
1813
|
+
options.cut ?? 1,
|
|
1814
|
+
DROP_CUT_MIN,
|
|
1815
|
+
dropCutMax(score),
|
|
1816
|
+
"cut",
|
|
1817
|
+
parts,
|
|
1818
|
+
);
|
|
1819
|
+
const cut = Math.round(cutBeats * score.ticksPerBeat);
|
|
1820
|
+
let next = score;
|
|
1821
|
+
if (cut > 0 && at > 0) {
|
|
1822
|
+
const from = Math.max(0, at - cut);
|
|
1823
|
+
const notes: Note[] = [];
|
|
1824
|
+
const clipped: Note[] = [];
|
|
1825
|
+
for (const note of next.notes) {
|
|
1826
|
+
if (note.startTick >= from && note.startTick < at) continue;
|
|
1827
|
+
if (note.startTick < from && note.startTick + note.durationTicks > from) {
|
|
1828
|
+
const cut = { ...note, durationTicks: from - note.startTick };
|
|
1829
|
+
notes.push(cut);
|
|
1830
|
+
if (isUplifter(note.trackId)) clipped.push(cut);
|
|
1831
|
+
} else notes.push(note);
|
|
1832
|
+
}
|
|
1833
|
+
next = new TrackScore({ ...next.toJSON(), notes });
|
|
1834
|
+
// A clipped uplifter moves to a track whose rise ends at the cut, so it
|
|
1835
|
+
// still peaks right where the silence starts.
|
|
1836
|
+
for (const note of clipped) {
|
|
1837
|
+
const seconds = Math.min(10, ticksToSeconds(next, note.durationTicks));
|
|
1838
|
+
const placed = uplifterTrack(next, seconds);
|
|
1839
|
+
next = new TrackScore({
|
|
1840
|
+
...placed.score.toJSON(),
|
|
1841
|
+
notes: placed.score.notes.map((candidate) =>
|
|
1842
|
+
candidate.id === note.id
|
|
1843
|
+
? { ...candidate, trackId: placed.id }
|
|
1844
|
+
: candidate,
|
|
1845
|
+
),
|
|
1846
|
+
});
|
|
1847
|
+
}
|
|
1848
|
+
if (clipped.length > 0) {
|
|
1849
|
+
const used = new Set(next.notes.map((note) => note.trackId));
|
|
1850
|
+
next = new TrackScore({
|
|
1851
|
+
...next.toJSON(),
|
|
1852
|
+
tracks: next.tracks.filter(
|
|
1853
|
+
(track) => !isUplifter(track.id) || used.has(track.id),
|
|
1854
|
+
),
|
|
1855
|
+
});
|
|
1856
|
+
}
|
|
1857
|
+
parts.push(`${cutBeats}-beat cut`);
|
|
1858
|
+
}
|
|
1859
|
+
if (options.impact ?? true) {
|
|
1860
|
+
next = ensureTrack(next, GENERATOR_TRACKS.impact, () => ({
|
|
1861
|
+
id: GENERATOR_TRACKS.impact,
|
|
1862
|
+
name: "impact",
|
|
1863
|
+
instrument: "sine",
|
|
1864
|
+
volume: 0.9,
|
|
1865
|
+
pan: 0,
|
|
1866
|
+
muted: false,
|
|
1867
|
+
volumeAutomation: [],
|
|
1868
|
+
panAutomation: [],
|
|
1869
|
+
synth: {
|
|
1870
|
+
penv: 24,
|
|
1871
|
+
pattack: 0,
|
|
1872
|
+
pdecay: 0.25,
|
|
1873
|
+
psustain: 0,
|
|
1874
|
+
attack: 0.001,
|
|
1875
|
+
decay: 1.8,
|
|
1876
|
+
sustain: 0,
|
|
1877
|
+
release: 0.6,
|
|
1878
|
+
noise: 0.4,
|
|
1879
|
+
},
|
|
1880
|
+
}));
|
|
1881
|
+
const nextId = idMaker(next, `drop${bar + 1}-`);
|
|
1882
|
+
const exists = next.notes.some(
|
|
1883
|
+
(note) =>
|
|
1884
|
+
note.trackId === GENERATOR_TRACKS.impact && note.startTick === at,
|
|
1885
|
+
);
|
|
1886
|
+
if (!exists)
|
|
1887
|
+
next = withNotes(next, [
|
|
1888
|
+
{
|
|
1889
|
+
id: nextId(),
|
|
1890
|
+
trackId: GENERATOR_TRACKS.impact,
|
|
1891
|
+
startTick: at,
|
|
1892
|
+
durationTicks: Math.min(ticks, Math.max(1, next.bars * ticks - at)),
|
|
1893
|
+
pitch: 33,
|
|
1894
|
+
velocity: 1,
|
|
1895
|
+
},
|
|
1896
|
+
]);
|
|
1897
|
+
parts.push("impact");
|
|
1898
|
+
}
|
|
1899
|
+
return {
|
|
1900
|
+
score: next,
|
|
1901
|
+
summary: `drop at bar ${bar + 1}: ${parts.join(", ") || "nothing"}`,
|
|
1902
|
+
};
|
|
1903
|
+
}
|
|
1904
|
+
|
|
1905
|
+
export type FillStyle = "toms" | "roll" | "kick";
|
|
1906
|
+
|
|
1907
|
+
export const FILL_STYLES: readonly FillStyle[] = Object.freeze([
|
|
1908
|
+
"toms",
|
|
1909
|
+
"roll",
|
|
1910
|
+
"kick",
|
|
1911
|
+
]);
|
|
1912
|
+
|
|
1913
|
+
export type FillOptions = Readonly<{
|
|
1914
|
+
/**
|
|
1915
|
+
* Fill leading into this section (on the bars before its first bar);
|
|
1916
|
+
* without it, at every section boundary.
|
|
1917
|
+
*/
|
|
1918
|
+
section?: string;
|
|
1919
|
+
/** Or fill leading into this bar, 0-based. */
|
|
1920
|
+
bar?: number;
|
|
1921
|
+
/** Beats the fill lasts, default 1. */
|
|
1922
|
+
beats?: number;
|
|
1923
|
+
style?: FillStyle;
|
|
1924
|
+
/** Crash (open hat with kick) on the next downbeat, default on. */
|
|
1925
|
+
crash?: boolean;
|
|
1926
|
+
}>;
|
|
1927
|
+
|
|
1928
|
+
/** Drum fills at section boundaries: 16ths that replace the groove there. */
|
|
1929
|
+
export function generateFill(
|
|
1930
|
+
score: TrackScore,
|
|
1931
|
+
options: FillOptions = {},
|
|
1932
|
+
): GeneratorResult {
|
|
1933
|
+
const ticks = barTicks(score);
|
|
1934
|
+
let ends: number[];
|
|
1935
|
+
if (options.section !== undefined) {
|
|
1936
|
+
const section = requireSection(score, options.section);
|
|
1937
|
+
if (section.startBar === 0)
|
|
1938
|
+
throw new ScoreValidationError(
|
|
1939
|
+
`${section.name} starts at bar 1; a fill leads into a later section`,
|
|
1940
|
+
);
|
|
1941
|
+
ends = [section.startBar];
|
|
1942
|
+
} else if (options.bar !== undefined) {
|
|
1943
|
+
ends = [Math.max(1, Math.min(score.bars, options.bar))];
|
|
1944
|
+
} else {
|
|
1945
|
+
const boundaries = new Set<number>();
|
|
1946
|
+
for (const section of score.sections) {
|
|
1947
|
+
const end = section.startBar + section.bars;
|
|
1948
|
+
if (end < score.bars || score.form.length > 0) boundaries.add(end);
|
|
1949
|
+
}
|
|
1950
|
+
ends = [...boundaries].sort((a, b) => a - b);
|
|
1951
|
+
if (ends.length === 0) ends = [score.bars];
|
|
1952
|
+
}
|
|
1953
|
+
let next = score;
|
|
1954
|
+
let drums = drumTrackId(next);
|
|
1955
|
+
if (!drums) {
|
|
1956
|
+
next = ensureTrack(next, GENERATOR_TRACKS.fills, () => ({
|
|
1957
|
+
id: GENERATOR_TRACKS.fills,
|
|
1958
|
+
name: "fills",
|
|
1959
|
+
instrument: "kit",
|
|
1960
|
+
volume: 0.8,
|
|
1961
|
+
pan: 0,
|
|
1962
|
+
muted: false,
|
|
1963
|
+
volumeAutomation: [],
|
|
1964
|
+
panAutomation: [],
|
|
1965
|
+
}));
|
|
1966
|
+
drums = GENERATOR_TRACKS.fills;
|
|
1967
|
+
}
|
|
1968
|
+
const style = options.style ?? "toms";
|
|
1969
|
+
const clamped: string[] = [];
|
|
1970
|
+
const beats = clampNote(
|
|
1971
|
+
options.beats ?? 1,
|
|
1972
|
+
FILL_BEATS_MIN,
|
|
1973
|
+
fillBeatsMax(score),
|
|
1974
|
+
"beats",
|
|
1975
|
+
clamped,
|
|
1976
|
+
);
|
|
1977
|
+
const span = Math.round(beats * score.ticksPerBeat);
|
|
1978
|
+
const step = Math.max(1, Math.round(score.ticksPerBeat / 4));
|
|
1979
|
+
const nextId = idMaker(next, "fill-");
|
|
1980
|
+
const fill: Note[] = [];
|
|
1981
|
+
const clear: Array<[number, number]> = [];
|
|
1982
|
+
for (const endBar of ends) {
|
|
1983
|
+
const end = endBar * ticks;
|
|
1984
|
+
const from = Math.max(0, end - span);
|
|
1985
|
+
clear.push([from, end]);
|
|
1986
|
+
let index = 0;
|
|
1987
|
+
const hits = Math.max(1, Math.round((end - from) / step));
|
|
1988
|
+
for (let tick = from; tick < end; tick += step, index += 1) {
|
|
1989
|
+
const progress = hits <= 1 ? 1 : index / (hits - 1);
|
|
1990
|
+
const pitch =
|
|
1991
|
+
style === "roll"
|
|
1992
|
+
? SNARE
|
|
1993
|
+
: style === "kick"
|
|
1994
|
+
? index % 2 === 0
|
|
1995
|
+
? KICK
|
|
1996
|
+
: SNARE
|
|
1997
|
+
: progress < 0.25
|
|
1998
|
+
? SNARE
|
|
1999
|
+
: // High, mid, low tom down the fill (GM 50/47/45); the
|
|
2000
|
+
// built-in kit folds all three onto its tom.
|
|
2001
|
+
TOMS[Math.min(2, Math.floor(((progress - 0.25) / 0.75) * 3))]!;
|
|
2002
|
+
fill.push({
|
|
2003
|
+
id: nextId(),
|
|
2004
|
+
trackId: drums,
|
|
2005
|
+
startTick: tick,
|
|
2006
|
+
durationTicks: Math.max(1, Math.floor(step / 2)),
|
|
2007
|
+
pitch,
|
|
2008
|
+
velocity: Math.round((0.55 + 0.45 * progress) * 1000) / 1000,
|
|
2009
|
+
});
|
|
2010
|
+
}
|
|
2011
|
+
if ((options.crash ?? true) && end < next.bars * ticks)
|
|
2012
|
+
for (const pitch of [OPEN_HAT, KICK])
|
|
2013
|
+
fill.push({
|
|
2014
|
+
id: nextId(),
|
|
2015
|
+
trackId: drums,
|
|
2016
|
+
startTick: end,
|
|
2017
|
+
durationTicks: step,
|
|
2018
|
+
pitch,
|
|
2019
|
+
velocity: 1,
|
|
2020
|
+
});
|
|
2021
|
+
}
|
|
2022
|
+
const crashTicks = new Set(
|
|
2023
|
+
fill
|
|
2024
|
+
.filter((note) =>
|
|
2025
|
+
clear.every(
|
|
2026
|
+
([from, end]) => note.startTick < from || note.startTick >= end,
|
|
2027
|
+
),
|
|
2028
|
+
)
|
|
2029
|
+
.map((note) => `${note.startTick}:${note.pitch}`),
|
|
2030
|
+
);
|
|
2031
|
+
const notes = next.notes.filter((note) => {
|
|
2032
|
+
if (note.trackId !== drums) return true;
|
|
2033
|
+
if (
|
|
2034
|
+
clear.some(
|
|
2035
|
+
([from, end]) => note.startTick >= from && note.startTick < end,
|
|
2036
|
+
)
|
|
2037
|
+
)
|
|
2038
|
+
return false;
|
|
2039
|
+
return !crashTicks.has(`${note.startTick}:${note.pitch}`);
|
|
2040
|
+
});
|
|
2041
|
+
next = new TrackScore({ ...next.toJSON(), notes: [...notes, ...fill] });
|
|
2042
|
+
return {
|
|
2043
|
+
score: next,
|
|
2044
|
+
summary: `${style} fill${ends.length > 1 ? "s" : ""} into bar${ends.length > 1 ? "s" : ""} ${ends.map((bar) => bar + 1).join(", ")}${clamped.length ? ` (${clamped.join(", ")})` : ""}`,
|
|
2045
|
+
};
|
|
2046
|
+
}
|