@hraness/dawg 0.6.1 → 0.7.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 (253) hide show
  1. package/CHANGELOG.md +92 -0
  2. package/DAWG.md +343 -116
  3. package/README.md +27 -25
  4. package/core/autotune.ts +1119 -0
  5. package/core/chords.ts +271 -23
  6. package/core/clips.ts +499 -0
  7. package/core/diff.ts +182 -104
  8. package/core/expression.ts +15 -0
  9. package/core/fx.ts +99 -29
  10. package/core/instruments.ts +19 -0
  11. package/core/keys.ts +3 -3
  12. package/core/loop.ts +5 -0
  13. package/core/lyrics.ts +297 -0
  14. package/core/master.ts +3 -3
  15. package/core/resonators.ts +16 -2
  16. package/core/routing.ts +165 -0
  17. package/core/score.ts +832 -13
  18. package/core/sdk/eval-child.ts +7 -2
  19. package/core/sdk/eval.ts +35 -6
  20. package/core/sdk/print.ts +276 -3
  21. package/core/sdk/sync-lyrics.ts +49 -0
  22. package/core/sdk/v1.ts +1849 -42
  23. package/core/sections.ts +292 -26
  24. package/core/sing.ts +815 -0
  25. package/core/style-provenance.ts +80 -0
  26. package/core/styles/africa-mena-southasia.ts +2893 -0
  27. package/core/styles/americas.ts +3810 -0
  28. package/core/styles/art.ts +4993 -0
  29. package/core/styles/base.ts +123 -0
  30. package/core/styles/cycles.ts +106 -0
  31. package/core/styles/electronic.ts +2723 -0
  32. package/core/styles/europe-asia-pacific.ts +2838 -0
  33. package/core/styles/excerpt.ts +29 -0
  34. package/core/styles/gamelan.ts +283 -0
  35. package/core/styles/generate.ts +2199 -0
  36. package/core/styles/index.ts +515 -0
  37. package/core/styles/parts.ts +106 -0
  38. package/core/styles/pop.ts +3189 -0
  39. package/core/styles/rock.ts +2993 -0
  40. package/core/styles/roots.ts +4175 -0
  41. package/core/styles/schema.ts +429 -0
  42. package/core/styles/taxonomy.ts +940 -0
  43. package/core/styles/validate.ts +528 -0
  44. package/core/tempo.ts +32 -2
  45. package/core/tuning.ts +19 -3
  46. package/core/vocoder.ts +524 -0
  47. package/guides/agent.md +29 -0
  48. package/guides/arrange.md +30 -0
  49. package/guides/audition.md +20 -14
  50. package/guides/automation.md +12 -7
  51. package/guides/chords.md +15 -15
  52. package/guides/effects.md +17 -16
  53. package/guides/faders.md +20 -16
  54. package/guides/files.md +13 -9
  55. package/guides/getting-started.md +15 -11
  56. package/guides/keys.md +19 -15
  57. package/guides/media.md +17 -12
  58. package/guides/mix.md +12 -6
  59. package/guides/music.md +23 -8
  60. package/guides/notes.md +15 -9
  61. package/guides/performance.md +15 -13
  62. package/guides/play.md +21 -13
  63. package/guides/project.md +25 -8
  64. package/guides/providers.md +20 -14
  65. package/guides/resample.md +15 -9
  66. package/guides/rhythm.md +17 -13
  67. package/guides/sessions.md +17 -7
  68. package/guides/show-me.md +31 -0
  69. package/guides/sound.md +25 -9
  70. package/guides/sounds.md +15 -13
  71. package/guides/styles.md +31 -0
  72. package/guides/tempo.md +15 -10
  73. package/guides/tracks.md +15 -10
  74. package/guides/tuning.md +32 -0
  75. package/guides/voice.md +31 -0
  76. package/guides/web-search.md +18 -8
  77. package/native/prebuilt/darwin-arm64/libdawg_sink.dylib +0 -0
  78. package/native/prebuilt/darwin-x64/libdawg_sink.dylib +0 -0
  79. package/native/prebuilt/linux-arm64/libdawg_sink.so +0 -0
  80. package/native/prebuilt/linux-x64/libdawg_sink.so +0 -0
  81. package/native/prebuilt/manifest.json +21 -0
  82. package/package.json +5 -2
  83. package/src/agent/agent.ts +126 -14
  84. package/src/agent/calibration-tools.ts +53 -0
  85. package/src/agent/clip-tools.ts +453 -0
  86. package/src/agent/command-agent.ts +369 -0
  87. package/src/agent/drum-tools.ts +2 -2
  88. package/src/agent/expression-tools.ts +1 -1
  89. package/src/agent/gateway.ts +246 -60
  90. package/src/agent/models.ts +53 -12
  91. package/src/agent/ops.ts +12 -1
  92. package/src/agent/pack-tools.ts +1 -1
  93. package/src/agent/planner.ts +13 -0
  94. package/src/agent/portable-schema.ts +80 -0
  95. package/src/agent/preview-tool.ts +4 -1
  96. package/src/agent/provider.ts +22 -8
  97. package/src/agent/rhythm-tools.ts +1 -1
  98. package/src/agent/section-tools.ts +1 -1
  99. package/src/agent/show-me.ts +497 -0
  100. package/src/agent/steer.ts +15 -0
  101. package/src/agent/style-tools.ts +217 -0
  102. package/src/agent/tool-error.ts +12 -0
  103. package/src/agent/tools.ts +108 -23
  104. package/src/agent/usage.ts +2 -2
  105. package/src/agent/voice-tools.ts +925 -0
  106. package/src/agent/xcb-agent.ts +11 -7
  107. package/src/argv.ts +38 -0
  108. package/src/audio/analysis.ts +253 -0
  109. package/src/audio/arrange.ts +37 -3
  110. package/src/audio/autotune-engine.ts +101 -0
  111. package/src/audio/autotune.ts +640 -0
  112. package/src/audio/clips.ts +240 -0
  113. package/src/audio/doctor.ts +86 -0
  114. package/src/audio/dsp/bandbank.ts +138 -0
  115. package/src/audio/dsp/envelope.ts +10 -0
  116. package/src/audio/dsp/follow.ts +120 -0
  117. package/src/audio/dsp/formant.ts +427 -0
  118. package/src/audio/dsp/glottal.ts +243 -0
  119. package/src/audio/dsp/interp.ts +7 -2
  120. package/src/audio/dsp/lpc.ts +50 -0
  121. package/src/audio/dsp/periodicity.ts +59 -0
  122. package/src/audio/dsp/pitch.ts +995 -0
  123. package/src/audio/dsp/psola.ts +199 -0
  124. package/src/audio/effects/chain.ts +3 -1
  125. package/src/audio/effects/common.ts +43 -0
  126. package/src/audio/effects/convolution.ts +7 -4
  127. package/src/audio/effects/filter.ts +48 -69
  128. package/src/audio/effects/formant.ts +263 -0
  129. package/src/audio/engine.ts +160 -28
  130. package/src/audio/fit.ts +35 -3
  131. package/src/audio/instrument-check.ts +59 -43
  132. package/src/audio/instruments.ts +4 -0
  133. package/src/audio/keys/calibration.ts +56 -0
  134. package/src/audio/keys/electric.ts +8 -1
  135. package/src/audio/keys/engine.ts +13 -1
  136. package/src/audio/keys/piano.ts +22 -2
  137. package/src/audio/kits.ts +135 -6
  138. package/src/audio/live.ts +114 -20
  139. package/src/audio/native.ts +615 -0
  140. package/src/audio/preview.ts +30 -2
  141. package/src/audio/render-worker.ts +2 -0
  142. package/src/audio/renderer.ts +2 -0
  143. package/src/audio/resample.ts +2 -1
  144. package/src/audio/sampler.ts +85 -4
  145. package/src/audio/samples.ts +20 -2
  146. package/src/audio/sing/analysis.ts +193 -0
  147. package/src/audio/sing/engine.ts +949 -0
  148. package/src/audio/strings/bow.ts +48 -5
  149. package/src/audio/strings/engine.ts +8 -2
  150. package/src/audio/synth/oscillators.ts +31 -21
  151. package/src/audio/synth/voice.ts +34 -1
  152. package/src/audio/vocoder/bank.ts +314 -0
  153. package/src/audio/vocoder/carrier.ts +165 -0
  154. package/src/audio/vocoder/control.ts +68 -0
  155. package/src/audio/vocoder/detect.ts +50 -0
  156. package/src/audio/vocoder/index.ts +304 -0
  157. package/src/audio/vocoder/talkbox.ts +143 -0
  158. package/src/audio/wav.ts +500 -77
  159. package/src/audio/winds/engine.ts +5 -1
  160. package/src/audio/winds/trim.ts +28 -4
  161. package/src/audio/winds/trims1.ts +297 -0
  162. package/src/audio/winds/voice.ts +15 -2
  163. package/src/auth/cli.ts +38 -36
  164. package/src/auth/credentials.ts +30 -1
  165. package/src/auth/login.ts +15 -9
  166. package/src/auth/tui.ts +19 -10
  167. package/src/commands/arrange.ts +44 -29
  168. package/src/commands/autotune.ts +421 -0
  169. package/src/commands/calibration.ts +74 -0
  170. package/src/commands/clips.ts +887 -0
  171. package/src/commands/drums.ts +3 -2
  172. package/src/commands/edit.ts +11 -4
  173. package/src/commands/expression.ts +1 -1
  174. package/src/commands/formant.ts +221 -0
  175. package/src/commands/fx.ts +101 -32
  176. package/src/commands/grammar.ts +558 -0
  177. package/src/commands/help.ts +610 -380
  178. package/src/commands/history.ts +18 -0
  179. package/src/commands/keys.ts +8 -8
  180. package/src/commands/modal.ts +1 -1
  181. package/src/commands/music.ts +1 -1
  182. package/src/commands/nearest.ts +53 -0
  183. package/src/commands/pack.ts +9 -2
  184. package/src/commands/param-range.ts +56 -0
  185. package/src/commands/parses.ts +130 -0
  186. package/src/commands/progression.ts +170 -0
  187. package/src/commands/rhythm.ts +3 -0
  188. package/src/commands/rig.ts +3 -24
  189. package/src/commands/sing.ts +478 -0
  190. package/src/commands/strum.ts +13 -1
  191. package/src/commands/style.ts +415 -0
  192. package/src/commands/time.ts +6 -3
  193. package/src/commands/tuning.ts +3 -3
  194. package/src/commands/vocal-pitch.ts +616 -0
  195. package/src/commands/vocal.ts +147 -0
  196. package/src/commands/vocoder.ts +627 -0
  197. package/src/commands/wind.ts +2 -2
  198. package/src/fs/durable.ts +50 -0
  199. package/src/lang/glossary.ts +493 -0
  200. package/src/launch-args.ts +163 -0
  201. package/src/main.ts +1397 -253
  202. package/src/media/cli.ts +20 -3
  203. package/src/media/import.ts +3 -1
  204. package/src/project/check.ts +21 -2
  205. package/src/project/clip-pins.ts +72 -0
  206. package/src/project/init.ts +23 -8
  207. package/src/project/sync.ts +418 -86
  208. package/src/render.ts +20 -1
  209. package/src/session/daemon.ts +3 -0
  210. package/src/session/meta.ts +14 -0
  211. package/src/session/origin.ts +154 -0
  212. package/src/session/port.ts +22 -4
  213. package/src/session/presence.ts +34 -4
  214. package/src/session/protocol.ts +5 -1
  215. package/src/session/rebase.ts +18 -5
  216. package/src/session/receipt.ts +258 -0
  217. package/src/session/store.ts +65 -43
  218. package/src/tui/arrange-menu.ts +65 -31
  219. package/src/tui/audition.ts +1 -1
  220. package/src/tui/euclid.ts +18 -13
  221. package/src/tui/fader.ts +228 -41
  222. package/src/tui/granular-menu.ts +2 -4
  223. package/src/tui/menu-clips.ts +297 -0
  224. package/src/tui/menu-time.ts +20 -13
  225. package/src/tui/menu-voice.ts +405 -0
  226. package/src/tui/menu.ts +759 -177
  227. package/src/tui/modal-menu.ts +6 -6
  228. package/src/tui/performance-menu.ts +5 -2
  229. package/src/tui/play-chords.ts +4 -2
  230. package/src/tui/play-mode.ts +15 -1
  231. package/src/tui/play-session.ts +47 -7
  232. package/src/tui/sing-menu.ts +278 -0
  233. package/src/tui/style-menu.ts +104 -0
  234. package/src/tui/vocoder-menu.ts +244 -0
  235. package/src/tui/wind-menu.ts +3 -3
  236. package/src/version.ts +8 -0
  237. package/src/web/fetch.ts +115 -29
  238. package/tui/activity.ts +180 -9
  239. package/tui/app.ts +274 -40
  240. package/tui/clip-row.ts +132 -0
  241. package/tui/delight.ts +144 -0
  242. package/tui/drawer.ts +70 -22
  243. package/tui/frame-gate.ts +76 -0
  244. package/tui/grammar.ts +112 -63
  245. package/tui/guide.ts +42 -4
  246. package/tui/highway.ts +269 -25
  247. package/tui/hints.ts +192 -0
  248. package/tui/input.ts +60 -9
  249. package/tui/keys.ts +1 -1
  250. package/tui/play-strip.ts +68 -14
  251. package/tui/prompt.ts +1 -1
  252. package/tui/screen.ts +144 -14
  253. package/tui/theme.ts +27 -0
