@hraness/dawg 0.6.1 → 0.8.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 (277) hide show
  1. package/CHANGELOG.md +118 -0
  2. package/DAWG.md +454 -140
  3. package/README.md +30 -26
  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 +184 -104
  8. package/core/expression.ts +15 -0
  9. package/core/fx.ts +99 -29
  10. package/core/ids.ts +52 -0
  11. package/core/instruments.ts +19 -0
  12. package/core/keys.ts +3 -3
  13. package/core/loop.ts +8 -0
  14. package/core/lyrics.ts +297 -0
  15. package/core/master.ts +3 -3
  16. package/core/range.ts +581 -0
  17. package/core/resonators.ts +16 -2
  18. package/core/routing.ts +165 -0
  19. package/core/score.ts +955 -14
  20. package/core/sdk/eval-child.ts +7 -2
  21. package/core/sdk/eval.ts +35 -6
  22. package/core/sdk/print.ts +287 -5
  23. package/core/sdk/sync-lyrics.ts +49 -0
  24. package/core/sdk/v1.ts +2132 -46
  25. package/core/sections.ts +480 -35
  26. package/core/sing.ts +815 -0
  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/guides/agent.md +29 -0
  50. package/guides/arrange.md +31 -0
  51. package/guides/audio.md +31 -0
  52. package/guides/audition.md +20 -14
  53. package/guides/automation.md +12 -7
  54. package/guides/chords.md +15 -15
  55. package/guides/effects.md +17 -16
  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 +15 -7
  62. package/guides/music.md +23 -8
  63. package/guides/notes.md +15 -9
  64. package/guides/panes.md +32 -0
  65. package/guides/performance.md +15 -13
  66. package/guides/play.md +21 -13
  67. package/guides/project.md +26 -8
  68. package/guides/providers.md +20 -14
  69. package/guides/resample.md +15 -9
  70. package/guides/rhythm.md +18 -13
  71. package/guides/sessions.md +17 -7
  72. package/guides/show-me.md +31 -0
  73. package/guides/sound.md +25 -9
  74. package/guides/sounds.md +15 -13
  75. package/guides/styles.md +31 -0
  76. package/guides/tape.md +32 -0
  77. package/guides/tempo.md +15 -10
  78. package/guides/tracks.md +15 -10
  79. package/guides/tuning.md +32 -0
  80. package/guides/voice.md +31 -0
  81. package/guides/web-search.md +18 -8
  82. package/native/prebuilt/darwin-arm64/libdawg_sink.dylib +0 -0
  83. package/native/prebuilt/darwin-x64/libdawg_sink.dylib +0 -0
  84. package/native/prebuilt/linux-arm64/libdawg_sink.so +0 -0
  85. package/native/prebuilt/linux-x64/libdawg_sink.so +0 -0
  86. package/native/prebuilt/manifest.json +21 -0
  87. package/package.json +6 -2
  88. package/src/agent/agent.ts +130 -18
  89. package/src/agent/calibration-tools.ts +53 -0
  90. package/src/agent/clip-tools.ts +453 -0
  91. package/src/agent/command-agent.ts +378 -0
  92. package/src/agent/drum-tools.ts +2 -2
  93. package/src/agent/expression-tools.ts +1 -1
  94. package/src/agent/gateway.ts +246 -60
  95. package/src/agent/models.ts +53 -12
  96. package/src/agent/ops.ts +27 -1
  97. package/src/agent/pack-tools.ts +1 -1
  98. package/src/agent/planner.ts +25 -0
  99. package/src/agent/portable-schema.ts +80 -0
  100. package/src/agent/preview-tool.ts +4 -1
  101. package/src/agent/provider.ts +22 -8
  102. package/src/agent/range-tools.ts +216 -0
  103. package/src/agent/rhythm-tools.ts +1 -1
  104. package/src/agent/section-tools.ts +1 -1
  105. package/src/agent/show-me.ts +497 -0
  106. package/src/agent/steer.ts +15 -0
  107. package/src/agent/style-tools.ts +217 -0
  108. package/src/agent/tool-error.ts +12 -0
  109. package/src/agent/tools.ts +110 -23
  110. package/src/agent/usage.ts +2 -2
  111. package/src/agent/voice-tools.ts +925 -0
  112. package/src/agent/xcb-agent.ts +13 -10
  113. package/src/argv.ts +38 -0
  114. package/src/audio/analysis.ts +253 -0
  115. package/src/audio/arrange.ts +49 -5
  116. package/src/audio/audio-command.ts +218 -0
  117. package/src/audio/autotune-engine.ts +101 -0
  118. package/src/audio/autotune.ts +640 -0
  119. package/src/audio/clips.ts +240 -0
  120. package/src/audio/devices.ts +264 -0
  121. package/src/audio/doctor.ts +157 -0
  122. package/src/audio/dsp/bandbank.ts +138 -0
  123. package/src/audio/dsp/envelope.ts +10 -0
  124. package/src/audio/dsp/follow.ts +120 -0
  125. package/src/audio/dsp/formant.ts +427 -0
  126. package/src/audio/dsp/glottal.ts +243 -0
  127. package/src/audio/dsp/interp.ts +7 -2
  128. package/src/audio/dsp/lpc.ts +50 -0
  129. package/src/audio/dsp/periodicity.ts +59 -0
  130. package/src/audio/dsp/pitch.ts +995 -0
  131. package/src/audio/dsp/psola.ts +199 -0
  132. package/src/audio/effects/chain.ts +3 -1
  133. package/src/audio/effects/common.ts +43 -0
  134. package/src/audio/effects/convolution.ts +7 -4
  135. package/src/audio/effects/filter.ts +48 -69
  136. package/src/audio/effects/formant.ts +263 -0
  137. package/src/audio/engine.ts +360 -40
  138. package/src/audio/fit.ts +35 -3
  139. package/src/audio/instrument-check.ts +59 -43
  140. package/src/audio/instruments.ts +4 -0
  141. package/src/audio/keys/calibration.ts +56 -0
  142. package/src/audio/keys/electric.ts +8 -1
  143. package/src/audio/keys/engine.ts +13 -1
  144. package/src/audio/keys/piano.ts +22 -2
  145. package/src/audio/kits.ts +135 -6
  146. package/src/audio/live.ts +114 -20
  147. package/src/audio/native.ts +615 -0
  148. package/src/audio/preview.ts +30 -2
  149. package/src/audio/render-worker.ts +2 -0
  150. package/src/audio/renderer.ts +2 -0
  151. package/src/audio/resample.ts +2 -1
  152. package/src/audio/sampler.ts +85 -4
  153. package/src/audio/samples.ts +20 -2
  154. package/src/audio/sing/analysis.ts +193 -0
  155. package/src/audio/sing/engine.ts +949 -0
  156. package/src/audio/strings/bow.ts +48 -5
  157. package/src/audio/strings/engine.ts +8 -2
  158. package/src/audio/synth/oscillators.ts +31 -21
  159. package/src/audio/synth/voice.ts +34 -1
  160. package/src/audio/vocoder/bank.ts +314 -0
  161. package/src/audio/vocoder/carrier.ts +165 -0
  162. package/src/audio/vocoder/control.ts +68 -0
  163. package/src/audio/vocoder/detect.ts +50 -0
  164. package/src/audio/vocoder/index.ts +304 -0
  165. package/src/audio/vocoder/talkbox.ts +143 -0
  166. package/src/audio/wav.ts +500 -77
  167. package/src/audio/winds/engine.ts +5 -1
  168. package/src/audio/winds/trim.ts +28 -4
  169. package/src/audio/winds/trims1.ts +297 -0
  170. package/src/audio/winds/voice.ts +15 -2
  171. package/src/auth/cli.ts +38 -36
  172. package/src/auth/credentials.ts +30 -1
  173. package/src/auth/login.ts +15 -9
  174. package/src/auth/tui.ts +19 -10
  175. package/src/commands/arrange.ts +82 -32
  176. package/src/commands/autotune.ts +421 -0
  177. package/src/commands/calibration.ts +74 -0
  178. package/src/commands/clips.ts +887 -0
  179. package/src/commands/drums.ts +3 -2
  180. package/src/commands/edit.ts +11 -4
  181. package/src/commands/expression.ts +1 -1
  182. package/src/commands/formant.ts +221 -0
  183. package/src/commands/fx.ts +101 -32
  184. package/src/commands/grammar.ts +560 -0
  185. package/src/commands/help.ts +774 -366
  186. package/src/commands/history.ts +139 -10
  187. package/src/commands/keys.ts +25 -8
  188. package/src/commands/modal.ts +1 -1
  189. package/src/commands/music.ts +1 -1
  190. package/src/commands/nearest.ts +53 -0
  191. package/src/commands/pack.ts +9 -2
  192. package/src/commands/param-range.ts +56 -0
  193. package/src/commands/parses.ts +135 -0
  194. package/src/commands/progression.ts +170 -0
  195. package/src/commands/range.ts +763 -0
  196. package/src/commands/resample.ts +13 -10
  197. package/src/commands/rhythm.ts +3 -0
  198. package/src/commands/rig.ts +3 -24
  199. package/src/commands/sample.ts +11 -1
  200. package/src/commands/sing.ts +474 -0
  201. package/src/commands/strum.ts +13 -1
  202. package/src/commands/style.ts +415 -0
  203. package/src/commands/time.ts +6 -3
  204. package/src/commands/tuning.ts +3 -3
  205. package/src/commands/vocal-pitch.ts +617 -0
  206. package/src/commands/vocal.ts +147 -0
  207. package/src/commands/vocoder.ts +627 -0
  208. package/src/commands/wind.ts +2 -2
  209. package/src/fs/durable.ts +50 -0
  210. package/src/identity/actor.ts +127 -0
  211. package/src/lang/glossary.ts +511 -0
  212. package/src/launch-args.ts +267 -0
  213. package/src/main.ts +2508 -308
  214. package/src/media/cli.ts +20 -3
  215. package/src/media/import.ts +3 -1
  216. package/src/project/check.ts +21 -2
  217. package/src/project/clip-pins.ts +72 -0
  218. package/src/project/init.ts +23 -8
  219. package/src/project/sync.ts +418 -86
  220. package/src/render.ts +20 -1
  221. package/src/session/client.ts +107 -3
  222. package/src/session/clipboard.ts +79 -0
  223. package/src/session/daemon.ts +199 -5
  224. package/src/session/live-host.ts +232 -0
  225. package/src/session/meta.ts +14 -0
  226. package/src/session/origin.ts +174 -0
  227. package/src/session/port.ts +109 -14
  228. package/src/session/presence.ts +34 -4
  229. package/src/session/protocol.ts +365 -7
  230. package/src/session/rebase.ts +63 -12
  231. package/src/session/receipt.ts +265 -0
  232. package/src/session/shared-live.ts +131 -0
  233. package/src/session/store.ts +126 -45
  234. package/src/tui/arrange-menu.ts +225 -23
  235. package/src/tui/audition.ts +1 -1
  236. package/src/tui/euclid.ts +113 -27
  237. package/src/tui/fader.ts +282 -42
  238. package/src/tui/granular-menu.ts +2 -4
  239. package/src/tui/knob-fields.ts +107 -0
  240. package/src/tui/knob-map.ts +198 -0
  241. package/src/tui/menu-clips.ts +297 -0
  242. package/src/tui/menu-time.ts +20 -13
  243. package/src/tui/menu-voice.ts +405 -0
  244. package/src/tui/menu.ts +987 -179
  245. package/src/tui/modal-menu.ts +6 -6
  246. package/src/tui/performance-menu.ts +5 -2
  247. package/src/tui/play-chords.ts +4 -2
  248. package/src/tui/play-mode.ts +15 -1
  249. package/src/tui/play-session.ts +243 -28
  250. package/src/tui/sing-menu.ts +278 -0
  251. package/src/tui/style-menu.ts +104 -0
  252. package/src/tui/tape-mode.ts +580 -0
  253. package/src/tui/tape-view.ts +263 -0
  254. package/src/tui/vocoder-menu.ts +244 -0
  255. package/src/tui/wind-menu.ts +3 -3
  256. package/src/version.ts +8 -0
  257. package/src/web/fetch.ts +115 -29
  258. package/tui/activity.ts +180 -9
  259. package/tui/app.ts +491 -84
  260. package/tui/clip-row.ts +132 -0
  261. package/tui/delight.ts +193 -0
  262. package/tui/drawer.ts +233 -27
  263. package/tui/frame-gate.ts +76 -0
  264. package/tui/grammar.ts +143 -65
  265. package/tui/guide.ts +50 -5
  266. package/tui/highway.ts +309 -30
  267. package/tui/hints.ts +197 -0
  268. package/tui/hits.ts +7 -1
  269. package/tui/input.ts +60 -9
  270. package/tui/keys.ts +1 -1
  271. package/tui/knobs.ts +268 -0
  272. package/tui/play-strip.ts +80 -14
  273. package/tui/prompt.ts +1 -1
  274. package/tui/screen.ts +151 -18
  275. package/tui/tape.ts +439 -0
  276. package/tui/text.ts +35 -2
  277. package/tui/theme.ts +46 -1
