@hraness/dawg 0.6.0 → 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 (275) hide show
  1. package/CHANGELOG.md +126 -0
  2. package/DAWG.md +655 -141
  3. package/README.md +27 -25
  4. package/core/autotune.ts +1119 -0
  5. package/core/chords.ts +759 -23
  6. package/core/clips.ts +499 -0
  7. package/core/diff.ts +182 -99
  8. package/core/expression.ts +158 -6
  9. package/core/fx.ts +354 -32
  10. package/core/granular.ts +91 -0
  11. package/core/instruments.ts +180 -0
  12. package/core/keys.ts +573 -4
  13. package/core/loop.ts +5 -0
  14. package/core/lyrics.ts +297 -0
  15. package/core/master.ts +3 -3
  16. package/core/resonators.ts +306 -4
  17. package/core/routing.ts +165 -0
  18. package/core/score.ts +1181 -16
  19. package/core/sdk/eval-child.ts +7 -2
  20. package/core/sdk/eval.ts +35 -6
  21. package/core/sdk/print.ts +354 -6
  22. package/core/sdk/sync-lyrics.ts +49 -0
  23. package/core/sdk/v1.ts +3160 -69
  24. package/core/sections.ts +298 -24
  25. package/core/sing.ts +815 -0
  26. package/core/strings.ts +237 -2
  27. package/core/style-provenance.ts +80 -0
  28. package/core/styles/africa-mena-southasia.ts +2893 -0
  29. package/core/styles/americas.ts +3810 -0
  30. package/core/styles/art.ts +4993 -0
  31. package/core/styles/base.ts +123 -0
  32. package/core/styles/cycles.ts +106 -0
  33. package/core/styles/electronic.ts +2723 -0
  34. package/core/styles/europe-asia-pacific.ts +2838 -0
  35. package/core/styles/excerpt.ts +29 -0
  36. package/core/styles/gamelan.ts +283 -0
  37. package/core/styles/generate.ts +2199 -0
  38. package/core/styles/index.ts +515 -0
  39. package/core/styles/parts.ts +106 -0
  40. package/core/styles/pop.ts +3189 -0
  41. package/core/styles/rock.ts +2993 -0
  42. package/core/styles/roots.ts +4175 -0
  43. package/core/styles/schema.ts +429 -0
  44. package/core/styles/taxonomy.ts +940 -0
  45. package/core/styles/validate.ts +528 -0
  46. package/core/tempo.ts +32 -2
  47. package/core/tuning.ts +19 -3
  48. package/core/vocoder.ts +524 -0
  49. package/core/winds.ts +652 -0
  50. package/guides/agent.md +29 -0
  51. package/guides/arrange.md +30 -0
  52. package/guides/audition.md +20 -14
  53. package/guides/automation.md +12 -6
  54. package/guides/chords.md +16 -14
  55. package/guides/effects.md +17 -15
  56. package/guides/faders.md +20 -16
  57. package/guides/files.md +13 -9
  58. package/guides/getting-started.md +15 -11
  59. package/guides/keys.md +19 -15
  60. package/guides/media.md +17 -12
  61. package/guides/mix.md +12 -6
  62. package/guides/music.md +23 -8
  63. package/guides/notes.md +15 -9
  64. package/guides/performance.md +15 -10
  65. package/guides/play.md +21 -13
  66. package/guides/project.md +25 -8
  67. package/guides/providers.md +20 -14
  68. package/guides/resample.md +32 -0
  69. package/guides/rhythm.md +17 -13
  70. package/guides/sessions.md +17 -7
  71. package/guides/show-me.md +31 -0
  72. package/guides/sound.md +25 -9
  73. package/guides/sounds.md +15 -12
  74. package/guides/styles.md +31 -0
  75. package/guides/tempo.md +15 -10
  76. package/guides/tracks.md +15 -10
  77. package/guides/tuning.md +32 -0
  78. package/guides/voice.md +31 -0
  79. package/guides/web-search.md +18 -8
  80. package/native/prebuilt/darwin-arm64/libdawg_sink.dylib +0 -0
  81. package/native/prebuilt/darwin-x64/libdawg_sink.dylib +0 -0
  82. package/native/prebuilt/linux-arm64/libdawg_sink.so +0 -0
  83. package/native/prebuilt/linux-x64/libdawg_sink.so +0 -0
  84. package/native/prebuilt/manifest.json +21 -0
  85. package/package.json +5 -2
  86. package/src/agent/agent.ts +131 -13
  87. package/src/agent/calibration-tools.ts +53 -0
  88. package/src/agent/chord-tools.ts +153 -1
  89. package/src/agent/clip-tools.ts +453 -0
  90. package/src/agent/command-agent.ts +369 -0
  91. package/src/agent/drum-tools.ts +2 -2
  92. package/src/agent/expression-tools.ts +91 -0
  93. package/src/agent/gateway.ts +246 -60
  94. package/src/agent/models.ts +53 -12
  95. package/src/agent/ops.ts +15 -1
  96. package/src/agent/pack-tools.ts +1 -1
  97. package/src/agent/planner.ts +13 -0
  98. package/src/agent/portable-schema.ts +80 -0
  99. package/src/agent/preview-tool.ts +5 -1
  100. package/src/agent/provider.ts +22 -8
  101. package/src/agent/resample-tool.ts +131 -0
  102. package/src/agent/rhythm-tools.ts +1 -1
  103. package/src/agent/section-tools.ts +1 -1
  104. package/src/agent/show-me.ts +497 -0
  105. package/src/agent/steer.ts +15 -0
  106. package/src/agent/style-tools.ts +217 -0
  107. package/src/agent/tool-error.ts +12 -0
  108. package/src/agent/tools.ts +307 -37
  109. package/src/agent/usage.ts +2 -2
  110. package/src/agent/voice-tools.ts +925 -0
  111. package/src/agent/xcb-agent.ts +16 -6
  112. package/src/argv.ts +38 -0
  113. package/src/audio/analysis.ts +253 -0
  114. package/src/audio/arrange.ts +58 -3
  115. package/src/audio/autotune-engine.ts +101 -0
  116. package/src/audio/autotune.ts +640 -0
  117. package/src/audio/clips.ts +240 -0
  118. package/src/audio/doctor.ts +86 -0
  119. package/src/audio/dsp/bandbank.ts +138 -0
  120. package/src/audio/dsp/envelope.ts +171 -0
  121. package/src/audio/dsp/follow.ts +120 -0
  122. package/src/audio/dsp/formant.ts +427 -0
  123. package/src/audio/dsp/glottal.ts +243 -0
  124. package/src/audio/dsp/interp.ts +7 -2
  125. package/src/audio/dsp/lpc.ts +50 -0
  126. package/src/audio/dsp/periodicity.ts +59 -0
  127. package/src/audio/dsp/pitch.ts +995 -0
  128. package/src/audio/dsp/psola.ts +199 -0
  129. package/src/audio/dsp/shift.ts +256 -0
  130. package/src/audio/effects/chain.ts +21 -4
  131. package/src/audio/effects/common.ts +57 -0
  132. package/src/audio/effects/convolution.ts +113 -5
  133. package/src/audio/effects/filter.ts +48 -69
  134. package/src/audio/effects/formant.ts +263 -0
  135. package/src/audio/effects/gaze.ts +354 -0
  136. package/src/audio/engine.ts +206 -33
  137. package/src/audio/fit.ts +80 -3
  138. package/src/audio/granular.ts +352 -21
  139. package/src/audio/instrument-check.ts +67 -47
  140. package/src/audio/instruments.ts +36 -3
  141. package/src/audio/keys/calibration.ts +56 -0
  142. package/src/audio/keys/electric.ts +427 -0
  143. package/src/audio/keys/engine.ts +156 -20
  144. package/src/audio/keys/organ.ts +1335 -0
  145. package/src/audio/keys/piano.ts +72 -8
  146. package/src/audio/keys/sympathetic.ts +127 -0
  147. package/src/audio/kits.ts +135 -6
  148. package/src/audio/live-worker.ts +3 -1
  149. package/src/audio/live.ts +247 -26
  150. package/src/audio/native.ts +615 -0
  151. package/src/audio/preview.ts +45 -3
  152. package/src/audio/render-worker.ts +2 -0
  153. package/src/audio/renderer.ts +2 -0
  154. package/src/audio/resample.ts +286 -0
  155. package/src/audio/resonators.ts +10 -2
  156. package/src/audio/sampler.ts +252 -24
  157. package/src/audio/samples.ts +46 -3
  158. package/src/audio/sing/analysis.ts +193 -0
  159. package/src/audio/sing/engine.ts +949 -0
  160. package/src/audio/strings/body.ts +29 -16
  161. package/src/audio/strings/bow.ts +699 -0
  162. package/src/audio/strings/engine.ts +274 -22
  163. package/src/audio/strings/measure.test-helpers.ts +2 -0
  164. package/src/audio/synth/oscillators.ts +31 -21
  165. package/src/audio/synth/voice.ts +34 -1
  166. package/src/audio/vocoder/bank.ts +314 -0
  167. package/src/audio/vocoder/carrier.ts +165 -0
  168. package/src/audio/vocoder/control.ts +68 -0
  169. package/src/audio/vocoder/detect.ts +50 -0
  170. package/src/audio/vocoder/index.ts +304 -0
  171. package/src/audio/vocoder/talkbox.ts +143 -0
  172. package/src/audio/wav.ts +608 -85
  173. package/src/audio/winds/engine.ts +306 -0
  174. package/src/audio/winds/filters.ts +153 -0
  175. package/src/audio/winds/pitch.ts +104 -0
  176. package/src/audio/winds/trim.ts +104 -0
  177. package/src/audio/winds/trims.ts +917 -0
  178. package/src/audio/winds/trims1.ts +297 -0
  179. package/src/audio/winds/voice.ts +492 -0
  180. package/src/auth/cli.ts +38 -36
  181. package/src/auth/credentials.ts +30 -1
  182. package/src/auth/login.ts +15 -9
  183. package/src/auth/tui.ts +19 -10
  184. package/src/commands/arrange.ts +44 -29
  185. package/src/commands/autotune.ts +421 -0
  186. package/src/commands/calibration.ts +74 -0
  187. package/src/commands/clips.ts +887 -0
  188. package/src/commands/drums.ts +3 -2
  189. package/src/commands/edit.ts +11 -4
  190. package/src/commands/expression.ts +104 -23
  191. package/src/commands/formant.ts +221 -0
  192. package/src/commands/fx.ts +105 -33
  193. package/src/commands/grammar.ts +558 -0
  194. package/src/commands/granular.ts +7 -2
  195. package/src/commands/help.ts +652 -339
  196. package/src/commands/history.ts +18 -0
  197. package/src/commands/keys.ts +293 -17
  198. package/src/commands/modal.ts +67 -4
  199. package/src/commands/music.ts +1 -1
  200. package/src/commands/nearest.ts +53 -0
  201. package/src/commands/pack.ts +9 -2
  202. package/src/commands/param-range.ts +56 -0
  203. package/src/commands/parses.ts +130 -0
  204. package/src/commands/progression.ts +170 -0
  205. package/src/commands/resample.ts +281 -0
  206. package/src/commands/rhythm.ts +3 -0
  207. package/src/commands/rig.ts +8 -25
  208. package/src/commands/sample.ts +49 -2
  209. package/src/commands/shift.ts +119 -0
  210. package/src/commands/sing.ts +478 -0
  211. package/src/commands/string.ts +28 -2
  212. package/src/commands/strum.ts +485 -0
  213. package/src/commands/style.ts +415 -0
  214. package/src/commands/time.ts +6 -3
  215. package/src/commands/tuning.ts +3 -3
  216. package/src/commands/vocal-pitch.ts +616 -0
  217. package/src/commands/vocal.ts +147 -0
  218. package/src/commands/vocoder.ts +627 -0
  219. package/src/commands/wind.ts +244 -0
  220. package/src/fs/durable.ts +50 -0
  221. package/src/lang/glossary.ts +493 -0
  222. package/src/launch-args.ts +163 -0
  223. package/src/main.ts +1521 -255
  224. package/src/media/cli.ts +20 -3
  225. package/src/media/import.ts +3 -1
  226. package/src/project/check.ts +26 -3
  227. package/src/project/clip-pins.ts +72 -0
  228. package/src/project/init.ts +23 -8
  229. package/src/project/sync.ts +418 -86
  230. package/src/render.ts +36 -1
  231. package/src/session/daemon.ts +3 -0
  232. package/src/session/meta.ts +14 -0
  233. package/src/session/origin.ts +154 -0
  234. package/src/session/port.ts +22 -4
  235. package/src/session/presence.ts +50 -7
  236. package/src/session/protocol.ts +5 -1
  237. package/src/session/rebase.ts +18 -5
  238. package/src/session/receipt.ts +258 -0
  239. package/src/session/store.ts +65 -43
  240. package/src/tui/arrange-menu.ts +65 -31
  241. package/src/tui/audition.ts +1 -1
  242. package/src/tui/euclid.ts +18 -13
  243. package/src/tui/fader.ts +228 -41
  244. package/src/tui/granular-menu.ts +2 -4
  245. package/src/tui/menu-clips.ts +297 -0
  246. package/src/tui/menu-time.ts +20 -13
  247. package/src/tui/menu-voice.ts +405 -0
  248. package/src/tui/menu.ts +1377 -193
  249. package/src/tui/modal-menu.ts +38 -11
  250. package/src/tui/performance-menu.ts +46 -1
  251. package/src/tui/play-chords.ts +108 -5
  252. package/src/tui/play-mode.ts +16 -1
  253. package/src/tui/play-session.ts +81 -16
  254. package/src/tui/sing-menu.ts +278 -0
  255. package/src/tui/style-menu.ts +104 -0
  256. package/src/tui/vocoder-menu.ts +244 -0
  257. package/src/tui/wind-menu.ts +144 -0
  258. package/src/version.ts +8 -0
  259. package/src/web/fetch.ts +115 -29
  260. package/tui/activity.ts +180 -9
  261. package/tui/app.ts +274 -40
  262. package/tui/clip-row.ts +132 -0
  263. package/tui/delight.ts +144 -0
  264. package/tui/drawer.ts +70 -22
  265. package/tui/frame-gate.ts +76 -0
  266. package/tui/grammar.ts +112 -63
  267. package/tui/guide.ts +42 -4
  268. package/tui/highway.ts +269 -25
  269. package/tui/hints.ts +192 -0
  270. package/tui/input.ts +60 -9
  271. package/tui/keys.ts +1 -1
  272. package/tui/play-strip.ts +68 -14
  273. package/tui/prompt.ts +1 -1
  274. package/tui/screen.ts +144 -14
  275. package/tui/theme.ts +27 -0