package/src/tui/menu.ts CHANGED
@@ -8,19 +8,47 @@
8
8
  *
9
9
  * Rendering reuses the TUI's picker overlay: `view()` returns a picker.
10
10
  */
11
+ import { TOPIC_ALIASES } from "../lang/glossary.ts";
12
+ import {
13
+ voiceEffectRows,
14
+ voiceRootDetail,
15
+ voiceRootNodes,
16
+ } from "./menu-voice.ts";
11
17
  import { commandParam, sketchFor } from "./sketch.ts";
12
- import { HINTS } from "../../tui/grammar.ts";
18
+ import { nearest } from "../commands/nearest.ts";
19
+ import {
20
+ ARROW_DOWN,
21
+ ARROW_UP,
22
+ HINTS,
23
+ KEY_BACK,
24
+ KEY_BACKSPACE,
25
+ KEY_DOWN,
26
+ KEY_END,
27
+ KEY_ENTER,
28
+ KEY_FORWARD,
29
+ KEY_HOME,
30
+ KEY_LEFT,
31
+ KEY_PAGE_DOWN,
32
+ KEY_PAGE_UP,
33
+ KEY_RESET,
34
+ KEY_RIGHT,
35
+ KEY_UP,
36
+ } from "../../tui/grammar.ts";
13
37
  import { arrangeDetail, arrangeNodes } from "./arrange-menu.ts";
14
38
  import { auditionKey, isStageable, type AuditionKey } from "./audition.ts";
15
39
  import { performanceDetail, performanceNodes } from "./performance-menu.ts";
16
40
  import { malletsMenu, modalParameterNodes } from "./modal-menu.ts";
17
41
  import { windParameterNodes, windsMenu } from "./wind-menu.ts";
42
+ import { singParameterNodes } from "./sing-menu.ts";
18
43
  import type { FaderSpec } from "./fader.ts";
19
44
  import {
20
45
  openingMeterCommand,
21
46
  openingUnit,
22
47
  tempoDetail,
23
48
  tempoMenuNode,
49
+ START_BARS,
50
+ START_BEATS_PER_BAR,
51
+ START_TEMPO_BPM,
24
52
  } from "./menu-time.ts";
