@hraness/dawg 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +86 -0
- package/DAWG.md +300 -35
- package/README.md +4 -4
- package/core/chords.ts +288 -7
- package/core/diff.ts +37 -12
- package/core/expression.ts +1241 -0
- package/core/loop.ts +23 -0
- package/core/master.ts +455 -0
- package/core/midi.ts +452 -0
- package/core/rhythm.ts +7 -2
- package/core/score.ts +536 -56
- package/core/sdk/eval-child.ts +38 -3
- package/core/sdk/print.ts +445 -14
- package/core/sdk/v1.ts +1709 -23
- package/core/sections.ts +2046 -0
- package/core/synth.ts +11 -1
- package/core/tempo.ts +1318 -0
- package/core/tuning.ts +1180 -0
- package/guides/audition.md +26 -0
- package/guides/automation.md +26 -0
- package/guides/chords.md +28 -0
- package/guides/effects.md +27 -0
- package/guides/faders.md +29 -0
- package/guides/files.md +28 -0
- package/guides/getting-started.md +26 -0
- package/guides/index.ts +75 -0
- package/guides/keys.md +27 -0
- package/guides/media.md +27 -0
- package/guides/mix.md +23 -0
- package/guides/music.md +14 -0
- package/guides/notes.md +26 -0
- package/guides/performance.md +26 -0
- package/guides/play.md +24 -0
- package/guides/project.md +13 -0
- package/guides/providers.md +25 -0
- package/guides/rhythm.md +26 -0
- package/guides/sessions.md +21 -0
- package/guides/sound.md +15 -0
- package/guides/sounds.md +25 -0
- package/guides/tempo.md +26 -0
- package/guides/tracks.md +25 -0
- package/guides/web-search.md +21 -0
- package/package.json +3 -1
- package/src/agent/agent.ts +2 -0
- package/src/agent/brief.ts +77 -3
- package/src/agent/chord-tools.ts +9 -1
- package/src/agent/expression-tools.ts +336 -0
- package/src/agent/master-tools.ts +299 -0
- package/src/agent/models.ts +4 -4
- package/src/agent/ops.ts +29 -4
- package/src/agent/planner.ts +45 -2
- package/src/agent/preview-tool.ts +21 -3
- package/src/agent/section-tools.ts +411 -0
- package/src/agent/time-tools.ts +290 -0
- package/src/agent/tools.ts +36 -2
- package/src/agent/tuning-tools.ts +301 -0
- package/src/agent/xcb-agent.ts +2 -0
- package/src/audio/arrange.ts +471 -0
- package/src/audio/audition.ts +16 -3
- package/src/audio/click.ts +113 -1
- package/src/audio/clock.ts +71 -5
- package/src/audio/effects/bus.ts +5 -4
- package/src/audio/effects/chain.ts +12 -2
- package/src/audio/effects/common.ts +30 -1
- package/src/audio/effects/convolution.ts +43 -5
- package/src/audio/effects/dynamics.ts +2 -1
- package/src/audio/effects/filter.ts +5 -3
- package/src/audio/effects/modulation.ts +5 -1
- package/src/audio/effects/space.ts +197 -50
- package/src/audio/engine.ts +175 -20
- package/src/audio/live.ts +33 -6
- package/src/audio/loudness.ts +551 -0
- package/src/audio/master.ts +660 -0
- package/src/audio/measure-worker.ts +45 -0
- package/src/audio/measure.ts +110 -0
- package/src/audio/player.ts +11 -6
- package/src/audio/preview.ts +87 -16
- package/src/audio/render-worker.ts +6 -1
- package/src/audio/renderer.ts +7 -1
- package/src/audio/sampler.ts +108 -19
- package/src/audio/synth/voice.ts +60 -13
- package/src/audio/synth/zzfx.ts +10 -4
- package/src/audio/warp.ts +86 -0
- package/src/audio/wav.ts +448 -59
- package/src/commands/arrange.ts +949 -0
- package/src/commands/expression.ts +934 -0
- package/src/commands/help.ts +281 -24
- package/src/commands/master.ts +361 -0
- package/src/commands/music.ts +6 -1
- package/src/commands/synth.ts +11 -1
- package/src/commands/time.ts +964 -0
- package/src/commands/tuning.ts +490 -0
- package/src/main.ts +826 -47
- package/src/render.ts +109 -7
- package/src/session/daemon.ts +18 -8
- package/src/session/naming.ts +8 -1
- package/src/session/rebase.ts +21 -0
- package/src/tui/arrange-menu.ts +390 -0
- package/src/tui/audition.ts +58 -5
- package/src/tui/euclid.ts +50 -6
- package/src/tui/fader.ts +409 -0
- package/src/tui/menu-time.ts +401 -0
- package/src/tui/menu.ts +656 -16
- package/src/tui/performance-menu.ts +235 -0
- package/src/tui/play-chords.ts +95 -1
- package/src/tui/play-mode.ts +150 -6
- package/src/tui/play-session.ts +414 -41
- package/tui/app.ts +262 -13
- package/tui/arrange-strip.ts +174 -0
- package/tui/drawer.ts +478 -0
- package/tui/grammar.ts +79 -12
- package/tui/guide.ts +351 -0
- package/tui/highway.ts +87 -4
- package/tui/hits.ts +68 -0
- package/tui/input.ts +7 -0
- package/tui/keys.ts +72 -0
package/src/render.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `dawg render <out.wav
|
|
2
|
+
* `dawg render <out.wav>` (or `<out.mid>`): renders a session, the project files (`song.ts`,
|
|
3
3
|
* in a project with no `--session`), or a `track.loop/v1` file to
|
|
4
4
|
* a stereo 16-bit PCM WAV through the same deterministic renderer playback
|
|
5
5
|
* uses, so two renders of one score are byte-identical. It reads the session
|
|
@@ -10,7 +10,21 @@ import { readFile, rename, writeFile } from "node:fs/promises";
|
|
|
10
10
|
import { resolve } from "node:path";
|
|
11
11
|
import { scoreFromJSON, type TrackScore } from "../core/score.ts";
|
|
12
12
|
import { decodeLoop } from "../core/loop.ts";
|
|
13
|
-
import {
|
|
13
|
+
import { scoreToMidi } from "../core/midi.ts";
|
|
14
|
+
import { RENDER_CHANNELS, encodeWav, withWavCues } from "./audio/wav.ts";
|
|
15
|
+
import { exportSampleRate, measureRendered } from "./audio/measure.ts";
|
|
16
|
+
import {
|
|
17
|
+
applyMasterCommand,
|
|
18
|
+
loudnessLine,
|
|
19
|
+
measurementLine,
|
|
20
|
+
parseMasterCommand,
|
|
21
|
+
} from "./commands/master.ts";
|
|
22
|
+
import { bakeTrackTime, findSection, sectionScore } from "../core/sections.ts";
|
|
23
|
+
import {
|
|
24
|
+
exportScore,
|
|
25
|
+
renderArrangedPcm,
|
|
26
|
+
sectionCues,
|
|
27
|
+
} from "./audio/arrange.ts";
|
|
14
28
|
import { SampleLibrary, hasSamplerTracks } from "./audio/samples.ts";
|
|
15
29
|
import {
|
|
16
30
|
PackStore,
|
|
@@ -29,7 +43,7 @@ import {
|
|
|
29
43
|
} from "./session/store.ts";
|
|
30
44
|
|
|
31
45
|
export const RENDER_USAGE =
|
|
32
|
-
"usage: dawg render <out.wav> [--session <name|id>] [--import <file.track.json>]";
|
|
46
|
+
"usage: dawg render <out.wav> [--session <name|id>] [--import <file.track.json>] [--section <name>] [--normalize <lufs|streaming|club|loud|…>] [--measure] [--rate <hz>] · <out.mid> writes MIDI";
|
|
33
47
|
|
|
34
48
|
const MAX_LOOP_FILE_BYTES = 512 * 1024;
|
|
35
49
|
|
|
@@ -50,7 +64,14 @@ export async function runRenderCommand(
|
|
|
50
64
|
const positional: string[] = [];
|
|
51
65
|
for (let index = 0; index < rest.length; index += 1) {
|
|
52
66
|
const arg = rest[index]!;
|
|
53
|
-
if (arg === "--
|
|
67
|
+
if (arg === "--measure") options.set(arg, "");
|
|
68
|
+
else if (
|
|
69
|
+
arg === "--session" ||
|
|
70
|
+
arg === "--import" ||
|
|
71
|
+
arg === "--normalize" ||
|
|
72
|
+
arg === "--rate" ||
|
|
73
|
+
arg === "--section"
|
|
74
|
+
) {
|
|
54
75
|
const value = rest[index + 1];
|
|
55
76
|
if (value === undefined || value.startsWith("--")) {
|
|
56
77
|
stderr.write(`${arg} needs a value · ${RENDER_USAGE}\n`);
|
|
@@ -64,7 +85,7 @@ export async function runRenderCommand(
|
|
|
64
85
|
} else positional.push(arg);
|
|
65
86
|
}
|
|
66
87
|
const target = positional[0];
|
|
67
|
-
if (positional.length !== 1 || !target || !/\.wav$/i.test(target)) {
|
|
88
|
+
if (positional.length !== 1 || !target || !/\.(wav|midi?)$/i.test(target)) {
|
|
68
89
|
stderr.write(`${RENDER_USAGE}\n`);
|
|
69
90
|
return 2;
|
|
70
91
|
}
|
|
@@ -77,13 +98,85 @@ export async function runRenderCommand(
|
|
|
77
98
|
);
|
|
78
99
|
return 1;
|
|
79
100
|
}
|
|
101
|
+
const only = options.get("--section");
|
|
102
|
+
if (only !== undefined) {
|
|
103
|
+
const section = findSection(score, only);
|
|
104
|
+
if (!section) {
|
|
105
|
+
const names = score.sections.map((entry) => entry.name).join(", ");
|
|
106
|
+
stderr.write(
|
|
107
|
+
`render failed · no section "${only}"${names ? ` · sections: ${names}` : ""}\n`,
|
|
108
|
+
);
|
|
109
|
+
return 1;
|
|
110
|
+
}
|
|
111
|
+
// One section as it plays when looped: its mutes and variations, no form.
|
|
112
|
+
score = sectionScore(bakeTrackTime(score), section);
|
|
113
|
+
}
|
|
114
|
+
if (/\.midi?$/i.test(target)) {
|
|
115
|
+
// MIDI carries notes, not audio: the master and loudness flags do not apply.
|
|
116
|
+
const audioOnly = ["--normalize", "--measure", "--rate"].find((flag) =>
|
|
117
|
+
options.has(flag),
|
|
118
|
+
);
|
|
119
|
+
if (audioOnly) {
|
|
120
|
+
stderr.write(`render failed · ${audioOnly} applies to .wav only\n`);
|
|
121
|
+
return 2;
|
|
122
|
+
}
|
|
123
|
+
const midi = scoreToMidi(exportScore(score));
|
|
124
|
+
const midiPath = resolve(workspace, target);
|
|
125
|
+
const midiTemporary = `${midiPath}.${process.pid}.tmp`;
|
|
126
|
+
await writeFile(midiTemporary, midi);
|
|
127
|
+
await rename(midiTemporary, midiPath);
|
|
128
|
+
const midiSha = createHash("sha256").update(midi).digest("hex");
|
|
129
|
+
stdout.write(
|
|
130
|
+
`rendered · ${target} · ${midi.byteLength} bytes · ${midiSha}\n`,
|
|
131
|
+
);
|
|
132
|
+
return 0;
|
|
133
|
+
}
|
|
134
|
+
// `--normalize` is the song master's loudness target for this export only:
|
|
135
|
+
// a named target also brings the limiter in at that target's ceiling.
|
|
136
|
+
const normalize = options.get("--normalize");
|
|
137
|
+
if (normalize !== undefined) {
|
|
138
|
+
const command = parseMasterCommand(`master target ${normalize}`);
|
|
139
|
+
const result = command && applyMasterCommand(score, command);
|
|
140
|
+
if (!result?.ok) {
|
|
141
|
+
stderr.write(
|
|
142
|
+
`render failed · --normalize takes LUFS (-40 to -3) or a target name · ${RENDER_USAGE}\n`,
|
|
143
|
+
);
|
|
144
|
+
return 2;
|
|
145
|
+
}
|
|
146
|
+
score = result.next ?? score;
|
|
147
|
+
const flipped =
|
|
148
|
+
command?.type === "master-target" ? command.flipped : undefined;
|
|
149
|
+
if (flipped !== undefined)
|
|
150
|
+
stderr.write(
|
|
151
|
+
`render · --normalize ${normalize} read as ${-flipped} LUFS\n`,
|
|
152
|
+
);
|
|
153
|
+
}
|
|
80
154
|
let samples;
|
|
81
155
|
if (hasSamplerTracks(score)) {
|
|
82
156
|
samples = await new SampleLibrary({ projectRoot: workspace }).load(score);
|
|
83
157
|
for (const problem of samples.problems)
|
|
84
158
|
stderr.write(`sample ${problem.level} · ${problem.message}\n`);
|
|
85
159
|
}
|
|
86
|
-
|
|
160
|
+
// A song with a master is a deliverable: 48 kHz unless --rate says
|
|
161
|
+
// otherwise; a plain song keeps the engine's rate, as dawg 0.4 wrote it.
|
|
162
|
+
const rateArg = options.get("--rate");
|
|
163
|
+
const sampleRate =
|
|
164
|
+
rateArg === undefined ? exportSampleRate(score) : Number(rateArg);
|
|
165
|
+
if (
|
|
166
|
+
!Number.isInteger(sampleRate) ||
|
|
167
|
+
sampleRate < 8_000 ||
|
|
168
|
+
sampleRate > 48_000
|
|
169
|
+
) {
|
|
170
|
+
stderr.write(
|
|
171
|
+
`render failed · --rate takes 8000 to 48000 Hz · ${RENDER_USAGE}\n`,
|
|
172
|
+
);
|
|
173
|
+
return 2;
|
|
174
|
+
}
|
|
175
|
+
const audio = renderArrangedPcm(score, { samples, sampleRate });
|
|
176
|
+
let wav = encodeWav(audio.pcm, audio.sampleRate, RENDER_CHANNELS);
|
|
177
|
+
// Section starts as cue points (a --section render is one section).
|
|
178
|
+
if (only === undefined)
|
|
179
|
+
wav = withWavCues(wav, sectionCues(score, audio.sampleRate));
|
|
87
180
|
// Pack sounds: name the packs (and CC-BY attributions) in the WAV's INFO
|
|
88
181
|
// comment and on stdout; CREDITS.md in a project keeps the attributions.
|
|
89
182
|
const refs = score.tracks.flatMap((track) =>
|
|
@@ -102,8 +195,17 @@ export async function runRenderCommand(
|
|
|
102
195
|
await writeFile(temporary, wav);
|
|
103
196
|
await rename(temporary, path);
|
|
104
197
|
const sha = createHash("sha256").update(wav).digest("hex");
|
|
105
|
-
|
|
198
|
+
const seconds = audio.frames / audio.sampleRate;
|
|
199
|
+
stdout.write(
|
|
200
|
+
`rendered · ${target} · ${seconds.toFixed(2)} s · ${wav.byteLength} bytes · ${sha}\n`,
|
|
201
|
+
);
|
|
106
202
|
if (credits) stdout.write(`credits · ${credits}\n`);
|
|
203
|
+
if (options.has("--measure"))
|
|
204
|
+
stdout.write(
|
|
205
|
+
`loudness · ${measurementLine(measureRendered(audio, false).mix, audio.master)}\n`,
|
|
206
|
+
);
|
|
207
|
+
else if (audio.master)
|
|
208
|
+
stdout.write(`loudness · ${loudnessLine(audio.master)}\n`);
|
|
107
209
|
return 0;
|
|
108
210
|
}
|
|
109
211
|
|
package/src/session/daemon.ts
CHANGED
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
type ScoreOperation,
|
|
7
7
|
type TrackScore,
|
|
8
8
|
} from "../../core/score.ts";
|
|
9
|
-
import { TransportClock } from "../audio/clock.ts";
|
|
9
|
+
import { TransportClock, transportMapFor } from "../audio/clock.ts";
|
|
10
10
|
import { AudioEngine } from "../audio/engine.ts";
|
|
11
11
|
import { acquireSessionLock } from "./lock.ts";
|
|
12
12
|
import {
|
|
@@ -127,7 +127,7 @@ export class DawgDaemon {
|
|
|
127
127
|
}
|
|
128
128
|
try {
|
|
129
129
|
await this.reload();
|
|
130
|
-
this.clock.
|
|
130
|
+
this.clock.follow(this.score);
|
|
131
131
|
// We hold the daemon lock, so any socket file left here belongs to a
|
|
132
132
|
// crashed daemon and is safe to reclaim.
|
|
133
133
|
await rm(this.socketPath, { force: true });
|
|
@@ -340,7 +340,7 @@ export class DawgDaemon {
|
|
|
340
340
|
);
|
|
341
341
|
if (result.record.revision !== this.record.revision) {
|
|
342
342
|
// A file-fallback window wrote the score too; adopt everything.
|
|
343
|
-
const previousTempo = this.
|
|
343
|
+
const previousTempo = this.timeKey();
|
|
344
344
|
await this.reload();
|
|
345
345
|
this.broadcast({
|
|
346
346
|
v: 1,
|
|
@@ -454,7 +454,7 @@ export class DawgDaemon {
|
|
|
454
454
|
rebasedFrom: message.base,
|
|
455
455
|
}
|
|
456
456
|
: message.payload;
|
|
457
|
-
const previousTempo = this.
|
|
457
|
+
const previousTempo = this.timeKey();
|
|
458
458
|
try {
|
|
459
459
|
this.record = await appendSessionEvent(
|
|
460
460
|
this.paths,
|
|
@@ -528,9 +528,14 @@ export class DawgDaemon {
|
|
|
528
528
|
}
|
|
529
529
|
}
|
|
530
530
|
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
531
|
+
/** Tempo and tempo map, compared to tell when the transport must follow. */
|
|
532
|
+
private timeKey(): string {
|
|
533
|
+
return JSON.stringify([this.score.tempoBpm, this.score.time ?? null]);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
private afterScoreChange(previousTempo: string): void {
|
|
537
|
+
if (this.timeKey() !== previousTempo) {
|
|
538
|
+
this.clock.follow(this.score);
|
|
534
539
|
this.broadcastTransport();
|
|
535
540
|
}
|
|
536
541
|
// Gapless: a streaming engine swaps the loop at the current beat
|
|
@@ -560,6 +565,7 @@ export class DawgDaemon {
|
|
|
560
565
|
this.audio.seek(this.clock.beatAt());
|
|
561
566
|
} else if (action === "tempo" && bpm !== undefined) {
|
|
562
567
|
this.clock.setTempo(bpm);
|
|
568
|
+
this.clock.setTimeMap(transportMapFor(this.score));
|
|
563
569
|
}
|
|
564
570
|
this.broadcastTransport();
|
|
565
571
|
}
|
|
@@ -641,7 +647,7 @@ export class DawgDaemon {
|
|
|
641
647
|
}
|
|
642
648
|
return;
|
|
643
649
|
}
|
|
644
|
-
const previousTempo = this.
|
|
650
|
+
const previousTempo = this.timeKey();
|
|
645
651
|
await this.reload();
|
|
646
652
|
this.broadcast({
|
|
647
653
|
v: 1,
|
|
@@ -695,6 +701,10 @@ const OPERATION_TYPES = new Set([
|
|
|
695
701
|
"moveTrack",
|
|
696
702
|
"setKey",
|
|
697
703
|
"setMeter",
|
|
704
|
+
"setTime",
|
|
705
|
+
"setTuning",
|
|
706
|
+
"setMaster",
|
|
707
|
+
"setSections",
|
|
698
708
|
]);
|
|
699
709
|
|
|
700
710
|
/** Shape gate before the reducer, which validates every field it reads. */
|
package/src/session/naming.ts
CHANGED
|
@@ -107,7 +107,14 @@ export function musicalFingerprint(score: TrackScore): Fingerprint {
|
|
|
107
107
|
: `${role}(${instrument},${register},${density})`,
|
|
108
108
|
);
|
|
109
109
|
}
|
|
110
|
-
|
|
110
|
+
// A tuned song is named by its tuning (`pelog`), not a guessed 12-TET key.
|
|
111
|
+
const tuned = score.tuning
|
|
112
|
+
? (score.tuning.name ??
|
|
113
|
+
(score.tuning.edo ? `${score.tuning.edo}-edo` : "tuned"))
|
|
114
|
+
: undefined;
|
|
115
|
+
const key = score.key
|
|
116
|
+
? score.key.toLowerCase()
|
|
117
|
+
: (tuned?.toLowerCase() ?? estimateKey(histogram));
|
|
111
118
|
const line = [
|
|
112
119
|
`${bpm} bpm`,
|
|
113
120
|
key ?? "no key",
|
package/src/session/rebase.ts
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
type TrackScore,
|
|
17
17
|
} from "../../core/score.ts";
|
|
18
18
|
import { rewindComposition, type Rewind } from "./delta.ts";
|
|
19
|
+
import { deepEqual } from "../../core/diff.ts";
|
|
19
20
|
|
|
20
21
|
/** How far behind a base may be and still be rebased. */
|
|
21
22
|
export const MAX_REBASE_DISTANCE = 64;
|
|
@@ -114,6 +115,26 @@ export function rebaseOperations(
|
|
|
114
115
|
if (base.beatsPerBar !== current.beatsPerBar)
|
|
115
116
|
return { ok: false, reason: "meter changed" };
|
|
116
117
|
break;
|
|
118
|
+
case "setTime":
|
|
119
|
+
if (!same(base.time, current.time))
|
|
120
|
+
return { ok: false, reason: "tempo map changed" };
|
|
121
|
+
break;
|
|
122
|
+
case "setTuning":
|
|
123
|
+
if (!same(base.tuning ?? null, current.tuning ?? null))
|
|
124
|
+
return { ok: false, reason: "tuning changed" };
|
|
125
|
+
break;
|
|
126
|
+
case "setMaster":
|
|
127
|
+
if (!deepEqual(base.master, current.master))
|
|
128
|
+
return { ok: false, reason: "master changed" };
|
|
129
|
+
break;
|
|
130
|
+
case "setSections":
|
|
131
|
+
if (
|
|
132
|
+
JSON.stringify(base.sections) !== JSON.stringify(current.sections) ||
|
|
133
|
+
JSON.stringify(base.form) !== JSON.stringify(current.form) ||
|
|
134
|
+
base.loopSection !== current.loopSection
|
|
135
|
+
)
|
|
136
|
+
return { ok: false, reason: "sections changed" };
|
|
137
|
+
break;
|
|
117
138
|
case "removeTrack": {
|
|
118
139
|
if (added.tracks.has(operation.trackId)) break;
|
|
119
140
|
if (
|
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ctrl-k menu's Arrange section (0.5): song sections, the form, and
|
|
3
|
+
* build/drop/fill generators. Every row runs a `section …`, `form …`,
|
|
4
|
+
* `build …`, `drop …` or `fill …` prompt command (src/commands/arrange.ts),
|
|
5
|
+
* so the menu, the prompt and the agent tools share one code path.
|
|
6
|
+
*/
|
|
7
|
+
import type { Section } from "../../core/score.ts";
|
|
8
|
+
import { formatForm } from "../../core/sections.ts";
|
|
9
|
+
import { barsLabel } from "../commands/arrange.ts";
|
|
10
|
+
import type { MenuContext, MenuNode } from "./menu.ts";
|
|
11
|
+
|
|
12
|
+
/** `3 sections · form A×2 B · loop chorus`, or how to start. */
|
|
13
|
+
export function arrangeDetail(context: MenuContext): string {
|
|
14
|
+
const score = context.score;
|
|
15
|
+
if (score.sections.length === 0) return "no sections · mark bars";
|
|
16
|
+
const count = score.sections.length;
|
|
17
|
+
const parts = [`${count} section${count === 1 ? "" : "s"}`];
|
|
18
|
+
if (score.form.length) parts.push(`form ${formatForm(score.form)}`);
|
|
19
|
+
if (score.loopSection) parts.push(`loop ${score.loopSection}`);
|
|
20
|
+
return parts.join(" · ");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function arrangeNodes(context: MenuContext): MenuNode[] {
|
|
24
|
+
const score = context.score;
|
|
25
|
+
const nodes: MenuNode[] = score.sections.map((section) => ({
|
|
26
|
+
kind: "menu",
|
|
27
|
+
id: `section:${section.name}`,
|
|
28
|
+
label: section.name,
|
|
29
|
+
detail: sectionDetail(context, section),
|
|
30
|
+
help: "loop, jump, mute, vary, move, rename, transitions",
|
|
31
|
+
build: (next) => sectionNodes(next, section.name),
|
|
32
|
+
}));
|
|
33
|
+
nodes.push(
|
|
34
|
+
{
|
|
35
|
+
kind: "entry",
|
|
36
|
+
label: "mark bars",
|
|
37
|
+
value: "",
|
|
38
|
+
placeholder: "name and bars, e.g. verse 1-8",
|
|
39
|
+
example: "section verse 1-8",
|
|
40
|
+
help: "name a bar range; re-marking a name moves its marker",
|
|
41
|
+
command: (text) => {
|
|
42
|
+
const trimmed = text.trim();
|
|
43
|
+
return /\S\s+\d{1,4}(?:[-–]\d{1,4})?$/u.test(trimmed)
|
|
44
|
+
? `section ${trimmed}`
|
|
45
|
+
: undefined;
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
kind: "entry",
|
|
50
|
+
label: "add section",
|
|
51
|
+
value: "",
|
|
52
|
+
placeholder: "name and bars, e.g. chorus 8 (empty: next name, 8 bars)",
|
|
53
|
+
example: "section add chorus 8",
|
|
54
|
+
help: "a new section after the last one (extends the song)",
|
|
55
|
+
command: (text) => `section add ${text.trim()}`.trim(),
|
|
56
|
+
},
|
|
57
|
+
);
|
|
58
|
+
if (score.sections.length > 0) {
|
|
59
|
+
nodes.push({
|
|
60
|
+
kind: "entry",
|
|
61
|
+
label: "form",
|
|
62
|
+
value: score.form.length ? formatForm(score.form) : "bars in order",
|
|
63
|
+
placeholder: "sections in play order, e.g. intro verse chorus*2 outro",
|
|
64
|
+
example: "form intro verse chorus*2 outro",
|
|
65
|
+
help: "the order playback and export follow; empty clears it",
|
|
66
|
+
command: (text) => (text.trim() ? `form ${text.trim()}` : "form off"),
|
|
67
|
+
});
|
|
68
|
+
if (score.form.length)
|
|
69
|
+
nodes.push({
|
|
70
|
+
kind: "action",
|
|
71
|
+
label: "bake form",
|
|
72
|
+
command: "form bake",
|
|
73
|
+
help: "write the form out as plain bars, then clear it",
|
|
74
|
+
});
|
|
75
|
+
if (score.loopSection)
|
|
76
|
+
nodes.push({
|
|
77
|
+
kind: "action",
|
|
78
|
+
label: `stop looping ${score.loopSection}`,
|
|
79
|
+
command: "section loop off",
|
|
80
|
+
help: "play the whole song (or form) again",
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
return nodes;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function sectionDetail(context: MenuContext, section: Section): string {
|
|
87
|
+
const parts = [barsLabel(section)];
|
|
88
|
+
if (context.score.loopSection === section.name) parts.push("looping");
|
|
89
|
+
if (section.mute?.length) parts.push(`mutes ${section.mute.join(" ")}`);
|
|
90
|
+
const varied = Object.keys(section.vary ?? {});
|
|
91
|
+
if (varied.length) parts.push(`varies ${varied.join(" ")}`);
|
|
92
|
+
return parts.join(" · ");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function sectionNodes(context: MenuContext, name: string): MenuNode[] {
|
|
96
|
+
const score = context.score;
|
|
97
|
+
const section = score.sections.find((entry) => entry.name === name);
|
|
98
|
+
if (!section)
|
|
99
|
+
return [{ kind: "info", label: "gone", value: `${name} was removed` }];
|
|
100
|
+
const looping = score.loopSection === name;
|
|
101
|
+
const track = context.trackId;
|
|
102
|
+
const vary = section.vary?.[track];
|
|
103
|
+
const mutes: MenuNode[] = score.tracks.map((entry) => ({
|
|
104
|
+
kind: "toggle",
|
|
105
|
+
label: `mute ${entry.id}`,
|
|
106
|
+
value: section.mute?.includes(entry.id) ?? false,
|
|
107
|
+
help: `silence ${entry.name} in this section only`,
|
|
108
|
+
command: (on) => `section ${on ? "mute" : "unmute"} ${name} ${entry.id}`,
|
|
109
|
+
}));
|
|
110
|
+
return [
|
|
111
|
+
{
|
|
112
|
+
kind: "action",
|
|
113
|
+
label: looping ? "stop looping" : "loop",
|
|
114
|
+
command: looping ? "section loop off" : `section loop ${name}`,
|
|
115
|
+
help: "loop this section in playback (export still plays the song)",
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
kind: "action",
|
|
119
|
+
label: "jump here",
|
|
120
|
+
command: `section jump ${name}`,
|
|
121
|
+
help: "move the playhead to its first bar",
|
|
122
|
+
},
|
|
123
|
+
...mutes,
|
|
124
|
+
{
|
|
125
|
+
kind: "number",
|
|
126
|
+
label: `transpose ${track}`,
|
|
127
|
+
help: "shift the focused track here, in semitones (x resets)",
|
|
128
|
+
// As written is +0, so the first nudge already moves a semitone.
|
|
129
|
+
value: vary?.transpose ?? 0,
|
|
130
|
+
min: -24,
|
|
131
|
+
max: 24,
|
|
132
|
+
step: (value, direction) =>
|
|
133
|
+
Math.max(-24, Math.min(24, Math.round(value) + direction)),
|
|
134
|
+
format: (value) => `${value > 0 ? "+" : ""}${Math.round(value)} st`,
|
|
135
|
+
command: (value) => {
|
|
136
|
+
const rounded = Math.round(value);
|
|
137
|
+
return `section vary ${name} ${track} ${rounded < 0 ? "" : "+"}${rounded}`;
|
|
138
|
+
},
|
|
139
|
+
reset: `section vary ${name} ${track} off`,
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
kind: "number",
|
|
143
|
+
label: `gain ${track}`,
|
|
144
|
+
help: "scale the focused track's velocity here (x resets)",
|
|
145
|
+
value: vary?.gain ?? 1,
|
|
146
|
+
min: 0,
|
|
147
|
+
max: 2,
|
|
148
|
+
step: (value, direction) =>
|
|
149
|
+
Math.max(
|
|
150
|
+
0,
|
|
151
|
+
Math.min(2, Math.round((value + 0.1 * direction) * 10) / 10),
|
|
152
|
+
),
|
|
153
|
+
format: (value) => `×${value.toFixed(1)}`,
|
|
154
|
+
command: (value) =>
|
|
155
|
+
`section vary ${name} ${track} gain ${(Math.round(value * 10) / 10).toString()}`,
|
|
156
|
+
reset: `section vary ${name} ${track} off`,
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
kind: "menu",
|
|
160
|
+
id: `section:${name}:build`,
|
|
161
|
+
label: "build",
|
|
162
|
+
detail: section.startBar > 0 ? "into it or over it" : "over it",
|
|
163
|
+
help: "riser, snare roll, filter sweep and uplifter",
|
|
164
|
+
build: () => buildNodes(section),
|
|
165
|
+
},
|
|
166
|
+
...(section.startBar > 0
|
|
167
|
+
? ([
|
|
168
|
+
{
|
|
169
|
+
kind: "menu",
|
|
170
|
+
id: `section:${name}:drop`,
|
|
171
|
+
label: "drop",
|
|
172
|
+
detail: "cut and impact",
|
|
173
|
+
help: "a pre-drop cut before it and an impact on its downbeat",
|
|
174
|
+
build: () => dropNodes(section),
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
kind: "menu",
|
|
178
|
+
id: `section:${name}:fill`,
|
|
179
|
+
label: "fill",
|
|
180
|
+
detail: "toms, roll or kick",
|
|
181
|
+
help: "a drum fill on the beats before it and a crash on its downbeat",
|
|
182
|
+
build: () => fillNodes(section),
|
|
183
|
+
},
|
|
184
|
+
] satisfies MenuNode[])
|
|
185
|
+
: []),
|
|
186
|
+
{
|
|
187
|
+
kind: "action",
|
|
188
|
+
label: "duplicate",
|
|
189
|
+
command: `section dup ${name}`,
|
|
190
|
+
help: "copy it and its bars right after it",
|
|
191
|
+
},
|
|
192
|
+
{
|
|
193
|
+
kind: "entry",
|
|
194
|
+
label: "duplicate as",
|
|
195
|
+
value: "",
|
|
196
|
+
placeholder: "the copy's name",
|
|
197
|
+
example: `section dup ${name} as ${name} 2`,
|
|
198
|
+
help: "copy it and its bars right after it, under a new name",
|
|
199
|
+
command: (text) =>
|
|
200
|
+
text.trim() ? `section dup ${name} as ${text.trim()}` : undefined,
|
|
201
|
+
},
|
|
202
|
+
{
|
|
203
|
+
kind: "entry",
|
|
204
|
+
label: "move to",
|
|
205
|
+
value: "",
|
|
206
|
+
placeholder: "a bar, or before/after a section, e.g. 17 or after chorus",
|
|
207
|
+
example: `section move ${name} to 17`,
|
|
208
|
+
help: "move it and its bars; later sections make room",
|
|
209
|
+
command: (text) => {
|
|
210
|
+
const trimmed = text.trim();
|
|
211
|
+
if (/^\d{1,4}$/u.test(trimmed))
|
|
212
|
+
return `section move ${name} to ${trimmed}`;
|
|
213
|
+
return /^(before|after)\s+\S/u.test(trimmed)
|
|
214
|
+
? `section move ${name} ${trimmed}`
|
|
215
|
+
: undefined;
|
|
216
|
+
},
|
|
217
|
+
},
|
|
218
|
+
{
|
|
219
|
+
kind: "action",
|
|
220
|
+
label: "move left",
|
|
221
|
+
command: `section move ${name} left`,
|
|
222
|
+
help: "swap it with the section before (its bars move too)",
|
|
223
|
+
},
|
|
224
|
+
{
|
|
225
|
+
kind: "action",
|
|
226
|
+
label: "move right",
|
|
227
|
+
command: `section move ${name} right`,
|
|
228
|
+
help: "swap it with the section after (its bars move too)",
|
|
229
|
+
},
|
|
230
|
+
{
|
|
231
|
+
kind: "entry",
|
|
232
|
+
label: "rename",
|
|
233
|
+
value: name,
|
|
234
|
+
placeholder: "new name",
|
|
235
|
+
example: `section rename ${name} to hook`,
|
|
236
|
+
help: "the form and the loop follow the new name",
|
|
237
|
+
command: (text) =>
|
|
238
|
+
text.trim() ? `section rename ${name} to ${text.trim()}` : undefined,
|
|
239
|
+
},
|
|
240
|
+
{
|
|
241
|
+
kind: "action",
|
|
242
|
+
label: "clear mutes and variations",
|
|
243
|
+
command: `section reset ${name}`,
|
|
244
|
+
help: "every track plays as written here",
|
|
245
|
+
},
|
|
246
|
+
{
|
|
247
|
+
kind: "action",
|
|
248
|
+
label: "unmark",
|
|
249
|
+
command: `section unmark ${name}`,
|
|
250
|
+
help: "remove the marker; the music stays",
|
|
251
|
+
},
|
|
252
|
+
{
|
|
253
|
+
kind: "action",
|
|
254
|
+
label: "delete with its bars",
|
|
255
|
+
command: `section delete ${name}`,
|
|
256
|
+
help: "remove it and its bars; later bars move up",
|
|
257
|
+
},
|
|
258
|
+
];
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Build rows: into the section (the bars before it) or over it. */
|
|
262
|
+
export function buildNodes(section: Section): MenuNode[] {
|
|
263
|
+
const name = section.name;
|
|
264
|
+
const nodes: MenuNode[] = [];
|
|
265
|
+
if (section.startBar > 0) {
|
|
266
|
+
const bars = Math.min(4, section.startBar);
|
|
267
|
+
nodes.push({
|
|
268
|
+
kind: "action",
|
|
269
|
+
label: `into it (${bars} bar${bars === 1 ? "" : "s"} before)`,
|
|
270
|
+
command: `build into ${name}`,
|
|
271
|
+
help: `riser, snare roll, filter sweep and uplifter over bars ${section.startBar - bars + 1}–${section.startBar}, landing on its downbeat`,
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
nodes.push(
|
|
275
|
+
{
|
|
276
|
+
kind: "action",
|
|
277
|
+
label: "over it",
|
|
278
|
+
command: `build ${name}`,
|
|
279
|
+
help: "riser, snare roll, filter sweep and uplifter over this section, landing on the bar after",
|
|
280
|
+
},
|
|
281
|
+
{
|
|
282
|
+
kind: "entry",
|
|
283
|
+
label: "custom",
|
|
284
|
+
value: "",
|
|
285
|
+
placeholder:
|
|
286
|
+
"bars and layers, e.g. 8 bars riser sweep (layers: riser roll sweep uplifter)",
|
|
287
|
+
example: `build into ${name} 8 bars riser sweep`,
|
|
288
|
+
help: "choose the length and which layers play",
|
|
289
|
+
command: (text) => {
|
|
290
|
+
const words = text.trim().toLowerCase().split(/\s+/u).filter(Boolean);
|
|
291
|
+
const ok = words.every(
|
|
292
|
+
(word, index) =>
|
|
293
|
+
/^(riser|roll|sweep|uplifter)s?$/u.test(word) ||
|
|
294
|
+
(/^\d{1,3}$/u.test(word) &&
|
|
295
|
+
/^bars?$/u.test(words[index + 1] ?? "")) ||
|
|
296
|
+
(/^bars?$/u.test(word) &&
|
|
297
|
+
/^\d{1,3}$/u.test(words[index - 1] ?? "")),
|
|
298
|
+
);
|
|
299
|
+
if (!ok) return undefined;
|
|
300
|
+
const target = section.startBar > 0 ? `into ${name}` : name;
|
|
301
|
+
return `build ${target} ${words.join(" ")}`.trim();
|
|
302
|
+
},
|
|
303
|
+
},
|
|
304
|
+
);
|
|
305
|
+
return nodes;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Drop rows: cut length and the impact. */
|
|
309
|
+
export function dropNodes(section: Section): MenuNode[] {
|
|
310
|
+
const name = section.name;
|
|
311
|
+
return [
|
|
312
|
+
{
|
|
313
|
+
kind: "action",
|
|
314
|
+
label: "cut 1 beat and impact",
|
|
315
|
+
command: `drop ${name}`,
|
|
316
|
+
help: "a one-beat cut before it and an impact on its downbeat",
|
|
317
|
+
},
|
|
318
|
+
{
|
|
319
|
+
kind: "action",
|
|
320
|
+
label: "cut 2 beats and impact",
|
|
321
|
+
command: `drop ${name} cut 2`,
|
|
322
|
+
help: "a two-beat cut before it and an impact on its downbeat",
|
|
323
|
+
},
|
|
324
|
+
{
|
|
325
|
+
kind: "action",
|
|
326
|
+
label: "impact only",
|
|
327
|
+
command: `drop ${name} no cut`,
|
|
328
|
+
help: "an impact on its downbeat, no cut",
|
|
329
|
+
},
|
|
330
|
+
{
|
|
331
|
+
kind: "action",
|
|
332
|
+
label: "cut only",
|
|
333
|
+
command: `drop ${name} no impact`,
|
|
334
|
+
help: "a one-beat cut before it, no impact",
|
|
335
|
+
},
|
|
336
|
+
{
|
|
337
|
+
kind: "entry",
|
|
338
|
+
label: "custom",
|
|
339
|
+
value: "",
|
|
340
|
+
placeholder: "cut beats and impact, e.g. cut 4 no impact",
|
|
341
|
+
example: `drop ${name} cut 4 no impact`,
|
|
342
|
+
help: "cut 0 up to two bars of beats; no impact skips the hit",
|
|
343
|
+
command: (text) =>
|
|
344
|
+
/^(cut\s+\d+(\.\d+)?|no\s+cut|no\s+impact|impact|\s)*$/iu.test(
|
|
345
|
+
text.trim(),
|
|
346
|
+
)
|
|
347
|
+
? `drop ${name} ${text.trim()}`.trim()
|
|
348
|
+
: undefined,
|
|
349
|
+
},
|
|
350
|
+
];
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** Fill rows: a style each, plus length and crash. */
|
|
354
|
+
export function fillNodes(section: Section): MenuNode[] {
|
|
355
|
+
const name = section.name;
|
|
356
|
+
return [
|
|
357
|
+
{
|
|
358
|
+
kind: "action",
|
|
359
|
+
label: "toms",
|
|
360
|
+
command: `fill ${name} toms`,
|
|
361
|
+
help: "snare into high, mid and low toms on the beat before it, crash on its downbeat",
|
|
362
|
+
},
|
|
363
|
+
{
|
|
364
|
+
kind: "action",
|
|
365
|
+
label: "snare roll",
|
|
366
|
+
command: `fill ${name} roll`,
|
|
367
|
+
help: "snare 16ths on the beat before it, crash on its downbeat",
|
|
368
|
+
},
|
|
369
|
+
{
|
|
370
|
+
kind: "action",
|
|
371
|
+
label: "kick and snare",
|
|
372
|
+
command: `fill ${name} kick`,
|
|
373
|
+
help: "kick and snare 16ths on the beat before it, crash on its downbeat",
|
|
374
|
+
},
|
|
375
|
+
{
|
|
376
|
+
kind: "entry",
|
|
377
|
+
label: "custom",
|
|
378
|
+
value: "",
|
|
379
|
+
placeholder: "style, beats and crash, e.g. toms 2 beats no crash",
|
|
380
|
+
example: `fill ${name} toms 2 beats no crash`,
|
|
381
|
+
help: "styles toms, roll, kick; half a beat up to two bars",
|
|
382
|
+
command: (text) =>
|
|
383
|
+
/^(toms|roll|kick|\d+(\.\d+)?\s+beats?|no\s+crash|crash|\s)*$/iu.test(
|
|
384
|
+
text.trim(),
|
|
385
|
+
)
|
|
386
|
+
? `fill ${name} ${text.trim()}`.trim()
|
|
387
|
+
: undefined,
|
|
388
|
+
},
|
|
389
|
+
];
|
|
390
|
+
}
|