package/src/tui/menu.ts CHANGED
@@ -8,19 +8,49 @@
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";
44
+ import { knobFields, type KnobField } from "./knob-fields.ts";
45
+ import { soundFamily } from "./knob-map.ts";
19
46
  import {
20
47
  openingMeterCommand,
21
48
  openingUnit,
22
49
  tempoDetail,
23
50
  tempoMenuNode,
51
+ START_BARS,
52
+ START_BEATS_PER_BAR,
53
+ START_TEMPO_BPM,
24
54
  } from "./menu-time.ts";
25
55
  import {
26
56
  AUTOMATION_PARAMETERS,
@@ -40,6 +70,8 @@ import {
40
70
  type Track,
41
71
  type TrackAutomationParameter,
42
72
  type TrackScore,
73
+ CALIBRATION_LATEST,
74
+ createScore,
43
75
  } from "../../core/score.ts";
44
76
  import {
45
77
  CORE_EFFECTS,
@@ -49,6 +81,7 @@ import {
49
81
  RIG_STAGES,
50
82
  rigPresetOf,
51
83
  SHOEGAZE_EFFECTS,
84
+ VOWEL_VALUES,
52
85
  effectPresetNames,
53
86
  effectSpec,
54
87
  type EffectName,
@@ -176,6 +209,12 @@ export type MenuContext = Readonly<{
176
209
  grids: readonly string[];
177
210
  clickOn: boolean;
178
211
  countInBars: number;
212
+ /** How much agent turns show (`/showme`); absent hides the row. */
213
+ showMe?: string;
214
+ /** Project › audio: the chosen devices and the lists to pick from. */
215
+ audio?: MenuAudioDevices;
216
+ /** The session's name, for Project › session and export file names. */
217
+ sessionName?: string;
179
218
  /** Play mode's chord settings (defaults when absent). */
180
219
  chords?: ChordSettings;
181
220
  /** Project root, for the project's own wavetables (none when absent). */
@@ -188,11 +227,25 @@ export type MenuContext = Readonly<{
188
227
  audition?: MenuAudition;
189
228
  }>;
190
229
 
230
+ /** One output and one input (src/audio/devices.ts); no routing. */
231
+ export type MenuAudioDevices = Readonly<{
232
+ /** `Speakers` or `default (Speakers)`. */
233
+ output: string;
234
+ input: string;
235
+ /** Output names; empty when the backend cannot list them. */
236
+ outputs: readonly string[];
237
+ inputs: readonly string[];
238
+ /** Why device choice is unavailable (fallback backends). */
239
+ unavailable?: string;
240
+ }>;
241
+
191
242
  /** What the menu needs from the audition controller. */
192
243
  export type MenuAudition = Readonly<{
193
244
  looping: boolean;
194
245
  /** Edits are staged (an Enter keeps them, Esc reverts them). */
195
246
  dirty: boolean;
247
+ /** How many edits are staged, for the drawer's A/B badge. */
248
+ staged?: number;
196
249
  /** The committed score, for `staged ← committed` on changed rows. */
197
250
  committed: TrackScore;
198
251
  /** The footer while auditioning. */
@@ -361,7 +414,7 @@ const TRACK_LANE_LABEL: Readonly<Record<TrackAutomationParameter, string>> = {
361
414
  resonance: "filter resonance",
362
415
  "delay-feedback": "delay feedback",
363
416
  "delay-mix": "delay mix",
364
- wt: "wavetable position",
417
+ wt: "table position",
365
418
  };
366
419
 
367
420
  const FX_LANE_INFO = new Map(FX_LANES.map((entry) => [entry.lane, entry]));
@@ -405,7 +458,9 @@ function laneLabel(lane: AutomationParameter): string {
405
458
  info.effect === "keys" ||
406
459
  info.effect === "modal" ||
407
460
  info.effect === "grain" ||
408
- info.effect === "wind"
461
+ info.effect === "wind" ||
462
+ info.effect === "sing" ||
463
+ info.effect === "vocoder"
409
464
  ? info.effect
410
465
  : effectSpec(info.effect).label;
411
466
  return `${owner} ${info.param}${unit}`;
@@ -439,9 +494,17 @@ export function rootNodes(context: MenuContext): MenuNode[] {
439
494
  id: "sound",
440
495
  label: "Sound",
441
496
  detail: `${name} · ${track?.instrument ?? "?"}`,
442
- help: "the focused track's instrument and its voice",
497
+ help: "the focused track's instrument, its controls and performance",
443
498
  build: soundSectionNodes,
444
499
  },
500
+ {
501
+ kind: "menu",
502
+ id: "voice",
503
+ label: "Voice",
504
+ detail: voiceRootDetail(context),
505
+ help: "sing, lyrics, clips, pitch, autotune, formant and vocoder",
506
+ build: voiceRootNodes,
507
+ },
445
508
  {
446
509
  kind: "menu",
447
510
  id: "effects",
@@ -454,26 +517,34 @@ export function rootNodes(context: MenuContext): MenuNode[] {
454
517
  kind: "menu",
455
518
  id: "rhythm",
456
519
  label: "Rhythm",
457
- detail: "euclid editor · patterns · kits",
458
- help: "drum hits per voice, ready-made grooves and drum kits",
520
+ detail: "euclid editor · grooves · kits",
521
+ help: "hits per drum, ready-made grooves and kits",
459
522
  build: rhythmNodes,
460
523
  },
461
524
  {
462
525
  kind: "menu",
463
526
  id: "chords",
464
- label: "Chords",
527
+ label: "Chords and key",
465
528
  detail: chordsDetail(context),
466
- help: "song key and how chords play in play mode (Ctrl-P)",
529
+ help: "the song key and tuning, and how chords play in play mode (Ctrl-P)",
467
530
  build: chordNodes,
468
531
  },
469
532
  {
470
533
  kind: "menu",
471
534
  id: "mix",
472
- label: "Mix & automation",
535
+ label: "Mix",
473
536
  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",
537
+ help: "volume, pan, mute and solo per track, automation and the master",
475
538
  build: mixSectionNodes,
476
539
  },
540
+ {
541
+ kind: "menu",
542
+ id: "arrange",
543
+ label: "Arrange",
544
+ detail: arrangeDetail(context),
545
+ help: "tracks, song sections, the form, builds, drops, fills and styles",
546
+ build: arrangeRootNodes,
547
+ },
477
548
  {
478
549
  kind: "menu",
479
550
  id: "project",
@@ -481,47 +552,94 @@ export function rootNodes(context: MenuContext): MenuNode[] {
481
552
  detail: context.score.time
482
553
  ? `${tempoDetail(context.score)} · ${context.score.bars} bars`
483
554
  : `${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",
555
+ help: "play, tempo, meter, loop, click, export, session, agent and help",
485
556
  build: transportNodes,
486
557
  },
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
558
  ];
496
559
  }
497
560
 
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
- };
561
+ /**
562
+ * Where each `/menu <id>` opens: the ten topic ids (sound voice effects
563
+ * rhythm chords mix arrange project keys agent) first, then every older id,
564
+ * as a path of menu ids from the root. `keys` opens Project › help and
565
+ * guides; the ? panel itself belongs to the window.
566
+ */
567
+ export const SECTION_ALIASES: Readonly<Record<string, readonly string[]>> =
568
+ Object.freeze(
569
+ withTopicAliases({
570
+ sound: ["sound"],
571
+ voice: ["voice"],
572
+ effects: ["effects"],
573
+ rhythm: ["rhythm"],
574
+ chords: ["chords"],
575
+ mix: ["mix"],
576
+ arrange: ["arrange"],
577
+ project: ["project"],
578
+ keys: ["project", "help"],
579
+ agent: ["project", "agent"],
580
+ // Older ids, kept as aliases.
581
+ parameters: ["sound"],
582
+ sounds: ["sound", "browse"],
583
+ instruments: ["sound", "browse"],
584
+ performance: ["sound", "performance"],
585
+ expression: ["sound", "performance"],
586
+ clips: ["voice"],
587
+ lyrics: ["voice"],
588
+ autotune: ["voice"],
589
+ sing: ["voice"],
590
+ vocoder: ["voice"],
591
+ formant: ["voice"],
592
+ grooves: ["rhythm", "patterns"],
593
+ groove: ["rhythm", "patterns"],
594
+ patterns: ["rhythm", "patterns"],
595
+ kits: ["rhythm", "kits"],
596
+ euclid: ["rhythm"],
597
+ tuning: ["chords", "tuning"],
598
+ scale: ["chords"],
599
+ key: ["chords"],
600
+ track: ["mix"],
601
+ automation: ["mix", "automation"],
602
+ master: ["mix", "master"],
603
+ tracks: ["arrange", "tracks"],
604
+ music: ["arrange"],
605
+ style: ["arrange", "style"],
606
+ styles: ["arrange", "style"],
607
+ genre: ["arrange", "style"],
608
+ sections: ["arrange", "sections"],
609
+ form: ["arrange"],
610
+ transport: ["project"],
611
+ tempo: ["project", "tempo"],
612
+ meter: ["project", "tempo"],
613
+ time: ["project", "tempo"],
614
+ export: ["project", "export"],
615
+ session: ["project", "session"],
616
+ sessions: ["project", "session"],
617
+ window: ["project", "help"],
618
+ help: ["project", "help"],
619
+ guides: ["project", "help"],
620
+ model: ["project", "agent"],
621
+ audio: ["project", "audio"],
622
+ devices: ["project", "audio"],
623
+ showme: ["project", "agent"],
624
+ models: ["project", "agent"],
625
+ fx: ["effects"],
626
+ }),
627
+ );
628
+
629
+ /**
630
+ * Every glossary topic alias (`/help drums`, `/guide mixer`) opens the same
631
+ * topic in `/menu` too: a word with no deeper path here opens its topic.
632
+ */
633
+ function withTopicAliases(
634
+ paths: Record<string, readonly string[]>,
635
+ ): Record<string, readonly string[]> {
636
+ const all = { ...paths };
637
+ for (const [word, topic] of Object.entries(TOPIC_ALIASES))
638
+ if (!all[word] && all[topic]) all[word] = all[topic]!;
639
+ return all;
640
+ }
523
641
 
524
- /** Sound: instrument and voice first, then the sound browser. */
642
+ /** Sound: the instrument and its controls first, then the instruments. */
525
643
  function soundSectionNodes(context: MenuContext): MenuNode[] {
526
644
  const track = focused(context);
527
645
  const tuning: MenuNode[] =
@@ -530,7 +648,7 @@ function soundSectionNodes(context: MenuContext): MenuNode[] {
530
648
  {
531
649
  kind: "menu",
532
650
  id: "track-tuning",
533
- label: "tuning",
651
+ label: "track tuning",
534
652
  detail: track.tuning
535
653
  ? tuningLabel(track.tuning)
536
654
  : `song · ${tuningLabel(context.score.tuning)}`,
@@ -561,7 +679,7 @@ function soundSectionNodes(context: MenuContext): MenuNode[] {
561
679
  id: "keys",
562
680
  label: "keys",
563
681
  detail: `${track.instrument}${track.keys.preset ? ` · ${track.keys.preset}` : ""}`,
564
- help: "the modelled organ: preset, drawbars, registers or stops, rotary",
682
+ help: "the modeled organ: preset, drawbars, registers or stops, rotary",
565
683
  build: (inner) => {
566
684
  const current = focused(inner);
567
685
  return current ? organNodes(current) : [];
@@ -577,7 +695,7 @@ function soundSectionNodes(context: MenuContext): MenuNode[] {
577
695
  detail: `${track.instrument}${track.keys.preset ? ` · ${track.keys.preset}` : ""}`,
578
696
  help: isElectricFamily(track.instrument)
579
697
  ? "electric keys: preset, bark, bell, tone, vibe/trem, pickup and mute"
580
- : "the modelled piano: preset, touch, hammers, dampers, stretch",
698
+ : "the modeled piano: preset, touch, hammers, dampers, stretch",
581
699
  build: (inner) => {
582
700
  const current = focused(inner);
583
701
  return current
@@ -622,9 +740,9 @@ function soundSectionNodes(context: MenuContext): MenuNode[] {
622
740
  {
623
741
  kind: "menu",
624
742
  id: "browse",
625
- label: "browse sounds",
743
+ label: "instruments",
626
744
  detail: soundsDetail(focused(context)),
627
- help: "instruments, wavetables, sample packs",
745
+ help: "every instrument: keys, strings, mallets, winds, granular, wavetables, sample packs",
628
746
  build: soundNodes,
629
747
  },
630
748
  ];
@@ -709,20 +827,262 @@ function guitarNodes(track: Track | undefined): MenuNode[] {
709
827
  }
710
828
 
711
829
  /** Rhythm: the euclid editor, then grooves and kits. */
712
- function rhythmNodes(): MenuNode[] {
830
+ function rhythmNodes(context: MenuContext): MenuNode[] {
713
831
  return [
714
832
  {
715
833
  kind: "action",
716
834
  label: "euclid editor",
717
835
  command: "/euclid",
718
- help: "hits, steps and rotation per drum voice",
836
+ help: "hits, steps and rotation per drum",
719
837
  },
720
838
  patternsMenu(),
721
839
  kitsMenu(),
840
+ gridNode(context),
722
841
  ];
723
842
  }
724
843
 
725
- /** Mix & automation: this track, every track, then automation lanes. */
844
+ /** The record and euclid grid (Rhythm and Project share this row). */
845
+ function gridNode(context: MenuContext): MenuNode {
846
+ return {
847
+ kind: "choice",
848
+ label: "grid",
849
+ help: "the step play-mode recording and the euclid editor snap to",
850
+ value: context.grid,
851
+ options: context.grids,
852
+ command: (option) => `/grid ${option}`,
853
+ };
854
+ }
855
+
856
+ /** Arrange: the tracks first, then sections, form and styles. */
857
+ function arrangeRootNodes(context: MenuContext): MenuNode[] {
858
+ const count = context.score.tracks.length;
859
+ return [
860
+ {
861
+ kind: "menu",
862
+ id: "tracks",
863
+ label: "tracks",
864
+ detail: `${count} track${count === 1 ? "" : "s"} · add, focus, move, remove`,
865
+ help: "add a track, focus one, rename, reorder or remove it",
866
+ build: tracksNodes,
867
+ },
868
+ ...arrangeNodes(context),
869
+ ];
870
+ }
871
+
872
+ /** Arrange › tracks: add, focus, rename, move and remove. */
873
+ function tracksNodes(context: MenuContext): MenuNode[] {
874
+ const score = context.score;
875
+ const track = focused(context);
876
+ const nodes: MenuNode[] = [
877
+ {
878
+ kind: "entry",
879
+ label: "add a track",
880
+ value: "",
881
+ placeholder: "name, e.g. bass or piano b",
882
+ example: "/track bass",
883
+ help: "a new track with that name (or focus it when it exists)",
884
+ command: (text) => {
885
+ const name = text.trim().replace(/\s+/g, " ");
886
+ return /^[a-z0-9._ -]{1,64}$/i.test(name)
887
+ ? `/track ${name}`
888
+ : undefined;
889
+ },
890
+ },
891
+ ...score.tracks.map((item): MenuNode => ({
892
+ kind: "action",
893
+ label: `${item.id === context.trackId ? "● " : ""}${item.name ?? item.id}`,
894
+ command: `/track ${item.id}`,
895
+ help: `focus ${item.id} · ${item.instrument}`,
896
+ })),
897
+ ];
898
+ if (track) {
899
+ nodes.push({
900
+ kind: "entry",
901
+ label: "rename",
902
+ value: track.name,
903
+ placeholder: "new name",
904
+ command: (text) =>
905
+ text.trim() ? `track name ${text.trim()}` : undefined,
906
+ example: `track name ${track.name}`,
907
+ help: "rename the focused track",
908
+ });
909
+ if (score.tracks.length > 1) {
910
+ const index = score.tracks.findIndex((item) => item.id === track.id);
911
+ nodes.push(
912
+ {
913
+ kind: "number",
914
+ label: "position",
915
+ help: "where the focused track sits in the list",
916
+ value: index + 1,
917
+ min: 1,
918
+ max: score.tracks.length,
919
+ step: linear(1, 1, score.tracks.length),
920
+ format: (value) => `${num(value)} of ${score.tracks.length}`,
921
+ command: (value) => `/track move ${track.id} ${Math.round(value)}`,
922
+ },
923
+ {
924
+ kind: "action",
925
+ label: `remove ${track.id}`,
926
+ command: `/track remove ${track.id}`,
927
+ help: "remove the focused track (^z brings it back)",
928
+ },
929
+ );
930
+ }
931
+ }
932
+ return nodes;
933
+ }
934
+
935
+ /** A file-safe stem for export names: the session's song name or `song`. */
936
+ function exportStem(context: MenuContext): string {
937
+ const stem = (context.sessionName ?? "song")
938
+ .toLowerCase()
939
+ .replace(/[^a-z0-9]+/g, "-")
940
+ .replace(/^-+|-+$/g, "")
941
+ .slice(0, 40);
942
+ return stem || "song";
943
+ }
944
+
945
+ /** Project › export: the project file and MIDI; audio renders offline. */
946
+ function exportNodes(context: MenuContext): MenuNode[] {
947
+ const stem = exportStem(context);
948
+ return [
949
+ {
950
+ kind: "entry",
951
+ label: "project file",
952
+ value: "",
953
+ placeholder: `file name, e.g. ${stem}.track.json`,
954
+ example: `/export ${stem}.track.json`,
955
+ help: "the whole song as one .track.json file (open it with /import)",
956
+ command: (text) => {
957
+ const name = text.trim() || `${stem}.track.json`;
958
+ return /^\S+$/.test(name)
959
+ ? `/export ${/\.json$/i.test(name) ? name : `${name}.track.json`}`
960
+ : undefined;
961
+ },
962
+ },
963
+ {
964
+ kind: "entry",
965
+ label: "MIDI",
966
+ value: "",
967
+ placeholder: `file name, e.g. ${stem}.mid`,
968
+ example: `/export ${stem}.mid`,
969
+ help: "notes, tempo and tracks as a standard MIDI file",
970
+ command: (text) => {
971
+ const name = text.trim() || `${stem}.mid`;
972
+ return /^\S+$/.test(name)
973
+ ? `/export ${/\.midi?$/i.test(name) ? name : `${name}.mid`}`
974
+ : undefined;
975
+ },
976
+ },
977
+ {
978
+ kind: "info",
979
+ label: "WAV and stems",
980
+ value: `dawg render ${stem}.wav`,
981
+ help: "audio renders offline in the shell: dawg render out.wav (--stems for one file per track)",
982
+ },
983
+ ];
984
+ }
985
+
986
+ /** Project › session: rename, fork and resume (the window's verbs). */
987
+ function sessionNodes(context: MenuContext): MenuNode[] {
988
+ return [
989
+ {
990
+ kind: "entry",
991
+ label: "rename",
992
+ value: context.sessionName ?? "",
993
+ placeholder: "song name (--auto names it for you)",
994
+ example: "/rename night drive",
995
+ help: "name this session; --auto hands naming back to dawg",
996
+ command: (text) => (text.trim() ? `/rename ${text.trim()}` : undefined),
997
+ },
998
+ {
999
+ kind: "action",
1000
+ label: "fork",
1001
+ command: "/fork",
1002
+ help: "a copy of this session to try something; the original stays",
1003
+ },
1004
+ {
1005
+ kind: "action",
1006
+ label: "resume",
1007
+ command: "/resume",
1008
+ help: "pick an earlier session in this folder",
1009
+ },
1010
+ {
1011
+ kind: "action",
1012
+ label: "list sessions",
1013
+ command: "/sessions",
1014
+ help: "every session in this folder, newest first",
1015
+ },
1016
+ ];
1017
+ }
1018
+
1019
+ /** Project › agent: the model, show-me and the model key. */
1020
+ function agentNodes(context: MenuContext): MenuNode[] {
1021
+ return [
1022
+ {
1023
+ kind: "action",
1024
+ label: "model",
1025
+ command: "/model",
1026
+ help: "pick the model the agent runs on",
1027
+ },
1028
+ ...(context.showMe === undefined
1029
+ ? []
1030
+ : [
1031
+ {
1032
+ kind: "choice" as const,
1033
+ label: "show me",
1034
+ help: "agent turns: its commands as ghost text, faders, keys",
1035
+ value: context.showMe,
1036
+ options: ["on", "quiet", "off"],
1037
+ command: (option: string) => `/showme ${option}`,
1038
+ },
1039
+ ]),
1040
+ {
1041
+ kind: "action",
1042
+ label: "model key",
1043
+ command: "/model key",
1044
+ help: "add a model key so typed requests reach the agent (dawg works offline without it)",
1045
+ },
1046
+ {
1047
+ kind: "action",
1048
+ label: "agent help",
1049
+ command: "/help agent",
1050
+ help: "what the agent can do and how its turns show",
1051
+ },
1052
+ ];
1053
+ }
1054
+
1055
+ /** Project › help and guides: the reference, guides and keys. */
1056
+ function helpNodes(): MenuNode[] {
1057
+ return [
1058
+ {
1059
+ kind: "action",
1060
+ label: "help",
1061
+ command: "/help",
1062
+ help: "start here: the ten topics",
1063
+ },
1064
+ {
1065
+ kind: "action",
1066
+ label: "all commands",
1067
+ command: "/help all",
1068
+ help: "the full command reference",
1069
+ },
1070
+ {
1071
+ kind: "action",
1072
+ label: "guides",
1073
+ command: "/guide",
1074
+ help: "short walkthroughs, one screen each",
1075
+ },
1076
+ {
1077
+ kind: "action",
1078
+ label: "keys",
1079
+ command: "/help keys",
1080
+ help: "every key in the window (? shows them too)",
1081
+ },
1082
+ ];
1083
+ }
1084
+
1085
+ /** Mix: this track, every track, automation lanes, then the master. */
726
1086
  function mixSectionNodes(context: MenuContext): MenuNode[] {
727
1087
  const track = focused(context);
728
1088
  const automated = AUTOMATION_PARAMETERS.filter(
@@ -730,6 +1090,14 @@ function mixSectionNodes(context: MenuContext): MenuNode[] {
730
1090
  ).join(" ");
731
1091
  return [
732
1092
  ...trackNodes(context),
1093
+ {
1094
+ kind: "menu",
1095
+ id: "mixer",
1096
+ label: "mixer",
1097
+ detail: `${context.score.tracks.length} levels on one page`,
1098
+ help: "every track's level as a fader, the drawer's all-tracks page (mix) · tab flips to this track",
1099
+ build: mixerNodes,
1100
+ },
733
1101
  {
734
1102
  kind: "menu",
735
1103
  id: "tracks",
@@ -759,7 +1127,7 @@ function mixSectionNodes(context: MenuContext): MenuNode[] {
759
1127
  ];
760
1128
  }
761
1129
 
762
- /** Mix & automation > Master: the loudness target, then each unit in order. */
1130
+ /** Mix › master: the loudness target, then each unit in order. */
763
1131
  function masterNodes(context: MenuContext): MenuNode[] {
764
1132
  const master = context.score.master;
765
1133
  // A name only when the LUFS and the limiter ceiling both match it, and no
@@ -932,7 +1300,7 @@ function chordsDetail(context: MenuContext): string {
932
1300
  return `${chords.mode} · ${key ? keyName(key) : "no key"} · ${chords.perform}`;
933
1301
  }
934
1302
 
935
- /** Play mode's chord settings and the song key (`/chords …`, `key …`). */
1303
+ /** Chords and key: the song key and tuning, then play mode's chord settings. */
936
1304
  function chordNodes(context: MenuContext): MenuNode[] {
937
1305
  const chords = context.chords ?? defaultChordSettings();
938
1306
  const key = parseKey(context.score.key ?? undefined);
@@ -965,11 +1333,20 @@ function chordNodes(context: MenuContext): MenuNode[] {
965
1333
  options: [...MODE_NAMES, ...SCALE_NAMES],
966
1334
  command: (option) => `key ${tonic} ${option}`,
967
1335
  },
1336
+ {
1337
+ kind: "menu",
1338
+ id: "tuning",
1339
+ label: "tuning",
1340
+ detail: `${tuningLabel(context.score.tuning)} · ${keyLabel(context.score.key)}`,
1341
+ help: "the song tuning: 12-TET, EDOs, just, gamelan, Scala, and the scale",
1342
+ build: songTuningNodes,
1343
+ },
968
1344
  {
969
1345
  kind: "number",
970
1346
  label: "voicing",
971
1347
  help: "inversion steps up or down (play mode: - =)",
972
1348
  value: chords.inversion,
1349
+ start: 0,
973
1350
  min: -MAX_VOICING_STEP,
974
1351
  max: MAX_VOICING_STEP,
975
1352
  step: linear(1, -MAX_VOICING_STEP, MAX_VOICING_STEP),
@@ -1056,6 +1433,7 @@ function chordNodes(context: MenuContext): MenuNode[] {
1056
1433
  label: "arp octaves",
1057
1434
  help: "how many octaves an arpeggio climbs",
1058
1435
  value: chords.octaves,
1436
+ start: 1,
1059
1437
  min: 1,
1060
1438
  max: 4,
1061
1439
  step: linear(1, 1, 4),
@@ -1072,12 +1450,22 @@ function chordNodes(context: MenuContext): MenuNode[] {
1072
1450
  },
1073
1451
  {
1074
1452
  kind: "choice",
1075
- label: "style",
1076
- help: "the style n suggests next chords in",
1453
+ label: "idiom",
1454
+ help: "the musical idiom n suggests next chords in (pop, jazz, blues…)",
1077
1455
  value: chords.style,
1078
1456
  options: PROGRESSION_STYLES,
1079
1457
  command: (option) => `/chords style ${option}`,
1080
1458
  },
1459
+ {
1460
+ kind: "entry",
1461
+ label: "progression",
1462
+ value: "",
1463
+ placeholder: "chords, e.g. i7 IV7 each 8",
1464
+ help: "sustained, voice-led block chords on this track in the song key",
1465
+ command: (text) =>
1466
+ text.trim() ? `progression ${text.trim()}` : undefined,
1467
+ example: "progression i7 IV7 each 8",
1468
+ },
1081
1469
  ];
1082
1470
  }
1083
1471
 
@@ -1130,6 +1518,24 @@ function trackNodes(context: MenuContext): MenuNode[] {
1130
1518
  ];
1131
1519
  }
1132
1520
 
1521
+ /**
1522
+ * Mix › mixer: one level fader per track (design §8.5), each a typed
1523
+ * `volume <track> <0..1>`, so the page sets any track without moving the
1524
+ * focus. Labels are numbered, so two tracks with one name stay apart.
1525
+ */
1526
+ export function mixerNodes(context: MenuContext): MenuNode[] {
1527
+ return context.score.tracks.map((track, index): MenuNode => {
1528
+ const base = volumeNode(track);
1529
+ return {
1530
+ ...(base as Extract<MenuNode, { kind: "number" }>),
1531
+ label: `${index + 1}${track.id === context.trackId ? "›" : " "}${track.name}`,
1532
+ command: (value: number) => `volume ${track.id} ${num(value)}`,
1533
+ reset: `volume ${track.id} 1`,
1534
+ help: `${track.name}'s level; 1 is unity (0 dB) · x resets`,
1535
+ };
1536
+ });
1537
+ }
1538
+
1133
1539
  function instrumentNode(track: Track): MenuNode {
1134
1540
  const options: string[] = [...AVAILABLE_INSTRUMENTS, ...KEYS_FAMILIES];
1135
1541
  if (!options.includes(track.instrument)) options.push(track.instrument);
@@ -1173,7 +1579,7 @@ function panNode(track: Track): MenuNode {
1173
1579
  : `${num(Math.abs(value))} ${value < 0 ? "L" : "R"}`,
1174
1580
  command: (value) => `pan ${num(value)}`,
1175
1581
  reset: "pan 0",
1176
- help: "-1 left … 1 right · x centres",
1582
+ help: "-1 left … 1 right · x centers",
1177
1583
  };
1178
1584
  }
1179
1585
 
@@ -1256,7 +1662,7 @@ function parameterNodes(context: MenuContext): MenuNode[] {
1256
1662
  detail: `all ${all.length} params`,
1257
1663
  help: isElectricFamily(track.instrument)
1258
1664
  ? "every electric keys parameter for this family"
1259
- : "every modelled piano parameter",
1665
+ : "every modeled piano parameter",
1260
1666
  build: (inner) => {
1261
1667
  const current = focused(inner);
1262
1668
  return current
@@ -1270,6 +1676,11 @@ function parameterNodes(context: MenuContext): MenuNode[] {
1270
1676
  } else if (track.instrument === "wind" && track.wind) {
1271
1677
  // 0.6.1 wind engine: preset and WIND_PARAMS rows.
1272
1678
  nodes.push(...windParameterNodes(track));
1679
+ } else if (track.instrument === "sing" && track.sing) {
1680
+ // 0.7 sing engine: preset, SING_PARAMS rows and a Throat sub-menu.
1681
+ nodes.push(
1682
+ ...singParameterNodes(track, parseKey(context.score.key)?.tonic),
1683
+ );
1273
1684
  } else if (!isDrumInstrument(track.instrument)) {
1274
1685
  nodes.push(...synthNodes(track, SYNTH_SIMPLE));
1275
1686
  nodes.push({
@@ -1484,7 +1895,7 @@ function sampleVoiceNodes(context: MenuContext, voice: string): MenuNode[] {
1484
1895
  // 0.6.1 layers: velocity layer (SFZ lovel/hivel) and round-robin group.
1485
1896
  {
1486
1897
  kind: "choice",
1487
- label: "Velocity layer",
1898
+ label: "velocity layer",
1488
1899
  value: ref.vel ? `${ref.vel[0]}-${ref.vel[1]}` : "off",
1489
1900
  options: velocityLayerOptions(ref.vel),
1490
1901
  command: (option) => set(`vel ${option}`),
@@ -1492,7 +1903,7 @@ function sampleVoiceNodes(context: MenuContext, voice: string): MenuNode[] {
1492
1903
  },
1493
1904
  {
1494
1905
  kind: "entry",
1495
- label: "Round robin",
1906
+ label: "round robin",
1496
1907
  value: ref.rr ?? "",
1497
1908
  placeholder: "group name, e.g. sn",
1498
1909
  command: (text) => set(`rr ${text.trim() || "off"}`),
@@ -1524,7 +1935,7 @@ function matchingSynthPreset(track: Track): string | undefined {
1524
1935
  )?.[0];
1525
1936
  }
1526
1937
 
1527
- /** Modelled piano rows: the preset, then each parameter (`keys <p> <v>`). */
1938
+ /** Modeled piano rows: the preset, then each parameter (`keys <p> <v>`). */
1528
1939
  function keysNodes(
1529
1940
  track: Track,
1530
1941
  params: readonly string[],
@@ -1702,7 +2113,7 @@ function organNodes(track: Track): MenuNode[] {
1702
2113
  ? {
1703
2114
  kind: "menu",
1704
2115
  id: "keys:drawbars",
1705
- label: "Drawbars",
2116
+ label: "drawbars",
1706
2117
  detail: text("drawbars"),
1707
2118
  help: "nine drawbars 16' to 1' (0 in, 8 full out)",
1708
2119
  build: (inner) => {
@@ -1720,7 +2131,7 @@ function organNodes(track: Track): MenuNode[] {
1720
2131
  ? {
1721
2132
  kind: "menu",
1722
2133
  id: "keys:registers",
1723
- label: "Registers",
2134
+ label: "registers",
1724
2135
  detail: text("registers"),
1725
2136
  help: "five combo-organ registers 16' to 2' (0 off, 8 full)",
1726
2137
  build: (inner) => {
@@ -1737,7 +2148,7 @@ function organNodes(track: Track): MenuNode[] {
1737
2148
  : {
1738
2149
  kind: "menu",
1739
2150
  id: "keys:stops",
1740
- label: "Stops",
2151
+ label: "stops",
1741
2152
  detail: text("stops"),
1742
2153
  help: "pipe stops and registrations",
1743
2154
  build: (inner) => {
@@ -1814,9 +2225,9 @@ function synthParamNode(track: Track, key: string, named: boolean): MenuNode {
1814
2225
 
1815
2226
  /** Plain names for the simple synth rows; the note shows the command name. */
1816
2227
  const SYNTH_LABEL: Readonly<Record<string, string>> = {
1817
- lpf: "filter cutoff",
1818
- lpq: "filter res",
1819
- lpenv: "filter env",
2228
+ lpf: "synth filter",
2229
+ lpq: "synth filter res",
2230
+ lpenv: "synth filter env",
1820
2231
  vib: "vibrato",
1821
2232
  fm: "FM amount",
1822
2233
  };
@@ -2000,7 +2411,7 @@ function wavetableNodes(track: Track, projectRoot?: string): MenuNode[] {
2000
2411
  nodes.push({
2001
2412
  kind: "info",
2002
2413
  label: "position automation",
2003
- value: `${track.wtAutomation!.length} points · Automation › wavetable position`,
2414
+ value: `${track.wtAutomation!.length} points · Automation › table position`,
2004
2415
  });
2005
2416
  return nodes;
2006
2417
  }
@@ -2015,7 +2426,7 @@ function effectNodes(context: MenuContext): MenuNode[] {
2015
2426
  return {
2016
2427
  kind: "menu",
2017
2428
  id: effect,
2018
- label: spec.label[0]!.toUpperCase() + spec.label.slice(1),
2429
+ label: spec.label,
2019
2430
  detail: values ? effectSummary(effect, values) : "off",
2020
2431
  help: spec.doc,
2021
2432
  build: (inner) => effectParamNodes(inner, effect, false),
@@ -2028,7 +2439,9 @@ function effectNodes(context: MenuContext): MenuNode[] {
2028
2439
  (effect) =>
2029
2440
  !CORE_EFFECTS.includes(effect) &&
2030
2441
  !rigStages.includes(effect) &&
2031
- !gaze.includes(effect),
2442
+ !gaze.includes(effect) &&
2443
+ // 0.7: the formant shift lives in Effects > Voice.
2444
+ effect !== "formant",
2032
2445
  );
2033
2446
  const gazeOn = SHOEGAZE_EFFECTS.filter((effect) =>
2034
2447
  effectValues(track, effect),
@@ -2036,7 +2449,7 @@ function effectNodes(context: MenuContext): MenuNode[] {
2036
2449
  const shoegaze: MenuNode = {
2037
2450
  kind: "menu",
2038
2451
  id: "shoegaze",
2039
- label: "Shoegaze",
2452
+ label: "shoegaze",
2040
2453
  help: "wobble (tremolo-arm bend), bloom (feedback), swell (volume swell), double (two takes); `rig shoegaze` loads them all",
2041
2454
  detail:
2042
2455
  gazeOn.length === 0
@@ -2050,7 +2463,7 @@ function effectNodes(context: MenuContext): MenuNode[] {
2050
2463
  const rig: MenuNode = {
2051
2464
  kind: "menu",
2052
2465
  id: "guitar rig",
2053
- label: "Guitar rig",
2466
+ label: "guitar rig",
2054
2467
  help: "stomp box, amp head with noise gate and speaker cabinet; `rig <name>` loads a whole rig",
2055
2468
  detail:
2056
2469
  rigOn.length === 0 ? "off" : (rigPresetOf(track.fx) ?? rigOn.join(" → ")),
@@ -2073,6 +2486,19 @@ function effectNodes(context: MenuContext): MenuNode[] {
2073
2486
  ...CORE_EFFECTS.map(node),
2074
2487
  rig,
2075
2488
  shoegaze,
2489
+ // Formant and vocoder live under Voice; this row is the way there.
2490
+ ...(voiceEffectRows(context).length > 0
2491
+ ? [
2492
+ {
2493
+ kind: "menu" as const,
2494
+ id: "voice",
2495
+ label: "voice effects",
2496
+ detail: "formant · vocoder · also in Voice",
2497
+ help: "formant and vocoder for voices (the same rows as Ctrl-K › Voice)",
2498
+ build: voiceEffectRows,
2499
+ },
2500
+ ]
2501
+ : []),
2076
2502
  {
2077
2503
  kind: "menu",
2078
2504
  id: "more effects",
@@ -2109,16 +2535,17 @@ function withUnit(text: string, unit: string | undefined): string {
2109
2535
 
2110
2536
  function formatParam(spec: ParamSpec, value: number): string {
2111
2537
  if (spec.kind !== "number") return num(value);
2112
- return spec.step === "log" && value >= 100
2113
- ? `${Math.round(value)}`
2114
- : num(value);
2538
+ const text =
2539
+ spec.step === "log" && value >= 100 ? `${Math.round(value)}` : num(value);
2540
+ // Rounding never leaves the range: attack 0.0001 types as 0.0001, not 0.
2541
+ return Number(text) < spec.min ? String(spec.min) : text;
2115
2542
  }
2116
2543
 
2117
2544
  /**
2118
2545
  * One effect's rows: on/off, presets, then its simple parameters; the
2119
2546
  * `advanced` submenu holds every parameter with its Strudel names.
2120
2547
  */
2121
- function effectParamNodes(
2548
+ export function effectParamNodes(
2122
2549
  context: MenuContext,
2123
2550
  effect: EffectName,
2124
2551
  advanced: boolean,
@@ -2209,6 +2636,37 @@ function effectParamNodes(
2209
2636
  });
2210
2637
  }
2211
2638
  }
2639
+ if (!advanced && effect === "vowel") {
2640
+ // 0.7 vowel morph: To picks the target (morph starts halfway), Morph
2641
+ // moves between the two; both through `/vowel`.
2642
+ const to = values?.to;
2643
+ nodes.push(
2644
+ {
2645
+ kind: "choice",
2646
+ label: "to",
2647
+ value: typeof to === "string" ? to : "—",
2648
+ options: ["—", ...VOWEL_VALUES],
2649
+ command: (option) =>
2650
+ option === "—" ? "/vowel to off" : `/vowel to ${option}`,
2651
+ help: "a second vowel to morph toward (— keeps one vowel)",
2652
+ },
2653
+ {
2654
+ kind: "number",
2655
+ label: "morph",
2656
+ value: typeof values?.morph === "number" ? values.morph : undefined,
2657
+ start: 0.5,
2658
+ off: typeof to === "string" ? "0" : "set to first",
2659
+ min: 0,
2660
+ max: 1,
2661
+ step: specStep(spec.params.morph as NumberParam),
2662
+ format: (value) => num(value),
2663
+ command: (value) =>
2664
+ typeof to === "string" ? `/vowel morph ${num(value)}` : `/vowel to o`,
2665
+ help: "0 is the vowel, 1 is the to vowel; log-frequency formant glide, automatable as vowel-morph",
2666
+ ...(typeof to === "string" ? { reset: "/vowel morph 0" } : {}),
2667
+ },
2668
+ );
2669
+ }
2212
2670
  if (!advanced) {
2213
2671
  nodes.push({
2214
2672
  kind: "menu",
@@ -2251,6 +2709,8 @@ function automationNodes(context: MenuContext): MenuNode[] {
2251
2709
  if (info?.effect === "modal") return track.modal !== undefined;
2252
2710
  if (info?.effect === "grain") return track.granular !== undefined;
2253
2711
  if (info?.effect === "wind") return track.wind !== undefined;
2712
+ if (info?.effect === "sing") return track.sing !== undefined;
2713
+ if (info?.effect === "vocoder") return track.vocoder !== undefined;
2254
2714
  return info !== undefined && effectValues(track, info.effect) !== undefined;
2255
2715
  });
2256
2716
  const hidden = AUTOMATION_PARAMETERS.filter((lane) => !shown.includes(lane));
@@ -2376,14 +2836,14 @@ function soundsDetail(track: Track | undefined): string {
2376
2836
  }
2377
2837
 
2378
2838
  /**
2379
- * The instrument browser: drum kits and soundfont instruments from the
2839
+ * The instrument browser: kits and soundfont instruments from the
2380
2840
  * built-in packs (fetched on first use), and the pack list.
2381
2841
  */
2382
2842
  function kitsMenu(): MenuNode {
2383
2843
  return {
2384
2844
  kind: "menu",
2385
2845
  id: "kits",
2386
- label: "drum kits",
2846
+ label: "kits",
2387
2847
  help: "make this a drum track with a synth or sample kit",
2388
2848
  detail: `synth ${SYNTH_KIT_NAMES.join(" ")} · samples ${Object.keys(DEFAULT_KITS).join(" ")}`,
2389
2849
  // Synth kits first (offline), then the pack sample kits.
@@ -2420,27 +2880,27 @@ function patternsMenu(): MenuNode {
2420
2880
  return {
2421
2881
  kind: "menu",
2422
2882
  id: "patterns",
2423
- label: "drum patterns",
2424
- detail: `${DRUM_PATTERNS.length} grooves`,
2883
+ label: "grooves",
2884
+ detail: `${DRUM_PATTERNS.length} ready-made drum parts`,
2425
2885
  help: "replace the drum part with a ready-made groove",
2426
2886
  build: () =>
2427
2887
  DRUM_PATTERNS.map((entry): MenuNode => ({
2428
2888
  kind: "action",
2429
2889
  label: `${entry.label} ${entry.tempo.bpm} BPM · ${entry.tags.join(", ")}`,
2430
- command: `/pattern ${entry.name}`,
2890
+ command: `/groove ${entry.name}`,
2431
2891
  })),
2432
2892
  };
2433
2893
  }
2434
2894
 
2435
2895
  const GRANULAR_PRESETS_COUNT = granularBrowseNodes().length;
2436
2896
 
2437
- function soundNodes(): MenuNode[] {
2897
+ function soundNodes(context: MenuContext): MenuNode[] {
2438
2898
  return [
2439
2899
  {
2440
2900
  kind: "menu",
2441
2901
  id: "group:keys",
2442
- label: "Keys",
2443
- help: "modelled pianos, electric keys and organs, built in (no download)",
2902
+ label: "keys",
2903
+ help: "modeled pianos, electric keys and organs, built in (no download)",
2444
2904
  detail: `${PIANO_PRESET_NAMES.join(" ")} · electric: ${ELECTRIC_PRESET_NAMES.join(" ")} · organs: ${ORGAN_PRESET_NAMES.join(" ")}`,
2445
2905
  build: () => [
2446
2906
  ...PIANO_PRESET_NAMES.map((name): MenuNode => {
@@ -2456,8 +2916,8 @@ function soundNodes(): MenuNode[] {
2456
2916
  {
2457
2917
  kind: "menu",
2458
2918
  id: "group:keys:electric",
2459
- label: "Electric",
2460
- help: "electric pianos (tine, reed) and the clavinet, modelled",
2919
+ label: "electric",
2920
+ help: "electric pianos (tine, reed) and the clavinet, modeled",
2461
2921
  detail: ELECTRIC_PRESET_NAMES.join(" "),
2462
2922
  build: () =>
2463
2923
  Object.entries(KEYS_PRESETS)
@@ -2473,7 +2933,7 @@ function soundNodes(): MenuNode[] {
2473
2933
  {
2474
2934
  kind: "menu",
2475
2935
  id: "group:organs",
2476
- label: "Organs",
2936
+ label: "organs",
2477
2937
  help: "tonewheel, combo and pipe organs on the keys engine",
2478
2938
  detail: ORGAN_PRESET_NAMES.join(" "),
2479
2939
  build: () =>
@@ -2491,7 +2951,7 @@ function soundNodes(): MenuNode[] {
2491
2951
  },
2492
2952
  {
2493
2953
  kind: "action",
2494
- label: "wavetable synth basic shapes morph · built-in",
2954
+ label: "wavetable basic shapes morph · built-in",
2495
2955
  command: "wt basic",
2496
2956
  help: "switch this track to the morphing wavetable synth",
2497
2957
  },
@@ -2501,7 +2961,7 @@ function soundNodes(): MenuNode[] {
2501
2961
  {
2502
2962
  kind: "menu",
2503
2963
  id: "strings",
2504
- label: "Strings",
2964
+ label: "strings",
2505
2965
  help: "plucked strings: guitars, basses, sitar, harpsichord, oud, koto…; Bowed: violin to contrabass, sections",
2506
2966
  detail: `${STRING_PRESET_NAMES.length - BOWED_PRESET_NAMES.length} plucked · ${BOWED_PRESET_NAMES.length} bowed · built-in`,
2507
2967
  build: () => [
@@ -2516,7 +2976,7 @@ function soundNodes(): MenuNode[] {
2516
2976
  {
2517
2977
  kind: "menu",
2518
2978
  id: "strings:bowed",
2519
- label: "Bowed",
2979
+ label: "bowed",
2520
2980
  help: "bowed strings: violin, viola, cello, bass, fiddle, erhu, sections",
2521
2981
  detail: BOWED_PRESET_NAMES.join(" "),
2522
2982
  build: () =>
@@ -2532,7 +2992,7 @@ function soundNodes(): MenuNode[] {
2532
2992
  {
2533
2993
  kind: "menu",
2534
2994
  id: "granular-browse",
2535
- label: "Granular",
2995
+ label: "granular",
2536
2996
  help: "grain clouds from a built-in synth or this track's own sound",
2537
2997
  detail: `${GRANULAR_PRESETS_COUNT} presets · built-in`,
2538
2998
  build: granularBrowseNodes,
@@ -2540,7 +3000,7 @@ function soundNodes(): MenuNode[] {
2540
3000
  {
2541
3001
  kind: "menu",
2542
3002
  id: "instruments",
2543
- label: "instruments",
3003
+ label: "all instruments",
2544
3004
  help: "sampled piano and General MIDI instruments",
2545
3005
  detail: "General MIDI soundfont, piano",
2546
3006
  build: () => [
@@ -2558,9 +3018,9 @@ function soundNodes(): MenuNode[] {
2558
3018
  },
2559
3019
  {
2560
3020
  kind: "entry",
2561
- label: "use a sound",
3021
+ label: "use a sample",
2562
3022
  value: "",
2563
- help: "type a pack sound name, e.g. dirt-samples/bd:3",
3023
+ help: "type a pack sample name, e.g. dirt-samples/bd:3",
2564
3024
  placeholder: "<pack>/<sound>[:<n>]",
2565
3025
  command: (text) => (text.trim() ? `/pack use ${text.trim()}` : undefined),
2566
3026
  example: "/pack use dirt-samples/bd:3",
@@ -2605,6 +3065,7 @@ function transportNodes(context: MenuContext): MenuNode[] {
2605
3065
  label: "tempo",
2606
3066
  help: "beats per minute",
2607
3067
  value: score.tempoBpm,
3068
+ start: START_TEMPO_BPM,
2608
3069
  min: SCORE_LIMITS.minTempoBpm,
2609
3070
  max: SCORE_LIMITS.maxTempoBpm,
2610
3071
  step: linear(1, SCORE_LIMITS.minTempoBpm, SCORE_LIMITS.maxTempoBpm),
@@ -2616,6 +3077,7 @@ function transportNodes(context: MenuContext): MenuNode[] {
2616
3077
  label: "beats per bar",
2617
3078
  help: "the meter",
2618
3079
  value: score.beatsPerBar,
3080
+ start: START_BEATS_PER_BAR,
2619
3081
  min: 1,
2620
3082
  max: SCORE_LIMITS.maxBeatsPerBar,
2621
3083
  step: linear(1, 1, SCORE_LIMITS.maxBeatsPerBar),
@@ -2628,20 +3090,14 @@ function transportNodes(context: MenuContext): MenuNode[] {
2628
3090
  label: "loop length",
2629
3091
  help: "how long the loop is",
2630
3092
  value: score.bars,
3093
+ start: START_BARS,
2631
3094
  min: 1,
2632
3095
  max: SCORE_LIMITS.maxBars,
2633
3096
  step: linear(1, 1, SCORE_LIMITS.maxBars),
2634
3097
  format: (value) => `${num(value)} bar${value === 1 ? "" : "s"}`,
2635
3098
  command: (value) => `bars ${Math.round(value)}`,
2636
3099
  },
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
- },
3100
+ gridNode(context),
2645
3101
  {
2646
3102
  kind: "toggle",
2647
3103
  label: "click",
@@ -2658,12 +3114,14 @@ function transportNodes(context: MenuContext): MenuNode[] {
2658
3114
  command: (option) => `/count-in ${option}`,
2659
3115
  },
2660
3116
  {
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,
3117
+ kind: "choice",
3118
+ label: "calibration",
3119
+ help: "sound fixes: 1 chokes hats, tunes toms, levels keys, steadies brass · 0 keeps the legacy sound",
3120
+ value: String(score.calibration ?? 0),
3121
+ options: Array.from({ length: CALIBRATION_LATEST + 1 }, (_, i) =>
3122
+ String(i),
3123
+ ),
3124
+ command: (option) => `/calibration ${option}`,
2667
3125
  },
2668
3126
  {
2669
3127
  kind: "menu",
@@ -2673,6 +3131,115 @@ function transportNodes(context: MenuContext): MenuNode[] {
2673
3131
  help: "render a track, an orbit or the mix to a pinned WAV on a new sampler or granular track",
2674
3132
  build: resampleNodes,
2675
3133
  },
3134
+ {
3135
+ kind: "menu",
3136
+ id: "export",
3137
+ label: "export",
3138
+ detail: "project file · MIDI · WAV",
3139
+ help: "save the song as a project file or MIDI; audio renders offline",
3140
+ build: exportNodes,
3141
+ },
3142
+ {
3143
+ kind: "menu",
3144
+ id: "session",
3145
+ label: "session",
3146
+ detail: context.sessionName ?? "rename · fork · resume",
3147
+ help: "rename, fork or resume a session",
3148
+ build: sessionNodes,
3149
+ },
3150
+ {
3151
+ kind: "menu",
3152
+ id: "agent",
3153
+ label: "agent",
3154
+ detail: context.showMe
3155
+ ? `show me ${context.showMe}`
3156
+ : "model · model key",
3157
+ help: "the agent's model, show-me and model key",
3158
+ build: agentNodes,
3159
+ },
3160
+ {
3161
+ kind: "menu",
3162
+ id: "audio",
3163
+ label: "audio",
3164
+ detail: context.audio
3165
+ ? `out ${context.audio.output} · in ${context.audio.input}`
3166
+ : "output · input",
3167
+ help: "pick the audio output and input (one each, saved on this machine)",
3168
+ build: audioNodes,
3169
+ },
3170
+ {
3171
+ kind: "menu",
3172
+ id: "help",
3173
+ label: "help and guides",
3174
+ detail: "help · guides · keys",
3175
+ help: "the command reference, the guides and the keys",
3176
+ build: helpNodes,
3177
+ },
3178
+ ];
3179
+ }
3180
+
3181
+ /** Project › audio: two rows, each opening a device list. */
3182
+ function audioNodes(context: MenuContext): MenuNode[] {
3183
+ const audio = context.audio;
3184
+ if (!audio || audio.unavailable)
3185
+ return [
3186
+ {
3187
+ kind: "info",
3188
+ label: "devices",
3189
+ value: "system default",
3190
+ help: audio?.unavailable ?? "device choice needs the native sink",
3191
+ },
3192
+ {
3193
+ kind: "action",
3194
+ label: "show audio",
3195
+ command: "/audio",
3196
+ help: "the current output, input and why choice is unavailable",
3197
+ },
3198
+ ];
3199
+ const list = (
3200
+ side: "out" | "in",
3201
+ names: readonly string[],
3202
+ current: string,
3203
+ ): MenuNode[] => [
3204
+ {
3205
+ kind: "action",
3206
+ label: current.startsWith("default") ? "● default" : "default",
3207
+ command: `/audio ${side} default`,
3208
+ help: "the system default device (follows the system setting)",
3209
+ },
3210
+ ...names.map((name): MenuNode => ({
3211
+ kind: "action",
3212
+ label: name === current ? `● ${name}` : name,
3213
+ command: `/audio ${side} ${name}`,
3214
+ help:
3215
+ side === "out"
3216
+ ? `play through ${name} (a soft blip confirms it)`
3217
+ : `listen on ${name} (audio test meters it)`,
3218
+ })),
3219
+ ];
3220
+ return [
3221
+ {
3222
+ kind: "menu",
3223
+ id: "output",
3224
+ label: "output",
3225
+ detail: audio.output,
3226
+ help: "where dawg plays: Enter picks, Esc goes back",
3227
+ build: () => list("out", audio.outputs, audio.output),
3228
+ },
3229
+ {
3230
+ kind: "menu",
3231
+ id: "input",
3232
+ label: "input",
3233
+ detail: audio.input,
3234
+ help: "the input to meter now and record from later",
3235
+ build: () => list("in", audio.inputs, audio.input),
3236
+ },
3237
+ {
3238
+ kind: "action",
3239
+ label: "test",
3240
+ command: "/audio test",
3241
+ help: "a short tone on the output, then a level meter for the input",
3242
+ },
2676
3243
  ];
2677
3244
  }
2678
3245
 
@@ -2720,9 +3287,9 @@ function resampleNodes(context: MenuContext): MenuNode[] {
2720
3287
  },
2721
3288
  {
2722
3289
  kind: "action",
2723
- label: "mix → sampler (pre-master)",
3290
+ label: "mix → sampler",
2724
3291
  command: "resample orbit 1",
2725
- help: "render every track on orbit 1 (the default bus) without the song master",
3292
+ help: "pre-master: render every track on orbit 1 (the default bus) without the song master",
2726
3293
  },
2727
3294
  {
2728
3295
  kind: "action",
@@ -2899,7 +3466,7 @@ function songTuningNodes(context: MenuContext): MenuNode[] {
2899
3466
  help: "the song scale: modes, minors, pentatonics, maqam, ragas, Messiaen",
2900
3467
  value: scale,
2901
3468
  options: [...MODE_NAMES, ...SCALE_NAMES],
2902
- command: (option) => `scale ${option}`,
3469
+ command: (option) => `key ${option}`,
2903
3470
  },
2904
3471
  {
2905
3472
  kind: "choice",
@@ -2907,7 +3474,7 @@ function songTuningNodes(context: MenuContext): MenuNode[] {
2907
3474
  help: "the song key's tonic; keeps the scale",
2908
3475
  value: tonic,
2909
3476
  options: TONICS,
2910
- command: (option) => `scale ${option} ${scale}`,
3477
+ command: (option) => `key ${option} ${scale}`,
2911
3478
  },
2912
3479
  {
2913
3480
  kind: "action",
@@ -2918,7 +3485,7 @@ function songTuningNodes(context: MenuContext): MenuNode[] {
2918
3485
  {
2919
3486
  kind: "action",
2920
3487
  label: "list scales",
2921
- command: "scale list",
3488
+ command: "key list",
2922
3489
  help: "every mode and scale, by family",
2923
3490
  },
2924
3491
  ];
@@ -2960,14 +3527,7 @@ type Frame = {
2960
3527
  hovering?: boolean;
2961
3528
  };
2962
3529
 
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;
3530
+ export const LABEL_WIDTH = 16;
2971
3531
 
2972
3532
  /**
2973
3533
  * A submenu frame whose rows are rebuilt from the parent's fresh build, so
@@ -2998,6 +3558,11 @@ function childFrame(
2998
3558
  export class EditMenu {
2999
3559
  private stack: Frame[] = [];
3000
3560
  private filtering = false;
3561
+ /**
3562
+ * The fader drawer's front page (design §8.6): the open level's knob page
3563
+ * and whether Tab paged to every param. Undefined: plain rows.
3564
+ */
3565
+ private knobView: { page: string; all: boolean } | undefined;
3001
3566
  /** Typed value for the selected row (`entry` holds the row's label). */
3002
3567
  private entry: { label: string; buffer: string } | undefined;
3003
3568
 
@@ -3020,10 +3585,8 @@ export class EditMenu {
3020
3585
  this.stack = [{ title: "menu", build: rootNodes, index: 0, query: "" }];
3021
3586
  this.filtering = false;
3022
3587
  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
- : []) {
3588
+ // Walk the section path (`automation` is Mix › automation).
3589
+ for (const id of section ? (menuSectionPath(context, section) ?? []) : []) {
3027
3590
  const frame = this.stack.at(-1)!;
3028
3591
  const nodes = frame.build(context);
3029
3592
  const index = nodes.findIndex(
@@ -3036,6 +3599,21 @@ export class EditMenu {
3036
3599
  }
3037
3600
  }
3038
3601
 
3602
+ /** Step into the open level's submenu `id`; false when it has none. */
3603
+ enter(context: MenuContext, id: string): boolean {
3604
+ const frame = this.stack.at(-1);
3605
+ if (!frame) return false;
3606
+ const nodes = frame.build(context);
3607
+ const index = nodes.findIndex(
3608
+ (node) => node.kind === "menu" && node.id === id,
3609
+ );
3610
+ const node = nodes[index];
3611
+ if (node?.kind !== "menu") return false;
3612
+ frame.index = index;
3613
+ this.stack.push(childFrame(frame, node));
3614
+ return true;
3615
+ }
3616
+
3039
3617
  /**
3040
3618
  * Open on the level a bare parameter command edits, for the fader drawer:
3041
3619
  * `volume` / `pan` (Mix), `fx filter` / `fx filter cutoff` (that effect,
@@ -3122,6 +3700,77 @@ export class EditMenu {
3122
3700
  this.stack = [];
3123
3701
  this.filtering = false;
3124
3702
  this.entry = undefined;
3703
+ this.knobView = undefined;
3704
+ }
3705
+
3706
+ /**
3707
+ * The knob page of the open level (design §7.3), or undefined where the
3708
+ * level has no knobs: Sound (by engine), Mix and its mixer (this track),
3709
+ * the master, Project (tempo) and one effect.
3710
+ */
3711
+ knobPageHere(context: MenuContext): string | undefined {
3712
+ const ids = this.stack.slice(1).map((frame) => frame.id ?? "");
3713
+ const [top, sub] = ids;
3714
+ const track = focused(context);
3715
+ if (top === "sound" && ids.length === 1)
3716
+ return track ? `sound:${soundFamily(track)}` : undefined;
3717
+ if (
3718
+ top === "mix" &&
3719
+ (ids.length === 1 || (ids.length === 2 && sub === "mixer"))
3720
+ )
3721
+ return "mix";
3722
+ if (top === "mix" && ids.length === 2 && sub === "master") return "master";
3723
+ if (top === "project" && ids.length === 1) return "tempo";
3724
+ const last = ids.at(-1) ?? "";
3725
+ if (top === "effects" && (EFFECT_NAMES as readonly string[]).includes(last))
3726
+ return `fx:${last}`;
3727
+ return undefined;
3728
+ }
3729
+
3730
+ /**
3731
+ * Turn the drawer's knob front page on for the open level. `label` is the
3732
+ * row the drawer opens on: when it is not one of the knobs (or the level
3733
+ * is the mixer), the drawer opens on every param instead. Returns the
3734
+ * label to focus, or undefined when the level has no knob page.
3735
+ */
3736
+ openKnobs(context: MenuContext, label?: string): string | undefined {
3737
+ const page = this.knobPageHere(context);
3738
+ this.knobView = undefined;
3739
+ if (!page) return undefined;
3740
+ const frame = this.stack.at(-1)!;
3741
+ const fallback = faderSpecs(frame.build(context));
3742
+ const knobs = knobFields(context, page, fallback);
3743
+ if (knobs.length === 0) return undefined;
3744
+ const mixer = frame.id === "mixer";
3745
+ const onKnob =
3746
+ label === undefined || knobs.some((knob) => knob.label === label);
3747
+ this.knobView = { page, all: mixer || !onKnob };
3748
+ if (this.knobView.all) return label ?? fallback[0]?.label;
3749
+ return label ?? knobs[0]!.label;
3750
+ }
3751
+
3752
+ /** Which drawer page is up: the four knobs, every param, or no knobs. */
3753
+ get knobPage(): "knobs" | "all" | undefined {
3754
+ return this.knobView ? (this.knobView.all ? "all" : "knobs") : undefined;
3755
+ }
3756
+
3757
+ /** The knob page id behind the drawer (`sound:synth`, `mix`), if any. */
3758
+ get knobPageId(): string | undefined {
3759
+ return this.knobView?.page;
3760
+ }
3761
+
3762
+ /**
3763
+ * Tab in the drawer: flip between the four knobs and every param (on the
3764
+ * mixer, between all tracks and this track). Returns the label to focus:
3765
+ * `keep` when it is on the new page, else the page's first field.
3766
+ */
3767
+ pageKnobs(context: MenuContext, keep: string): string | undefined {
3768
+ if (!this.knobView) return undefined;
3769
+ this.knobView = { ...this.knobView, all: !this.knobView.all };
3770
+ const fields = this.faderFields(context);
3771
+ return fields.some((field) => field.label === keep)
3772
+ ? keep
3773
+ : fields[0]?.label;
3125
3774
  }
3126
3775
 
3127
3776
  /** The visible rows of the current level (filtered). */
@@ -3130,9 +3779,15 @@ export class EditMenu {
3130
3779
  if (!frame) return [];
3131
3780
  const all = frame.build(context);
3132
3781
  const needle = frame.query.toLowerCase();
3133
- return needle
3134
- ? all.filter((node) => nodeText(node).toLowerCase().includes(needle))
3135
- : all;
3782
+ if (!needle) return all;
3783
+ const direct = all.filter((node) =>
3784
+ nodeText(node).toLowerCase().includes(needle),
3785
+ );
3786
+ // At the root a filter also looks two levels down for groups
3787
+ // (`/autotune`, `/formant`, `/tuning`) and offers them by path, after
3788
+ // the roots that match.
3789
+ if (this.stack.length !== 1) return direct;
3790
+ return [...direct, ...deepGroups(all, context, needle)];
3136
3791
  }
3137
3792
 
3138
3793
  private selected(context: MenuContext): MenuNode | undefined {
@@ -3160,11 +3815,7 @@ export class EditMenu {
3160
3815
  frame.index = 0;
3161
3816
  return { type: "handled" };
3162
3817
  }
3163
- if (
3164
- KEY_ENTER.has(value) ||
3165
- value === "\u001b[B" ||
3166
- value === "\u001b[A"
3167
- ) {
3818
+ if (KEY_ENTER.has(value) || value === ARROW_DOWN || value === ARROW_UP) {
3168
3819
  this.filtering = false;
3169
3820
  return this.key(value, context);
3170
3821
  }
@@ -3203,11 +3854,11 @@ export class EditMenu {
3203
3854
  frame.index = (frame.index + step + nodes.length) % nodes.length;
3204
3855
  return this.hovered(frame, context);
3205
3856
  }
3206
- if (value === "\u001b[5~" || value === "\u001b[H") {
3857
+ if (KEY_PAGE_UP.has(value) || KEY_HOME.has(value)) {
3207
3858
  frame.index = 0;
3208
3859
  return this.hovered(frame, context);
3209
3860
  }
3210
- if (value === "\u001b[6~" || value === "\u001b[F") {
3861
+ if (KEY_PAGE_DOWN.has(value) || KEY_END.has(value)) {
3211
3862
  frame.index = Math.max(0, nodes.length - 1);
3212
3863
  return this.hovered(frame, context);
3213
3864
  }
@@ -3226,11 +3877,19 @@ export class EditMenu {
3226
3877
  node.kind === "info")
3227
3878
  )
3228
3879
  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") {
3880
+ // ← → adjust a value; on any other row ← h go back and → l go in
3881
+ // (or run the action), and - + do nothing.
3882
+ const isValue = isValueNode(node);
3883
+ if (isValue && (KEY_LEFT.has(value) || KEY_RIGHT.has(value)))
3884
+ return this.nudge(node, KEY_RIGHT.has(value) ? 1 : -1);
3885
+ if (!isValue && KEY_BACK.has(value)) return this.back();
3886
+ if (!isValue && (KEY_LEFT.has(value) || KEY_RIGHT.has(value)))
3887
+ if (!(
3888
+ KEY_FORWARD.has(value) &&
3889
+ (node.kind === "menu" || node.kind === "action")
3890
+ ))
3891
+ return { type: "handled" };
3892
+ if (KEY_RESET.has(value) && node.kind === "number") {
3234
3893
  const reset =
3235
3894
  node.reset ??
3236
3895
  (node.start !== undefined && node.value !== undefined
@@ -3238,7 +3897,7 @@ export class EditMenu {
3238
3897
  : undefined);
3239
3898
  return reset ? { type: "run", command: reset } : { type: "handled" };
3240
3899
  }
3241
- if (KEY_DELETE.has(value) && node.kind === "point")
3900
+ if (KEY_RESET.has(value) && node.kind === "point")
3242
3901
  return {
3243
3902
  type: "run",
3244
3903
  command: `automate ${node.lane} remove ${num(node.beat)}`,
@@ -3250,7 +3909,7 @@ export class EditMenu {
3250
3909
  this.entry = { label: node.label, buffer: value };
3251
3910
  return { type: "handled" };
3252
3911
  }
3253
- if (KEY_ENTER.has(value) || value === " ") {
3912
+ if (KEY_ENTER.has(value) || KEY_FORWARD.has(value) || value === " ") {
3254
3913
  if (node.kind === "menu") {
3255
3914
  // Going back lands on the opened row with the filter cleared.
3256
3915
  if (frame.query) {
@@ -3326,15 +3985,16 @@ export class EditMenu {
3326
3985
  return { type: "hover", command: node.command, key: frame.hover };
3327
3986
  }
3328
3987
 
3988
+ /** ← on a row that is not a value: up one level (never past the root). */
3989
+ private back(): MenuResult {
3990
+ if (this.stack.length <= 1) return { type: "handled" };
3991
+ const left = this.stack.pop();
3992
+ if (left?.hover && left.hovering)
3993
+ return { type: "unhover", key: left.hover };
3994
+ return { type: "handled" };
3995
+ }
3996
+
3329
3997
  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
3998
  if (node.kind === "number") {
3339
3999
  const next =
3340
4000
  node.value === undefined
@@ -3367,11 +4027,6 @@ export class EditMenu {
3367
4027
  ? { type: "handled" }
3368
4028
  : { type: "run", command: node.command(next) };
3369
4029
  }
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
4030
  return { type: "handled" };
3376
4031
  }
3377
4032
 
@@ -3413,10 +4068,26 @@ export class EditMenu {
3413
4068
  * related params: every filter control, every reverb control), built
3414
4069
  * from `context` (the staged score while auditioning).
3415
4070
  */
3416
- faderFields(context: MenuContext): FaderSpec[] {
4071
+ faderFields(context: MenuContext): (FaderSpec | KnobField)[] {
3417
4072
  const frame = this.stack.at(-1);
3418
4073
  if (!frame) return [];
3419
- return faderSpecs(frame.build(context));
4074
+ const rows = faderSpecs(frame.build(context));
4075
+ if (!this.knobView) return rows;
4076
+ if (!this.knobView.all)
4077
+ return knobFields(context, this.knobView.page, rows);
4078
+ // The mixer's rows are all levels: each wears the orange knob.
4079
+ if (frame.id === "mixer")
4080
+ return rows.map((row) => ({ ...row, knob: 3 as const }));
4081
+ return rows;
4082
+ }
4083
+
4084
+ /**
4085
+ * Every param of the current level, whichever drawer page is up: the
4086
+ * side list a wide drawer shows beside the four knobs.
4087
+ */
4088
+ pageFields(context: MenuContext): FaderSpec[] {
4089
+ const frame = this.stack.at(-1);
4090
+ return frame ? faderSpecs(frame.build(context)) : [];
3420
4091
  }
3421
4092
 
3422
4093
  /** The same fields as committed, for the drawer's `staged ← committed`. */
@@ -3502,6 +4173,16 @@ export class EditMenu {
3502
4173
  }
3503
4174
  }
3504
4175
 
4176
+ /** Rows that hold a value: ← → adjust them instead of navigating. */
4177
+ function isValueNode(node: MenuNode): boolean {
4178
+ return (
4179
+ node.kind === "number" ||
4180
+ node.kind === "point" ||
4181
+ node.kind === "toggle" ||
4182
+ node.kind === "choice"
4183
+ );
4184
+ }
4185
+
3505
4186
  /** The focused row's one-line description. */
3506
4187
  function describe(node: MenuNode): string {
3507
4188
  if (node.help) return node.help;
@@ -3663,28 +4344,155 @@ function commandText(node: MenuNode): string | undefined {
3663
4344
  }
3664
4345
  }
3665
4346
 
4347
+ /** Menu groups below `nodes` (two levels) whose label matches `needle`. */
4348
+ function deepGroups(
4349
+ nodes: readonly MenuNode[],
4350
+ context: MenuContext,
4351
+ needle: string,
4352
+ ): MenuNode[] {
4353
+ const found: MenuNode[] = [];
4354
+ const visit = (list: readonly MenuNode[], path: string, depth: number) => {
4355
+ for (const node of list) {
4356
+ if (node.kind !== "menu" || found.length >= 12) continue;
4357
+ const label = path ? `${path} › ${node.label}` : node.label;
4358
+ if (path && node.label.toLowerCase().includes(needle))
4359
+ found.push({ ...node, label });
4360
+ if (depth < 2) {
4361
+ let children: MenuNode[] = [];
4362
+ try {
4363
+ children = node.build(context);
4364
+ } catch {
4365
+ continue;
4366
+ }
4367
+ visit(children, label, depth + 1);
4368
+ }
4369
+ }
4370
+ };
4371
+ visit(nodes, "", 0);
4372
+ return found;
4373
+ }
4374
+
3666
4375
  function nodeText(node: MenuNode): string {
3667
4376
  return `${node.label} ${valueText(node)} ${commandText(node) ?? ""}`;
3668
4377
  }
3669
4378
 
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;
4379
+ /** Every id `/menu <id>` opens: the ten topics first, then older ids. */
4380
+ export const MENU_SECTIONS: readonly string[] = Object.freeze(
4381
+ Object.keys(SECTION_ALIASES),
4382
+ );
4383
+
4384
+ /** The ten topic ids, the roots `/menu` names first. */
4385
+ export const MENU_TOPICS: readonly string[] = Object.freeze(
4386
+ MENU_SECTIONS.slice(0, 10),
4387
+ );
4388
+
4389
+ /** The names `/menu` and `/help menu` list: one per topic, no aliases. */
4390
+ export const MENU_SHOWN_SECTIONS: readonly string[] = MENU_TOPICS;
4391
+
4392
+ /** `/menu`'s usage line: the short form; `/help menu` lists every topic. */
4393
+ export const MENU_USAGE = "usage: /menu <topic or row> · /help menu lists them";
4394
+
4395
+ /**
4396
+ * The error for `/menu <section>` when nothing matches: the nearest topic
4397
+ * or row name first, so it fits 80 columns, then the short usage.
4398
+ */
4399
+ export function menuUsage(context: MenuContext, section: string): string {
4400
+ const names = new Set<string>(MENU_SECTIONS);
4401
+ for (const node of rootNodes(context))
4402
+ if (node.kind === "menu") {
4403
+ let children: MenuNode[] = [];
4404
+ try {
4405
+ children = node.build(context);
4406
+ } catch {
4407
+ continue;
4408
+ }
4409
+ for (const child of children)
4410
+ if (child.kind === "menu") names.add(labelName(child.label));
4411
+ }
4412
+ const near = nearest(section, names);
4413
+ return `no menu "${section}"${near ? ` · did you mean /menu ${near}?` : ""} · /menu <topic or row>`;
4414
+ }
4415
+
4416
+ /** A row label without its padded detail: `granular 12 presets` → `granular`. */
4417
+ function labelName(label: string): string {
4418
+ return label
4419
+ .split(/\s{2,}/)[0]!
4420
+ .trim()
4421
+ .toLowerCase();
4422
+ }
4423
+
4424
+ /**
4425
+ * The path of menu ids `/menu <section>` walks: a topic or older id from
4426
+ * SECTION_ALIASES, else the first menu row (three levels deep, breadth
4427
+ * first) whose id or label is `section`. Undefined when nothing matches.
4428
+ */
4429
+ export function menuSectionPath(
4430
+ context: MenuContext,
4431
+ section: string,
4432
+ ): readonly string[] | undefined {
4433
+ const needle = section.trim().toLowerCase();
4434
+ if (!needle) return undefined;
4435
+ const alias = SECTION_ALIASES[needle];
4436
+ if (alias) return alias;
4437
+ type Step = { nodes: readonly MenuNode[]; path: readonly string[] };
4438
+ let level: Step[] = [{ nodes: rootNodes(context), path: [] }];
4439
+ for (let depth = 0; depth < 3 && level.length; depth += 1) {
4440
+ const next: Step[] = [];
4441
+ for (const { nodes, path } of level) {
4442
+ for (const node of nodes) {
4443
+ if (node.kind !== "menu") continue;
4444
+ const at = [...path, node.id];
4445
+ if (node.id === needle || node.label.toLowerCase() === needle)
4446
+ return at;
4447
+ if (depth < 2) {
4448
+ try {
4449
+ next.push({ nodes: node.build(context), path: at });
4450
+ } catch {
4451
+ // A section that cannot build here has no rows to match.
4452
+ }
4453
+ }
4454
+ }
4455
+ }
4456
+ level = next;
4457
+ }
4458
+ return undefined;
4459
+ }
4460
+
4461
+ /** A one-track project, for breadcrumbs asked for without a window. */
4462
+ function breadcrumbContext(): MenuContext {
4463
+ return {
4464
+ score: createScore({
4465
+ tempoBpm: 120,
4466
+ bars: 8,
4467
+ tracks: [{ id: "keys", name: "keys", instrument: "piano" }],
4468
+ notes: [],
4469
+ }),
4470
+ trackId: "keys",
4471
+ playing: false,
4472
+ grid: "1/16",
4473
+ grids: ["1/16"],
4474
+ clickOn: false,
4475
+ countInBars: 1,
4476
+ };
4477
+ }
4478
+
4479
+ /**
4480
+ * `Ctrl-K › Chords and key › tuning`: where `/menu <id>` opens, from the
4481
+ * live labels. Undefined when `id` opens nothing.
4482
+ */
4483
+ export function menuPath(
4484
+ id: string,
4485
+ context: MenuContext = breadcrumbContext(),
4486
+ ): string | undefined {
4487
+ const path = menuSectionPath(context, id);
4488
+ if (!path) return undefined;
4489
+ const labels: string[] = [];
4490
+ let nodes: readonly MenuNode[] = rootNodes(context);
4491
+ for (const step of path) {
4492
+ const node = nodes.find((row) => row.kind === "menu" && row.id === step);
4493
+ if (node?.kind !== "menu") return undefined;
4494
+ labels.push(node.label);
4495
+ nodes = node.build(context);
4496
+ }
4497
+ return ["Ctrl-K", ...labels].join(" › ");
4498
+ }