25
53
  import {
26
54
  AUTOMATION_PARAMETERS,
@@ -40,6 +68,8 @@ import {
40
68
  type Track,
41
69
  type TrackAutomationParameter,
42
70
  type TrackScore,
71
+ CALIBRATION_LATEST,
72
+ createScore,
43
73
  } from "../../core/score.ts";
44
74
  import {
45
75
  CORE_EFFECTS,
@@ -49,6 +79,7 @@ import {
49
79
  RIG_STAGES,
50
80
  rigPresetOf,
51
81
  SHOEGAZE_EFFECTS,
82
+ VOWEL_VALUES,
52
83
  effectPresetNames,
53
84
  effectSpec,
54
85
  type EffectName,
@@ -176,6 +207,10 @@ export type MenuContext = Readonly<{
176
207
  grids: readonly string[];
177
208
  clickOn: boolean;
178
209
  countInBars: number;
210
+ /** How much agent turns show (`/showme`); absent hides the row. */
211
+ showMe?: string;
212
+ /** The session's name, for Project › session and export file names. */
213
+ sessionName?: string;
179
214
  /** Play mode's chord settings (defaults when absent). */
180
215
  chords?: ChordSettings;
181
216
  /** Project root, for the project's own wavetables (none when absent). */
@@ -193,6 +228,8 @@ export type MenuAudition = Readonly<{
193
228
  looping: boolean;
194
229
  /** Edits are staged (an Enter keeps them, Esc reverts them). */
195
230
  dirty: boolean;
231
+ /** How many edits are staged, for the drawer's A/B badge. */
232
+ staged?: number;
196
233
  /** The committed score, for `staged ← committed` on changed rows. */
197
234
  committed: TrackScore;
198
235
  /** The footer while auditioning. */
@@ -361,7 +398,7 @@ const TRACK_LANE_LABEL: Readonly<Record<TrackAutomationParameter, string>> = {
361
398
  resonance: "filter resonance",
362
399
  "delay-feedback": "delay feedback",
363
400
  "delay-mix": "delay mix",
364
- wt: "wavetable position",
401
+ wt: "table position",
365
402
  };
366
403
 
367
404
  const FX_LANE_INFO = new Map(FX_LANES.map((entry) => [entry.lane, entry]));
@@ -405,7 +442,9 @@ function laneLabel(lane: AutomationParameter): string {
405
442
  info.effect === "keys" ||
406
443
  info.effect === "modal" ||
407
444
  info.effect === "grain" ||
408
- info.effect === "wind"
445
+ info.effect === "wind" ||
446
+ info.effect === "sing" ||
447
+ info.effect === "vocoder"
409
448
  ? info.effect
410
449
  : effectSpec(info.effect).label;
411
450
  return `${owner} ${info.param}${unit}`;
@@ -439,9 +478,17 @@ export function rootNodes(context: MenuContext): MenuNode[] {
439
478
  id: "sound",
440
479
  label: "Sound",
441
480
  detail: `${name} · ${track?.instrument ?? "?"}`,
442
- help: "the focused track's instrument and its voice",
481
+ help: "the focused track's instrument, its controls and performance",
443
482
  build: soundSectionNodes,
444
483
  },
484
+ {
485
+ kind: "menu",
486
+ id: "voice",
487
+ label: "Voice",
488
+ detail: voiceRootDetail(context),
489
+ help: "sing, lyrics, clips, pitch, autotune, formant and vocoder",
490
+ build: voiceRootNodes,
491
+ },
445
492
  {
446
493
  kind: "menu",
447
494
  id: "effects",
@@ -454,26 +501,34 @@ export function rootNodes(context: MenuContext): MenuNode[] {
454
501
  kind: "menu",
455
502
  id: "rhythm",
456
503
  label: "Rhythm",
457
- detail: "euclid editor · patterns · kits",
458
- help: "drum hits per voice, ready-made grooves and drum kits",
504
+ detail: "euclid editor · grooves · kits",
505
+ help: "hits per drum, ready-made grooves and kits",
459
506
  build: rhythmNodes,
460
507
  },
461
508
  {
462
509
  kind: "menu",
463
510
  id: "chords",
464
- label: "Chords",
511
+ label: "Chords and key",
465
512
  detail: chordsDetail(context),
466
- help: "song key and how chords play in play mode (Ctrl-P)",
513
+ help: "the song key and tuning, and how chords play in play mode (Ctrl-P)",
467
514
  build: chordNodes,
468
515
  },
469
516
  {
470
517
  kind: "menu",
471
518
  id: "mix",
472
- label: "Mix & automation",
519
+ label: "Mix",
473
520
  detail: `${tracks} track${tracks === 1 ? "" : "s"} · ${automated} lane${automated === 1 ? "" : "s"} moving`,
474
- help: "volume, pan, mute and solo per track; values that change over time",
521
+ help: "volume, pan, mute and solo per track, automation and the master",
475
522
  build: mixSectionNodes,
476
523
  },
524
+ {
525
+ kind: "menu",
526
+ id: "arrange",
527
+ label: "Arrange",
528
+ detail: arrangeDetail(context),
529
+ help: "tracks, song sections, the form, builds, drops, fills and styles",
530
+ build: arrangeRootNodes,
531
+ },
477
532
  {
478
533
  kind: "menu",
479
534
  id: "project",
@@ -481,47 +536,92 @@ export function rootNodes(context: MenuContext): MenuNode[] {
481
536
  detail: context.score.time
482
537
  ? `${tempoDetail(context.score)} · ${context.score.bars} bars`
483
538
  : `${num(context.score.tempoBpm)} BPM · ${context.score.beatsPerBar}/4 · ${context.score.bars} bars`,
484
- help: "tempo, meter, tempo map, loop length, play, click and count-in",
539
+ help: "play, tempo, meter, loop, click, export, session, agent and help",
485
540
  build: transportNodes,
486
541
  },
487
- {
488
- kind: "menu",
489
- id: "arrange",
490
- label: "Arrange",
491
- detail: arrangeDetail(context),
492
- help: "song sections, the form, builds, drops and fills",
493
- build: arrangeNodes,
494
- },
495
542
  ];
496
543
  }
497
544
 
498
- /** Old section names still open the menu where that content now lives. */
499
- const SECTION_ALIASES: Readonly<Record<string, readonly string[]>> = {
500
- sound: ["sound"],
501
- parameters: ["sound"],
502
- sounds: ["sound", "browse"],
503
- performance: ["sound", "performance"],
504
- expression: ["sound", "performance"],
505
- effects: ["effects"],
506
- rhythm: ["rhythm"],
507
- chords: ["chords"],
508
- mix: ["mix"],
509
- track: ["mix"],
510
- automation: ["mix", "automation"],
511
- master: ["mix", "master"],
512
- project: ["project"],
513
- transport: ["project"],
514
- tempo: ["project", "tempo"],
515
- meter: ["project", "tempo"],
516
- time: ["project", "tempo"],
517
- tuning: ["project", "tuning"],
518
- scale: ["project", "tuning"],
519
- arrange: ["arrange"],
520
- sections: ["arrange"],
521
- form: ["arrange"],
522
- };
545
+ /**
546
+ * Where each `/menu <id>` opens: the ten topic ids (sound voice effects
547
+ * rhythm chords mix arrange project keys agent) first, then every older id,
548
+ * as a path of menu ids from the root. `keys` opens Project › help and
549
+ * guides; the ? panel itself belongs to the window.
550
+ */
551
+ export const SECTION_ALIASES: Readonly<Record<string, readonly string[]>> =
552
+ Object.freeze(
553
+ withTopicAliases({
554
+ sound: ["sound"],
555
+ voice: ["voice"],
556
+ effects: ["effects"],
557
+ rhythm: ["rhythm"],
558
+ chords: ["chords"],
559
+ mix: ["mix"],
560
+ arrange: ["arrange"],
561
+ project: ["project"],
562
+ keys: ["project", "help"],
563
+ agent: ["project", "agent"],
564
+ // Older ids, kept as aliases.
565
+ parameters: ["sound"],
566
+ sounds: ["sound", "browse"],
567
+ instruments: ["sound", "browse"],
568
+ performance: ["sound", "performance"],
569
+ expression: ["sound", "performance"],
570
+ clips: ["voice"],
571
+ lyrics: ["voice"],
572
+ autotune: ["voice"],
573
+ sing: ["voice"],
574
+ vocoder: ["voice"],
575
+ formant: ["voice"],
576
+ grooves: ["rhythm", "patterns"],
577
+ groove: ["rhythm", "patterns"],
578
+ patterns: ["rhythm", "patterns"],
579
+ kits: ["rhythm", "kits"],
580
+ euclid: ["rhythm"],
581
+ tuning: ["chords", "tuning"],
582
+ scale: ["chords"],
583
+ key: ["chords"],
584
+ track: ["mix"],
585
+ automation: ["mix", "automation"],
586
+ master: ["mix", "master"],
587
+ tracks: ["arrange", "tracks"],
588
+ music: ["arrange"],
589
+ style: ["arrange", "style"],
590
+ styles: ["arrange", "style"],
591
+ genre: ["arrange", "style"],
592
+ sections: ["arrange", "sections"],
593
+ form: ["arrange"],
594
+ transport: ["project"],
595
+ tempo: ["project", "tempo"],
596
+ meter: ["project", "tempo"],
597
+ time: ["project", "tempo"],
598
+ export: ["project", "export"],
599
+ session: ["project", "session"],
600
+ sessions: ["project", "session"],
601
+ window: ["project", "help"],
602
+ help: ["project", "help"],
603
+ guides: ["project", "help"],
604
+ model: ["project", "agent"],
605
+ showme: ["project", "agent"],
606
+ models: ["project", "agent"],
607
+ fx: ["effects"],
608
+ }),
609
+ );
610
+
611
+ /**
612
+ * Every glossary topic alias (`/help drums`, `/guide mixer`) opens the same
613
+ * topic in `/menu` too: a word with no deeper path here opens its topic.
614
+ */
615
+ function withTopicAliases(
616
+ paths: Record<string, readonly string[]>,
617
+ ): Record<string, readonly string[]> {
618
+ const all = { ...paths };
619
+ for (const [word, topic] of Object.entries(TOPIC_ALIASES))
620
+ if (!all[word] && all[topic]) all[word] = all[topic]!;
621
+ return all;
622
+ }
523
623
 
524
- /** Sound: instrument and voice first, then the sound browser. */
624
+ /** Sound: the instrument and its controls first, then the instruments. */
525
625
  function soundSectionNodes(context: MenuContext): MenuNode[] {
526
626
  const track = focused(context);
527
627
  const tuning: MenuNode[] =
@@ -530,7 +630,7 @@ function soundSectionNodes(context: MenuContext): MenuNode[] {
530
630
  {
531
631
  kind: "menu",
532
632
  id: "track-tuning",
533
- label: "tuning",
633
+ label: "track tuning",
534
634
  detail: track.tuning
535
635
  ? tuningLabel(track.tuning)
536
636
  : `song · ${tuningLabel(context.score.tuning)}`,
@@ -561,7 +661,7 @@ function soundSectionNodes(context: MenuContext): MenuNode[] {
561
661
  id: "keys",
562
662
  label: "keys",
563
663
  detail: `${track.instrument}${track.keys.preset ? ` · ${track.keys.preset}` : ""}`,
564
- help: "the modelled organ: preset, drawbars, registers or stops, rotary",
664
+ help: "the modeled organ: preset, drawbars, registers or stops, rotary",
565
665
  build: (inner) => {
566
666
  const current = focused(inner);
567
667
  return current ? organNodes(current) : [];
@@ -577,7 +677,7 @@ function soundSectionNodes(context: MenuContext): MenuNode[] {
577
677
  detail: `${track.instrument}${track.keys.preset ? ` · ${track.keys.preset}` : ""}`,
578
678
  help: isElectricFamily(track.instrument)
579
679
  ? "electric keys: preset, bark, bell, tone, vibe/trem, pickup and mute"
580
- : "the modelled piano: preset, touch, hammers, dampers, stretch",
680
+ : "the modeled piano: preset, touch, hammers, dampers, stretch",
581
681
  build: (inner) => {
582
682
  const current = focused(inner);
583
683
  return current
@@ -622,9 +722,9 @@ function soundSectionNodes(context: MenuContext): MenuNode[] {
622
722
  {
623
723
  kind: "menu",
624
724
  id: "browse",
625
- label: "browse sounds",
725
+ label: "instruments",
626
726
  detail: soundsDetail(focused(context)),
627
- help: "instruments, wavetables, sample packs",
727
+ help: "every instrument: keys, strings, mallets, winds, granular, wavetables, sample packs",
628
728
  build: soundNodes,
629
729
  },
630
730
  ];
@@ -709,20 +809,262 @@ function guitarNodes(track: Track | undefined): MenuNode[] {
709
809
  }
710
810
 
711
811
  /** Rhythm: the euclid editor, then grooves and kits. */
712
- function rhythmNodes(): MenuNode[] {
812
+ function rhythmNodes(context: MenuContext): MenuNode[] {
713
813
  return [
714
814
  {
715
815
  kind: "action",
716
816
  label: "euclid editor",
717
817
  command: "/euclid",
718
- help: "hits, steps and rotation per drum voice",
818
+ help: "hits, steps and rotation per drum",
719
819
  },
720
820
  patternsMenu(),
721
821
  kitsMenu(),
822
+ gridNode(context),
823
+ ];
824
+ }
825
+
826
+ /** The record and euclid grid (Rhythm and Project share this row). */
827
+ function gridNode(context: MenuContext): MenuNode {
828
+ return {
829
+ kind: "choice",
830
+ label: "grid",
831
+ help: "the step play-mode recording and the euclid editor snap to",
832
+ value: context.grid,
833
+ options: context.grids,
834
+ command: (option) => `/grid ${option}`,
835
+ };
836
+ }
837
+
838
+ /** Arrange: the tracks first, then sections, form and styles. */
839
+ function arrangeRootNodes(context: MenuContext): MenuNode[] {
840
+ const count = context.score.tracks.length;
841
+ return [
842
+ {
843
+ kind: "menu",
844
+ id: "tracks",
845
+ label: "tracks",
846
+ detail: `${count} track${count === 1 ? "" : "s"} · add, focus, move, remove`,
847
+ help: "add a track, focus one, rename, reorder or remove it",
848
+ build: tracksNodes,
849
+ },
850
+ ...arrangeNodes(context),
851
+ ];
852
+ }
853
+
854
+ /** Arrange › tracks: add, focus, rename, move and remove. */
855
+ function tracksNodes(context: MenuContext): MenuNode[] {
856
+ const score = context.score;
857
+ const track = focused(context);
858
+ const nodes: MenuNode[] = [
859
+ {
860
+ kind: "entry",
861
+ label: "add a track",
862
+ value: "",
863
+ placeholder: "name, e.g. bass or piano b",
864
+ example: "/track bass",
865
+ help: "a new track with that name (or focus it when it exists)",
866
+ command: (text) => {
867
+ const name = text.trim().replace(/\s+/g, " ");
868
+ return /^[a-z0-9._ -]{1,64}$/i.test(name)
869
+ ? `/track ${name}`
870
+ : undefined;
871
+ },
872
+ },
873
+ ...score.tracks.map((item): MenuNode => ({
874
+ kind: "action",
875
+ label: `${item.id === context.trackId ? "● " : ""}${item.name ?? item.id}`,
876
+ command: `/track ${item.id}`,
877
+ help: `focus ${item.id} · ${item.instrument}`,
878
+ })),
879
+ ];
880
+ if (track) {
881
+ nodes.push({
882
+ kind: "entry",
883
+ label: "rename",
884
+ value: track.name,
885
+ placeholder: "new name",
886
+ command: (text) =>
887
+ text.trim() ? `track name ${text.trim()}` : undefined,
888
+ example: `track name ${track.name}`,
889
+ help: "rename the focused track",
890
+ });
891
+ if (score.tracks.length > 1) {
892
+ const index = score.tracks.findIndex((item) => item.id === track.id);
893
+ nodes.push(
894
+ {
895
+ kind: "number",
896
+ label: "position",
897
+ help: "where the focused track sits in the list",
898
+ value: index + 1,
899
+ min: 1,
900
+ max: score.tracks.length,
901
+ step: linear(1, 1, score.tracks.length),
902
+ format: (value) => `${num(value)} of ${score.tracks.length}`,
903
+ command: (value) => `/track move ${track.id} ${Math.round(value)}`,
904
+ },
905
+ {
906
+ kind: "action",
907
+ label: `remove ${track.id}`,
908
+ command: `/track remove ${track.id}`,
909
+ help: "remove the focused track (^z brings it back)",
910
+ },
911
+ );
912
+ }
913
+ }
914
+ return nodes;
915
+ }
916
+
917
+ /** A file-safe stem for export names: the session's song name or `song`. */
918
+ function exportStem(context: MenuContext): string {
919
+ const stem = (context.sessionName ?? "song")
920
+ .toLowerCase()
921
+ .replace(/[^a-z0-9]+/g, "-")
922
+ .replace(/^-+|-+$/g, "")
923
+ .slice(0, 40);
924
+ return stem || "song";
925
+ }
926
+
927
+ /** Project › export: the project file and MIDI; audio renders offline. */
928
+ function exportNodes(context: MenuContext): MenuNode[] {
929
+ const stem = exportStem(context);
930
+ return [
931
+ {
932
+ kind: "entry",
933
+ label: "project file",
934
+ value: "",
935
+ placeholder: `file name, e.g. ${stem}.track.json`,
936
+ example: `/export ${stem}.track.json`,
937
+ help: "the whole song as one .track.json file (open it with /import)",
938
+ command: (text) => {
939
+ const name = text.trim() || `${stem}.track.json`;
940
+ return /^\S+$/.test(name)
941
+ ? `/export ${/\.json$/i.test(name) ? name : `${name}.track.json`}`
942
+ : undefined;
943
+ },
944
+ },
945
+ {
946
+ kind: "entry",
947
+ label: "MIDI",
948
+ value: "",
949
+ placeholder: `file name, e.g. ${stem}.mid`,
950
+ example: `/export ${stem}.mid`,
951
+ help: "notes, tempo and tracks as a standard MIDI file",
952
+ command: (text) => {
953
+ const name = text.trim() || `${stem}.mid`;
954
+ return /^\S+$/.test(name)
955
+ ? `/export ${/\.midi?$/i.test(name) ? name : `${name}.mid`}`
956
+ : undefined;
957
+ },
958
+ },
959
+ {
960
+ kind: "info",
961
+ label: "WAV and stems",
962
+ value: `dawg render ${stem}.wav`,
963
+ help: "audio renders offline in the shell: dawg render out.wav (--stems for one file per track)",
964
+ },
965
+ ];
966
+ }
967
+
968
+ /** Project › session: rename, fork and resume (the window's verbs). */
969
+ function sessionNodes(context: MenuContext): MenuNode[] {
970
+ return [
971
+ {
972
+ kind: "entry",
973
+ label: "rename",
974
+ value: context.sessionName ?? "",
975
+ placeholder: "song name (--auto names it for you)",
976
+ example: "/rename night drive",
977
+ help: "name this session; --auto hands naming back to dawg",
978
+ command: (text) => (text.trim() ? `/rename ${text.trim()}` : undefined),
979
+ },
980
+ {
981
+ kind: "action",
982
+ label: "fork",
983
+ command: "/fork",
984
+ help: "a copy of this session to try something; the original stays",
985
+ },
986
+ {
987
+ kind: "action",
988
+ label: "resume",
989
+ command: "/resume",
990
+ help: "pick an earlier session in this folder",
991
+ },
992
+ {
993
+ kind: "action",
994
+ label: "list sessions",
995
+ command: "/sessions",
996
+ help: "every session in this folder, newest first",
997
+ },
998
+ ];
999
+ }
1000
+
1001
+ /** Project › agent: the model, show-me and the model key. */
1002
+ function agentNodes(context: MenuContext): MenuNode[] {
1003
+ return [
1004
+ {
1005
+ kind: "action",
1006
+ label: "model",
1007
+ command: "/model",
1008
+ help: "pick the model the agent runs on",
1009
+ },
1010
+ ...(context.showMe === undefined
1011
+ ? []
1012
+ : [
1013
+ {
1014
+ kind: "choice" as const,
1015
+ label: "show me",
1016
+ help: "agent turns: its commands as ghost text, faders, keys",
1017
+ value: context.showMe,
1018
+ options: ["on", "quiet", "off"],
1019
+ command: (option: string) => `/showme ${option}`,
1020
+ },
1021
+ ]),
1022
+ {
1023
+ kind: "action",
1024
+ label: "model key",
1025
+ command: "/model key",
1026
+ help: "add a model key so typed requests reach the agent (dawg works offline without it)",
1027
+ },
1028
+ {
1029
+ kind: "action",
1030
+ label: "agent help",
1031
+ command: "/help agent",
1032
+ help: "what the agent can do and how its turns show",
1033
+ },
1034
+ ];
1035
+ }
1036
+
1037
+ /** Project › help and guides: the reference, guides and keys. */
1038
+ function helpNodes(): MenuNode[] {
1039
+ return [
1040
+ {
1041
+ kind: "action",
1042
+ label: "help",
1043
+ command: "/help",
1044
+ help: "start here: the ten topics",
1045
+ },
1046
+ {
1047
+ kind: "action",
1048
+ label: "all commands",
1049
+ command: "/help all",
1050
+ help: "the full command reference",
1051
+ },
1052
+ {
1053
+ kind: "action",
1054
+ label: "guides",
1055
+ command: "/guide",
1056
+ help: "short walkthroughs, one screen each",
1057
+ },
1058
+ {
1059
+ kind: "action",
1060
+ label: "keys",
1061
+ command: "/help keys",
1062
+ help: "every key in the window (? shows them too)",
1063
+ },
722
1064
  ];
723
1065
  }
724
1066
 
725
- /** Mix & automation: this track, every track, then automation lanes. */
1067
+ /** Mix: this track, every track, automation lanes, then the master. */
726
1068
  function mixSectionNodes(context: MenuContext): MenuNode[] {
727
1069
  const track = focused(context);
728
1070
  const automated = AUTOMATION_PARAMETERS.filter(
@@ -759,7 +1101,7 @@ function mixSectionNodes(context: MenuContext): MenuNode[] {
759
1101
  ];
760
1102
  }
761
1103
 
762
- /** Mix & automation > Master: the loudness target, then each unit in order. */
1104
+ /** Mix › master: the loudness target, then each unit in order. */
763
1105
  function masterNodes(context: MenuContext): MenuNode[] {
764
1106
  const master = context.score.master;
765
1107
  // A name only when the LUFS and the limiter ceiling both match it, and no
@@ -932,7 +1274,7 @@ function chordsDetail(context: MenuContext): string {
932
1274
  return `${chords.mode} · ${key ? keyName(key) : "no key"} · ${chords.perform}`;
933
1275
  }
934
1276
 
935
- /** Play mode's chord settings and the song key (`/chords …`, `key …`). */
1277
+ /** Chords and key: the song key and tuning, then play mode's chord settings. */
936
1278
  function chordNodes(context: MenuContext): MenuNode[] {
937
1279
  const chords = context.chords ?? defaultChordSettings();
938
1280
  const key = parseKey(context.score.key ?? undefined);
@@ -965,11 +1307,20 @@ function chordNodes(context: MenuContext): MenuNode[] {
965
1307
  options: [...MODE_NAMES, ...SCALE_NAMES],
966
1308
  command: (option) => `key ${tonic} ${option}`,
967
1309
  },
1310
+ {
1311
+ kind: "menu",
1312
+ id: "tuning",
1313
+ label: "tuning",
1314
+ detail: `${tuningLabel(context.score.tuning)} · ${keyLabel(context.score.key)}`,
1315
+ help: "the song tuning: 12-TET, EDOs, just, gamelan, Scala, and the scale",
1316
+ build: songTuningNodes,
1317
+ },
968
1318
  {
969
1319
  kind: "number",
970
1320
  label: "voicing",
971
1321
  help: "inversion steps up or down (play mode: - =)",
972
1322
  value: chords.inversion,
1323
+ start: 0,
973
1324
  min: -MAX_VOICING_STEP,
974
1325
  max: MAX_VOICING_STEP,
975
1326
  step: linear(1, -MAX_VOICING_STEP, MAX_VOICING_STEP),
@@ -1056,6 +1407,7 @@ function chordNodes(context: MenuContext): MenuNode[] {
1056
1407
  label: "arp octaves",
1057
1408
  help: "how many octaves an arpeggio climbs",
1058
1409
  value: chords.octaves,
1410
+ start: 1,
1059
1411
  min: 1,
1060
1412
  max: 4,
1061
1413
  step: linear(1, 1, 4),
@@ -1072,12 +1424,22 @@ function chordNodes(context: MenuContext): MenuNode[] {
1072
1424
  },
1073
1425
  {
1074
1426
  kind: "choice",
1075
- label: "style",
1076
- help: "the style n suggests next chords in",
1427
+ label: "idiom",
1428
+ help: "the musical idiom n suggests next chords in (pop, jazz, blues…)",
1077
1429
  value: chords.style,
1078
1430
  options: PROGRESSION_STYLES,
1079
1431
  command: (option) => `/chords style ${option}`,
1080
1432
  },
1433
+ {
1434
+ kind: "entry",
1435
+ label: "progression",
1436
+ value: "",
1437
+ placeholder: "chords, e.g. i7 IV7 each 8",
1438
+ help: "sustained, voice-led block chords on this track in the song key",
1439
+ command: (text) =>
1440
+ text.trim() ? `progression ${text.trim()}` : undefined,
1441
+ example: "progression i7 IV7 each 8",
1442
+ },
1081
1443
  ];
1082
1444
  }
1083
1445
 
@@ -1173,7 +1535,7 @@ function panNode(track: Track): MenuNode {
1173
1535
  : `${num(Math.abs(value))} ${value < 0 ? "L" : "R"}`,
1174
1536
  command: (value) => `pan ${num(value)}`,
1175
1537
  reset: "pan 0",
1176
- help: "-1 left … 1 right · x centres",
1538
+ help: "-1 left … 1 right · x centers",
1177
1539
  };
1178
1540
  }
1179
1541
 
@@ -1256,7 +1618,7 @@ function parameterNodes(context: MenuContext): MenuNode[] {
1256
1618
  detail: `all ${all.length} params`,
1257
1619
  help: isElectricFamily(track.instrument)
1258
1620
  ? "every electric keys parameter for this family"
1259
- : "every modelled piano parameter",
1621
+ : "every modeled piano parameter",
1260
1622
  build: (inner) => {
1261
1623
  const current = focused(inner);
1262
1624
  return current
@@ -1270,6 +1632,11 @@ function parameterNodes(context: MenuContext): MenuNode[] {
1270
1632
  } else if (track.instrument === "wind" && track.wind) {
1271
1633
  // 0.6.1 wind engine: preset and WIND_PARAMS rows.
1272
1634
  nodes.push(...windParameterNodes(track));
1635
+ } else if (track.instrument === "sing" && track.sing) {
1636
+ // 0.7 sing engine: preset, SING_PARAMS rows and a Throat sub-menu.
1637
+ nodes.push(
1638
+ ...singParameterNodes(track, parseKey(context.score.key)?.tonic),
1639
+ );
1273
1640
  } else if (!isDrumInstrument(track.instrument)) {
1274
1641
  nodes.push(...synthNodes(track, SYNTH_SIMPLE));
1275
1642
  nodes.push({
@@ -1484,7 +1851,7 @@ function sampleVoiceNodes(context: MenuContext, voice: string): MenuNode[] {
1484
1851
  // 0.6.1 layers: velocity layer (SFZ lovel/hivel) and round-robin group.
1485
1852
  {
1486
1853
  kind: "choice",
1487
- label: "Velocity layer",
1854
+ label: "velocity layer",
1488
1855
  value: ref.vel ? `${ref.vel[0]}-${ref.vel[1]}` : "off",
1489
1856
  options: velocityLayerOptions(ref.vel),
1490
1857
  command: (option) => set(`vel ${option}`),
@@ -1492,7 +1859,7 @@ function sampleVoiceNodes(context: MenuContext, voice: string): MenuNode[] {
1492
1859
  },
1493
1860
  {
1494
1861
  kind: "entry",
1495
- label: "Round robin",
1862
+ label: "round robin",
1496
1863
  value: ref.rr ?? "",
1497
1864
  placeholder: "group name, e.g. sn",
1498
1865
  command: (text) => set(`rr ${text.trim() || "off"}`),
@@ -1524,7 +1891,7 @@ function matchingSynthPreset(track: Track): string | undefined {
1524
1891
  )?.[0];
1525
1892
  }
1526
1893
 
1527
- /** Modelled piano rows: the preset, then each parameter (`keys <p> <v>`). */
1894
+ /** Modeled piano rows: the preset, then each parameter (`keys <p> <v>`). */
1528
1895
  function keysNodes(
1529
1896
  track: Track,
1530
1897
  params: readonly string[],
@@ -1702,7 +2069,7 @@ function organNodes(track: Track): MenuNode[] {
1702
2069
  ? {
1703
2070
  kind: "menu",
1704
2071
  id: "keys:drawbars",
1705
- label: "Drawbars",
2072
+ label: "drawbars",
1706
2073
  detail: text("drawbars"),
1707
2074
  help: "nine drawbars 16' to 1' (0 in, 8 full out)",
1708
2075
  build: (inner) => {
@@ -1720,7 +2087,7 @@ function organNodes(track: Track): MenuNode[] {
1720
2087
  ? {
1721
2088
  kind: "menu",
1722
2089
  id: "keys:registers",
1723
- label: "Registers",
2090
+ label: "registers",
1724
2091
  detail: text("registers"),
1725
2092
  help: "five combo-organ registers 16' to 2' (0 off, 8 full)",
1726
2093
  build: (inner) => {
@@ -1737,7 +2104,7 @@ function organNodes(track: Track): MenuNode[] {
1737
2104
  : {
1738
2105
  kind: "menu",
1739
2106
  id: "keys:stops",
1740
- label: "Stops",
2107
+ label: "stops",
1741
2108
  detail: text("stops"),
1742
2109
  help: "pipe stops and registrations",
1743
2110
  build: (inner) => {
@@ -1814,9 +2181,9 @@ function synthParamNode(track: Track, key: string, named: boolean): MenuNode {
1814
2181
 
1815
2182
  /** Plain names for the simple synth rows; the note shows the command name. */
1816
2183
  const SYNTH_LABEL: Readonly<Record<string, string>> = {
1817
- lpf: "filter cutoff",
1818
- lpq: "filter res",
1819
- lpenv: "filter env",
2184
+ lpf: "synth filter",
2185
+ lpq: "synth filter res",
2186
+ lpenv: "synth filter env",
1820
2187
  vib: "vibrato",
1821
2188
  fm: "FM amount",
1822
2189
  };
@@ -2000,7 +2367,7 @@ function wavetableNodes(track: Track, projectRoot?: string): MenuNode[] {
2000
2367
  nodes.push({
2001
2368
  kind: "info",
2002
2369
  label: "position automation",
2003
- value: `${track.wtAutomation!.length} points · Automation › wavetable position`,
2370
+ value: `${track.wtAutomation!.length} points · Automation › table position`,
2004
2371
  });
2005
2372
  return nodes;
2006
2373
  }
@@ -2015,7 +2382,7 @@ function effectNodes(context: MenuContext): MenuNode[] {
2015
2382
  return {
2016
2383
  kind: "menu",
2017
2384
  id: effect,
2018
- label: spec.label[0]!.toUpperCase() + spec.label.slice(1),
2385
+ label: spec.label,
2019
2386
  detail: values ? effectSummary(effect, values) : "off",
2020
2387
  help: spec.doc,
2021
2388
  build: (inner) => effectParamNodes(inner, effect, false),
@@ -2028,7 +2395,9 @@ function effectNodes(context: MenuContext): MenuNode[] {
2028
2395
  (effect) =>
2029
2396
  !CORE_EFFECTS.includes(effect) &&
2030
2397
  !rigStages.includes(effect) &&
2031
- !gaze.includes(effect),
2398
+ !gaze.includes(effect) &&
2399
+ // 0.7: the formant shift lives in Effects > Voice.
2400
+ effect !== "formant",
2032
2401
  );
2033
2402
  const gazeOn = SHOEGAZE_EFFECTS.filter((effect) =>
2034
2403
  effectValues(track, effect),
@@ -2036,7 +2405,7 @@ function effectNodes(context: MenuContext): MenuNode[] {
2036
2405
  const shoegaze: MenuNode = {
2037
2406
  kind: "menu",
2038
2407
  id: "shoegaze",
2039
- label: "Shoegaze",
2408
+ label: "shoegaze",
2040
2409
  help: "wobble (tremolo-arm bend), bloom (feedback), swell (volume swell), double (two takes); `rig shoegaze` loads them all",
2041
2410
  detail:
2042
2411
  gazeOn.length === 0
@@ -2050,7 +2419,7 @@ function effectNodes(context: MenuContext): MenuNode[] {
2050
2419
  const rig: MenuNode = {
2051
2420
  kind: "menu",
2052
2421
  id: "guitar rig",
2053
- label: "Guitar rig",
2422
+ label: "guitar rig",
2054
2423
  help: "stomp box, amp head with noise gate and speaker cabinet; `rig <name>` loads a whole rig",
2055
2424
  detail:
2056
2425
  rigOn.length === 0 ? "off" : (rigPresetOf(track.fx) ?? rigOn.join(" → ")),
@@ -2073,6 +2442,19 @@ function effectNodes(context: MenuContext): MenuNode[] {
2073
2442
  ...CORE_EFFECTS.map(node),
2074
2443
  rig,
2075
2444
  shoegaze,
2445
+ // Formant and vocoder live under Voice; this row is the way there.
2446
+ ...(voiceEffectRows(context).length > 0
2447
+ ? [
2448
+ {
2449
+ kind: "menu" as const,
2450
+ id: "voice",
2451
+ label: "voice effects",
2452
+ detail: "formant · vocoder · also in Voice",
2453
+ help: "formant and vocoder for voices (the same rows as Ctrl-K › Voice)",
2454
+ build: voiceEffectRows,
2455
+ },
2456
+ ]
2457
+ : []),
2076
2458
  {
2077
2459
  kind: "menu",
2078
2460
  id: "more effects",
@@ -2109,16 +2491,17 @@ function withUnit(text: string, unit: string | undefined): string {
2109
2491
 
2110
2492
  function formatParam(spec: ParamSpec, value: number): string {
2111
2493
  if (spec.kind !== "number") return num(value);
2112
- return spec.step === "log" && value >= 100
2113
- ? `${Math.round(value)}`
2114
- : num(value);
2494
+ const text =
2495
+ spec.step === "log" && value >= 100 ? `${Math.round(value)}` : num(value);
2496
+ // Rounding never leaves the range: attack 0.0001 types as 0.0001, not 0.
2497
+ return Number(text) < spec.min ? String(spec.min) : text;
2115
2498
  }
2116
2499
 
2117
2500
  /**
2118
2501
  * One effect's rows: on/off, presets, then its simple parameters; the
2119
2502
  * `advanced` submenu holds every parameter with its Strudel names.
2120
2503
  */
2121
- function effectParamNodes(
2504
+ export function effectParamNodes(
2122
2505
  context: MenuContext,
2123
2506
  effect: EffectName,
2124
2507
  advanced: boolean,
@@ -2209,6 +2592,37 @@ function effectParamNodes(
2209
2592
  });
2210
2593
  }
2211
2594
  }
2595
+ if (!advanced && effect === "vowel") {
2596
+ // 0.7 vowel morph: To picks the target (morph starts halfway), Morph
2597
+ // moves between the two; both through `/vowel`.
2598
+ const to = values?.to;
2599
+ nodes.push(
2600
+ {
2601
+ kind: "choice",
2602
+ label: "to",
2603
+ value: typeof to === "string" ? to : "—",
2604
+ options: ["—", ...VOWEL_VALUES],
2605
+ command: (option) =>
2606
+ option === "—" ? "/vowel to off" : `/vowel to ${option}`,
2607
+ help: "a second vowel to morph toward (— keeps one vowel)",
2608
+ },
2609
+ {
2610
+ kind: "number",
2611
+ label: "morph",
2612
+ value: typeof values?.morph === "number" ? values.morph : undefined,
2613
+ start: 0.5,
2614
+ off: typeof to === "string" ? "0" : "set to first",
2615
+ min: 0,
2616
+ max: 1,
2617
+ step: specStep(spec.params.morph as NumberParam),
2618
+ format: (value) => num(value),
2619
+ command: (value) =>
2620
+ typeof to === "string" ? `/vowel morph ${num(value)}` : `/vowel to o`,
2621
+ help: "0 is the vowel, 1 is the to vowel; log-frequency formant glide, automatable as vowel-morph",
2622
+ ...(typeof to === "string" ? { reset: "/vowel morph 0" } : {}),
2623
+ },
2624
+ );
2625
+ }
2212
2626
  if (!advanced) {
2213
2627
  nodes.push({
2214
2628
  kind: "menu",
@@ -2251,6 +2665,8 @@ function automationNodes(context: MenuContext): MenuNode[] {
2251
2665
  if (info?.effect === "modal") return track.modal !== undefined;
2252
2666
  if (info?.effect === "grain") return track.granular !== undefined;
2253
2667
  if (info?.effect === "wind") return track.wind !== undefined;
2668
+ if (info?.effect === "sing") return track.sing !== undefined;
2669
+ if (info?.effect === "vocoder") return track.vocoder !== undefined;
2254
2670
  return info !== undefined && effectValues(track, info.effect) !== undefined;
2255
2671
  });
2256
2672
  const hidden = AUTOMATION_PARAMETERS.filter((lane) => !shown.includes(lane));
@@ -2376,14 +2792,14 @@ function soundsDetail(track: Track | undefined): string {
2376
2792
  }
2377
2793
 
2378
2794
  /**
2379
- * The instrument browser: drum kits and soundfont instruments from the
2795
+ * The instrument browser: kits and soundfont instruments from the
2380
2796
  * built-in packs (fetched on first use), and the pack list.
2381
2797
  */
2382
2798
  function kitsMenu(): MenuNode {
2383
2799
  return {
2384
2800
  kind: "menu",
2385
2801
  id: "kits",
2386
- label: "drum kits",
2802
+ label: "kits",
2387
2803
  help: "make this a drum track with a synth or sample kit",
2388
2804
  detail: `synth ${SYNTH_KIT_NAMES.join(" ")} · samples ${Object.keys(DEFAULT_KITS).join(" ")}`,
2389
2805
  // Synth kits first (offline), then the pack sample kits.
@@ -2420,27 +2836,27 @@ function patternsMenu(): MenuNode {
2420
2836
  return {
2421
2837
  kind: "menu",
2422
2838
  id: "patterns",
2423
- label: "drum patterns",
2424
- detail: `${DRUM_PATTERNS.length} grooves`,
2839
+ label: "grooves",
2840
+ detail: `${DRUM_PATTERNS.length} ready-made drum parts`,
2425
2841
  help: "replace the drum part with a ready-made groove",
2426
2842
  build: () =>
2427
2843
  DRUM_PATTERNS.map((entry): MenuNode => ({
2428
2844
  kind: "action",
2429
2845
  label: `${entry.label} ${entry.tempo.bpm} BPM · ${entry.tags.join(", ")}`,
2430
- command: `/pattern ${entry.name}`,
2846
+ command: `/groove ${entry.name}`,
2431
2847
  })),
2432
2848
  };
2433
2849
  }
2434
2850
 
2435
2851
  const GRANULAR_PRESETS_COUNT = granularBrowseNodes().length;
2436
2852
 
2437
- function soundNodes(): MenuNode[] {
2853
+ function soundNodes(context: MenuContext): MenuNode[] {
2438
2854
  return [
2439
2855
  {
2440
2856
  kind: "menu",
2441
2857
  id: "group:keys",
2442
- label: "Keys",
2443
- help: "modelled pianos, electric keys and organs, built in (no download)",
2858
+ label: "keys",
2859
+ help: "modeled pianos, electric keys and organs, built in (no download)",
2444
2860
  detail: `${PIANO_PRESET_NAMES.join(" ")} · electric: ${ELECTRIC_PRESET_NAMES.join(" ")} · organs: ${ORGAN_PRESET_NAMES.join(" ")}`,
2445
2861
  build: () => [
2446
2862
  ...PIANO_PRESET_NAMES.map((name): MenuNode => {
@@ -2456,8 +2872,8 @@ function soundNodes(): MenuNode[] {
2456
2872
  {
2457
2873
  kind: "menu",
2458
2874
  id: "group:keys:electric",
2459
- label: "Electric",
2460
- help: "electric pianos (tine, reed) and the clavinet, modelled",
2875
+ label: "electric",
2876
+ help: "electric pianos (tine, reed) and the clavinet, modeled",
2461
2877
  detail: ELECTRIC_PRESET_NAMES.join(" "),
2462
2878
  build: () =>
2463
2879
  Object.entries(KEYS_PRESETS)
@@ -2473,7 +2889,7 @@ function soundNodes(): MenuNode[] {
2473
2889
  {
2474
2890
  kind: "menu",
2475
2891
  id: "group:organs",
2476
- label: "Organs",
2892
+ label: "organs",
2477
2893
  help: "tonewheel, combo and pipe organs on the keys engine",
2478
2894
  detail: ORGAN_PRESET_NAMES.join(" "),
2479
2895
  build: () =>
@@ -2491,7 +2907,7 @@ function soundNodes(): MenuNode[] {
2491
2907
  },
2492
2908
  {
2493
2909
  kind: "action",
2494
- label: "wavetable synth basic shapes morph · built-in",
2910
+ label: "wavetable basic shapes morph · built-in",
2495
2911
  command: "wt basic",
2496
2912
  help: "switch this track to the morphing wavetable synth",
2497
2913
  },
@@ -2501,7 +2917,7 @@ function soundNodes(): MenuNode[] {
2501
2917
  {
2502
2918
  kind: "menu",
2503
2919
  id: "strings",
2504
- label: "Strings",
2920
+ label: "strings",
2505
2921
  help: "plucked strings: guitars, basses, sitar, harpsichord, oud, koto…; Bowed: violin to contrabass, sections",
2506
2922
  detail: `${STRING_PRESET_NAMES.length - BOWED_PRESET_NAMES.length} plucked · ${BOWED_PRESET_NAMES.length} bowed · built-in`,
2507
2923
  build: () => [
@@ -2516,7 +2932,7 @@ function soundNodes(): MenuNode[] {
2516
2932
  {
2517
2933
  kind: "menu",
2518
2934
  id: "strings:bowed",
2519
- label: "Bowed",
2935
+ label: "bowed",
2520
2936
  help: "bowed strings: violin, viola, cello, bass, fiddle, erhu, sections",
2521
2937
  detail: BOWED_PRESET_NAMES.join(" "),
2522
2938
  build: () =>
@@ -2532,7 +2948,7 @@ function soundNodes(): MenuNode[] {
2532
2948
  {
2533
2949
  kind: "menu",
2534
2950
  id: "granular-browse",
2535
- label: "Granular",
2951
+ label: "granular",
2536
2952
  help: "grain clouds from a built-in synth or this track's own sound",
2537
2953
  detail: `${GRANULAR_PRESETS_COUNT} presets · built-in`,
2538
2954
  build: granularBrowseNodes,
@@ -2540,7 +2956,7 @@ function soundNodes(): MenuNode[] {
2540
2956
  {
2541
2957
  kind: "menu",
2542
2958
  id: "instruments",
2543
- label: "instruments",
2959
+ label: "all instruments",
2544
2960
  help: "sampled piano and General MIDI instruments",
2545
2961
  detail: "General MIDI soundfont, piano",
2546
2962
  build: () => [
@@ -2558,9 +2974,9 @@ function soundNodes(): MenuNode[] {
2558
2974
  },
2559
2975
  {
2560
2976
  kind: "entry",
2561
- label: "use a sound",
2977
+ label: "use a sample",
2562
2978
  value: "",
2563
- help: "type a pack sound name, e.g. dirt-samples/bd:3",
2979
+ help: "type a pack sample name, e.g. dirt-samples/bd:3",
2564
2980
  placeholder: "<pack>/<sound>[:<n>]",
2565
2981
  command: (text) => (text.trim() ? `/pack use ${text.trim()}` : undefined),
2566
2982
  example: "/pack use dirt-samples/bd:3",
@@ -2605,6 +3021,7 @@ function transportNodes(context: MenuContext): MenuNode[] {
2605
3021
  label: "tempo",
2606
3022
  help: "beats per minute",
2607
3023
  value: score.tempoBpm,
3024
+ start: START_TEMPO_BPM,
2608
3025
  min: SCORE_LIMITS.minTempoBpm,
2609
3026
  max: SCORE_LIMITS.maxTempoBpm,
2610
3027
  step: linear(1, SCORE_LIMITS.minTempoBpm, SCORE_LIMITS.maxTempoBpm),
@@ -2616,6 +3033,7 @@ function transportNodes(context: MenuContext): MenuNode[] {
2616
3033
  label: "beats per bar",
2617
3034
  help: "the meter",
2618
3035
  value: score.beatsPerBar,
3036
+ start: START_BEATS_PER_BAR,
2619
3037
  min: 1,
2620
3038
  max: SCORE_LIMITS.maxBeatsPerBar,
2621
3039
  step: linear(1, 1, SCORE_LIMITS.maxBeatsPerBar),
@@ -2628,20 +3046,14 @@ function transportNodes(context: MenuContext): MenuNode[] {
2628
3046
  label: "loop length",
2629
3047
  help: "how long the loop is",
2630
3048
  value: score.bars,
3049
+ start: START_BARS,
2631
3050
  min: 1,
2632
3051
  max: SCORE_LIMITS.maxBars,
2633
3052
  step: linear(1, 1, SCORE_LIMITS.maxBars),
2634
3053
  format: (value) => `${num(value)} bar${value === 1 ? "" : "s"}`,
2635
3054
  command: (value) => `bars ${Math.round(value)}`,
2636
3055
  },
2637
- {
2638
- kind: "choice",
2639
- label: "grid",
2640
- help: "the step play-mode recording and the euclid editor snap to",
2641
- value: context.grid,
2642
- options: context.grids,
2643
- command: (option) => `/grid ${option}`,
2644
- },
3056
+ gridNode(context),
2645
3057
  {
2646
3058
  kind: "toggle",
2647
3059
  label: "click",
@@ -2658,12 +3070,14 @@ function transportNodes(context: MenuContext): MenuNode[] {
2658
3070
  command: (option) => `/count-in ${option}`,
2659
3071
  },
2660
3072
  {
2661
- kind: "menu",
2662
- id: "tuning",
2663
- label: "tuning & scale",
2664
- detail: `${tuningLabel(score.tuning)} · ${keyLabel(score.key)}`,
2665
- help: "the song tuning (12-TET, EDOs, just, gamelan, Scala) and scale",
2666
- build: songTuningNodes,
3073
+ kind: "choice",
3074
+ label: "calibration",
3075
+ help: "sound fixes: 1 chokes hats, tunes toms, levels keys, steadies brass · 0 keeps the legacy sound",
3076
+ value: String(score.calibration ?? 0),
3077
+ options: Array.from({ length: CALIBRATION_LATEST + 1 }, (_, i) =>
3078
+ String(i),
3079
+ ),
3080
+ command: (option) => `/calibration ${option}`,
2667
3081
  },
2668
3082
  {
2669
3083
  kind: "menu",
@@ -2673,6 +3087,40 @@ function transportNodes(context: MenuContext): MenuNode[] {
2673
3087
  help: "render a track, an orbit or the mix to a pinned WAV on a new sampler or granular track",
2674
3088
  build: resampleNodes,
2675
3089
  },
3090
+ {
3091
+ kind: "menu",
3092
+ id: "export",
3093
+ label: "export",
3094
+ detail: "project file · MIDI · WAV",
3095
+ help: "save the song as a project file or MIDI; audio renders offline",
3096
+ build: exportNodes,
3097
+ },
3098
+ {
3099
+ kind: "menu",
3100
+ id: "session",
3101
+ label: "session",
3102
+ detail: context.sessionName ?? "rename · fork · resume",
3103
+ help: "rename, fork or resume a session",
3104
+ build: sessionNodes,
3105
+ },
3106
+ {
3107
+ kind: "menu",
3108
+ id: "agent",
3109
+ label: "agent",
3110
+ detail: context.showMe
3111
+ ? `show me ${context.showMe}`
3112
+ : "model · model key",
3113
+ help: "the agent's model, show-me and model key",
3114
+ build: agentNodes,
3115
+ },
3116
+ {
3117
+ kind: "menu",
3118
+ id: "help",
3119
+ label: "help and guides",
3120
+ detail: "help · guides · keys",
3121
+ help: "the command reference, the guides and the keys",
3122
+ build: helpNodes,
3123
+ },
2676
3124
  ];
2677
3125
  }
2678
3126
 
@@ -2720,9 +3168,9 @@ function resampleNodes(context: MenuContext): MenuNode[] {
2720
3168
  },
2721
3169
  {
2722
3170
  kind: "action",
2723
- label: "mix → sampler (pre-master)",
3171
+ label: "mix → sampler",
2724
3172
  command: "resample orbit 1",
2725
- help: "render every track on orbit 1 (the default bus) without the song master",
3173
+ help: "pre-master: render every track on orbit 1 (the default bus) without the song master",
2726
3174
  },
2727
3175
  {
2728
3176
  kind: "action",
@@ -2899,7 +3347,7 @@ function songTuningNodes(context: MenuContext): MenuNode[] {
2899
3347
  help: "the song scale: modes, minors, pentatonics, maqam, ragas, Messiaen",
2900
3348
  value: scale,
2901
3349
  options: [...MODE_NAMES, ...SCALE_NAMES],
2902
- command: (option) => `scale ${option}`,
3350
+ command: (option) => `key ${option}`,
2903
3351
  },
2904
3352
  {
2905
3353
  kind: "choice",
@@ -2907,7 +3355,7 @@ function songTuningNodes(context: MenuContext): MenuNode[] {
2907
3355
  help: "the song key's tonic; keeps the scale",
2908
3356
  value: tonic,
2909
3357
  options: TONICS,
2910
- command: (option) => `scale ${option} ${scale}`,
3358
+ command: (option) => `key ${option} ${scale}`,
2911
3359
  },
2912
3360
  {
2913
3361
  kind: "action",
@@ -2918,7 +3366,7 @@ function songTuningNodes(context: MenuContext): MenuNode[] {
2918
3366
  {
2919
3367
  kind: "action",
2920
3368
  label: "list scales",
2921
- command: "scale list",
3369
+ command: "key list",
2922
3370
  help: "every mode and scale, by family",
2923
3371
  },
2924
3372
  ];
@@ -2960,14 +3408,7 @@ type Frame = {
2960
3408
  hovering?: boolean;
2961
3409
  };
2962
3410
 
2963
- const KEY_UP = new Set(["\u001b[A", "\u001bOA", "k"]);
2964
- const KEY_DOWN = new Set(["\u001b[B", "\u001bOB", "j"]);
2965
- const KEY_LEFT = new Set(["\u001b[D", "\u001bOD", "h", "-", "_"]);
2966
- const KEY_RIGHT = new Set(["\u001b[C", "\u001bOC", "l", "+", "="]);
2967
- const KEY_ENTER = new Set(["\r", "\n"]);
2968
- const KEY_BACKSPACE = new Set(["\u007f", "\b"]);
2969
- const KEY_DELETE = new Set(["\u001b[3~", "x"]);
2970
- const LABEL_WIDTH = 16;
3411
+ export const LABEL_WIDTH = 16;
2971
3412
 
2972
3413
  /**
2973
3414
  * A submenu frame whose rows are rebuilt from the parent's fresh build, so
@@ -3020,10 +3461,8 @@ export class EditMenu {
3020
3461
  this.stack = [{ title: "menu", build: rootNodes, index: 0, query: "" }];
3021
3462
  this.filtering = false;
3022
3463
  this.entry = undefined;
3023
- // Walk the section path (`automation` is Mix & automation › automation).
3024
- for (const id of section
3025
- ? (SECTION_ALIASES[section.toLowerCase()] ?? [])
3026
- : []) {
3464
+ // Walk the section path (`automation` is Mix › automation).
3465
+ for (const id of section ? (menuSectionPath(context, section) ?? []) : []) {
3027
3466
  const frame = this.stack.at(-1)!;
3028
3467
  const nodes = frame.build(context);
3029
3468
  const index = nodes.findIndex(
@@ -3130,9 +3569,15 @@ export class EditMenu {
3130
3569
  if (!frame) return [];
3131
3570
  const all = frame.build(context);
3132
3571
  const needle = frame.query.toLowerCase();
3133
- return needle
3134
- ? all.filter((node) => nodeText(node).toLowerCase().includes(needle))
3135
- : all;
3572
+ if (!needle) return all;
3573
+ const direct = all.filter((node) =>
3574
+ nodeText(node).toLowerCase().includes(needle),
3575
+ );
3576
+ // At the root a filter also looks two levels down for groups
3577
+ // (`/autotune`, `/formant`, `/tuning`) and offers them by path, after
3578
+ // the roots that match.
3579
+ if (this.stack.length !== 1) return direct;
3580
+ return [...direct, ...deepGroups(all, context, needle)];
3136
3581
  }
3137
3582
 
3138
3583
  private selected(context: MenuContext): MenuNode | undefined {
@@ -3160,11 +3605,7 @@ export class EditMenu {
3160
3605
  frame.index = 0;
3161
3606
  return { type: "handled" };
3162
3607
  }
3163
- if (
3164
- KEY_ENTER.has(value) ||
3165
- value === "\u001b[B" ||
3166
- value === "\u001b[A"
3167
- ) {
3608
+ if (KEY_ENTER.has(value) || value === ARROW_DOWN || value === ARROW_UP) {
3168
3609
  this.filtering = false;
3169
3610
  return this.key(value, context);
3170
3611
  }
@@ -3203,11 +3644,11 @@ export class EditMenu {
3203
3644
  frame.index = (frame.index + step + nodes.length) % nodes.length;
3204
3645
  return this.hovered(frame, context);
3205
3646
  }
3206
- if (value === "\u001b[5~" || value === "\u001b[H") {
3647
+ if (KEY_PAGE_UP.has(value) || KEY_HOME.has(value)) {
3207
3648
  frame.index = 0;
3208
3649
  return this.hovered(frame, context);
3209
3650
  }
3210
- if (value === "\u001b[6~" || value === "\u001b[F") {
3651
+ if (KEY_PAGE_DOWN.has(value) || KEY_END.has(value)) {
3211
3652
  frame.index = Math.max(0, nodes.length - 1);
3212
3653
  return this.hovered(frame, context);
3213
3654
  }
@@ -3226,11 +3667,19 @@ export class EditMenu {
3226
3667
  node.kind === "info")
3227
3668
  )
3228
3669
  return { type: "keep" };
3229
- if (KEY_LEFT.has(value) || KEY_RIGHT.has(value)) {
3230
- const direction = KEY_RIGHT.has(value) ? 1 : -1;
3231
- return this.nudge(node, direction);
3232
- }
3233
- if (KEY_DELETE.has(value) && node.kind === "number") {
3670
+ // ← → adjust a value; on any other row ← h go back and → l go in
3671
+ // (or run the action), and - + do nothing.
3672
+ const isValue = isValueNode(node);
3673
+ if (isValue && (KEY_LEFT.has(value) || KEY_RIGHT.has(value)))
3674
+ return this.nudge(node, KEY_RIGHT.has(value) ? 1 : -1);
3675
+ if (!isValue && KEY_BACK.has(value)) return this.back();
3676
+ if (!isValue && (KEY_LEFT.has(value) || KEY_RIGHT.has(value)))
3677
+ if (!(
3678
+ KEY_FORWARD.has(value) &&
3679
+ (node.kind === "menu" || node.kind === "action")
3680
+ ))
3681
+ return { type: "handled" };
3682
+ if (KEY_RESET.has(value) && node.kind === "number") {
3234
3683
  const reset =
3235
3684
  node.reset ??
3236
3685
  (node.start !== undefined && node.value !== undefined
@@ -3238,7 +3687,7 @@ export class EditMenu {
3238
3687
  : undefined);
3239
3688
  return reset ? { type: "run", command: reset } : { type: "handled" };
3240
3689
  }
3241
- if (KEY_DELETE.has(value) && node.kind === "point")
3690
+ if (KEY_RESET.has(value) && node.kind === "point")
3242
3691
  return {
3243
3692
  type: "run",
3244
3693
  command: `automate ${node.lane} remove ${num(node.beat)}`,
@@ -3250,7 +3699,7 @@ export class EditMenu {
3250
3699
  this.entry = { label: node.label, buffer: value };
3251
3700
  return { type: "handled" };
3252
3701
  }
3253
- if (KEY_ENTER.has(value) || value === " ") {
3702
+ if (KEY_ENTER.has(value) || KEY_FORWARD.has(value) || value === " ") {
3254
3703
  if (node.kind === "menu") {
3255
3704
  // Going back lands on the opened row with the filter cleared.
3256
3705
  if (frame.query) {
@@ -3326,15 +3775,16 @@ export class EditMenu {
3326
3775
  return { type: "hover", command: node.command, key: frame.hover };
3327
3776
  }
3328
3777
 
3778
+ /** ← on a row that is not a value: up one level (never past the root). */
3779
+ private back(): MenuResult {
3780
+ if (this.stack.length <= 1) return { type: "handled" };
3781
+ const left = this.stack.pop();
3782
+ if (left?.hover && left.hovering)
3783
+ return { type: "unhover", key: left.hover };
3784
+ return { type: "handled" };
3785
+ }
3786
+
3329
3787
  private nudge(node: MenuNode, direction: 1 | -1): MenuResult {
3330
- if (node.kind === "menu") {
3331
- if (direction > 0) {
3332
- this.stack.push(childFrame(this.stack.at(-1)!, node));
3333
- return { type: "handled" };
3334
- }
3335
- if (this.stack.length > 1) this.stack.pop();
3336
- return { type: "handled" };
3337
- }
3338
3788
  if (node.kind === "number") {
3339
3789
  const next =
3340
3790
  node.value === undefined
@@ -3367,11 +3817,6 @@ export class EditMenu {
3367
3817
  ? { type: "handled" }
3368
3818
  : { type: "run", command: node.command(next) };
3369
3819
  }
3370
- if (direction < 0 && this.stack.length > 1) {
3371
- const left = this.stack.pop();
3372
- if (left?.hover && left.hovering)
3373
- return { type: "unhover", key: left.hover };
3374
- }
3375
3820
  return { type: "handled" };
3376
3821
  }
3377
3822
 
@@ -3502,6 +3947,16 @@ export class EditMenu {
3502
3947
  }
3503
3948
  }
3504
3949
 
3950
+ /** Rows that hold a value: ← → adjust them instead of navigating. */
3951
+ function isValueNode(node: MenuNode): boolean {
3952
+ return (
3953
+ node.kind === "number" ||
3954
+ node.kind === "point" ||
3955
+ node.kind === "toggle" ||
3956
+ node.kind === "choice"
3957
+ );
3958
+ }
3959
+
3505
3960
  /** The focused row's one-line description. */
3506
3961
  function describe(node: MenuNode): string {
3507
3962
  if (node.help) return node.help;
@@ -3663,28 +4118,155 @@ function commandText(node: MenuNode): string | undefined {
3663
4118
  }
3664
4119
  }
3665
4120
 
4121
+ /** Menu groups below `nodes` (two levels) whose label matches `needle`. */
4122
+ function deepGroups(
4123
+ nodes: readonly MenuNode[],
4124
+ context: MenuContext,
4125
+ needle: string,
4126
+ ): MenuNode[] {
4127
+ const found: MenuNode[] = [];
4128
+ const visit = (list: readonly MenuNode[], path: string, depth: number) => {
4129
+ for (const node of list) {
4130
+ if (node.kind !== "menu" || found.length >= 12) continue;
4131
+ const label = path ? `${path} › ${node.label}` : node.label;
4132
+ if (path && node.label.toLowerCase().includes(needle))
4133
+ found.push({ ...node, label });
4134
+ if (depth < 2) {
4135
+ let children: MenuNode[] = [];
4136
+ try {
4137
+ children = node.build(context);
4138
+ } catch {
4139
+ continue;
4140
+ }
4141
+ visit(children, label, depth + 1);
4142
+ }
4143
+ }
4144
+ };
4145
+ visit(nodes, "", 0);
4146
+ return found;
4147
+ }
4148
+
3666
4149
  function nodeText(node: MenuNode): string {
3667
4150
  return `${node.label} ${valueText(node)} ${commandText(node) ?? ""}`;
3668
4151
  }
3669
4152
 
3670
- /** Root sections, for `/menu <section>`. */
3671
- export const MENU_SECTIONS = [
3672
- "sound",
3673
- "effects",
3674
- "rhythm",
3675
- "chords",
3676
- "mix",
3677
- "master",
3678
- "project",
3679
- "arrange",
3680
- // Older names, still accepted.
3681
- "parameters",
3682
- "sounds",
3683
- "track",
3684
- "automation",
3685
- "transport",
3686
- // Subsections.
3687
- "tempo",
3688
- "meter",
3689
- "time",
3690
- ] as const;
4153
+ /** Every id `/menu <id>` opens: the ten topics first, then older ids. */
4154
+ export const MENU_SECTIONS: readonly string[] = Object.freeze(
4155
+ Object.keys(SECTION_ALIASES),
4156
+ );
4157
+
4158
+ /** The ten topic ids, the roots `/menu` names first. */
4159
+ export const MENU_TOPICS: readonly string[] = Object.freeze(
4160
+ MENU_SECTIONS.slice(0, 10),
4161
+ );
4162
+
4163
+ /** The names `/menu` and `/help menu` list: one per topic, no aliases. */
4164
+ export const MENU_SHOWN_SECTIONS: readonly string[] = MENU_TOPICS;
4165
+
4166
+ /** `/menu`'s usage line: the short form; `/help menu` lists every topic. */
4167
+ export const MENU_USAGE = "usage: /menu <topic or row> · /help menu lists them";
4168
+
4169
+ /**
4170
+ * The error for `/menu <section>` when nothing matches: the nearest topic
4171
+ * or row name first, so it fits 80 columns, then the short usage.
4172
+ */
4173
+ export function menuUsage(context: MenuContext, section: string): string {
4174
+ const names = new Set<string>(MENU_SECTIONS);
4175
+ for (const node of rootNodes(context))
4176
+ if (node.kind === "menu") {
4177
+ let children: MenuNode[] = [];
4178
+ try {
4179
+ children = node.build(context);
4180
+ } catch {
4181
+ continue;
4182
+ }
4183
+ for (const child of children)
4184
+ if (child.kind === "menu") names.add(labelName(child.label));
4185
+ }
4186
+ const near = nearest(section, names);
4187
+ return `no menu "${section}"${near ? ` · did you mean /menu ${near}?` : ""} · /menu <topic or row>`;
4188
+ }
4189
+
4190
+ /** A row label without its padded detail: `granular 12 presets` → `granular`. */
4191
+ function labelName(label: string): string {
4192
+ return label
4193
+ .split(/\s{2,}/)[0]!
4194
+ .trim()
4195
+ .toLowerCase();
4196
+ }
4197
+
4198
+ /**
4199
+ * The path of menu ids `/menu <section>` walks: a topic or older id from
4200
+ * SECTION_ALIASES, else the first menu row (three levels deep, breadth
4201
+ * first) whose id or label is `section`. Undefined when nothing matches.
4202
+ */
4203
+ export function menuSectionPath(
4204
+ context: MenuContext,
4205
+ section: string,
4206
+ ): readonly string[] | undefined {
4207
+ const needle = section.trim().toLowerCase();
4208
+ if (!needle) return undefined;
4209
+ const alias = SECTION_ALIASES[needle];
4210
+ if (alias) return alias;
4211
+ type Step = { nodes: readonly MenuNode[]; path: readonly string[] };
4212
+ let level: Step[] = [{ nodes: rootNodes(context), path: [] }];
4213
+ for (let depth = 0; depth < 3 && level.length; depth += 1) {
4214
+ const next: Step[] = [];
4215
+ for (const { nodes, path } of level) {
4216
+ for (const node of nodes) {
4217
+ if (node.kind !== "menu") continue;
4218
+ const at = [...path, node.id];
4219
+ if (node.id === needle || node.label.toLowerCase() === needle)
4220
+ return at;
4221
+ if (depth < 2) {
4222
+ try {
4223
+ next.push({ nodes: node.build(context), path: at });
4224
+ } catch {
4225
+ // A section that cannot build here has no rows to match.
4226
+ }
4227
+ }
4228
+ }
4229
+ }
4230
+ level = next;
4231
+ }
4232
+ return undefined;
4233
+ }
4234
+
4235
+ /** A one-track project, for breadcrumbs asked for without a window. */
4236
+ function breadcrumbContext(): MenuContext {
4237
+ return {
4238
+ score: createScore({
4239
+ tempoBpm: 120,
4240
+ bars: 8,
4241
+ tracks: [{ id: "keys", name: "keys", instrument: "piano" }],
4242
+ notes: [],
4243
+ }),
4244
+ trackId: "keys",
4245
+ playing: false,
4246
+ grid: "1/16",
4247
+ grids: ["1/16"],
4248
+ clickOn: false,
4249
+ countInBars: 1,
4250
+ };
4251
+ }
4252
+
4253
+ /**
4254
+ * `Ctrl-K › Chords and key › tuning`: where `/menu <id>` opens, from the
4255
+ * live labels. Undefined when `id` opens nothing.
4256
+ */
4257
+ export function menuPath(
4258
+ id: string,
4259
+ context: MenuContext = breadcrumbContext(),
4260
+ ): string | undefined {
4261
+ const path = menuSectionPath(context, id);
4262
+ if (!path) return undefined;
4263
+ const labels: string[] = [];
4264
+ let nodes: readonly MenuNode[] = rootNodes(context);
4265
+ for (const step of path) {
4266
+ const node = nodes.find((row) => row.kind === "menu" && row.id === step);
4267
+ if (node?.kind !== "menu") return undefined;
4268
+ labels.push(node.label);
4269
+ nodes = node.build(context);
4270
+ }
4271
+ return ["Ctrl-K", ...labels].join(" › ");
4272
+ }