package/src/tui/menu.ts CHANGED
@@ -8,18 +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";
41
+ import { windParameterNodes, windsMenu } from "./wind-menu.ts";
42
+ import { singParameterNodes } from "./sing-menu.ts";
17
43
  import type { FaderSpec } from "./fader.ts";
18
44
  import {
19
45
  openingMeterCommand,
20
46
  openingUnit,
21
47
  tempoDetail,
22
48
  tempoMenuNode,
49
+ START_BARS,
50
+ START_BEATS_PER_BAR,
51
+ START_TEMPO_BPM,
23
52
  } from "./menu-time.ts";
24
53
  import {
25
54
  AUTOMATION_PARAMETERS,
@@ -39,6 +68,8 @@ import {
39
68
  type Track,
40
69
  type TrackAutomationParameter,
41
70
  type TrackScore,
71
+ CALIBRATION_LATEST,
72
+ createScore,
42
73
  } from "../../core/score.ts";
43
74
  import {
44
75
  CORE_EFFECTS,
@@ -47,6 +78,8 @@ import {
47
78
  RIG_PRESETS,
48
79
  RIG_STAGES,
49
80
  rigPresetOf,
81
+ SHOEGAZE_EFFECTS,
82
+ VOWEL_VALUES,
50
83
  effectPresetNames,
51
84
  effectSpec,
52
85
  type EffectName,
@@ -95,16 +128,29 @@ import {
95
128
  STRING_PARAMS,
96
129
  STRING_PRESET_NAMES,
97
130
  STRING_PRESETS,
131
+ BOWED_PRESET_NAMES,
132
+ BOW_SIMPLE_PARAMS,
133
+ resolveString,
98
134
  STRING_SIMPLE_PARAMS,
99
135
  stringPresetOf,
100
136
  } from "../../core/strings.ts";
101
137
  import {
102
138
  KEYS_PARAMS,
103
139
  KEYS_PRESETS,
104
- KEYS_SIMPLE,
105
- PIANO_FAMILIES,
140
+ KEYS_FAMILIES,
141
+ ORGAN_ROWS,
142
+ ORGAN_TEXT,
143
+ PIPE_REGISTRATIONS,
144
+ PIPE_STOPS,
145
+ isElectricFamily,
146
+ isKeysFamily,
147
+ isOrganFamily,
106
148
  isPianoFamily,
149
+ keysParamsFor,
150
+ keysSimpleFor,
151
+ pipeStops,
107
152
  resolvedKeys,
153
+ type OrganFamily,
108
154
  } from "../../core/keys.ts";
109
155
  import type { PickerItem } from "../../tui/app.ts";
110
156
  import { BUILTIN_TABLES, BUILTIN_TABLE_NAMES } from "../audio/wavetable.ts";
@@ -124,6 +170,8 @@ import {
124
170
  PROGRESSION_STYLES,
125
171
  SCALE_NAMES,
126
172
  SPREADS,
173
+ STROKE_PATTERN_NAMES,
174
+ GUITAR_TUNING_NAMES,
127
175
  keyName,
128
176
  parseKey,
129
177
  } from "../../core/chords.ts";
@@ -135,6 +183,7 @@ import {
135
183
  type Tuning,
136
184
  } from "../../core/tuning.ts";
137
185
  import { rootName, tuningStepsText } from "../commands/tuning.ts";
186
+ import { describeGuitar } from "../commands/strum.ts";
138
187
  import {
139
188
  granularBrowseNodes,
140
189
  granularMenuDetail,
@@ -144,6 +193,7 @@ import {
144
193
  import {
145
194
  ARP_RATES,
146
195
  CHORD_MODES,
196
+ STROKE_SPEED_MS,
147
197
  defaultChordSettings,
148
198
  type ChordSettings,
149
199
  } from "./play-chords.ts";
@@ -157,6 +207,10 @@ export type MenuContext = Readonly<{
157
207
  grids: readonly string[];
158
208
  clickOn: boolean;
159
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;
160
214
  /** Play mode's chord settings (defaults when absent). */
161
215
  chords?: ChordSettings;
162
216
  /** Project root, for the project's own wavetables (none when absent). */
@@ -174,6 +228,8 @@ export type MenuAudition = Readonly<{
174
228
  looping: boolean;
175
229
  /** Edits are staged (an Enter keeps them, Esc reverts them). */
176
230
  dirty: boolean;
231
+ /** How many edits are staged, for the drawer's A/B badge. */
232
+ staged?: number;
177
233
  /** The committed score, for `staged ← committed` on changed rows. */
178
234
  committed: TrackScore;
179
235
  /** The footer while auditioning. */
@@ -342,7 +398,7 @@ const TRACK_LANE_LABEL: Readonly<Record<TrackAutomationParameter, string>> = {
342
398
  resonance: "filter resonance",
343
399
  "delay-feedback": "delay feedback",
344
400
  "delay-mix": "delay mix",
345
- wt: "wavetable position",
401
+ wt: "table position",
346
402
  };
347
403
 
348
404
  const FX_LANE_INFO = new Map(FX_LANES.map((entry) => [entry.lane, entry]));
@@ -384,7 +440,11 @@ function laneLabel(lane: AutomationParameter): string {
384
440
  info.effect === "synth" ||
385
441
  info.effect === "string" ||
386
442
  info.effect === "keys" ||
387
- info.effect === "modal"
443
+ info.effect === "modal" ||
444
+ info.effect === "grain" ||
445
+ info.effect === "wind" ||
446
+ info.effect === "sing" ||
447
+ info.effect === "vocoder"
388
448
  ? info.effect
389
449
  : effectSpec(info.effect).label;
390
450
  return `${owner} ${info.param}${unit}`;
@@ -418,9 +478,17 @@ export function rootNodes(context: MenuContext): MenuNode[] {
418
478
  id: "sound",
419
479
  label: "Sound",
420
480
  detail: `${name} · ${track?.instrument ?? "?"}`,
421
- help: "the focused track's instrument and its voice",
481
+ help: "the focused track's instrument, its controls and performance",
422
482
  build: soundSectionNodes,
423
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
+ },
424
492
  {
425
493
  kind: "menu",
426
494
  id: "effects",
@@ -433,26 +501,34 @@ export function rootNodes(context: MenuContext): MenuNode[] {
433
501
  kind: "menu",
434
502
  id: "rhythm",
435
503
  label: "Rhythm",
436
- detail: "euclid editor · patterns · kits",
437
- 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",
438
506
  build: rhythmNodes,
439
507
  },
440
508
  {
441
509
  kind: "menu",
442
510
  id: "chords",
443
- label: "Chords",
511
+ label: "Chords and key",
444
512
  detail: chordsDetail(context),
445
- 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)",
446
514
  build: chordNodes,
447
515
  },
448
516
  {
449
517
  kind: "menu",
450
518
  id: "mix",
451
- label: "Mix & automation",
519
+ label: "Mix",
452
520
  detail: `${tracks} track${tracks === 1 ? "" : "s"} · ${automated} lane${automated === 1 ? "" : "s"} moving`,
453
- 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",
454
522
  build: mixSectionNodes,
455
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
+ },
456
532
  {
457
533
  kind: "menu",
458
534
  id: "project",
@@ -460,47 +536,92 @@ export function rootNodes(context: MenuContext): MenuNode[] {
460
536
  detail: context.score.time
461
537
  ? `${tempoDetail(context.score)} · ${context.score.bars} bars`
462
538
  : `${num(context.score.tempoBpm)} BPM · ${context.score.beatsPerBar}/4 · ${context.score.bars} bars`,
463
- help: "tempo, meter, tempo map, loop length, play, click and count-in",
539
+ help: "play, tempo, meter, loop, click, export, session, agent and help",
464
540
  build: transportNodes,
465
541
  },
466
- {
467
- kind: "menu",
468
- id: "arrange",
469
- label: "Arrange",
470
- detail: arrangeDetail(context),
471
- help: "song sections, the form, builds, drops and fills",
472
- build: arrangeNodes,
473
- },
474
542
  ];
475
543
  }
476
544
 
477
- /** Old section names still open the menu where that content now lives. */
478
- const SECTION_ALIASES: Readonly<Record<string, readonly string[]>> = {
479
- sound: ["sound"],
480
- parameters: ["sound"],
481
- sounds: ["sound", "browse"],
482
- performance: ["sound", "performance"],
483
- expression: ["sound", "performance"],
484
- effects: ["effects"],
485
- rhythm: ["rhythm"],
486
- chords: ["chords"],
487
- mix: ["mix"],
488
- track: ["mix"],
489
- automation: ["mix", "automation"],
490
- master: ["mix", "master"],
491
- project: ["project"],
492
- transport: ["project"],
493
- tempo: ["project", "tempo"],
494
- meter: ["project", "tempo"],
495
- time: ["project", "tempo"],
496
- tuning: ["project", "tuning"],
497
- scale: ["project", "tuning"],
498
- arrange: ["arrange"],
499
- sections: ["arrange"],
500
- form: ["arrange"],
501
- };
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
+ }
502
623
 
503
- /** Sound: instrument and voice first, then the sound browser. */
624
+ /** Sound: the instrument and its controls first, then the instruments. */
504
625
  function soundSectionNodes(context: MenuContext): MenuNode[] {
505
626
  const track = focused(context);
506
627
  const tuning: MenuNode[] =
@@ -509,7 +630,7 @@ function soundSectionNodes(context: MenuContext): MenuNode[] {
509
630
  {
510
631
  kind: "menu",
511
632
  id: "track-tuning",
512
- label: "tuning",
633
+ label: "track tuning",
513
634
  detail: track.tuning
514
635
  ? tuningLabel(track.tuning)
515
636
  : `song · ${tuningLabel(context.score.tuning)}`,
@@ -533,27 +654,62 @@ function soundSectionNodes(context: MenuContext): MenuNode[] {
533
654
  ]
534
655
  : [];
535
656
  const keys: MenuNode[] =
536
- track && isPianoFamily(track.instrument) && track.keys
657
+ track && isOrganFamily(track.instrument) && track.keys
537
658
  ? [
538
659
  {
539
660
  kind: "menu",
540
661
  id: "keys",
541
662
  label: "keys",
542
663
  detail: `${track.instrument}${track.keys.preset ? ` · ${track.keys.preset}` : ""}`,
543
- help: "the modelled piano: preset, touch, hammers, dampers, stretch",
664
+ help: "the modeled organ: preset, drawbars, registers or stops, rotary",
544
665
  build: (inner) => {
545
666
  const current = focused(inner);
546
- return current
547
- ? keysNodes(current, Object.keys(KEYS_PARAMS))
548
- : [];
667
+ return current ? organNodes(current) : [];
549
668
  },
550
669
  },
551
670
  ]
671
+ : track && isKeysFamily(track.instrument) && track.keys
672
+ ? [
673
+ {
674
+ kind: "menu",
675
+ id: "keys",
676
+ label: "keys",
677
+ detail: `${track.instrument}${track.keys.preset ? ` · ${track.keys.preset}` : ""}`,
678
+ help: isElectricFamily(track.instrument)
679
+ ? "electric keys: preset, bark, bell, tone, vibe/trem, pickup and mute"
680
+ : "the modeled piano: preset, touch, hammers, dampers, stretch",
681
+ build: (inner) => {
682
+ const current = focused(inner);
683
+ return current
684
+ ? keysNodes(current, keysParamsFor(current.instrument))
685
+ : [];
686
+ },
687
+ },
688
+ ]
689
+ : [];
690
+ // 0.6.1: fretting for strum and the guitar perform mode, on guitar-like
691
+ // tracks (a string voice, a rig, or a guitar setup already stored).
692
+ const guitar: MenuNode[] =
693
+ track &&
694
+ (track.guitar ||
695
+ track.string ||
696
+ RIG_STAGES.some((stage) => track.fx?.[stage] !== undefined))
697
+ ? [
698
+ {
699
+ kind: "menu",
700
+ id: "guitar",
701
+ label: "guitar",
702
+ detail: describeGuitar(track.guitar),
703
+ help: "tuning, capo, hand stretch, ringing open strings and position for strum and chords perform guitar",
704
+ build: (inner) => guitarNodes(focused(inner)),
705
+ },
706
+ ]
552
707
  : [];
553
708
  return [
554
709
  ...parameterNodes(context),
555
710
  ...granular,
556
711
  ...keys,
712
+ ...guitar,
557
713
  ...tuning,
558
714
  {
559
715
  kind: "menu",
@@ -566,29 +722,349 @@ function soundSectionNodes(context: MenuContext): MenuNode[] {
566
722
  {
567
723
  kind: "menu",
568
724
  id: "browse",
569
- label: "browse sounds",
725
+ label: "instruments",
570
726
  detail: soundsDetail(focused(context)),
571
- help: "instruments, wavetables, sample packs",
727
+ help: "every instrument: keys, strings, mallets, winds, granular, wavetables, sample packs",
572
728
  build: soundNodes,
573
729
  },
574
730
  ];
575
731
  }
576
732
 
733
+ /** Sound > guitar: Track.guitar rows plus a strum action. */
734
+ function guitarNodes(track: Track | undefined): MenuNode[] {
735
+ if (!track) return [];
736
+ const g = track.guitar;
737
+ const tune = g?.tune ?? "standard";
738
+ const tuneName = typeof tune === "string" ? tune : tune.join(" ");
739
+ const int = (
740
+ label: string,
741
+ field: "capo" | "hand" | "position",
742
+ min: number,
743
+ max: number,
744
+ fallback: number,
745
+ help: string,
746
+ ): MenuNode => ({
747
+ kind: "number",
748
+ label,
749
+ help,
750
+ value: g?.[field] ?? fallback,
751
+ min,
752
+ max,
753
+ step: linear(1, min, max),
754
+ format: (value) => num(Math.round(value)),
755
+ command: (value) => `guitar ${field} ${Math.round(value)}`,
756
+ });
757
+ return [
758
+ {
759
+ kind: "choice",
760
+ label: "tune",
761
+ help: "open strings; type guitar tune D A D G A D for your own",
762
+ value: tuneName,
763
+ options: (GUITAR_TUNING_NAMES as readonly string[]).includes(tuneName)
764
+ ? GUITAR_TUNING_NAMES
765
+ : [tuneName, ...GUITAR_TUNING_NAMES],
766
+ command: (option) => `guitar tune ${option}`,
767
+ },
768
+ int(
769
+ "capo",
770
+ "capo",
771
+ 0,
772
+ 12,
773
+ 0,
774
+ "capo fret; chord shapes sound this many semitones up",
775
+ ),
776
+ int("hand", "hand", 3, 6, 4, "how many frets one hand shape may span"),
777
+ {
778
+ kind: "number",
779
+ label: "ring",
780
+ help: "0 closed shapes .. 1 lets open strings ring",
781
+ value: g?.ring ?? 0.5,
782
+ min: 0,
783
+ max: 1,
784
+ step: linear(0.1, 0, 1),
785
+ format: (value) => num(value),
786
+ command: (value) => `guitar ring ${Math.round(value * 10) / 10}`,
787
+ },
788
+ int(
789
+ "position",
790
+ "position",
791
+ 0,
792
+ 12,
793
+ 0,
794
+ "preferred fret position for voicings",
795
+ ),
796
+ {
797
+ kind: "action",
798
+ label: "strum the chords on this track",
799
+ command: "strum",
800
+ help: "replaces block chords with strummed guitar notes",
801
+ },
802
+ {
803
+ kind: "action",
804
+ label: "reset",
805
+ command: "guitar reset",
806
+ help: "standard tuning, no capo",
807
+ },
808
+ ];
809
+ }
810
+
577
811
  /** Rhythm: the euclid editor, then grooves and kits. */
578
- function rhythmNodes(): MenuNode[] {
812
+ function rhythmNodes(context: MenuContext): MenuNode[] {
579
813
  return [
580
814
  {
581
815
  kind: "action",
582
816
  label: "euclid editor",
583
817
  command: "/euclid",
584
- help: "hits, steps and rotation per drum voice",
818
+ help: "hits, steps and rotation per drum",
585
819
  },
586
820
  patternsMenu(),
587
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
+ },
588
1034
  ];
589
1035
  }
590
1036
 
591
- /** Mix & automation: this track, every track, then automation lanes. */
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
+ },
1064
+ ];
1065
+ }
1066
+
1067
+ /** Mix: this track, every track, automation lanes, then the master. */
592
1068
  function mixSectionNodes(context: MenuContext): MenuNode[] {
593
1069
  const track = focused(context);
594
1070
  const automated = AUTOMATION_PARAMETERS.filter(
@@ -625,7 +1101,7 @@ function mixSectionNodes(context: MenuContext): MenuNode[] {
625
1101
  ];
626
1102
  }
627
1103
 
628
- /** Mix & automation > Master: the loudness target, then each unit in order. */
1104
+ /** Mix › master: the loudness target, then each unit in order. */
629
1105
  function masterNodes(context: MenuContext): MenuNode[] {
630
1106
  const master = context.score.master;
631
1107
  // A name only when the LUFS and the limiter ceiling both match it, and no
@@ -798,7 +1274,7 @@ function chordsDetail(context: MenuContext): string {
798
1274
  return `${chords.mode} · ${key ? keyName(key) : "no key"} · ${chords.perform}`;
799
1275
  }
800
1276
 
801
- /** 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. */
802
1278
  function chordNodes(context: MenuContext): MenuNode[] {
803
1279
  const chords = context.chords ?? defaultChordSettings();
804
1280
  const key = parseKey(context.score.key ?? undefined);
@@ -831,11 +1307,20 @@ function chordNodes(context: MenuContext): MenuNode[] {
831
1307
  options: [...MODE_NAMES, ...SCALE_NAMES],
832
1308
  command: (option) => `key ${tonic} ${option}`,
833
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
+ },
834
1318
  {
835
1319
  kind: "number",
836
1320
  label: "voicing",
837
1321
  help: "inversion steps up or down (play mode: - =)",
838
1322
  value: chords.inversion,
1323
+ start: 0,
839
1324
  min: -MAX_VOICING_STEP,
840
1325
  max: MAX_VOICING_STEP,
841
1326
  step: linear(1, -MAX_VOICING_STEP, MAX_VOICING_STEP),
@@ -881,6 +1366,34 @@ function chordNodes(context: MenuContext): MenuNode[] {
881
1366
  options: CHORD_PATTERNS.map((pattern) => pattern.name),
882
1367
  command: (option) => `/chords pattern ${option}`,
883
1368
  },
1369
+ // Strum grid and speed only act when perform is guitar.
1370
+ ...(chords.perform !== "guitar"
1371
+ ? []
1372
+ : ([
1373
+ {
1374
+ kind: "choice",
1375
+ label: "strokes",
1376
+ help: "guitar strum grid (D down, U up, d u light, x chuck, - rest); type /chords strokes D-DU-UDU for your own",
1377
+ value: chords.strokes,
1378
+ options: (STROKE_PATTERN_NAMES as readonly string[]).includes(
1379
+ chords.strokes,
1380
+ )
1381
+ ? STROKE_PATTERN_NAMES
1382
+ : [chords.strokes, ...STROKE_PATTERN_NAMES],
1383
+ command: (option) => `/chords strokes ${option}`,
1384
+ },
1385
+ {
1386
+ kind: "number",
1387
+ label: "speed",
1388
+ help: "ms a full down stroke takes (0 hits every string at once); type /chords speed 1/32b to set it in beats",
1389
+ value: chords.speed,
1390
+ min: STROKE_SPEED_MS.min,
1391
+ max: STROKE_SPEED_MS.max,
1392
+ step: linear(1, STROKE_SPEED_MS.min, STROKE_SPEED_MS.max),
1393
+ format: (value) => `${num(value)} ms`,
1394
+ command: (value) => `/chords speed ${Math.round(value)}ms`,
1395
+ },
1396
+ ] satisfies MenuNode[])),
884
1397
  {
885
1398
  kind: "choice",
886
1399
  label: "arp rate",
@@ -894,6 +1407,7 @@ function chordNodes(context: MenuContext): MenuNode[] {
894
1407
  label: "arp octaves",
895
1408
  help: "how many octaves an arpeggio climbs",
896
1409
  value: chords.octaves,
1410
+ start: 1,
897
1411
  min: 1,
898
1412
  max: 4,
899
1413
  step: linear(1, 1, 4),
@@ -910,12 +1424,22 @@ function chordNodes(context: MenuContext): MenuNode[] {
910
1424
  },
911
1425
  {
912
1426
  kind: "choice",
913
- label: "style",
914
- help: "the style n suggests next chords in",
1427
+ label: "idiom",
1428
+ help: "the musical idiom n suggests next chords in (pop, jazz, blues…)",
915
1429
  value: chords.style,
916
1430
  options: PROGRESSION_STYLES,
917
1431
  command: (option) => `/chords style ${option}`,
918
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
+ },
919
1443
  ];
920
1444
  }
921
1445
 
@@ -969,7 +1493,7 @@ function trackNodes(context: MenuContext): MenuNode[] {
969
1493
  }
970
1494
 
971
1495
  function instrumentNode(track: Track): MenuNode {
972
- const options: string[] = [...AVAILABLE_INSTRUMENTS, ...PIANO_FAMILIES];
1496
+ const options: string[] = [...AVAILABLE_INSTRUMENTS, ...KEYS_FAMILIES];
973
1497
  if (!options.includes(track.instrument)) options.push(track.instrument);
974
1498
  return {
975
1499
  kind: "choice",
@@ -1011,7 +1535,7 @@ function panNode(track: Track): MenuNode {
1011
1535
  : `${num(Math.abs(value))} ${value < 0 ? "L" : "R"}`,
1012
1536
  command: (value) => `pan ${num(value)}`,
1013
1537
  reset: "pan 0",
1014
- help: "-1 left … 1 right · x centres",
1538
+ help: "-1 left … 1 right · x centers",
1015
1539
  };
1016
1540
  }
1017
1541
 
@@ -1023,13 +1547,20 @@ function parameterNodes(context: MenuContext): MenuNode[] {
1023
1547
  if (isWavetableInstrument(track.instrument))
1024
1548
  nodes.push(...wavetableNodes(track, context.projectRoot));
1025
1549
  if (isStringTrack(track)) {
1026
- nodes.push(...stringNodes(track, STRING_SIMPLE_PARAMS));
1550
+ nodes.push(
1551
+ ...stringNodes(
1552
+ track,
1553
+ resolveString(track.string).exciter === "bow"
1554
+ ? BOW_SIMPLE_PARAMS
1555
+ : STRING_SIMPLE_PARAMS,
1556
+ ),
1557
+ );
1027
1558
  nodes.push({
1028
1559
  kind: "menu",
1029
1560
  id: "string:advanced",
1030
1561
  label: "advanced",
1031
1562
  detail: `all ${Object.keys(STRING_PARAMS).length} params`,
1032
- help: "every string parameter: exciter, loss, body, buzz, sympathetics",
1563
+ help: "every string parameter: exciter, loss, body, buzz, sympathetics, bow (pressure, speed, attack, vib, vibdelay, tremhz, sord, dyn)",
1033
1564
  build: (inner) => {
1034
1565
  const current = focused(inner);
1035
1566
  return current
@@ -1072,22 +1603,40 @@ function parameterNodes(context: MenuContext): MenuNode[] {
1072
1603
  label: "add a voice",
1073
1604
  value: "/sample <path> [as <voice>]",
1074
1605
  });
1075
- } else if (isPianoFamily(track.instrument) && track.keys) {
1076
- nodes.push(...keysNodes(track, KEYS_SIMPLE));
1606
+ } else if (isOrganFamily(track.instrument) && track.keys) {
1607
+ // f061-organ: preset, Drawbars/Registers/Stops sub-menus, organ rows.
1608
+ nodes.push(...organNodes(track));
1609
+ } else if (isKeysFamily(track.instrument) && track.keys) {
1610
+ nodes.push(...keysNodes(track, keysSimpleFor(track.instrument)));
1611
+ const all = keysParamsFor(track.instrument);
1077
1612
  nodes.push({
1078
1613
  kind: "menu",
1079
1614
  id: "keys:all",
1080
- label: "all piano params",
1081
- detail: `all ${Object.keys(KEYS_PARAMS).length} params`,
1082
- help: "every modelled piano parameter",
1615
+ label: isElectricFamily(track.instrument)
1616
+ ? `all ${track.instrument} params`
1617
+ : "all piano params",
1618
+ detail: `all ${all.length} params`,
1619
+ help: isElectricFamily(track.instrument)
1620
+ ? "every electric keys parameter for this family"
1621
+ : "every modeled piano parameter",
1083
1622
  build: (inner) => {
1084
1623
  const current = focused(inner);
1085
- return current ? keysNodes(current, Object.keys(KEYS_PARAMS)) : [];
1624
+ return current
1625
+ ? keysNodes(current, keysParamsFor(current.instrument))
1626
+ : [];
1086
1627
  },
1087
1628
  });
1088
1629
  } else if (track.instrument === "modal") {
1089
1630
  // 0.6 modal percussion: preset, mallet and MODAL_PARAMS rows.
1090
1631
  nodes.push(...modalParameterNodes(track));
1632
+ } else if (track.instrument === "wind" && track.wind) {
1633
+ // 0.6.1 wind engine: preset and WIND_PARAMS rows.
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
+ );
1091
1640
  } else if (!isDrumInstrument(track.instrument)) {
1092
1641
  nodes.push(...synthNodes(track, SYNTH_SIMPLE));
1093
1642
  nodes.push({
@@ -1233,10 +1782,103 @@ function sampleVoiceNodes(context: MenuContext, voice: string): MenuNode[] {
1233
1782
  reset: `/len off ${voice}`,
1234
1783
  help: SAMPLE_CONTROLS.len,
1235
1784
  },
1785
+ // 0.6.1 shift: pitch without changing length, formants kept or moved,
1786
+ // and the voice's fade in and out.
1787
+ {
1788
+ kind: "number",
1789
+ label: "shift",
1790
+ value: ref.shift,
1791
+ start: 0,
1792
+ off: "off",
1793
+ min: -SCORE_LIMITS.maxSampleShift,
1794
+ max: SCORE_LIMITS.maxSampleShift,
1795
+ step: linear(
1796
+ 1,
1797
+ -SCORE_LIMITS.maxSampleShift,
1798
+ SCORE_LIMITS.maxSampleShift,
1799
+ ),
1800
+ format: num,
1801
+ command: (value) => `/shift ${num(value)} ${voice}`,
1802
+ reset: `/shift off ${voice}`,
1803
+ help: SAMPLE_CONTROLS.shift,
1804
+ },
1805
+ {
1806
+ kind: "number",
1807
+ label: "formant",
1808
+ value: ref.formant,
1809
+ start: 0,
1810
+ off: "follow",
1811
+ min: -SCORE_LIMITS.maxSampleShift,
1812
+ max: SCORE_LIMITS.maxSampleShift,
1813
+ step: linear(
1814
+ 1,
1815
+ -SCORE_LIMITS.maxSampleShift,
1816
+ SCORE_LIMITS.maxSampleShift,
1817
+ ),
1818
+ format: (value) => (value === 0 ? "keep" : num(value)),
1819
+ command: (value) => set(`formant ${num(value)}`),
1820
+ reset: set("formant off"),
1821
+ help: `${SAMPLE_CONTROLS.formant} · needs shift`,
1822
+ },
1823
+ {
1824
+ kind: "number",
1825
+ label: "fade in",
1826
+ value: ref.fadeInTime,
1827
+ start: 0.01,
1828
+ off: "off",
1829
+ min: 0,
1830
+ max: SCORE_LIMITS.maxSampleFadeSeconds,
1831
+ step: linear(0.01, 0, SCORE_LIMITS.maxSampleFadeSeconds),
1832
+ format: num,
1833
+ command: (value) => `/fade in ${num(value)} ${voice}`,
1834
+ reset: `/fade in off ${voice}`,
1835
+ help: SAMPLE_CONTROLS.fadeInTime,
1836
+ },
1837
+ {
1838
+ kind: "number",
1839
+ label: "fade out",
1840
+ value: ref.fadeTime,
1841
+ start: 0.1,
1842
+ off: "off",
1843
+ min: 0,
1844
+ max: SCORE_LIMITS.maxSampleFadeSeconds,
1845
+ step: linear(0.05, 0, SCORE_LIMITS.maxSampleFadeSeconds),
1846
+ format: num,
1847
+ command: (value) => `/fade out ${num(value)} ${voice}`,
1848
+ reset: `/fade out off ${voice}`,
1849
+ help: SAMPLE_CONTROLS.fadeTime,
1850
+ },
1851
+ // 0.6.1 layers: velocity layer (SFZ lovel/hivel) and round-robin group.
1852
+ {
1853
+ kind: "choice",
1854
+ label: "velocity layer",
1855
+ value: ref.vel ? `${ref.vel[0]}-${ref.vel[1]}` : "off",
1856
+ options: velocityLayerOptions(ref.vel),
1857
+ command: (option) => set(`vel ${option}`),
1858
+ help: `${SAMPLE_CONTROLS.vel} · two layers: 0-63 and 64-127 · three: 0-42 43-84 85-127`,
1859
+ },
1860
+ {
1861
+ kind: "entry",
1862
+ label: "round robin",
1863
+ value: ref.rr ?? "",
1864
+ placeholder: "group name, e.g. sn",
1865
+ command: (text) => set(`rr ${text.trim() || "off"}`),
1866
+ example: `/sample set ${voice} rr sn`,
1867
+ help: SAMPLE_CONTROLS.rr,
1868
+ },
1236
1869
  );
1237
1870
  return nodes;
1238
1871
  }
1239
1872
 
1873
+ /** Common velocity splits, plus the voice's own range when it is another. */
1874
+ function velocityLayerOptions(
1875
+ vel: readonly [number, number] | undefined,
1876
+ ): string[] {
1877
+ const options = ["off", "0-63", "64-127", "0-42", "43-84", "85-127"];
1878
+ const own = vel ? `${vel[0]}-${vel[1]}` : undefined;
1879
+ return own && !options.includes(own) ? [...options, own] : options;
1880
+ }
1881
+
1240
1882
  /** Synth preset first, then one row per parameter. */
1241
1883
  /** The preset this track's voice still matches exactly, if any. */
1242
1884
  function matchingSynthPreset(track: Track): string | undefined {
@@ -1249,17 +1891,25 @@ function matchingSynthPreset(track: Track): string | undefined {
1249
1891
  )?.[0];
1250
1892
  }
1251
1893
 
1252
- /** Modelled piano rows: the preset, then each parameter (`keys <p> <v>`). */
1253
- function keysNodes(track: Track, params: readonly string[]): MenuNode[] {
1894
+ /** Modeled piano rows: the preset, then each parameter (`keys <p> <v>`). */
1895
+ function keysNodes(
1896
+ track: Track,
1897
+ params: readonly string[],
1898
+ presets: readonly string[] = keysPresetNames(track.instrument),
1899
+ ): MenuNode[] {
1254
1900
  const effective = resolvedKeys(track.instrument, track.keys);
1255
1901
  const nodes: MenuNode[] = [
1256
1902
  {
1257
1903
  kind: "choice",
1258
1904
  label: "preset",
1259
1905
  value: track.keys?.preset ?? "—",
1260
- options: Object.keys(KEYS_PRESETS),
1906
+ options: presets,
1261
1907
  command: (preset) => `keys preset ${preset}`,
1262
- help: "a starting piano; every value stays editable",
1908
+ help: isOrganFamily(track.instrument)
1909
+ ? "a starting organ; every value stays editable"
1910
+ : isElectricFamily(track.instrument)
1911
+ ? "a starting electric piano or clav; every value stays editable"
1912
+ : "a starting piano; every value stays editable",
1263
1913
  },
1264
1914
  ];
1265
1915
  for (const key of params) {
@@ -1300,6 +1950,174 @@ function keysNodes(track: Track, params: readonly string[]): MenuNode[] {
1300
1950
  return nodes;
1301
1951
  }
1302
1952
 
1953
+ // ---- f061-organ menu rows -------------------------------------------------
1954
+
1955
+ const PIANO_PRESET_NAMES = Object.keys(KEYS_PRESETS).filter((name) =>
1956
+ isPianoFamily(KEYS_PRESETS[name]!.instrument),
1957
+ );
1958
+ const ORGAN_PRESET_NAMES = Object.keys(KEYS_PRESETS).filter((name) =>
1959
+ isOrganFamily(KEYS_PRESETS[name]!.instrument),
1960
+ );
1961
+ const ELECTRIC_PRESET_NAMES = Object.keys(KEYS_PRESETS).filter((name) =>
1962
+ isElectricFamily(KEYS_PRESETS[name]!.instrument),
1963
+ );
1964
+
1965
+ /** The presets of the track's kind of keys (pianos, electric or organs). */
1966
+ function keysPresetNames(instrument: string): readonly string[] {
1967
+ if (isOrganFamily(instrument)) return ORGAN_PRESET_NAMES;
1968
+ if (isElectricFamily(instrument)) return ELECTRIC_PRESET_NAMES;
1969
+ return PIANO_PRESET_NAMES;
1970
+ }
1971
+
1972
+ const DRAWBAR_FEET = [
1973
+ "16'",
1974
+ "5⅓'",
1975
+ "8'",
1976
+ "4'",
1977
+ "2⅔'",
1978
+ "2'",
1979
+ "1⅗'",
1980
+ "1⅓'",
1981
+ "1'",
1982
+ ];
1983
+ const REGISTER_FEET = ["16'", "8'", "4'", "2⅔'", "2'"];
1984
+
1985
+ /**
1986
+ * One 0-8 row per footage of a digit string (`keys drawbars 888000000`):
1987
+ * left/right pull a bar in or out, x puts the whole set back.
1988
+ */
1989
+ function digitNodes(
1990
+ name: "drawbars" | "registers",
1991
+ text: string,
1992
+ feet: readonly string[],
1993
+ stored: boolean,
1994
+ ): MenuNode[] {
1995
+ const digits = text.split("").map(Number);
1996
+ const nodes: MenuNode[] = feet.map((foot, index): MenuNode => {
1997
+ const at = (value: number) =>
1998
+ digits.map((d, i) => (i === index ? value : d)).join("");
1999
+ return {
2000
+ kind: "number",
2001
+ label: foot,
2002
+ value: digits[index]!,
2003
+ min: 0,
2004
+ max: 8,
2005
+ step: (value, direction) => Math.max(0, Math.min(8, value + direction)),
2006
+ format: (value) =>
2007
+ `${value} ${"█".repeat(value)}${"·".repeat(8 - value)}`,
2008
+ command: (value) => `keys ${name} ${at(value)}`,
2009
+ // x resets this bar only, to its default digit.
2010
+ reset: `keys ${name} ${at(Number(ORGAN_TEXT[name].default[index] ?? 0))}`,
2011
+ help: `${foot} ${name === "drawbars" ? "drawbar" : "register"} 0-8`,
2012
+ };
2013
+ });
2014
+ nodes.push({
2015
+ kind: "entry",
2016
+ label: `${name} (type)`,
2017
+ value: stored ? text : `${text} (default)`,
2018
+ placeholder: `${feet.length} digits 0-8`,
2019
+ command: (typed) =>
2020
+ new RegExp(`^[0-8]{${feet.length}}$`).test(typed.trim())
2021
+ ? `keys ${name} ${typed.trim()}`
2022
+ : undefined,
2023
+ example: `keys ${name} ${text}`,
2024
+ });
2025
+ return nodes;
2026
+ }
2027
+
2028
+ /** Pipe stops: a registration, then one toggle per stop. */
2029
+ function stopNodes(text: string): MenuNode[] {
2030
+ const on = pipeStops(text);
2031
+ const nodes: MenuNode[] = [
2032
+ {
2033
+ kind: "choice",
2034
+ label: "registration",
2035
+ value: Object.keys(PIPE_REGISTRATIONS).includes(text) ? text : "—",
2036
+ options: Object.keys(PIPE_REGISTRATIONS),
2037
+ command: (option) => `keys stops ${option}`,
2038
+ help: "a named registration (stops drawn together)",
2039
+ },
2040
+ ];
2041
+ for (const stop of PIPE_STOPS)
2042
+ nodes.push({
2043
+ kind: "toggle",
2044
+ label: stop,
2045
+ value: on.includes(stop),
2046
+ command: (value) => {
2047
+ const next = value
2048
+ ? PIPE_STOPS.filter((name) => name === stop || on.includes(name))
2049
+ : on.filter((name) => name !== stop);
2050
+ return next.length > 0
2051
+ ? `keys stops ${next.join(" ")}`
2052
+ : "keys stops off";
2053
+ },
2054
+ help: `draw or retire the ${stop} stop`,
2055
+ });
2056
+ return nodes;
2057
+ }
2058
+
2059
+ /** The organ's rows: preset, its family's sub-menu, then its parameters. */
2060
+ function organNodes(track: Track): MenuNode[] {
2061
+ const family = track.instrument as OrganFamily;
2062
+ const effective = resolvedKeys(track.instrument, track.keys);
2063
+ const rows = ORGAN_ROWS[family].filter((row) => row in KEYS_PARAMS);
2064
+ const nodes = keysNodes(track, rows, ORGAN_PRESET_NAMES);
2065
+ const text = (name: keyof typeof ORGAN_TEXT) =>
2066
+ String(effective[name] ?? ORGAN_TEXT[name].default);
2067
+ const sub: MenuNode =
2068
+ family === "tonewheel"
2069
+ ? {
2070
+ kind: "menu",
2071
+ id: "keys:drawbars",
2072
+ label: "drawbars",
2073
+ detail: text("drawbars"),
2074
+ help: "nine drawbars 16' to 1' (0 in, 8 full out)",
2075
+ build: (inner) => {
2076
+ const current = focused(inner) ?? track;
2077
+ const value = resolvedKeys(current.instrument, current.keys);
2078
+ return digitNodes(
2079
+ "drawbars",
2080
+ String(value.drawbars ?? ORGAN_TEXT.drawbars.default),
2081
+ DRAWBAR_FEET,
2082
+ current.keys?.drawbars !== undefined,
2083
+ );
2084
+ },
2085
+ }
2086
+ : family === "combo"
2087
+ ? {
2088
+ kind: "menu",
2089
+ id: "keys:registers",
2090
+ label: "registers",
2091
+ detail: text("registers"),
2092
+ help: "five combo-organ registers 16' to 2' (0 off, 8 full)",
2093
+ build: (inner) => {
2094
+ const current = focused(inner) ?? track;
2095
+ const value = resolvedKeys(current.instrument, current.keys);
2096
+ return digitNodes(
2097
+ "registers",
2098
+ String(value.registers ?? ORGAN_TEXT.registers.default),
2099
+ REGISTER_FEET,
2100
+ current.keys?.registers !== undefined,
2101
+ );
2102
+ },
2103
+ }
2104
+ : {
2105
+ kind: "menu",
2106
+ id: "keys:stops",
2107
+ label: "stops",
2108
+ detail: text("stops"),
2109
+ help: "pipe stops and registrations",
2110
+ build: (inner) => {
2111
+ const current = focused(inner) ?? track;
2112
+ const value = resolvedKeys(current.instrument, current.keys);
2113
+ return stopNodes(String(value.stops ?? ORGAN_TEXT.stops.default));
2114
+ },
2115
+ };
2116
+ // After the preset row, before the parameters.
2117
+ nodes.splice(1, 0, sub);
2118
+ return nodes;
2119
+ }
2120
+
1303
2121
  function synthNodes(track: Track, keys: readonly string[]): MenuNode[] {
1304
2122
  const nodes: MenuNode[] = [];
1305
2123
  if (keys === SYNTH_SIMPLE)
@@ -1363,9 +2181,9 @@ function synthParamNode(track: Track, key: string, named: boolean): MenuNode {
1363
2181
 
1364
2182
  /** Plain names for the simple synth rows; the note shows the command name. */
1365
2183
  const SYNTH_LABEL: Readonly<Record<string, string>> = {
1366
- lpf: "filter cutoff",
1367
- lpq: "filter res",
1368
- lpenv: "filter env",
2184
+ lpf: "synth filter",
2185
+ lpq: "synth filter res",
2186
+ lpenv: "synth filter env",
1369
2187
  vib: "vibrato",
1370
2188
  fm: "FM amount",
1371
2189
  };
@@ -1549,7 +2367,7 @@ function wavetableNodes(track: Track, projectRoot?: string): MenuNode[] {
1549
2367
  nodes.push({
1550
2368
  kind: "info",
1551
2369
  label: "position automation",
1552
- value: `${track.wtAutomation!.length} points · Automation › wavetable position`,
2370
+ value: `${track.wtAutomation!.length} points · Automation › table position`,
1553
2371
  });
1554
2372
  return nodes;
1555
2373
  }
@@ -1564,23 +2382,44 @@ function effectNodes(context: MenuContext): MenuNode[] {
1564
2382
  return {
1565
2383
  kind: "menu",
1566
2384
  id: effect,
1567
- label: spec.label[0]!.toUpperCase() + spec.label.slice(1),
2385
+ label: spec.label,
1568
2386
  detail: values ? effectSummary(effect, values) : "off",
1569
2387
  help: spec.doc,
1570
2388
  build: (inner) => effectParamNodes(inner, effect, false),
1571
2389
  };
1572
2390
  };
1573
2391
  const rigStages = RIG_STAGES as readonly string[];
2392
+ // 0.6.1 shoegaze stages get their own sub-menu.
2393
+ const gaze = SHOEGAZE_EFFECTS as readonly string[];
1574
2394
  const more = EFFECT_NAMES.filter(
1575
- (effect) => !CORE_EFFECTS.includes(effect) && !rigStages.includes(effect),
2395
+ (effect) =>
2396
+ !CORE_EFFECTS.includes(effect) &&
2397
+ !rigStages.includes(effect) &&
2398
+ !gaze.includes(effect) &&
2399
+ // 0.7: the formant shift lives in Effects > Voice.
2400
+ effect !== "formant",
1576
2401
  );
2402
+ const gazeOn = SHOEGAZE_EFFECTS.filter((effect) =>
2403
+ effectValues(track, effect),
2404
+ );
2405
+ const shoegaze: MenuNode = {
2406
+ kind: "menu",
2407
+ id: "shoegaze",
2408
+ label: "shoegaze",
2409
+ help: "wobble (tremolo-arm bend), bloom (feedback), swell (volume swell), double (two takes); `rig shoegaze` loads them all",
2410
+ detail:
2411
+ gazeOn.length === 0
2412
+ ? "off"
2413
+ : gazeOn.map((effect) => effectSpec(effect).label).join(", "),
2414
+ build: () => SHOEGAZE_EFFECTS.map(node),
2415
+ };
1577
2416
  const moreOn = more.filter((effect) => effectValues(track, effect));
1578
2417
  // 0.6 guitar rig: stomp → head (with its gate) → cab, plus whole rigs.
1579
2418
  const rigOn = RIG_STAGES.filter((stage) => effectValues(track, stage));
1580
2419
  const rig: MenuNode = {
1581
2420
  kind: "menu",
1582
2421
  id: "guitar rig",
1583
- label: "Guitar rig",
2422
+ label: "guitar rig",
1584
2423
  help: "stomp box, amp head with noise gate and speaker cabinet; `rig <name>` loads a whole rig",
1585
2424
  detail:
1586
2425
  rigOn.length === 0 ? "off" : (rigPresetOf(track.fx) ?? rigOn.join(" → ")),
@@ -1602,6 +2441,20 @@ function effectNodes(context: MenuContext): MenuNode[] {
1602
2441
  return [
1603
2442
  ...CORE_EFFECTS.map(node),
1604
2443
  rig,
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
+ : []),
1605
2458
  {
1606
2459
  kind: "menu",
1607
2460
  id: "more effects",
@@ -1638,16 +2491,17 @@ function withUnit(text: string, unit: string | undefined): string {
1638
2491
 
1639
2492
  function formatParam(spec: ParamSpec, value: number): string {
1640
2493
  if (spec.kind !== "number") return num(value);
1641
- return spec.step === "log" && value >= 100
1642
- ? `${Math.round(value)}`
1643
- : 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;
1644
2498
  }
1645
2499
 
1646
2500
  /**
1647
2501
  * One effect's rows: on/off, presets, then its simple parameters; the
1648
2502
  * `advanced` submenu holds every parameter with its Strudel names.
1649
2503
  */
1650
- function effectParamNodes(
2504
+ export function effectParamNodes(
1651
2505
  context: MenuContext,
1652
2506
  effect: EffectName,
1653
2507
  advanced: boolean,
@@ -1738,6 +2592,37 @@ function effectParamNodes(
1738
2592
  });
1739
2593
  }
1740
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
+ }
1741
2626
  if (!advanced) {
1742
2627
  nodes.push({
1743
2628
  kind: "menu",
@@ -1770,8 +2655,18 @@ function automationNodes(context: MenuContext): MenuNode[] {
1770
2655
  if (info?.effect === "synth")
1771
2656
  return track.synth?.[info.param] !== undefined;
1772
2657
  if (info?.effect === "string") return track.string !== undefined;
1773
- if (info?.effect === "keys") return track.keys !== undefined;
2658
+ // Only the lanes this family's engine reads (keys-rotary on organs,
2659
+ // keys-hardness on pianos); the rest wait under "all lanes".
2660
+ if (info?.effect === "keys")
2661
+ return (
2662
+ track.keys !== undefined &&
2663
+ keysParamsFor(track.instrument).includes(info.param)
2664
+ );
1774
2665
  if (info?.effect === "modal") return track.modal !== undefined;
2666
+ if (info?.effect === "grain") return track.granular !== undefined;
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;
1775
2670
  return info !== undefined && effectValues(track, info.effect) !== undefined;
1776
2671
  });
1777
2672
  const hidden = AUTOMATION_PARAMETERS.filter((lane) => !shown.includes(lane));
@@ -1897,14 +2792,14 @@ function soundsDetail(track: Track | undefined): string {
1897
2792
  }
1898
2793
 
1899
2794
  /**
1900
- * The instrument browser: drum kits and soundfont instruments from the
2795
+ * The instrument browser: kits and soundfont instruments from the
1901
2796
  * built-in packs (fetched on first use), and the pack list.
1902
2797
  */
1903
2798
  function kitsMenu(): MenuNode {
1904
2799
  return {
1905
2800
  kind: "menu",
1906
2801
  id: "kits",
1907
- label: "drum kits",
2802
+ label: "kits",
1908
2803
  help: "make this a drum track with a synth or sample kit",
1909
2804
  detail: `synth ${SYNTH_KIT_NAMES.join(" ")} · samples ${Object.keys(DEFAULT_KITS).join(" ")}`,
1910
2805
  // Synth kits first (offline), then the pack sample kits.
@@ -1941,62 +2836,119 @@ function patternsMenu(): MenuNode {
1941
2836
  return {
1942
2837
  kind: "menu",
1943
2838
  id: "patterns",
1944
- label: "drum patterns",
1945
- detail: `${DRUM_PATTERNS.length} grooves`,
2839
+ label: "grooves",
2840
+ detail: `${DRUM_PATTERNS.length} ready-made drum parts`,
1946
2841
  help: "replace the drum part with a ready-made groove",
1947
2842
  build: () =>
1948
2843
  DRUM_PATTERNS.map((entry): MenuNode => ({
1949
2844
  kind: "action",
1950
2845
  label: `${entry.label} ${entry.tempo.bpm} BPM · ${entry.tags.join(", ")}`,
1951
- command: `/pattern ${entry.name}`,
2846
+ command: `/groove ${entry.name}`,
1952
2847
  })),
1953
2848
  };
1954
2849
  }
1955
2850
 
1956
2851
  const GRANULAR_PRESETS_COUNT = granularBrowseNodes().length;
1957
2852
 
1958
- function soundNodes(): MenuNode[] {
2853
+ function soundNodes(context: MenuContext): MenuNode[] {
1959
2854
  return [
1960
2855
  {
1961
2856
  kind: "menu",
1962
2857
  id: "group:keys",
1963
- label: "Keys",
1964
- help: "modelled pianos, built in (no download)",
1965
- detail: Object.keys(KEYS_PRESETS).join(" "),
1966
- build: () =>
1967
- Object.entries(KEYS_PRESETS).map(([name, preset]): MenuNode => ({
1968
- kind: "action",
1969
- label: `${name.padEnd(10)} ${preset.doc}`,
1970
- command: `piano ${name}`,
1971
- help: preset.styles,
1972
- })),
2858
+ label: "keys",
2859
+ help: "modeled pianos, electric keys and organs, built in (no download)",
2860
+ detail: `${PIANO_PRESET_NAMES.join(" ")} · electric: ${ELECTRIC_PRESET_NAMES.join(" ")} · organs: ${ORGAN_PRESET_NAMES.join(" ")}`,
2861
+ build: () => [
2862
+ ...PIANO_PRESET_NAMES.map((name): MenuNode => {
2863
+ const preset = KEYS_PRESETS[name]!;
2864
+ return {
2865
+ kind: "action",
2866
+ label: `${name.padEnd(10)} ${preset.doc}`,
2867
+ command: `piano ${name}`,
2868
+ help: preset.styles,
2869
+ };
2870
+ }),
2871
+ // keys-electric (0.6.1): tine and reed pianos and the clavinet.
2872
+ {
2873
+ kind: "menu",
2874
+ id: "group:keys:electric",
2875
+ label: "electric",
2876
+ help: "electric pianos (tine, reed) and the clavinet, modeled",
2877
+ detail: ELECTRIC_PRESET_NAMES.join(" "),
2878
+ build: () =>
2879
+ Object.entries(KEYS_PRESETS)
2880
+ .filter(([, preset]) => isElectricFamily(preset.instrument))
2881
+ .map(([name, preset]): MenuNode => ({
2882
+ kind: "action",
2883
+ label: `${name.padEnd(10)} ${preset.doc}`,
2884
+ command: `keys preset ${name}`,
2885
+ help: preset.styles,
2886
+ })),
2887
+ },
2888
+ // f061-organ
2889
+ {
2890
+ kind: "menu",
2891
+ id: "group:organs",
2892
+ label: "organs",
2893
+ help: "tonewheel, combo and pipe organs on the keys engine",
2894
+ detail: ORGAN_PRESET_NAMES.join(" "),
2895
+ build: () =>
2896
+ ORGAN_PRESET_NAMES.map((name): MenuNode => {
2897
+ const preset = KEYS_PRESETS[name]!;
2898
+ return {
2899
+ kind: "action",
2900
+ label: `${name.padEnd(10)} ${preset.doc}`,
2901
+ command: name,
2902
+ help: preset.styles,
2903
+ };
2904
+ }),
2905
+ },
2906
+ ],
1973
2907
  },
1974
2908
  {
1975
2909
  kind: "action",
1976
- label: "wavetable synth basic shapes morph · built-in",
2910
+ label: "wavetable basic shapes morph · built-in",
1977
2911
  command: "wt basic",
1978
2912
  help: "switch this track to the morphing wavetable synth",
1979
2913
  },
1980
2914
  // 0.6 instrument groups, one per lane.
1981
2915
  malletsMenu(),
2916
+ windsMenu(),
1982
2917
  {
1983
2918
  kind: "menu",
1984
2919
  id: "strings",
1985
- label: "Strings",
1986
- help: "plucked strings: guitars, basses, sitar, harpsichord, oud, koto…",
1987
- detail: `${STRING_PRESET_NAMES.length} plucked · built-in`,
1988
- build: () =>
1989
- STRING_PRESET_NAMES.map((name): MenuNode => ({
2920
+ label: "strings",
2921
+ help: "plucked strings: guitars, basses, sitar, harpsichord, oud, koto…; Bowed: violin to contrabass, sections",
2922
+ detail: `${STRING_PRESET_NAMES.length - BOWED_PRESET_NAMES.length} plucked · ${BOWED_PRESET_NAMES.length} bowed · built-in`,
2923
+ build: () => [
2924
+ ...STRING_PRESET_NAMES.filter(
2925
+ (name) => !BOWED_PRESET_NAMES.includes(name),
2926
+ ).map((name): MenuNode => ({
1990
2927
  kind: "action",
1991
2928
  label: `${name.padEnd(12)} ${STRING_PRESETS[name]!.doc}`,
1992
2929
  command: `string ${name}`,
1993
2930
  help: STRING_PRESETS[name]!.styles,
1994
2931
  })),
2932
+ {
2933
+ kind: "menu",
2934
+ id: "strings:bowed",
2935
+ label: "bowed",
2936
+ help: "bowed strings: violin, viola, cello, bass, fiddle, erhu, sections",
2937
+ detail: BOWED_PRESET_NAMES.join(" "),
2938
+ build: () =>
2939
+ BOWED_PRESET_NAMES.map((name): MenuNode => ({
2940
+ kind: "action",
2941
+ label: `${name.padEnd(12)} ${STRING_PRESETS[name]!.doc}`,
2942
+ command: `bowed ${name}`,
2943
+ help: STRING_PRESETS[name]!.styles,
2944
+ })),
2945
+ },
2946
+ ],
1995
2947
  },
1996
2948
  {
1997
2949
  kind: "menu",
1998
2950
  id: "granular-browse",
1999
- label: "Granular",
2951
+ label: "granular",
2000
2952
  help: "grain clouds from a built-in synth or this track's own sound",
2001
2953
  detail: `${GRANULAR_PRESETS_COUNT} presets · built-in`,
2002
2954
  build: granularBrowseNodes,
@@ -2004,7 +2956,7 @@ function soundNodes(): MenuNode[] {
2004
2956
  {
2005
2957
  kind: "menu",
2006
2958
  id: "instruments",
2007
- label: "instruments",
2959
+ label: "all instruments",
2008
2960
  help: "sampled piano and General MIDI instruments",
2009
2961
  detail: "General MIDI soundfont, piano",
2010
2962
  build: () => [
@@ -2022,9 +2974,9 @@ function soundNodes(): MenuNode[] {
2022
2974
  },
2023
2975
  {
2024
2976
  kind: "entry",
2025
- label: "use a sound",
2977
+ label: "use a sample",
2026
2978
  value: "",
2027
- 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",
2028
2980
  placeholder: "<pack>/<sound>[:<n>]",
2029
2981
  command: (text) => (text.trim() ? `/pack use ${text.trim()}` : undefined),
2030
2982
  example: "/pack use dirt-samples/bd:3",
@@ -2069,6 +3021,7 @@ function transportNodes(context: MenuContext): MenuNode[] {
2069
3021
  label: "tempo",
2070
3022
  help: "beats per minute",
2071
3023
  value: score.tempoBpm,
3024
+ start: START_TEMPO_BPM,
2072
3025
  min: SCORE_LIMITS.minTempoBpm,
2073
3026
  max: SCORE_LIMITS.maxTempoBpm,
2074
3027
  step: linear(1, SCORE_LIMITS.minTempoBpm, SCORE_LIMITS.maxTempoBpm),
@@ -2080,6 +3033,7 @@ function transportNodes(context: MenuContext): MenuNode[] {
2080
3033
  label: "beats per bar",
2081
3034
  help: "the meter",
2082
3035
  value: score.beatsPerBar,
3036
+ start: START_BEATS_PER_BAR,
2083
3037
  min: 1,
2084
3038
  max: SCORE_LIMITS.maxBeatsPerBar,
2085
3039
  step: linear(1, 1, SCORE_LIMITS.maxBeatsPerBar),
@@ -2092,20 +3046,14 @@ function transportNodes(context: MenuContext): MenuNode[] {
2092
3046
  label: "loop length",
2093
3047
  help: "how long the loop is",
2094
3048
  value: score.bars,
3049
+ start: START_BARS,
2095
3050
  min: 1,
2096
3051
  max: SCORE_LIMITS.maxBars,
2097
3052
  step: linear(1, 1, SCORE_LIMITS.maxBars),
2098
3053
  format: (value) => `${num(value)} bar${value === 1 ? "" : "s"}`,
2099
3054
  command: (value) => `bars ${Math.round(value)}`,
2100
3055
  },
2101
- {
2102
- kind: "choice",
2103
- label: "grid",
2104
- help: "the step play-mode recording and the euclid editor snap to",
2105
- value: context.grid,
2106
- options: context.grids,
2107
- command: (option) => `/grid ${option}`,
2108
- },
3056
+ gridNode(context),
2109
3057
  {
2110
3058
  kind: "toggle",
2111
3059
  label: "click",
@@ -2121,17 +3069,119 @@ function transportNodes(context: MenuContext): MenuNode[] {
2121
3069
  options: ["0", "1", "2"],
2122
3070
  command: (option) => `/count-in ${option}`,
2123
3071
  },
3072
+ {
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}`,
3081
+ },
2124
3082
  {
2125
3083
  kind: "menu",
2126
- id: "tuning",
2127
- label: "tuning & scale",
2128
- detail: `${tuningLabel(score.tuning)} · ${keyLabel(score.key)}`,
2129
- help: "the song tuning (12-TET, EDOs, just, gamelan, Scala) and scale",
2130
- build: songTuningNodes,
3084
+ id: "resample",
3085
+ label: "resample",
3086
+ detail: "track · section · bars → new sample track",
3087
+ help: "render a track, an orbit or the mix to a pinned WAV on a new sampler or granular track",
3088
+ build: resampleNodes,
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,
2131
3123
  },
2132
3124
  ];
2133
3125
  }
2134
3126
 
3127
+ /** Project > Resample: one action per useful source and range. */
3128
+ function resampleNodes(context: MenuContext): MenuNode[] {
3129
+ const track = focused(context);
3130
+ const score = context.score;
3131
+ const nodes: MenuNode[] = [];
3132
+ if (track) {
3133
+ nodes.push(
3134
+ {
3135
+ kind: "action",
3136
+ label: `${track.id} → sampler`,
3137
+ command: `resample ${track.id}`,
3138
+ help: "render this track (pre-master) to a one-shot sampler track",
3139
+ },
3140
+ {
3141
+ kind: "action",
3142
+ label: `${track.id} → granular`,
3143
+ command: `resample ${track.id} grain`,
3144
+ help: "render this track to a granular track (cloud preset) holding one note",
3145
+ },
3146
+ );
3147
+ for (const section of score.sections)
3148
+ nodes.push({
3149
+ kind: "action",
3150
+ label: `${track.id} · section ${section.name}`,
3151
+ command: `resample ${track.id} section ${section.name}`,
3152
+ help: `render bars ${section.startBar + 1}-${section.startBar + section.bars} of this track`,
3153
+ });
3154
+ }
3155
+ nodes.push(
3156
+ {
3157
+ kind: "entry",
3158
+ label: "bars",
3159
+ value: "",
3160
+ placeholder: "a-b [grain]",
3161
+ example: "/resample <track> bars 1-2",
3162
+ command: (text) => {
3163
+ const match = /^\s*(\d{1,4})(?:-(\d{1,4}))?(\s+grain)?\s*$/.exec(text);
3164
+ if (!match || !track) return undefined;
3165
+ return `resample ${track.id} bars ${match[1]}-${match[2] ?? match[1]}${match[3] ? " grain" : ""}`;
3166
+ },
3167
+ help: "render a bar range of the focused track",
3168
+ },
3169
+ {
3170
+ kind: "action",
3171
+ label: "mix → sampler",
3172
+ command: "resample orbit 1",
3173
+ help: "pre-master: render every track on orbit 1 (the default bus) without the song master",
3174
+ },
3175
+ {
3176
+ kind: "action",
3177
+ label: "master → sampler",
3178
+ command: "resample master",
3179
+ help: "render the whole song through the master chain",
3180
+ },
3181
+ );
3182
+ return nodes;
3183
+ }
3184
+
2135
3185
  // ── tuning ────────────────────────────────────────────────────────────
2136
3186
 
2137
3187
  /** Short name of a tuning for a choice row: its library name or table. */
@@ -2297,7 +3347,7 @@ function songTuningNodes(context: MenuContext): MenuNode[] {
2297
3347
  help: "the song scale: modes, minors, pentatonics, maqam, ragas, Messiaen",
2298
3348
  value: scale,
2299
3349
  options: [...MODE_NAMES, ...SCALE_NAMES],
2300
- command: (option) => `scale ${option}`,
3350
+ command: (option) => `key ${option}`,
2301
3351
  },
2302
3352
  {
2303
3353
  kind: "choice",
@@ -2305,7 +3355,7 @@ function songTuningNodes(context: MenuContext): MenuNode[] {
2305
3355
  help: "the song key's tonic; keeps the scale",
2306
3356
  value: tonic,
2307
3357
  options: TONICS,
2308
- command: (option) => `scale ${option} ${scale}`,
3358
+ command: (option) => `key ${option} ${scale}`,
2309
3359
  },
2310
3360
  {
2311
3361
  kind: "action",
@@ -2316,7 +3366,7 @@ function songTuningNodes(context: MenuContext): MenuNode[] {
2316
3366
  {
2317
3367
  kind: "action",
2318
3368
  label: "list scales",
2319
- command: "scale list",
3369
+ command: "key list",
2320
3370
  help: "every mode and scale, by family",
2321
3371
  },
2322
3372
  ];
@@ -2358,14 +3408,7 @@ type Frame = {
2358
3408
  hovering?: boolean;
2359
3409
  };
2360
3410
 
2361
- const KEY_UP = new Set(["\u001b[A", "\u001bOA", "k"]);
2362
- const KEY_DOWN = new Set(["\u001b[B", "\u001bOB", "j"]);
2363
- const KEY_LEFT = new Set(["\u001b[D", "\u001bOD", "h", "-", "_"]);
2364
- const KEY_RIGHT = new Set(["\u001b[C", "\u001bOC", "l", "+", "="]);
2365
- const KEY_ENTER = new Set(["\r", "\n"]);
2366
- const KEY_BACKSPACE = new Set(["\u007f", "\b"]);
2367
- const KEY_DELETE = new Set(["\u001b[3~", "x"]);
2368
- const LABEL_WIDTH = 16;
3411
+ export const LABEL_WIDTH = 16;
2369
3412
 
2370
3413
  /**
2371
3414
  * A submenu frame whose rows are rebuilt from the parent's fresh build, so
@@ -2418,10 +3461,8 @@ export class EditMenu {
2418
3461
  this.stack = [{ title: "menu", build: rootNodes, index: 0, query: "" }];
2419
3462
  this.filtering = false;
2420
3463
  this.entry = undefined;
2421
- // Walk the section path (`automation` is Mix & automation › automation).
2422
- for (const id of section
2423
- ? (SECTION_ALIASES[section.toLowerCase()] ?? [])
2424
- : []) {
3464
+ // Walk the section path (`automation` is Mix › automation).
3465
+ for (const id of section ? (menuSectionPath(context, section) ?? []) : []) {
2425
3466
  const frame = this.stack.at(-1)!;
2426
3467
  const nodes = frame.build(context);
2427
3468
  const index = nodes.findIndex(
@@ -2528,9 +3569,15 @@ export class EditMenu {
2528
3569
  if (!frame) return [];
2529
3570
  const all = frame.build(context);
2530
3571
  const needle = frame.query.toLowerCase();
2531
- return needle
2532
- ? all.filter((node) => nodeText(node).toLowerCase().includes(needle))
2533
- : 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)];
2534
3581
  }
2535
3582
 
2536
3583
  private selected(context: MenuContext): MenuNode | undefined {
@@ -2558,11 +3605,7 @@ export class EditMenu {
2558
3605
  frame.index = 0;
2559
3606
  return { type: "handled" };
2560
3607
  }
2561
- if (
2562
- KEY_ENTER.has(value) ||
2563
- value === "\u001b[B" ||
2564
- value === "\u001b[A"
2565
- ) {
3608
+ if (KEY_ENTER.has(value) || value === ARROW_DOWN || value === ARROW_UP) {
2566
3609
  this.filtering = false;
2567
3610
  return this.key(value, context);
2568
3611
  }
@@ -2601,11 +3644,11 @@ export class EditMenu {
2601
3644
  frame.index = (frame.index + step + nodes.length) % nodes.length;
2602
3645
  return this.hovered(frame, context);
2603
3646
  }
2604
- if (value === "\u001b[5~" || value === "\u001b[H") {
3647
+ if (KEY_PAGE_UP.has(value) || KEY_HOME.has(value)) {
2605
3648
  frame.index = 0;
2606
3649
  return this.hovered(frame, context);
2607
3650
  }
2608
- if (value === "\u001b[6~" || value === "\u001b[F") {
3651
+ if (KEY_PAGE_DOWN.has(value) || KEY_END.has(value)) {
2609
3652
  frame.index = Math.max(0, nodes.length - 1);
2610
3653
  return this.hovered(frame, context);
2611
3654
  }
@@ -2624,11 +3667,19 @@ export class EditMenu {
2624
3667
  node.kind === "info")
2625
3668
  )
2626
3669
  return { type: "keep" };
2627
- if (KEY_LEFT.has(value) || KEY_RIGHT.has(value)) {
2628
- const direction = KEY_RIGHT.has(value) ? 1 : -1;
2629
- return this.nudge(node, direction);
2630
- }
2631
- 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") {
2632
3683
  const reset =
2633
3684
  node.reset ??
2634
3685
  (node.start !== undefined && node.value !== undefined
@@ -2636,7 +3687,7 @@ export class EditMenu {
2636
3687
  : undefined);
2637
3688
  return reset ? { type: "run", command: reset } : { type: "handled" };
2638
3689
  }
2639
- if (KEY_DELETE.has(value) && node.kind === "point")
3690
+ if (KEY_RESET.has(value) && node.kind === "point")
2640
3691
  return {
2641
3692
  type: "run",
2642
3693
  command: `automate ${node.lane} remove ${num(node.beat)}`,
@@ -2648,7 +3699,7 @@ export class EditMenu {
2648
3699
  this.entry = { label: node.label, buffer: value };
2649
3700
  return { type: "handled" };
2650
3701
  }
2651
- if (KEY_ENTER.has(value) || value === " ") {
3702
+ if (KEY_ENTER.has(value) || KEY_FORWARD.has(value) || value === " ") {
2652
3703
  if (node.kind === "menu") {
2653
3704
  // Going back lands on the opened row with the filter cleared.
2654
3705
  if (frame.query) {
@@ -2724,15 +3775,16 @@ export class EditMenu {
2724
3775
  return { type: "hover", command: node.command, key: frame.hover };
2725
3776
  }
2726
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
+
2727
3787
  private nudge(node: MenuNode, direction: 1 | -1): MenuResult {
2728
- if (node.kind === "menu") {
2729
- if (direction > 0) {
2730
- this.stack.push(childFrame(this.stack.at(-1)!, node));
2731
- return { type: "handled" };
2732
- }
2733
- if (this.stack.length > 1) this.stack.pop();
2734
- return { type: "handled" };
2735
- }
2736
3788
  if (node.kind === "number") {
2737
3789
  const next =
2738
3790
  node.value === undefined
@@ -2765,11 +3817,6 @@ export class EditMenu {
2765
3817
  ? { type: "handled" }
2766
3818
  : { type: "run", command: node.command(next) };
2767
3819
  }
2768
- if (direction < 0 && this.stack.length > 1) {
2769
- const left = this.stack.pop();
2770
- if (left?.hover && left.hovering)
2771
- return { type: "unhover", key: left.hover };
2772
- }
2773
3820
  return { type: "handled" };
2774
3821
  }
2775
3822
 
@@ -2900,6 +3947,16 @@ export class EditMenu {
2900
3947
  }
2901
3948
  }
2902
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
+
2903
3960
  /** The focused row's one-line description. */
2904
3961
  function describe(node: MenuNode): string {
2905
3962
  if (node.help) return node.help;
@@ -3061,28 +4118,155 @@ function commandText(node: MenuNode): string | undefined {
3061
4118
  }
3062
4119
  }
3063
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
+
3064
4149
  function nodeText(node: MenuNode): string {
3065
4150
  return `${node.label} ${valueText(node)} ${commandText(node) ?? ""}`;
3066
4151
  }
3067
4152
 
3068
- /** Root sections, for `/menu <section>`. */
3069
- export const MENU_SECTIONS = [
3070
- "sound",
3071
- "effects",
3072
- "rhythm",
3073
- "chords",
3074
- "mix",
3075
- "master",
3076
- "project",
3077
- "arrange",
3078
- // Older names, still accepted.
3079
- "parameters",
3080
- "sounds",
3081
- "track",
3082
- "automation",
3083
- "transport",
3084
- // Subsections.
3085
- "tempo",
3086
- "meter",
3087
- "time",
3088
- ] 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
+ }