@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.
Files changed (113) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/DAWG.md +293 -31
  3. package/README.md +4 -4
  4. package/core/chords.ts +288 -7
  5. package/core/diff.ts +37 -12
  6. package/core/expression.ts +1241 -0
  7. package/core/loop.ts +23 -0
  8. package/core/master.ts +455 -0
  9. package/core/midi.ts +452 -0
  10. package/core/rhythm.ts +7 -2
  11. package/core/score.ts +536 -56
  12. package/core/sdk/eval-child.ts +38 -3
  13. package/core/sdk/print.ts +445 -14
  14. package/core/sdk/v1.ts +1709 -23
  15. package/core/sections.ts +2046 -0
  16. package/core/synth.ts +11 -1
  17. package/core/tempo.ts +1318 -0
  18. package/core/tuning.ts +1180 -0
  19. package/guides/audition.md +26 -0
  20. package/guides/automation.md +26 -0
  21. package/guides/chords.md +28 -0
  22. package/guides/effects.md +27 -0
  23. package/guides/faders.md +29 -0
  24. package/guides/files.md +28 -0
  25. package/guides/getting-started.md +26 -0
  26. package/guides/index.ts +75 -0
  27. package/guides/keys.md +27 -0
  28. package/guides/media.md +27 -0
  29. package/guides/mix.md +23 -0
  30. package/guides/music.md +14 -0
  31. package/guides/notes.md +26 -0
  32. package/guides/performance.md +26 -0
  33. package/guides/play.md +24 -0
  34. package/guides/project.md +13 -0
  35. package/guides/providers.md +25 -0
  36. package/guides/rhythm.md +26 -0
  37. package/guides/sessions.md +21 -0
  38. package/guides/sound.md +15 -0
  39. package/guides/sounds.md +25 -0
  40. package/guides/tempo.md +26 -0
  41. package/guides/tracks.md +25 -0
  42. package/guides/web-search.md +21 -0
  43. package/package.json +3 -1
  44. package/src/agent/agent.ts +2 -0
  45. package/src/agent/brief.ts +77 -3
  46. package/src/agent/chord-tools.ts +9 -1
  47. package/src/agent/expression-tools.ts +336 -0
  48. package/src/agent/master-tools.ts +299 -0
  49. package/src/agent/models.ts +4 -4
  50. package/src/agent/ops.ts +29 -4
  51. package/src/agent/planner.ts +45 -2
  52. package/src/agent/preview-tool.ts +21 -3
  53. package/src/agent/section-tools.ts +411 -0
  54. package/src/agent/time-tools.ts +290 -0
  55. package/src/agent/tools.ts +36 -2
  56. package/src/agent/tuning-tools.ts +301 -0
  57. package/src/agent/xcb-agent.ts +2 -0
  58. package/src/audio/arrange.ts +471 -0
  59. package/src/audio/audition.ts +16 -3
  60. package/src/audio/click.ts +113 -1
  61. package/src/audio/clock.ts +71 -5
  62. package/src/audio/effects/bus.ts +5 -4
  63. package/src/audio/effects/common.ts +30 -1
  64. package/src/audio/effects/dynamics.ts +2 -1
  65. package/src/audio/effects/filter.ts +5 -3
  66. package/src/audio/effects/modulation.ts +5 -1
  67. package/src/audio/effects/space.ts +92 -2
  68. package/src/audio/engine.ts +90 -20
  69. package/src/audio/live.ts +33 -6
  70. package/src/audio/loudness.ts +551 -0
  71. package/src/audio/master.ts +660 -0
  72. package/src/audio/measure-worker.ts +45 -0
  73. package/src/audio/measure.ts +110 -0
  74. package/src/audio/player.ts +11 -6
  75. package/src/audio/preview.ts +74 -14
  76. package/src/audio/render-worker.ts +6 -1
  77. package/src/audio/renderer.ts +7 -1
  78. package/src/audio/sampler.ts +108 -19
  79. package/src/audio/synth/voice.ts +60 -13
  80. package/src/audio/synth/zzfx.ts +10 -4
  81. package/src/audio/warp.ts +86 -0
  82. package/src/audio/wav.ts +301 -26
  83. package/src/commands/arrange.ts +949 -0
  84. package/src/commands/expression.ts +934 -0
  85. package/src/commands/help.ts +281 -24
  86. package/src/commands/master.ts +361 -0
  87. package/src/commands/music.ts +6 -1
  88. package/src/commands/synth.ts +11 -1
  89. package/src/commands/time.ts +964 -0
  90. package/src/commands/tuning.ts +490 -0
  91. package/src/main.ts +709 -33
  92. package/src/render.ts +109 -7
  93. package/src/session/daemon.ts +18 -8
  94. package/src/session/naming.ts +8 -1
  95. package/src/session/rebase.ts +21 -0
  96. package/src/tui/arrange-menu.ts +390 -0
  97. package/src/tui/audition.ts +38 -5
  98. package/src/tui/fader.ts +409 -0
  99. package/src/tui/menu-time.ts +401 -0
  100. package/src/tui/menu.ts +635 -14
  101. package/src/tui/performance-menu.ts +235 -0
  102. package/src/tui/play-chords.ts +3 -1
  103. package/src/tui/play-mode.ts +150 -6
  104. package/src/tui/play-session.ts +414 -41
  105. package/tui/app.ts +262 -13
  106. package/tui/arrange-strip.ts +174 -0
  107. package/tui/drawer.ts +478 -0
  108. package/tui/grammar.ts +63 -1
  109. package/tui/guide.ts +351 -0
  110. package/tui/highway.ts +87 -4
  111. package/tui/hits.ts +68 -0
  112. package/tui/input.ts +7 -0
  113. package/tui/keys.ts +72 -0
package/src/render.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `dawg render <out.wav>`: renders a session, the project files (`song.ts`,
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 { renderScoreWav } from "./audio/wav.ts";
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 === "--session" || arg === "--import") {
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
- let wav = renderScoreWav(score, { samples });
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
- stdout.write(`rendered · ${target} · ${wav.byteLength} bytes · ${sha}\n`);
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
 
@@ -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.setTempo(this.score.tempoBpm);
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.score.tempoBpm;
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.score.tempoBpm;
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
- private afterScoreChange(previousTempo: number): void {
532
- if (this.score.tempoBpm !== previousTempo) {
533
- this.clock.setTempo(this.score.tempoBpm);
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.score.tempoBpm;
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. */
@@ -107,7 +107,14 @@ export function musicalFingerprint(score: TrackScore): Fingerprint {
107
107
  : `${role}(${instrument},${register},${density})`,
108
108
  );
109
109
  }
110
- const key = score.key ? score.key.toLowerCase() : estimateKey(histogram);
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",
@@ -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
+ }