@hraness/dawg 0.3.0 → 0.4.1

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 (81) hide show
  1. package/CHANGELOG.md +138 -0
  2. package/DAWG.md +608 -39
  3. package/README.md +4 -4
  4. package/core/chords.ts +1724 -0
  5. package/core/diff.ts +13 -1
  6. package/core/euclid.ts +670 -0
  7. package/core/fx.ts +1065 -0
  8. package/core/kits.ts +320 -0
  9. package/core/params.ts +111 -0
  10. package/core/rhythm.ts +287 -0
  11. package/core/score.ts +735 -18
  12. package/core/sdk/eval-child.ts +34 -22
  13. package/core/sdk/eval.ts +4 -1
  14. package/core/sdk/print.ts +304 -22
  15. package/core/sdk/sync-chords.ts +54 -0
  16. package/core/sdk/v1.ts +3226 -21
  17. package/core/synth.ts +1001 -0
  18. package/package.json +1 -1
  19. package/src/agent/agent.ts +38 -3
  20. package/src/agent/brief.ts +29 -4
  21. package/src/agent/chord-tools.ts +357 -0
  22. package/src/agent/drum-tools.ts +135 -0
  23. package/src/agent/models.ts +4 -4
  24. package/src/agent/pack-tools.ts +369 -0
  25. package/src/agent/planner.ts +17 -2
  26. package/src/agent/preview-tool.ts +270 -0
  27. package/src/agent/rhythm-tools.ts +145 -0
  28. package/src/agent/tools.ts +308 -14
  29. package/src/agent/xcb-agent.ts +8 -2
  30. package/src/audio/audition.ts +93 -0
  31. package/src/audio/cache.ts +160 -0
  32. package/src/audio/effects/bus.ts +147 -0
  33. package/src/audio/effects/chain.ts +109 -0
  34. package/src/audio/effects/common.ts +251 -0
  35. package/src/audio/effects/convolution.ts +339 -0
  36. package/src/audio/effects/drive.ts +142 -0
  37. package/src/audio/effects/duck.ts +122 -0
  38. package/src/audio/effects/dynamics.ts +121 -0
  39. package/src/audio/effects/filter.ts +319 -0
  40. package/src/audio/effects/modulation.ts +174 -0
  41. package/src/audio/effects/space.ts +394 -0
  42. package/src/audio/engine.ts +112 -4
  43. package/src/audio/kits.ts +200 -0
  44. package/src/audio/packs.ts +1787 -0
  45. package/src/audio/preview.ts +481 -0
  46. package/src/audio/random.ts +15 -0
  47. package/src/audio/sampler.ts +157 -29
  48. package/src/audio/samples.ts +386 -44
  49. package/src/audio/synth/oscillators.ts +268 -0
  50. package/src/audio/synth/voice.ts +555 -0
  51. package/src/audio/synth/zzfx.ts +137 -0
  52. package/src/audio/wav.ts +411 -295
  53. package/src/audio/wavetable-maker.ts +717 -0
  54. package/src/audio/wavetable.ts +624 -0
  55. package/src/commands/drums.ts +299 -0
  56. package/src/commands/edit.ts +26 -4
  57. package/src/commands/fx.ts +360 -0
  58. package/src/commands/help.ts +298 -10
  59. package/src/commands/music.ts +10 -8
  60. package/src/commands/pack.ts +423 -0
  61. package/src/commands/rhythm.ts +230 -0
  62. package/src/commands/sample.ts +162 -2
  63. package/src/commands/synth.ts +229 -0
  64. package/src/commands/wavetable.ts +370 -0
  65. package/src/main.ts +1222 -30
  66. package/src/media/cli.ts +15 -2
  67. package/src/media/tools.ts +106 -1
  68. package/src/media/wavetable.ts +202 -0
  69. package/src/render.ts +22 -1
  70. package/src/session/naming.ts +3 -1
  71. package/src/tui/audition.ts +521 -0
  72. package/src/tui/euclid.ts +516 -0
  73. package/src/tui/menu.ts +1305 -244
  74. package/src/tui/play-chords.ts +630 -0
  75. package/src/tui/play-session.ts +364 -16
  76. package/src/tui/sketch.ts +108 -0
  77. package/src/web/search.ts +4 -1
  78. package/tui/app.ts +161 -39
  79. package/tui/grammar.ts +286 -0
  80. package/tui/highway.ts +7 -1
  81. package/tui/play-strip.ts +59 -14
package/src/tui/menu.ts CHANGED
@@ -8,15 +8,84 @@
8
8
  *
9
9
  * Rendering reuses the TUI's picker overlay: `view()` returns a picker.
10
10
  */
11
+ import { commandParam, sketchFor } from "./sketch.ts";
12
+ import { HINTS } from "../../tui/grammar.ts";
13
+ import { auditionKey, isStageable, type AuditionKey } from "./audition.ts";
11
14
  import {
12
- AUTOMATION_LANES,
15
+ AUTOMATION_PARAMETERS,
16
+ automationPoints,
17
+ automationRange,
18
+ REVERB_IR_BUILTINS,
19
+ SAMPLE_UNITS,
13
20
  SCORE_LIMITS,
21
+ isTrackAutomationParameter,
22
+ WARP_MODES,
23
+ WAVETABLE_PARAMS,
24
+ isWavetableInstrument,
25
+ wavetableOf,
26
+ type WavetableParam,
14
27
  type AutomationParameter,
15
28
  type Track,
29
+ type TrackAutomationParameter,
16
30
  type TrackScore,
17
31
  } from "../../core/score.ts";
32
+ import {
33
+ CORE_EFFECTS,
34
+ EFFECT_NAMES,
35
+ FX_LANES,
36
+ effectPresetNames,
37
+ effectSpec,
38
+ type EffectName,
39
+ type FxLane,
40
+ type NumberParam,
41
+ type ParamSpec,
42
+ } from "../../core/fx.ts";
43
+ import { effectValues } from "../commands/fx.ts";
44
+ import { SAMPLE_CONTROLS, type SampleControl } from "../commands/sample.ts";
18
45
  import { AVAILABLE_INSTRUMENTS } from "../audio/wav.ts";
46
+ import {
47
+ DEFAULT_KITS,
48
+ GM_INSTRUMENTS,
49
+ PACK_CATALOG,
50
+ STRUDEL_BANK_ALIASES,
51
+ } from "../audio/packs.ts";
52
+ import { kitCatalog } from "../audio/kits.ts";
53
+ import { SYNTH_KIT_NAMES } from "../../core/kits.ts";
54
+ import { DRUM_PATTERNS } from "../../core/sdk/v1.ts";
55
+ import { isDrumInstrument } from "../../core/drums.ts";
56
+ import {
57
+ SYNTH_GROUPS,
58
+ SYNTH_PARAMS,
59
+ SYNTH_PRESETS,
60
+ SYNTH_SIMPLE,
61
+ normalizeSynth,
62
+ } from "../../core/synth.ts";
19
63
  import type { PickerItem } from "../../tui/app.ts";
64
+ import { BUILTIN_TABLES, BUILTIN_TABLE_NAMES } from "../audio/wavetable.ts";
65
+ import {
66
+ UZU_WAVETABLES,
67
+ WAVETABLE_PACK,
68
+ describeTable,
69
+ listLocalWavetables,
70
+ } from "../commands/wavetable.ts";
71
+ import {
72
+ BASS_MODES,
73
+ CHORD_PATTERNS,
74
+ MAX_VOICING_STEP,
75
+ MODE_NAMES,
76
+ PERFORM_MODES,
77
+ PROGRESSION_PRESETS,
78
+ PROGRESSION_STYLES,
79
+ SPREADS,
80
+ keyName,
81
+ parseKey,
82
+ } from "../../core/chords.ts";
83
+ import {
84
+ ARP_RATES,
85
+ CHORD_MODES,
86
+ defaultChordSettings,
87
+ type ChordSettings,
88
+ } from "./play-chords.ts";
20
89
 
21
90
  /** What the menu needs to know beyond the score. */
22
91
  export type MenuContext = Readonly<{
@@ -27,6 +96,36 @@ export type MenuContext = Readonly<{
27
96
  grids: readonly string[];
28
97
  clickOn: boolean;
29
98
  countInBars: number;
99
+ /** Play mode's chord settings (defaults when absent). */
100
+ chords?: ChordSettings;
101
+ /** Project root, for the project's own wavetables (none when absent). */
102
+ projectRoot?: string;
103
+ /**
104
+ * The audition loop (src/tui/audition.ts), when the window hosts one.
105
+ * `score` above is then the staged score; changed rows show the
106
+ * committed value beside the staged one.
107
+ */
108
+ audition?: MenuAudition;
109
+ }>;
110
+
111
+ /** What the menu needs from the audition controller. */
112
+ export type MenuAudition = Readonly<{
113
+ looping: boolean;
114
+ /** Edits are staged (an Enter keeps them, Esc reverts them). */
115
+ dirty: boolean;
116
+ /** The committed score, for `staged ← committed` on changed rows. */
117
+ committed: TrackScore;
118
+ /** The footer while auditioning. */
119
+ hint: string;
120
+ /** `♪ solo · B staged 2 · 42 ms`, shown in the title. */
121
+ status?: string | undefined;
122
+ /**
123
+ * Which commands stage while auditioning (default `isStageable`); the
124
+ * chord settings screen also stages `/chords …` and `key …`.
125
+ */
126
+ stageable?: (command: string) => boolean;
127
+ /** The committed chord settings, for `staged ← committed` on Chords rows. */
128
+ committedChords?: ChordSettings;
30
129
  }>;
31
130
 
32
131
  type NumberField = Readonly<{
@@ -44,9 +143,11 @@ type NumberField = Readonly<{
44
143
  off?: string;
45
144
  /** Starting value for a nudge while off. */
46
145
  start?: number;
146
+ /** The command `x` runs to put the value back to its default. */
147
+ reset?: string;
47
148
  }>;
48
149
 
49
- export type MenuNode =
150
+ export type MenuNode = (
50
151
  | Readonly<{
51
152
  kind: "menu";
52
153
  id: string;
@@ -87,13 +188,33 @@ export type MenuNode =
87
188
  beat: number;
88
189
  value: number;
89
190
  }>
90
- | Readonly<{ kind: "info"; label: string; value: string }>;
191
+ | Readonly<{ kind: "info"; label: string; value: string }>
192
+ ) &
193
+ Readonly<{
194
+ /** One line on what the focused row does, shown under the list. */
195
+ help?: string;
196
+ }>;
91
197
 
92
198
  /** What a key did; `run` carries the command to execute. */
93
199
  export type MenuResult =
94
200
  | { type: "handled" }
95
201
  | { type: "close" }
96
202
  | { type: "run"; command: string }
203
+ /** Space (loop), `a` (A/B) or `c` (context) for the audition loop. */
204
+ | { type: "audition"; key: AuditionKey }
205
+ /** Commit the staged edits as one operation. */
206
+ | { type: "keep" }
207
+ /** Drop the staged edits. */
208
+ | { type: "revert" }
209
+ /**
210
+ * The cursor moved in a list while the loop plays: audition `command`
211
+ * in place of the previous hover of `key` (one per list).
212
+ */
213
+ | { type: "hover"; command: string; key: string }
214
+ /** Left a list without choosing: drop its hover (`key`). */
215
+ | { type: "unhover"; key: string }
216
+ /** Chose a list item while the loop plays: stage it for good. */
217
+ | { type: "choose"; command: string; key: string }
97
218
  | { type: "pass" };
98
219
 
99
220
  export type MenuView = Readonly<{
@@ -101,6 +222,8 @@ export type MenuView = Readonly<{
101
222
  items: PickerItem[];
102
223
  index: number;
103
224
  hint: string;
225
+ /** What the focused row does, and the prompt command it stands for. */
226
+ note: string;
104
227
  }>;
105
228
 
106
229
  // ── formatting and steps ──────────────────────────────────────────────
@@ -137,8 +260,8 @@ function clamp(value: number, min: number, max: number): number {
137
260
  return Math.min(max, Math.max(min, value));
138
261
  }
139
262
 
140
- const LANE_STEP: Readonly<
141
- Record<AutomationParameter, (value: number, direction: 1 | -1) => number>
263
+ const TRACK_LANE_STEP: Readonly<
264
+ Record<TrackAutomationParameter, (value: number, direction: 1 | -1) => number>
142
265
  > = {
143
266
  volume: linear(0.05, 0, SCORE_LIMITS.maxVolume),
144
267
  pan: linear(0.1, -1, 1),
@@ -146,20 +269,65 @@ const LANE_STEP: Readonly<
146
269
  resonance: linear(0.05, 0, SCORE_LIMITS.maxFilterResonance),
147
270
  "delay-feedback": linear(0.05, 0, SCORE_LIMITS.maxDelayFeedback),
148
271
  "delay-mix": linear(0.05, 0, SCORE_LIMITS.maxDelayMix),
272
+ wt: linear(0.05, 0, 1),
149
273
  };
150
274
 
151
- const LANE_LABEL: Readonly<Record<AutomationParameter, string>> = {
275
+ const TRACK_LANE_LABEL: Readonly<Record<TrackAutomationParameter, string>> = {
152
276
  volume: "volume",
153
277
  pan: "pan",
154
278
  filter: "filter cutoff (Hz)",
155
279
  resonance: "filter resonance",
156
280
  "delay-feedback": "delay feedback",
157
281
  "delay-mix": "delay mix",
282
+ wt: "wavetable position",
158
283
  };
159
284
 
285
+ const FX_LANE_INFO = new Map(FX_LANES.map((entry) => [entry.lane, entry]));
286
+
287
+ /** Nudge for a number spec: linear by its step, or a sixth of an octave. */
288
+ function specStep(
289
+ spec: NumberParam,
290
+ ): (value: number, direction: 1 | -1) => number {
291
+ if (spec.step === "log")
292
+ return (value, direction) => {
293
+ const next = Math.max(value, 1) * 2 ** (direction / 6);
294
+ return clamp(
295
+ next >= 100 ? Math.round(next) : Math.round(next * 10) / 10,
296
+ spec.min,
297
+ spec.max,
298
+ );
299
+ };
300
+ const size = spec.step;
301
+ return (value, direction) => {
302
+ const next = Math.round((value + size * direction) / size) * size;
303
+ return clamp(Number(next.toFixed(6)), spec.min, spec.max);
304
+ };
305
+ }
306
+
307
+ function laneStep(
308
+ lane: AutomationParameter,
309
+ ): (value: number, direction: 1 | -1) => number {
310
+ if (isTrackAutomationParameter(lane)) return TRACK_LANE_STEP[lane];
311
+ const info = FX_LANE_INFO.get(lane);
312
+ return info ? specStep(info.spec) : linear(0.05, 0, 1);
313
+ }
314
+
315
+ function laneLabel(lane: AutomationParameter): string {
316
+ if (isTrackAutomationParameter(lane)) return TRACK_LANE_LABEL[lane];
317
+ const info = FX_LANE_INFO.get(lane);
318
+ if (!info) return lane;
319
+ const unit = info.spec.unit ? ` (${info.spec.unit})` : "";
320
+ const owner =
321
+ info.effect === "synth" ? "synth" : effectSpec(info.effect).label;
322
+ return `${owner} ${info.param}${unit}`;
323
+ }
324
+
160
325
  /** `volume 0.8`, `pan -0.3`: the command that sets a lane's static value. */
161
326
  function laneFormat(lane: AutomationParameter, value: number): string {
162
- return lane === "filter" ? `${Math.round(value)}` : num(value);
327
+ if (lane === "filter") return `${Math.round(value)}`;
328
+ const info = FX_LANE_INFO.get(lane as FxLane);
329
+ if (info?.spec.step === "log" && value >= 100) return `${Math.round(value)}`;
330
+ return num(value);
163
331
  }
164
332
 
165
333
  // ── the tree ──────────────────────────────────────────────────────────
@@ -171,60 +339,275 @@ function focused(context: MenuContext): Track | undefined {
171
339
  export function rootNodes(context: MenuContext): MenuNode[] {
172
340
  const track = focused(context);
173
341
  const name = track?.name ?? context.trackId;
174
- const effects = [
175
- track?.filter ? "filter" : undefined,
176
- track?.delay ? "delay" : undefined,
177
- track?.reverb ? "reverb" : undefined,
178
- ].filter(Boolean);
179
- const automated = (Object.keys(AUTOMATION_LANES) as AutomationParameter[])
180
- .filter((lane) => (track?.[AUTOMATION_LANES[lane].field]?.length ?? 0) > 0)
181
- .join(" ");
342
+ const effects = EFFECT_NAMES.filter((effect) => effectValues(track, effect));
343
+ const automated = AUTOMATION_PARAMETERS.filter(
344
+ (lane) => automationPoints(track, lane).length > 0,
345
+ ).length;
346
+ const tracks = context.score.tracks.length;
182
347
  return [
183
348
  {
184
349
  kind: "menu",
185
- id: "track",
186
- label: "Track",
350
+ id: "sound",
351
+ label: "Sound",
187
352
  detail: `${name} · ${track?.instrument ?? "?"}`,
188
- build: trackNodes,
189
- },
190
- {
191
- kind: "menu",
192
- id: "parameters",
193
- label: "Parameters",
194
- detail: `${track?.instrument ?? "?"}`,
195
- build: parameterNodes,
353
+ help: "the focused track's instrument and its voice",
354
+ build: soundSectionNodes,
196
355
  },
197
356
  {
198
357
  kind: "menu",
199
358
  id: "effects",
200
359
  label: "Effects",
201
- detail: effects.length ? effects.join(" · ") : "none",
360
+ detail: effects.length ? effects.join(" · ") : "none on",
361
+ help: "filter, delay, reverb and more on the focused track",
202
362
  build: effectNodes,
203
363
  },
204
364
  {
205
365
  kind: "menu",
206
- id: "automation",
207
- label: "Automation",
208
- detail: automated || "no lanes",
209
- build: automationNodes,
366
+ id: "rhythm",
367
+ label: "Rhythm",
368
+ detail: "euclid editor · patterns · kits",
369
+ help: "drum hits per voice, ready-made grooves and drum kits",
370
+ build: rhythmNodes,
371
+ },
372
+ {
373
+ kind: "menu",
374
+ id: "chords",
375
+ label: "Chords",
376
+ detail: chordsDetail(context),
377
+ help: "song key and how chords play in play mode (Ctrl-P)",
378
+ build: chordNodes,
210
379
  },
211
380
  {
212
381
  kind: "menu",
213
382
  id: "mix",
214
- label: "Mix",
215
- detail: `${context.score.tracks.length} track${context.score.tracks.length === 1 ? "" : "s"}`,
216
- build: mixNodes,
383
+ label: "Mix & automation",
384
+ detail: `${tracks} track${tracks === 1 ? "" : "s"} · ${automated} lane${automated === 1 ? "" : "s"} moving`,
385
+ help: "volume, pan, mute and solo per track; values that change over time",
386
+ build: mixSectionNodes,
217
387
  },
218
388
  {
219
389
  kind: "menu",
220
- id: "transport",
221
- label: "Transport",
222
- detail: `${num(context.score.tempoBpm)} BPM · ${context.score.beatsPerBar}/4 · ${context.score.bars} bars · grid ${context.grid}`,
390
+ id: "project",
391
+ label: "Project",
392
+ detail: `${num(context.score.tempoBpm)} BPM · ${context.score.beatsPerBar}/4 · ${context.score.bars} bars`,
393
+ help: "tempo, meter, loop length, play, click and count-in",
223
394
  build: transportNodes,
224
395
  },
225
396
  ];
226
397
  }
227
398
 
399
+ /** Old section names still open the menu where that content now lives. */
400
+ const SECTION_ALIASES: Readonly<Record<string, readonly string[]>> = {
401
+ sound: ["sound"],
402
+ parameters: ["sound"],
403
+ sounds: ["sound", "browse"],
404
+ effects: ["effects"],
405
+ rhythm: ["rhythm"],
406
+ chords: ["chords"],
407
+ mix: ["mix"],
408
+ track: ["mix"],
409
+ automation: ["mix", "automation"],
410
+ project: ["project"],
411
+ transport: ["project"],
412
+ };
413
+
414
+ /** Sound: instrument and voice first, then the sound browser. */
415
+ function soundSectionNodes(context: MenuContext): MenuNode[] {
416
+ return [
417
+ ...parameterNodes(context),
418
+ {
419
+ kind: "menu",
420
+ id: "browse",
421
+ label: "browse sounds",
422
+ detail: soundsDetail(focused(context)),
423
+ help: "instruments, wavetables, sample packs",
424
+ build: soundNodes,
425
+ },
426
+ ];
427
+ }
428
+
429
+ /** Rhythm: the euclid editor, then grooves and kits. */
430
+ function rhythmNodes(): MenuNode[] {
431
+ return [
432
+ {
433
+ kind: "action",
434
+ label: "euclid editor",
435
+ command: "/euclid",
436
+ help: "hits, steps and rotation per drum voice",
437
+ },
438
+ patternsMenu(),
439
+ kitsMenu(),
440
+ ];
441
+ }
442
+
443
+ /** Mix & automation: this track, every track, then automation lanes. */
444
+ function mixSectionNodes(context: MenuContext): MenuNode[] {
445
+ const track = focused(context);
446
+ const automated = AUTOMATION_PARAMETERS.filter(
447
+ (lane) => automationPoints(track, lane).length > 0,
448
+ ).join(" ");
449
+ return [
450
+ ...trackNodes(context),
451
+ {
452
+ kind: "menu",
453
+ id: "tracks",
454
+ label: "all tracks",
455
+ detail: `${context.score.tracks.length} · focus another track`,
456
+ help: "pick a track to edit; ● is the focused one",
457
+ build: mixNodes,
458
+ },
459
+ {
460
+ kind: "menu",
461
+ id: "automation",
462
+ label: "automation",
463
+ detail: automated || "no lanes yet",
464
+ help: "points that move a value over the loop",
465
+ build: automationNodes,
466
+ },
467
+ ];
468
+ }
469
+
470
+ function chordsDetail(context: MenuContext): string {
471
+ const chords = context.chords ?? defaultChordSettings();
472
+ const key = parseKey(context.score.key ?? undefined);
473
+ return `${chords.mode} · ${key ? keyName(key) : "no key"} · ${chords.perform}`;
474
+ }
475
+
476
+ /** Play mode's chord settings and the song key (`/chords …`, `key …`). */
477
+ function chordNodes(context: MenuContext): MenuNode[] {
478
+ const chords = context.chords ?? defaultChordSettings();
479
+ const key = parseKey(context.score.key ?? undefined);
480
+ const tonic = key ? keyName(key).split(" ")[0]! : "C";
481
+ const mode = key?.mode ?? "major";
482
+ const presets = PROGRESSION_PRESETS.map((preset) => preset.name);
483
+ return [
484
+ {
485
+ kind: "choice",
486
+ label: "mode",
487
+ help: "auto: keys play chords in the song key · manual: latch 1–8 · off",
488
+ value: chords.mode,
489
+ options: CHORD_MODES,
490
+ command: (option) => `/chords ${option}`,
491
+ },
492
+ {
493
+ kind: "choice",
494
+ label: "key tonic",
495
+ help: "the song key; auto chord mode follows it",
496
+ value: tonic,
497
+ options: TONICS,
498
+ command: (option) => `key ${option} ${mode}`,
499
+ },
500
+ {
501
+ kind: "choice",
502
+ label: "key mode",
503
+ help: "major, minor or a church mode",
504
+ value: mode,
505
+ options: MODE_NAMES,
506
+ command: (option) => `key ${tonic} ${option}`,
507
+ },
508
+ {
509
+ kind: "number",
510
+ label: "voicing",
511
+ help: "inversion steps up or down (play mode: - =)",
512
+ value: chords.inversion,
513
+ min: -MAX_VOICING_STEP,
514
+ max: MAX_VOICING_STEP,
515
+ step: linear(1, -MAX_VOICING_STEP, MAX_VOICING_STEP),
516
+ format: (value) => (value > 0 ? `+${value}` : String(value)),
517
+ command: (value) => `/chords voicing ${Math.round(value)}`,
518
+ },
519
+ {
520
+ kind: "choice",
521
+ label: "spread",
522
+ help: "close, open or wide chord voicing",
523
+ value: chords.spread,
524
+ options: SPREADS,
525
+ command: (option) => `/chords spread ${option}`,
526
+ },
527
+ {
528
+ kind: "choice",
529
+ label: "bass",
530
+ help: "what the left hand plays under the chord (play mode: b)",
531
+ value: chords.bass,
532
+ options: BASS_MODES,
533
+ command: (option) => `/chords bass ${option}`,
534
+ },
535
+ {
536
+ kind: "toggle",
537
+ label: "sevenths",
538
+ help: "auto mode plays sevenths instead of triads",
539
+ value: chords.sevenths,
540
+ command: (on) => `/chords sevenths ${on ? "on" : "off"}`,
541
+ },
542
+ {
543
+ kind: "choice",
544
+ label: "perform",
545
+ help: "block, strum, arpeggio or a rhythm pattern (play mode: 9)",
546
+ value: chords.perform,
547
+ options: PERFORM_MODES,
548
+ command: (option) => `/chords perform ${option}`,
549
+ },
550
+ {
551
+ kind: "choice",
552
+ label: "pattern",
553
+ help: "the rhythm used when perform is pattern",
554
+ value: chords.pattern,
555
+ options: CHORD_PATTERNS.map((pattern) => pattern.name),
556
+ command: (option) => `/chords pattern ${option}`,
557
+ },
558
+ {
559
+ kind: "choice",
560
+ label: "arp rate",
561
+ help: "arpeggio step length",
562
+ value: chords.rate,
563
+ options: ARP_RATES,
564
+ command: (option) => `/chords rate ${option}`,
565
+ },
566
+ {
567
+ kind: "number",
568
+ label: "arp octaves",
569
+ help: "how many octaves an arpeggio climbs",
570
+ value: chords.octaves,
571
+ min: 1,
572
+ max: 4,
573
+ step: linear(1, 1, 4),
574
+ format: (value) => `${num(value)} oct`,
575
+ command: (value) => `/chords octaves ${Math.round(value)}`,
576
+ },
577
+ {
578
+ kind: "choice",
579
+ label: "progression",
580
+ help: "a preset the n key walks through",
581
+ value: chords.preset,
582
+ options: ["none", ...presets],
583
+ command: (option) => `/chords preset ${option}`,
584
+ },
585
+ {
586
+ kind: "choice",
587
+ label: "style",
588
+ help: "the style n suggests next chords in",
589
+ value: chords.style,
590
+ options: PROGRESSION_STYLES,
591
+ command: (option) => `/chords style ${option}`,
592
+ },
593
+ ];
594
+ }
595
+
596
+ const TONICS = [
597
+ "C",
598
+ "Db",
599
+ "D",
600
+ "Eb",
601
+ "E",
602
+ "F",
603
+ "F#",
604
+ "G",
605
+ "Ab",
606
+ "A",
607
+ "Bb",
608
+ "B",
609
+ ] as const;
610
+
228
611
  function trackNodes(context: MenuContext): MenuNode[] {
229
612
  const track = focused(context);
230
613
  if (!track)
@@ -238,19 +621,21 @@ function trackNodes(context: MenuContext): MenuNode[] {
238
621
  command: (text) =>
239
622
  text.trim() ? `track name ${text.trim()}` : undefined,
240
623
  example: `track name ${track.name}`,
624
+ help: "rename the focused track",
241
625
  },
242
- instrumentNode(track),
243
626
  {
244
627
  kind: "toggle",
245
628
  label: "mute",
246
629
  value: track.muted,
247
630
  command: (on) => (on ? "mute" : "unmute"),
631
+ help: "silence this track",
248
632
  },
249
633
  {
250
634
  kind: "toggle",
251
635
  label: "solo",
252
636
  value: track.solo === true,
253
637
  command: (on) => (on ? "solo" : "unsolo"),
638
+ help: "hear only soloed tracks",
254
639
  },
255
640
  volumeNode(track),
256
641
  panNode(track),
@@ -266,6 +651,7 @@ function instrumentNode(track: Track): MenuNode {
266
651
  value: track.instrument,
267
652
  options,
268
653
  command: (option) => `instrument ${option}`,
654
+ help: "the sound source; drums use kit",
269
655
  };
270
656
  }
271
657
 
@@ -276,9 +662,12 @@ function volumeNode(track: Track): MenuNode {
276
662
  value: track.volume,
277
663
  min: 0,
278
664
  max: SCORE_LIMITS.maxVolume,
279
- step: LANE_STEP.volume,
280
- format: num,
665
+ step: TRACK_LANE_STEP.volume,
666
+ format: (value) =>
667
+ `${num(value)} · ${value > 0 ? `${(20 * Math.log10(value)).toFixed(1)} dB` : "silent"}`,
281
668
  command: (value) => `volume ${num(value)}`,
669
+ reset: "volume 1",
670
+ help: "track gain; 1 is unity (0 dB) · x resets",
282
671
  };
283
672
  }
284
673
 
@@ -289,9 +678,14 @@ function panNode(track: Track): MenuNode {
289
678
  value: track.pan,
290
679
  min: -1,
291
680
  max: 1,
292
- step: LANE_STEP.pan,
293
- format: num,
681
+ step: TRACK_LANE_STEP.pan,
682
+ format: (value) =>
683
+ value === 0
684
+ ? "center"
685
+ : `${num(Math.abs(value))} ${value < 0 ? "L" : "R"}`,
294
686
  command: (value) => `pan ${num(value)}`,
687
+ reset: "pan 0",
688
+ help: "-1 left … 1 right · x centres",
295
689
  };
296
690
  }
297
691
 
@@ -299,6 +693,9 @@ function parameterNodes(context: MenuContext): MenuNode[] {
299
693
  const track = focused(context);
300
694
  if (!track) return [];
301
695
  const nodes: MenuNode[] = [instrumentNode(track)];
696
+ // A wavetable track also has the synth voice's envelope, filters and FM.
697
+ if (isWavetableInstrument(track.instrument))
698
+ nodes.push(...wavetableNodes(track, context.projectRoot));
302
699
  if (track.sampler) {
303
700
  nodes.push({
304
701
  kind: "info",
@@ -312,9 +709,12 @@ function parameterNodes(context: MenuContext): MenuNode[] {
312
709
  ref.speed !== undefined ? `speed ${num(ref.speed)}` : "",
313
710
  ].filter(Boolean);
314
711
  nodes.push({
315
- kind: "info",
712
+ kind: "menu",
713
+ id: `sample:${voice}`,
316
714
  label: voice,
317
- value: [ref.src.split("/").at(-1), ...extras].join(" · "),
715
+ detail: [ref.src.split("/").at(-1), ...extras].join(" · "),
716
+ help: "this voice's sample controls (Strudel names)",
717
+ build: (context) => sampleVoiceNodes(context, voice),
318
718
  });
319
719
  }
320
720
  nodes.push({
@@ -322,188 +722,546 @@ function parameterNodes(context: MenuContext): MenuNode[] {
322
722
  label: "add a voice",
323
723
  value: "/sample <path> [as <voice>]",
324
724
  });
325
- } else {
725
+ } else if (!isDrumInstrument(track.instrument)) {
726
+ nodes.push(...synthNodes(track, SYNTH_SIMPLE));
326
727
  nodes.push({
327
- kind: "info",
328
- label: "voice",
329
- value:
330
- "the synth has no knobs beyond the instrument; shape it with Effects",
728
+ kind: "menu",
729
+ id: "synth:advanced",
730
+ label: "advanced",
731
+ detail: `all ${Object.keys(SYNTH_PARAMS).length} params`,
732
+ help: "every voice parameter, grouped, under its Strudel name",
733
+ build: synthAdvancedNodes,
331
734
  });
735
+ if (track.synth)
736
+ nodes.push({
737
+ kind: "action",
738
+ label: "reset to defaults",
739
+ command: "synth reset",
740
+ help: "clear every voice setting on this track",
741
+ });
332
742
  }
333
743
  return nodes;
334
744
  }
335
745
 
336
- function effectNodes(context: MenuContext): MenuNode[] {
337
- const track = focused(context);
338
- if (!track) return [];
339
- const filter = track.filter;
340
- const delay = track.delay;
341
- const reverb = track.reverb;
342
- return [
746
+ /** Number rows for `/sample set`: [control, min, max, step, default]. */
747
+ const SAMPLE_NUMBER_ROWS: readonly (readonly [
748
+ SampleControl,
749
+ number,
750
+ number,
751
+ number,
752
+ number,
753
+ ])[] = [
754
+ ["begin", 0, 0.99, 0.01, 0],
755
+ ["end", 0.01, 1, 0.01, 1],
756
+ ["gain", 0, SCORE_LIMITS.maxSampleGain, 0.05, 1],
757
+ ["speed", -SCORE_LIMITS.maxSampleSpeed, SCORE_LIMITS.maxSampleSpeed, 0.05, 1],
758
+ ["loopBegin", 0, 0.99, 0.01, 0],
759
+ ["loopEnd", 0.01, 1, 0.01, 1],
760
+ ["clip", 0.05, SCORE_LIMITS.maxSampleClip, 0.05, 1],
761
+ [
762
+ "accelerate",
763
+ -SCORE_LIMITS.maxSampleAccelerate,
764
+ SCORE_LIMITS.maxSampleAccelerate,
765
+ 0.1,
766
+ 0,
767
+ ],
768
+ ["squiz", 1, SCORE_LIMITS.maxSampleSquiz, 0.5, 1],
769
+ ];
770
+
771
+ /** One sampler voice: every sample control, run through `/sample set`. */
772
+ function sampleVoiceNodes(context: MenuContext, voice: string): MenuNode[] {
773
+ const track = context.score.tracks.find(
774
+ (item) => item.id === context.trackId,
775
+ );
776
+ const ref = track?.sampler?.voices[voice];
777
+ if (!ref) return [];
778
+ const set = (rest: string) => `/sample set ${voice} ${rest}`;
779
+ const nodes: MenuNode[] = [{ kind: "info", label: "file", value: ref.src }];
780
+ for (const [control, min, max, step, start] of SAMPLE_NUMBER_ROWS) {
781
+ const current = (ref as Record<string, unknown>)[control];
782
+ nodes.push({
783
+ kind: "number",
784
+ label: control,
785
+ value: typeof current === "number" ? current : undefined,
786
+ start,
787
+ off: num(start),
788
+ min,
789
+ max,
790
+ step:
791
+ control === "speed"
792
+ ? (value, direction) => {
793
+ // Speed 0 is invalid: step over it.
794
+ const next = linear(step, min, max)(value, direction);
795
+ return next === 0 ? step * direction : next;
796
+ }
797
+ : linear(step, min, max),
798
+ format: num,
799
+ command: (value) => set(`${control} ${num(value)}`),
800
+ reset: set(`${control} off`),
801
+ help: SAMPLE_CONTROLS[control],
802
+ });
803
+ }
804
+ nodes.push(
343
805
  {
344
- kind: "menu",
345
- id: "filter",
346
- label: "Filter",
347
- detail: filter
348
- ? `${Math.round(filter.cutoff)} Hz · res ${num(filter.resonance)}`
349
- : "off",
350
- build: () => [
351
- {
352
- kind: "toggle",
353
- label: "on",
354
- value: filter !== undefined,
355
- command: (on) => (on ? "filter 2000 0.2" : "filter off"),
356
- },
357
- {
358
- kind: "number",
359
- label: "cutoff Hz",
360
- value: filter?.cutoff,
361
- start: 2000,
362
- off: "off",
363
- min: SCORE_LIMITS.minFilterCutoff,
364
- max: SCORE_LIMITS.maxFilterCutoff,
365
- step: cutoffStep,
366
- format: (value) => `${Math.round(value)}`,
367
- command: (value) =>
368
- `filter ${Math.round(value)} ${num(filter?.resonance ?? 0)}`,
369
- },
370
- {
371
- kind: "number",
372
- label: "resonance",
373
- value: filter?.resonance,
374
- start: 0.2,
375
- off: "off",
376
- min: 0,
377
- max: SCORE_LIMITS.maxFilterResonance,
378
- step: LANE_STEP.resonance,
379
- format: num,
380
- command: (value) =>
381
- `filter ${Math.round(filter?.cutoff ?? 2000)} ${num(value)}`,
382
- },
383
- ],
806
+ kind: "choice",
807
+ label: "unit",
808
+ value: ref.unit ?? "r",
809
+ options: SAMPLE_UNITS,
810
+ command: (option) => set(`unit ${option}`),
811
+ help: SAMPLE_CONTROLS.unit,
384
812
  },
385
813
  {
814
+ kind: "toggle",
815
+ label: "loop",
816
+ value: ref.loop === true,
817
+ command: (on) => set(`loop ${on ? "on" : "off"}`),
818
+ help: SAMPLE_CONTROLS.loop,
819
+ },
820
+ {
821
+ kind: "toggle",
822
+ label: "fit",
823
+ value: ref.fit === true,
824
+ command: (on) => set(`fit ${on ? "on" : "off"}`),
825
+ help: SAMPLE_CONTROLS.fit,
826
+ },
827
+ {
828
+ kind: "info",
829
+ label: "cut / loopAt",
830
+ value: `/sample set ${voice} cut hats · loopAt 2`,
831
+ },
832
+ );
833
+ return nodes;
834
+ }
835
+
836
+ /** Synth preset first, then one row per parameter. */
837
+ /** The preset this track's voice still matches exactly, if any. */
838
+ function matchingSynthPreset(track: Track): string | undefined {
839
+ if (!track.synth) return undefined;
840
+ const current = JSON.stringify(track.synth);
841
+ return Object.entries(SYNTH_PRESETS).find(
842
+ ([, preset]) =>
843
+ preset.instrument === track.instrument &&
844
+ JSON.stringify(normalizeSynth(preset.synth)) === current,
845
+ )?.[0];
846
+ }
847
+
848
+ function synthNodes(track: Track, keys: readonly string[]): MenuNode[] {
849
+ const nodes: MenuNode[] = [];
850
+ if (keys === SYNTH_SIMPLE)
851
+ nodes.push({
852
+ kind: "choice",
853
+ label: "preset",
854
+ value: matchingSynthPreset(track) ?? "—",
855
+ options: Object.keys(SYNTH_PRESETS),
856
+ command: (preset) => `synth preset ${preset}`,
857
+ help: "a starting voice; every value stays editable",
858
+ });
859
+ for (const key of keys)
860
+ nodes.push(synthParamNode(track, key, keys !== SYNTH_SIMPLE));
861
+ return nodes;
862
+ }
863
+
864
+ function synthParamNode(track: Track, key: string, named: boolean): MenuNode {
865
+ const param = SYNTH_PARAMS[key]!;
866
+ const current = track.synth?.[key];
867
+ const aliases = (param.strudel ?? []).filter((name) => name !== key);
868
+ const label = named
869
+ ? aliases.length
870
+ ? `${key} (${aliases.join("/")})`
871
+ : key
872
+ : (SYNTH_LABEL[key] ?? key);
873
+ if (param.kind === "number")
874
+ return {
875
+ kind: "number",
876
+ label,
877
+ value: typeof current === "number" ? current : undefined,
878
+ start: param.default,
879
+ // An unset filter cutoff means no filter, not the default cutoff.
880
+ off: /^(lpf|hpf|bpf)$/.test(key)
881
+ ? "off"
882
+ : withUnit(formatParam(param, param.default), param.unit),
883
+ min: param.min,
884
+ max: param.max,
885
+ step: specStep(param),
886
+ format: (value) => withUnit(formatParam(param, value), param.unit),
887
+ command: (value) => `synth ${key} ${formatParam(param, value)}`,
888
+ reset: `synth ${key} off`,
889
+ help: param.doc,
890
+ };
891
+ if (param.kind === "enum")
892
+ return {
893
+ kind: "choice",
894
+ label,
895
+ value: typeof current === "string" ? current : param.default,
896
+ options: param.values,
897
+ command: (option) => `synth ${key} ${option}`,
898
+ help: param.doc,
899
+ };
900
+ return {
901
+ kind: "toggle",
902
+ label,
903
+ value: typeof current === "boolean" ? current : param.default,
904
+ command: (on) => `synth ${key} ${on ? "on" : "off"}`,
905
+ help: param.doc,
906
+ };
907
+ }
908
+
909
+ /** Plain names for the simple synth rows; the note shows the command name. */
910
+ const SYNTH_LABEL: Readonly<Record<string, string>> = {
911
+ lpf: "filter cutoff",
912
+ lpq: "filter res",
913
+ lpenv: "filter env",
914
+ vib: "vibrato",
915
+ fm: "FM amount",
916
+ };
917
+
918
+ /** Every synth parameter, grouped (amplitude, oscillator, FM 1..8, …). */
919
+ function synthAdvancedNodes(context: MenuContext): MenuNode[] {
920
+ const track = focused(context);
921
+ if (!track) return [];
922
+ const nodes: MenuNode[] = SYNTH_GROUPS.map((group) => {
923
+ const set = group.params.filter((key) => track.synth?.[key] !== undefined);
924
+ return {
386
925
  kind: "menu",
387
- id: "delay",
388
- label: "Delay",
389
- detail: delay
390
- ? `${num(delay.beats)} beats · fb ${num(delay.feedback)} · mix ${num(delay.mix)}`
391
- : "off",
392
- build: () => {
393
- const beats = delay?.beats ?? 0.5;
394
- const feedback = delay?.feedback ?? 0.3;
395
- const mix = delay?.mix ?? 0.35;
396
- return [
397
- {
398
- kind: "toggle",
399
- label: "on",
400
- value: delay !== undefined,
401
- command: (on) => (on ? "delay 0.5 0.3 0.35" : "delay off"),
402
- },
403
- {
404
- kind: "number",
405
- label: "time beats",
406
- value: delay?.beats,
407
- start: 0.5,
408
- off: "off",
409
- min: SCORE_LIMITS.minDelayBeats,
410
- max: SCORE_LIMITS.maxDelayBeats,
411
- step: delayStep,
412
- format: num,
413
- command: (value) =>
414
- `delay ${num(value)} ${num(feedback)} ${num(mix)}`,
415
- },
416
- {
417
- kind: "number",
418
- label: "feedback",
419
- value: delay?.feedback,
420
- start: 0.3,
421
- off: "off",
422
- min: 0,
423
- max: SCORE_LIMITS.maxDelayFeedback,
424
- step: LANE_STEP["delay-feedback"],
425
- format: num,
426
- command: (value) => `delay ${num(beats)} ${num(value)} ${num(mix)}`,
427
- },
428
- {
429
- kind: "number",
430
- label: "mix",
431
- value: delay?.mix,
432
- start: 0.35,
433
- off: "off",
434
- min: 0,
435
- max: SCORE_LIMITS.maxDelayMix,
436
- step: LANE_STEP["delay-mix"],
437
- format: num,
438
- command: (value) =>
439
- `delay ${num(beats)} ${num(feedback)} ${num(value)}`,
440
- },
441
- ];
926
+ id: `synth:${group.id}`,
927
+ label: group.label,
928
+ detail: set.length
929
+ ? set.map((key) => `${key} ${String(track.synth![key])}`).join(" · ")
930
+ : "defaults",
931
+ build: (inner) => {
932
+ const current = focused(inner);
933
+ return current ? synthNodes(current, group.params) : [];
442
934
  },
935
+ };
936
+ });
937
+ const partials = track.synth?.partials;
938
+ nodes.push({
939
+ kind: "entry",
940
+ label: "partials",
941
+ value: Array.isArray(partials) ? partials.join(" ") : "—",
942
+ placeholder: "harmonic amplitudes, e.g. 1 0.5 0.33",
943
+ command: (text) =>
944
+ text.trim() ? `synth partials ${text.trim()}` : "synth partials off",
945
+ example: "synth partials 1 0.5 0.33 0.25",
946
+ });
947
+ nodes.push({
948
+ kind: "entry",
949
+ label: "zzfx array",
950
+ value: "—",
951
+ placeholder: "a raw ZzFX array, e.g. ,,129,.01,,.15,2",
952
+ command: (text) => `synth zzfx ${text.trim()}`,
953
+ example: "synth zzfx ,,129,.01,,.15,2",
954
+ });
955
+ return nodes;
956
+ }
957
+
958
+ /** Short help for each wavetable parameter row. */
959
+ const WAVETABLE_LABELS: Readonly<Record<WavetableParam, string>> = {
960
+ wt: "position (wt)",
961
+ wtenv: "position env amount",
962
+ wtattack: "position env attack s",
963
+ wtdecay: "position env decay s",
964
+ wtsustain: "position env sustain",
965
+ wtrelease: "position env release s",
966
+ wtrate: "position LFO Hz",
967
+ wtdepth: "position LFO depth",
968
+ warp: "warp",
969
+ wtphaserand: "phase randomness",
970
+ };
971
+
972
+ const WAVETABLE_STEP: Readonly<Record<WavetableParam, number>> = {
973
+ wt: 0.05,
974
+ wtenv: 0.1,
975
+ wtattack: 0.01,
976
+ wtdecay: 0.05,
977
+ wtsustain: 0.05,
978
+ wtrelease: 0.05,
979
+ wtrate: 0.25,
980
+ wtdepth: 0.05,
981
+ warp: 0.05,
982
+ wtphaserand: 0.1,
983
+ };
984
+
985
+ function wavetableNodes(track: Track, projectRoot?: string): MenuNode[] {
986
+ const settings = wavetableOf(track);
987
+ const local = listLocalWavetables(projectRoot);
988
+ const tables: MenuNode[] = [
989
+ ...local.map((path): MenuNode => ({
990
+ kind: "action",
991
+ label: `${path.split("/").pop()} project · ${path.split("/")[1]}`,
992
+ command: `wt ${path}`,
993
+ })),
994
+ ...BUILTIN_TABLE_NAMES.map((name): MenuNode => ({
995
+ kind: "action",
996
+ label: `${name} ${BUILTIN_TABLES[name]!.title} · built-in`,
997
+ command: `wt ${name}`,
998
+ })),
999
+ ...Object.entries(UZU_WAVETABLES).map(([set, names]): MenuNode => ({
1000
+ kind: "menu",
1001
+ id: `wt-${set}`,
1002
+ label: set,
1003
+ detail: `${names.length} tables · ${WAVETABLE_PACK}`,
1004
+ build: () =>
1005
+ names.map((name, index): MenuNode => ({
1006
+ kind: "action",
1007
+ label: `${set}:${index} ${name}`,
1008
+ command: `wt ${set}:${index}`,
1009
+ })),
1010
+ })),
1011
+ ];
1012
+ const nodes: MenuNode[] = [
1013
+ {
1014
+ kind: "menu",
1015
+ id: "wavetables",
1016
+ label: "table",
1017
+ detail: describeTable(settings.table.src),
1018
+ build: () => tables,
443
1019
  },
1020
+ ];
1021
+ for (const name of Object.keys(WAVETABLE_PARAMS) as WavetableParam[]) {
1022
+ const [min, max, fallback] = WAVETABLE_PARAMS[name];
1023
+ nodes.push({
1024
+ kind: "number",
1025
+ label: WAVETABLE_LABELS[name],
1026
+ value: settings[name] ?? fallback,
1027
+ min,
1028
+ max,
1029
+ step: linear(WAVETABLE_STEP[name], min, max),
1030
+ format: num,
1031
+ command: (value) => `${name} ${num(value)}`,
1032
+ reset: `${name} ${num(fallback)}`,
1033
+ });
1034
+ }
1035
+ nodes.push({
1036
+ kind: "choice",
1037
+ label: "warp mode",
1038
+ value: settings.warpmode ?? "none",
1039
+ options: WARP_MODES,
1040
+ command: (option) => `warpmode ${option}`,
1041
+ });
1042
+ if ((track.wtAutomation?.length ?? 0) > 0)
1043
+ nodes.push({
1044
+ kind: "info",
1045
+ label: "position automation",
1046
+ value: `${track.wtAutomation!.length} points · Automation › wavetable position`,
1047
+ });
1048
+ return nodes;
1049
+ }
1050
+
1051
+ /** Effects in chain order; each opens on its presets and simple params. */
1052
+ function effectNodes(context: MenuContext): MenuNode[] {
1053
+ const track = focused(context);
1054
+ if (!track) return [];
1055
+ const node = (effect: EffectName): MenuNode => {
1056
+ const spec = effectSpec(effect);
1057
+ const values = effectValues(track, effect);
1058
+ return {
1059
+ kind: "menu",
1060
+ id: effect,
1061
+ label: spec.label[0]!.toUpperCase() + spec.label.slice(1),
1062
+ detail: values ? effectSummary(effect, values) : "off",
1063
+ help: spec.doc,
1064
+ build: (inner) => effectParamNodes(inner, effect, false),
1065
+ };
1066
+ };
1067
+ const more = EFFECT_NAMES.filter((effect) => !CORE_EFFECTS.includes(effect));
1068
+ const moreOn = more.filter((effect) => effectValues(track, effect));
1069
+ return [
1070
+ ...CORE_EFFECTS.map(node),
444
1071
  {
445
1072
  kind: "menu",
446
- id: "reverb",
447
- label: "Reverb",
448
- detail: reverb
449
- ? `mix ${num(reverb.mix)} · size ${num(reverb.size)}`
450
- : "off",
451
- build: () => [
452
- {
453
- kind: "toggle",
454
- label: "on",
455
- value: reverb !== undefined,
456
- command: (on) => (on ? "reverb 0.3 0.5" : "reverb off"),
457
- },
458
- {
459
- kind: "number",
460
- label: "mix",
461
- value: reverb?.mix,
462
- start: 0.3,
463
- off: "off",
464
- min: 0,
465
- max: SCORE_LIMITS.maxReverbMix,
466
- step: linear(0.05, 0, SCORE_LIMITS.maxReverbMix),
467
- format: num,
468
- command: (value) =>
469
- `reverb ${num(value)} ${num(reverb?.size ?? 0.5)}`,
470
- },
471
- {
472
- kind: "number",
473
- label: "size",
474
- value: reverb?.size,
475
- start: 0.5,
476
- off: "off",
477
- min: SCORE_LIMITS.minReverbSize,
478
- max: SCORE_LIMITS.maxReverbSize,
479
- step: linear(
480
- 0.05,
481
- SCORE_LIMITS.minReverbSize,
482
- SCORE_LIMITS.maxReverbSize,
483
- ),
484
- format: num,
485
- command: (value) => `reverb ${num(reverb?.mix ?? 0.3)} ${num(value)}`,
486
- },
487
- ],
1073
+ id: "more effects",
1074
+ label: "more effects",
1075
+ help: "less common effects, each with the same controls",
1076
+ detail:
1077
+ moreOn.length > 0
1078
+ ? `on: ${moreOn.map((effect) => effectSpec(effect).label).join(", ")}`
1079
+ : more.map((effect) => effectSpec(effect).label).join(", "),
1080
+ build: () => more.map(node),
488
1081
  },
489
1082
  ];
490
1083
  }
491
1084
 
1085
+ function effectSummary(
1086
+ effect: EffectName,
1087
+ values: Readonly<Record<string, number | string | boolean>>,
1088
+ ): string {
1089
+ const spec = effectSpec(effect);
1090
+ return spec.simple
1091
+ .filter((key) => values[key] !== undefined)
1092
+ .map((key) => {
1093
+ const value = values[key]!;
1094
+ const param = spec.params[key]!;
1095
+ return `${key} ${typeof value === "number" ? formatParam(param, value) : String(value)}`;
1096
+ })
1097
+ .join(" · ");
1098
+ }
1099
+
1100
+ /** `800 Hz`, `0.2 s`: a value with its unit, when it has one. */
1101
+ function withUnit(text: string, unit: string | undefined): string {
1102
+ return unit ? `${text} ${unit}` : text;
1103
+ }
1104
+
1105
+ function formatParam(spec: ParamSpec, value: number): string {
1106
+ if (spec.kind !== "number") return num(value);
1107
+ return spec.step === "log" && value >= 100
1108
+ ? `${Math.round(value)}`
1109
+ : num(value);
1110
+ }
1111
+
1112
+ /**
1113
+ * One effect's rows: on/off, presets, then its simple parameters; the
1114
+ * `advanced` submenu holds every parameter with its Strudel names.
1115
+ */
1116
+ function effectParamNodes(
1117
+ context: MenuContext,
1118
+ effect: EffectName,
1119
+ advanced: boolean,
1120
+ ): MenuNode[] {
1121
+ const track = focused(context);
1122
+ if (!track) return [];
1123
+ const spec = effectSpec(effect);
1124
+ const values = effectValues(track, effect);
1125
+ const nodes: MenuNode[] = [];
1126
+ if (!advanced) {
1127
+ nodes.push({
1128
+ kind: "toggle",
1129
+ label: "on",
1130
+ value: values !== undefined,
1131
+ command: (on) => `fx ${effect} ${on ? "on" : "off"}`,
1132
+ help: `switch the ${spec.label} on or off (settings are kept)`,
1133
+ });
1134
+ const presets = effectPresetNames(effect);
1135
+ if (presets.length > 0)
1136
+ nodes.push({
1137
+ kind: "choice",
1138
+ label: "preset",
1139
+ value: "—",
1140
+ options: presets,
1141
+ command: (preset) => `fx ${effect} preset ${preset}`,
1142
+ help: "a starting point; every value stays editable",
1143
+ });
1144
+ if (effect === "reverb") {
1145
+ // Convolution (Strudel `ir`): a generated impulse, or `fx ir <file>`.
1146
+ const ir = track.reverb?.ir?.src;
1147
+ const current = ir?.startsWith("builtin:")
1148
+ ? ir.slice("builtin:".length)
1149
+ : (ir ?? "off");
1150
+ nodes.push({
1151
+ kind: "choice",
1152
+ label: "impulse (ir)",
1153
+ value: current,
1154
+ options: ["off", ...REVERB_IR_BUILTINS],
1155
+ command: (choice) => `fx reverb ir ${choice}`,
1156
+ });
1157
+ }
1158
+ }
1159
+ const keys = advanced ? Object.keys(spec.params) : spec.simple;
1160
+ for (const key of keys) {
1161
+ const param = spec.params[key]!;
1162
+ const current = values?.[key];
1163
+ const label =
1164
+ advanced && param.strudel?.length
1165
+ ? `${key} (${param.strudel.filter((name) => !name.includes(" ")).join("/")})`
1166
+ : key;
1167
+ if (param.kind === "number") {
1168
+ nodes.push({
1169
+ kind: "number",
1170
+ label,
1171
+ value: typeof current === "number" ? current : undefined,
1172
+ start: param.default,
1173
+ off: values
1174
+ ? withUnit(formatParam(param, param.default), param.unit)
1175
+ : "off",
1176
+ min: param.min,
1177
+ max: param.max,
1178
+ step: specStep(param),
1179
+ format: (value) => withUnit(formatParam(param, value), param.unit),
1180
+ command: (value) => `fx ${effect} ${key} ${formatParam(param, value)}`,
1181
+ help: param.doc,
1182
+ ...(values
1183
+ ? {
1184
+ reset: `fx ${effect} ${key} ${formatParam(param, param.default)}`,
1185
+ }
1186
+ : {}),
1187
+ });
1188
+ } else if (param.kind === "enum") {
1189
+ nodes.push({
1190
+ kind: "choice",
1191
+ label,
1192
+ value: typeof current === "string" ? current : param.default,
1193
+ options: param.values,
1194
+ command: (option) => `fx ${effect} ${key} ${option}`,
1195
+ help: param.doc,
1196
+ });
1197
+ } else {
1198
+ nodes.push({
1199
+ kind: "toggle",
1200
+ label,
1201
+ value: typeof current === "boolean" ? current : param.default,
1202
+ command: (on) => `fx ${effect} ${key} ${on ? "on" : "off"}`,
1203
+ help: param.doc,
1204
+ });
1205
+ }
1206
+ }
1207
+ if (!advanced) {
1208
+ nodes.push({
1209
+ kind: "menu",
1210
+ id: `${effect}:advanced`,
1211
+ label: "advanced",
1212
+ detail: `all ${Object.keys(spec.params).length} params`,
1213
+ help: `Strudel: ${spec.strudel}`,
1214
+ build: (inner) => effectParamNodes(inner, effect, true),
1215
+ });
1216
+ if (values)
1217
+ nodes.push({
1218
+ kind: "action",
1219
+ label: "reset to defaults",
1220
+ command: `fx ${effect} reset`,
1221
+ help: `put every ${spec.label} value back to its default`,
1222
+ });
1223
+ }
1224
+ return nodes;
1225
+ }
1226
+
492
1227
  function automationNodes(context: MenuContext): MenuNode[] {
493
1228
  const track = focused(context);
494
1229
  if (!track) return [];
495
- return (Object.keys(AUTOMATION_LANES) as AutomationParameter[]).map(
496
- (lane) => {
497
- const count = track[AUTOMATION_LANES[lane].field]?.length ?? 0;
498
- return {
499
- kind: "menu",
500
- id: `lane:${lane}`,
501
- label: LANE_LABEL[lane],
502
- detail: `${count} point${count === 1 ? "" : "s"}`,
503
- build: (inner) => laneNodes(inner, lane),
504
- };
505
- },
506
- );
1230
+ // Track lanes, the lanes of effects that are on, and any lane with
1231
+ // points; every other effect lane is under "all lanes".
1232
+ const shown = AUTOMATION_PARAMETERS.filter((lane) => {
1233
+ if (isTrackAutomationParameter(lane)) return true;
1234
+ if (automationPoints(track, lane).length > 0) return true;
1235
+ const info = FX_LANE_INFO.get(lane as FxLane);
1236
+ if (info?.effect === "synth")
1237
+ return track.synth?.[info.param] !== undefined;
1238
+ return info !== undefined && effectValues(track, info.effect) !== undefined;
1239
+ });
1240
+ const hidden = AUTOMATION_PARAMETERS.filter((lane) => !shown.includes(lane));
1241
+ const nodes = shown.map((lane) => laneMenu(track, lane));
1242
+ if (hidden.length > 0)
1243
+ nodes.push({
1244
+ kind: "menu",
1245
+ id: "lanes:all",
1246
+ label: "all lanes",
1247
+ detail: `${hidden.length} more effect lanes`,
1248
+ build: (inner) => {
1249
+ const current = focused(inner);
1250
+ return current ? hidden.map((lane) => laneMenu(current, lane)) : [];
1251
+ },
1252
+ });
1253
+ return nodes;
1254
+ }
1255
+
1256
+ function laneMenu(track: Track, lane: AutomationParameter): MenuNode {
1257
+ const count = automationPoints(track, lane).length;
1258
+ return {
1259
+ kind: "menu",
1260
+ id: `lane:${lane}`,
1261
+ label: laneLabel(lane),
1262
+ detail: `${count} point${count === 1 ? "" : "s"}`,
1263
+ build: (inner) => laneNodes(inner, lane),
1264
+ };
507
1265
  }
508
1266
 
509
1267
  /** `4:0.5 8:1` → `automate <lane> points 4:0.5 8:1`, when every pair parses. */
@@ -518,7 +1276,7 @@ function pointsCommand(
518
1276
  .filter(Boolean);
519
1277
  if (pairs.length === 0 || (exactly !== undefined && pairs.length !== exactly))
520
1278
  return undefined;
521
- const { min, max } = AUTOMATION_LANES[lane];
1279
+ const { min, max } = automationRange(lane);
522
1280
  for (const pair of pairs) {
523
1281
  const match = pair.match(/^(\d+(?:\.\d+)?):(-?\d+(?:\.\d+)?)$/);
524
1282
  if (!match) return undefined;
@@ -534,9 +1292,9 @@ function laneNodes(
534
1292
  ): MenuNode[] {
535
1293
  const track = focused(context);
536
1294
  if (!track) return [];
537
- const { min, max, field } = AUTOMATION_LANES[lane];
1295
+ const { min, max } = automationRange(lane);
538
1296
  const range = `${laneFormat(lane, min)}…${laneFormat(lane, max)}`;
539
- const points = track[field] ?? [];
1297
+ const points = automationPoints(track, lane);
540
1298
  const nodes: MenuNode[] = [
541
1299
  {
542
1300
  kind: "entry",
@@ -592,6 +1350,134 @@ function mixNodes(context: MenuContext): MenuNode[] {
592
1350
  });
593
1351
  }
594
1352
 
1353
+ // ── sounds (sample packs) ──────────────────────────────────────────────
1354
+
1355
+ function soundsDetail(track: Track | undefined): string {
1356
+ const packs = new Set<string>();
1357
+ for (const ref of Object.values(track?.sampler?.voices ?? {}))
1358
+ if (ref.src.startsWith("pack:")) packs.add(ref.src.slice(5).split("/")[0]!);
1359
+ return packs.size ? [...packs].join(" · ") : "kits, instruments, packs";
1360
+ }
1361
+
1362
+ /**
1363
+ * The instrument browser: drum kits and soundfont instruments from the
1364
+ * built-in packs (fetched on first use), and the pack list.
1365
+ */
1366
+ function kitsMenu(): MenuNode {
1367
+ return {
1368
+ kind: "menu",
1369
+ id: "kits",
1370
+ label: "drum kits",
1371
+ help: "make this a drum track with a synth or sample kit",
1372
+ detail: `synth ${SYNTH_KIT_NAMES.join(" ")} · samples ${Object.keys(DEFAULT_KITS).join(" ")}`,
1373
+ // Synth kits first (offline), then the pack sample kits.
1374
+ build: () => [
1375
+ ...kitCatalog().map((entry): MenuNode => ({
1376
+ kind: "action",
1377
+ label:
1378
+ entry.kind === "synth"
1379
+ ? `${entry.label} · ${entry.detail}`
1380
+ : `${entry.label} · ${DEFAULT_KITS[entry.name]?.pack ?? entry.detail}`,
1381
+ command: entry.command,
1382
+ })),
1383
+ {
1384
+ kind: "menu",
1385
+ id: "kit-nicknames",
1386
+ label: "Strudel banks",
1387
+ detail: `${Object.keys(STRUDEL_BANK_ALIASES).length} drum machines by nickname`,
1388
+ build: () =>
1389
+ Object.entries(STRUDEL_BANK_ALIASES)
1390
+ .sort(([, a], [, b]) =>
1391
+ a.toLowerCase() < b.toLowerCase() ? -1 : 1,
1392
+ )
1393
+ .map(([bank, nickname]): MenuNode => ({
1394
+ kind: "action",
1395
+ label: `${nickname} ${bank}`,
1396
+ command: `/kit ${nickname}`,
1397
+ })),
1398
+ },
1399
+ ],
1400
+ };
1401
+ }
1402
+
1403
+ function patternsMenu(): MenuNode {
1404
+ return {
1405
+ kind: "menu",
1406
+ id: "patterns",
1407
+ label: "drum patterns",
1408
+ detail: `${DRUM_PATTERNS.length} grooves`,
1409
+ help: "replace the drum part with a ready-made groove",
1410
+ build: () =>
1411
+ DRUM_PATTERNS.map((entry): MenuNode => ({
1412
+ kind: "action",
1413
+ label: `${entry.label} ${entry.tempo.bpm} BPM · ${entry.tags.join(", ")}`,
1414
+ command: `/pattern ${entry.name}`,
1415
+ })),
1416
+ };
1417
+ }
1418
+
1419
+ function soundNodes(): MenuNode[] {
1420
+ return [
1421
+ {
1422
+ kind: "action",
1423
+ label: "wavetable synth basic shapes morph · built-in",
1424
+ command: "wt basic",
1425
+ help: "switch this track to the morphing wavetable synth",
1426
+ },
1427
+ {
1428
+ kind: "menu",
1429
+ id: "instruments",
1430
+ label: "instruments",
1431
+ help: "sampled piano and General MIDI instruments",
1432
+ detail: "General MIDI soundfont, piano",
1433
+ build: () => [
1434
+ {
1435
+ kind: "action",
1436
+ label: "piano Salamander grand · piano",
1437
+ command: "/pack use piano/piano",
1438
+ },
1439
+ ...GM_INSTRUMENTS.map((name): MenuNode => ({
1440
+ kind: "action",
1441
+ label: `${name.replace(/^gm_/, "").replace(/_/g, " ")} · gm`,
1442
+ command: `/pack use gm/${name}`,
1443
+ })),
1444
+ ],
1445
+ },
1446
+ {
1447
+ kind: "entry",
1448
+ label: "use a sound",
1449
+ value: "",
1450
+ help: "type a pack sound name, e.g. dirt-samples/bd:3",
1451
+ placeholder: "<pack>/<sound>[:<n>]",
1452
+ command: (text) => (text.trim() ? `/pack use ${text.trim()}` : undefined),
1453
+ example: "/pack use dirt-samples/bd:3",
1454
+ },
1455
+ {
1456
+ kind: "menu",
1457
+ id: "packs",
1458
+ label: "sample packs",
1459
+ help: "browse built-in packs or add one by URL",
1460
+ detail: `${PACK_CATALOG.length} built in`,
1461
+ build: () => [
1462
+ ...PACK_CATALOG.map((pack): MenuNode => ({
1463
+ kind: "action",
1464
+ label: `${pack.name} ${pack.license}`,
1465
+ command: `/pack info ${pack.name}`,
1466
+ })),
1467
+ {
1468
+ kind: "entry",
1469
+ label: "add a pack",
1470
+ value: "",
1471
+ placeholder: "manifest URL or github:user/repo",
1472
+ command: (text) =>
1473
+ text.trim() ? `/pack add ${text.trim()}` : undefined,
1474
+ example: "/pack add github:yaxu/clean-breaks",
1475
+ },
1476
+ ],
1477
+ },
1478
+ ];
1479
+ }
1480
+
595
1481
  function transportNodes(context: MenuContext): MenuNode[] {
596
1482
  const score = context.score;
597
1483
  return [
@@ -599,20 +1485,23 @@ function transportNodes(context: MenuContext): MenuNode[] {
599
1485
  kind: "action",
600
1486
  label: context.playing ? "pause" : "play",
601
1487
  command: context.playing ? "pause" : "play",
1488
+ help: "start or stop the loop (space on an empty prompt)",
602
1489
  },
603
1490
  {
604
1491
  kind: "number",
605
- label: "tempo BPM",
1492
+ label: "tempo",
1493
+ help: "beats per minute",
606
1494
  value: score.tempoBpm,
607
1495
  min: SCORE_LIMITS.minTempoBpm,
608
1496
  max: SCORE_LIMITS.maxTempoBpm,
609
1497
  step: linear(1, SCORE_LIMITS.minTempoBpm, SCORE_LIMITS.maxTempoBpm),
610
- format: num,
1498
+ format: (value) => `${num(value)} BPM`,
611
1499
  command: (value) => `tempo ${num(value)}`,
612
1500
  },
613
1501
  {
614
1502
  kind: "number",
615
1503
  label: "beats per bar",
1504
+ help: "the meter",
616
1505
  value: score.beatsPerBar,
617
1506
  min: 1,
618
1507
  max: SCORE_LIMITS.maxBeatsPerBar,
@@ -622,17 +1511,19 @@ function transportNodes(context: MenuContext): MenuNode[] {
622
1511
  },
623
1512
  {
624
1513
  kind: "number",
625
- label: "loop bars",
1514
+ label: "loop length",
1515
+ help: "how long the loop is",
626
1516
  value: score.bars,
627
1517
  min: 1,
628
1518
  max: SCORE_LIMITS.maxBars,
629
1519
  step: linear(1, 1, SCORE_LIMITS.maxBars),
630
- format: num,
1520
+ format: (value) => `${num(value)} bar${value === 1 ? "" : "s"}`,
631
1521
  command: (value) => `bars ${Math.round(value)}`,
632
1522
  },
633
1523
  {
634
1524
  kind: "choice",
635
1525
  label: "grid",
1526
+ help: "the step play-mode recording and the euclid editor snap to",
636
1527
  value: context.grid,
637
1528
  options: context.grids,
638
1529
  command: (option) => `/grid ${option}`,
@@ -640,12 +1531,14 @@ function transportNodes(context: MenuContext): MenuNode[] {
640
1531
  {
641
1532
  kind: "toggle",
642
1533
  label: "click",
1534
+ help: "metronome while playing and recording",
643
1535
  value: context.clickOn,
644
1536
  command: (on) => `/click ${on ? "on" : "off"}`,
645
1537
  },
646
1538
  {
647
1539
  kind: "choice",
648
1540
  label: "count-in bars",
1541
+ help: "bars of click before recording starts",
649
1542
  value: String(context.countInBars),
650
1543
  options: ["0", "1", "2"],
651
1544
  command: (option) => `/count-in ${option}`,
@@ -657,9 +1550,17 @@ function transportNodes(context: MenuContext): MenuNode[] {
657
1550
 
658
1551
  type Frame = {
659
1552
  title: string;
1553
+ /** The submenu's node id (`chords`), for `EditMenu.section`. */
1554
+ id?: string;
660
1555
  build: (context: MenuContext) => MenuNode[];
661
1556
  index: number;
662
1557
  query: string;
1558
+ /**
1559
+ * Lists audition the item under the cursor while the loop plays: the
1560
+ * hover key, and whether this frame has staged a hover yet.
1561
+ */
1562
+ hover?: string;
1563
+ hovering?: boolean;
663
1564
  };
664
1565
 
665
1566
  const KEY_UP = new Set(["\u001b[A", "\u001bOA", "k"]);
@@ -681,8 +1582,10 @@ function childFrame(
681
1582
  ): Frame {
682
1583
  return {
683
1584
  title: node.label,
1585
+ id: node.id,
684
1586
  index: 0,
685
1587
  query: "",
1588
+ hover: `menu:${node.id}`,
686
1589
  build: (context) => {
687
1590
  const fresh = parent
688
1591
  .build(context)
@@ -705,21 +1608,34 @@ export class EditMenu {
705
1608
  return this.stack.length > 0;
706
1609
  }
707
1610
 
1611
+ /** True while the menu takes typed text (a filter or a value). */
1612
+ get typing(): boolean {
1613
+ return this.filtering || this.entry !== undefined;
1614
+ }
1615
+
1616
+ /** The root section open now (`chords`), or undefined at the root. */
1617
+ get section(): string | undefined {
1618
+ return this.stack[1]?.id;
1619
+ }
1620
+
708
1621
  /** Open at the root, or at a section id (`effects`, `automation`). */
709
1622
  show(context: MenuContext, section?: string): void {
710
1623
  this.stack = [{ title: "menu", build: rootNodes, index: 0, query: "" }];
711
1624
  this.filtering = false;
712
1625
  this.entry = undefined;
713
- if (section) {
714
- const nodes = rootNodes(context);
1626
+ // Walk the section path (`automation` is Mix & automation › automation).
1627
+ for (const id of section
1628
+ ? (SECTION_ALIASES[section.toLowerCase()] ?? [])
1629
+ : []) {
1630
+ const frame = this.stack.at(-1)!;
1631
+ const nodes = frame.build(context);
715
1632
  const index = nodes.findIndex(
716
- (node) => node.kind === "menu" && node.id === section.toLowerCase(),
1633
+ (node) => node.kind === "menu" && node.id === id,
717
1634
  );
718
1635
  const node = nodes[index];
719
- if (node?.kind === "menu") {
720
- this.stack[0]!.index = index;
721
- this.stack.push(childFrame(this.stack[0]!, node));
722
- }
1636
+ if (node?.kind !== "menu") break;
1637
+ frame.index = index;
1638
+ this.stack.push(childFrame(frame, node));
723
1639
  }
724
1640
  }
725
1641
 
@@ -787,6 +1703,13 @@ export class EditMenu {
787
1703
  frame.query = "";
788
1704
  return { type: "handled" };
789
1705
  }
1706
+ // In a list, Esc drops the hovered item and goes back; elsewhere it
1707
+ // reverts staged edits first, then goes back as usual.
1708
+ if (frame.hover && frame.hovering && context.audition) {
1709
+ this.stack.pop();
1710
+ return { type: "unhover", key: frame.hover };
1711
+ }
1712
+ if (context.audition?.dirty) return { type: "revert" };
790
1713
  this.stack.pop();
791
1714
  return this.stack.length ? { type: "handled" } : { type: "close" };
792
1715
  }
@@ -799,21 +1722,43 @@ export class EditMenu {
799
1722
  const step = KEY_UP.has(value) ? -1 : 1;
800
1723
  if (nodes.length)
801
1724
  frame.index = (frame.index + step + nodes.length) % nodes.length;
802
- return { type: "handled" };
1725
+ return this.hovered(frame, context);
803
1726
  }
804
1727
  if (value === "\u001b[5~" || value === "\u001b[H") {
805
1728
  frame.index = 0;
806
- return { type: "handled" };
1729
+ return this.hovered(frame, context);
807
1730
  }
808
1731
  if (value === "\u001b[6~" || value === "\u001b[F") {
809
1732
  frame.index = Math.max(0, nodes.length - 1);
810
- return { type: "handled" };
1733
+ return this.hovered(frame, context);
811
1734
  }
1735
+ // Space hears the focused track (it still switches a toggle row);
1736
+ // `a` and `c` are A/B and context while the window hosts an audition.
1737
+ const audition = context.audition ? auditionKey(value) : undefined;
1738
+ if (audition && !(audition === "loop" && node?.kind === "toggle"))
1739
+ return { type: "audition", key: audition };
812
1740
  if (!node) return { type: "handled" };
1741
+ if (
1742
+ KEY_ENTER.has(value) &&
1743
+ context.audition?.dirty &&
1744
+ (node.kind === "number" ||
1745
+ node.kind === "toggle" ||
1746
+ node.kind === "point" ||
1747
+ node.kind === "info")
1748
+ )
1749
+ return { type: "keep" };
813
1750
  if (KEY_LEFT.has(value) || KEY_RIGHT.has(value)) {
814
1751
  const direction = KEY_RIGHT.has(value) ? 1 : -1;
815
1752
  return this.nudge(node, direction);
816
1753
  }
1754
+ if (KEY_DELETE.has(value) && node.kind === "number") {
1755
+ const reset =
1756
+ node.reset ??
1757
+ (node.start !== undefined && node.value !== undefined
1758
+ ? node.command(node.start)
1759
+ : undefined);
1760
+ return reset ? { type: "run", command: reset } : { type: "handled" };
1761
+ }
817
1762
  if (KEY_DELETE.has(value) && node.kind === "point")
818
1763
  return {
819
1764
  type: "run",
@@ -844,13 +1789,19 @@ export class EditMenu {
844
1789
  }
845
1790
  if (node.kind === "toggle")
846
1791
  return { type: "run", command: node.command(!node.value) };
847
- if (node.kind === "action") return { type: "run", command: node.command };
1792
+ if (node.kind === "action")
1793
+ return frame.hover &&
1794
+ context.audition?.looping &&
1795
+ (context.audition.stageable ?? isStageable)(node.command)
1796
+ ? { type: "choose", command: node.command, key: frame.hover }
1797
+ : { type: "run", command: node.command };
848
1798
  if (node.kind === "choice") {
849
1799
  const current = node.options.indexOf(node.value);
850
1800
  this.stack.push({
851
1801
  title: node.label,
852
1802
  index: Math.max(0, current),
853
1803
  query: "",
1804
+ hover: node.label,
854
1805
  build: (fresh) => {
855
1806
  const parent = this.stack.at(-2);
856
1807
  const live =
@@ -882,6 +1833,17 @@ export class EditMenu {
882
1833
  return { type: "handled" };
883
1834
  }
884
1835
 
1836
+ /** After a move: audition the item under the cursor in a list. */
1837
+ private hovered(frame: Frame, context: MenuContext): MenuResult {
1838
+ if (!frame.hover || !context.audition?.looping) return { type: "handled" };
1839
+ const node = this.selected(context);
1840
+ const stageable = context.audition?.stageable ?? isStageable;
1841
+ if (node?.kind !== "action" || !stageable(node.command))
1842
+ return { type: "handled" };
1843
+ frame.hovering = true;
1844
+ return { type: "hover", command: node.command, key: frame.hover };
1845
+ }
1846
+
885
1847
  private nudge(node: MenuNode, direction: 1 | -1): MenuResult {
886
1848
  if (node.kind === "menu") {
887
1849
  if (direction > 0) {
@@ -901,8 +1863,8 @@ export class EditMenu {
901
1863
  return { type: "run", command: node.command(next) };
902
1864
  }
903
1865
  if (node.kind === "point") {
904
- const { min, max } = AUTOMATION_LANES[node.lane];
905
- const next = clamp(LANE_STEP[node.lane](node.value, direction), min, max);
1866
+ const { min, max } = automationRange(node.lane);
1867
+ const next = clamp(laneStep(node.lane)(node.value, direction), min, max);
906
1868
  if (Math.abs(next - node.value) < 1e-9) return { type: "handled" };
907
1869
  return {
908
1870
  type: "run",
@@ -923,7 +1885,11 @@ export class EditMenu {
923
1885
  ? { type: "handled" }
924
1886
  : { type: "run", command: node.command(next) };
925
1887
  }
926
- if (direction < 0 && this.stack.length > 1) this.stack.pop();
1888
+ if (direction < 0 && this.stack.length > 1) {
1889
+ const left = this.stack.pop();
1890
+ if (left?.hover && left.hovering)
1891
+ return { type: "unhover", key: left.hover };
1892
+ }
927
1893
  return { type: "handled" };
928
1894
  }
929
1895
 
@@ -973,23 +1939,78 @@ export class EditMenu {
973
1939
  if (frame?.query || this.filtering) title += ` · /${frame?.query ?? ""}`;
974
1940
  if (this.entry)
975
1941
  title += ` · ${this.entry.label}: ${this.entry.buffer || placeholderFor(selected)}▏`;
976
- const items = nodes.map((node, at) => ({
977
- label: `${node.label.padEnd(LABEL_WIDTH)} ${valueText(node)}`.trimEnd(),
978
- detail: commandText(node),
979
- value: String(at),
980
- }));
1942
+ const audition = context.audition;
1943
+ if (audition?.dirty) title = `● ${title}`;
1944
+ if (audition?.status && !this.entry) title += ` · ${audition.status}`;
1945
+ // Changed rows show `staged ← committed` (Elektron's compare, inline).
1946
+ const before = new Map<string, string>();
1947
+ if (audition?.dirty && frame && !frame.query)
1948
+ for (const node of frame.build({
1949
+ ...context,
1950
+ score: audition.committed,
1951
+ ...(audition.committedChords
1952
+ ? { chords: audition.committedChords }
1953
+ : {}),
1954
+ }))
1955
+ before.set(node.label, valueText(node));
1956
+ const items = nodes.map((node, at) => {
1957
+ const now = valueText(node);
1958
+ const was = node.kind === "menu" ? undefined : before.get(node.label);
1959
+ const shown = was !== undefined && was !== now ? `${now} ← ${was}` : now;
1960
+ return {
1961
+ label: `${node.label.padEnd(LABEL_WIDTH)} ${shown}`.trimEnd(),
1962
+ value: String(at),
1963
+ };
1964
+ });
1965
+ const command = selected ? commandText(selected) : undefined;
1966
+ const note = [
1967
+ selected ? describe(selected) : "",
1968
+ selected?.kind === "number" && command
1969
+ ? rowSketch(selected, command, nodes)
1970
+ : "",
1971
+ command ? `› ${command}` : "",
1972
+ ]
1973
+ .filter(Boolean)
1974
+ .join(" ");
981
1975
  const hint = this.entry
982
- ? " type a value · enter apply · esc cancel "
1976
+ ? HINTS.typing
983
1977
  : this.filtering
984
- ? " type to filter · enter choose · esc clear "
985
- : selected?.kind === "point"
986
- ? " ←→ nudge · digits set · x delete · esc back "
987
- : selected?.kind === "number" ||
988
- selected?.kind === "toggle" ||
989
- selected?.kind === "choice"
990
- ? " ←→/+- nudge · digits set · enter edit · / filter · esc back "
991
- : " ↑↓ move · enter open · / filter · esc back ";
992
- return { title, items, index, hint };
1978
+ ? HINTS.filtering
1979
+ : audition?.looping && frame?.hover && selected?.kind === "action"
1980
+ ? HINTS.hover
1981
+ : audition && (audition.looping || audition.dirty)
1982
+ ? audition.hint
1983
+ : selected?.kind === "point"
1984
+ ? HINTS.point
1985
+ : selected?.kind === "number" ||
1986
+ selected?.kind === "toggle" ||
1987
+ selected?.kind === "choice"
1988
+ ? HINTS.value
1989
+ : selected?.kind === "action"
1990
+ ? HINTS.action
1991
+ : HINTS.menu;
1992
+ return { title, items, index, hint, note };
1993
+ }
1994
+ }
1995
+
1996
+ /** The focused row's one-line description. */
1997
+ function describe(node: MenuNode): string {
1998
+ if (node.help) return node.help;
1999
+ switch (node.kind) {
2000
+ case "number":
2001
+ return node.reset || node.start !== undefined
2002
+ ? "←→ adjust · type a number · x resets"
2003
+ : "←→ adjust · type a number";
2004
+ case "toggle":
2005
+ return "space or enter switches it";
2006
+ case "choice":
2007
+ return `${node.options.length} choices · enter lists them`;
2008
+ case "point":
2009
+ return "←→ change · type beat:value · x deletes";
2010
+ case "entry":
2011
+ return "enter, then type";
2012
+ default:
2013
+ return "";
993
2014
  }
994
2015
  }
995
2016
 
@@ -1013,7 +2034,7 @@ function entryCommand(node: MenuNode, text: string): string | undefined {
1013
2034
  }
1014
2035
  if (node.kind === "point") {
1015
2036
  // `0.8` sets the value; `6:0.8` moves to beat 6.
1016
- const { min, max } = AUTOMATION_LANES[node.lane];
2037
+ const { min, max } = automationRange(node.lane);
1017
2038
  const match = trimmed.match(/^(?:(\d+(?:\.\d+)?):)?(-?\d+(?:\.\d+)?)$/);
1018
2039
  if (!match) return undefined;
1019
2040
  const value = Number(match[2]);
@@ -1049,6 +2070,40 @@ function valueText(node: MenuNode): string {
1049
2070
  }
1050
2071
 
1051
2072
  /** The command a row stands for, shown so the menu teaches commands. */
2073
+ /** A filter curve, envelope or table position for a focused number row. */
2074
+ function rowSketch(
2075
+ node: Extract<MenuNode, { kind: "number" }>,
2076
+ command: string,
2077
+ nodes: readonly MenuNode[],
2078
+ ): string {
2079
+ const value = node.value ?? node.start ?? node.min;
2080
+ return (
2081
+ sketchFor(
2082
+ command,
2083
+ value,
2084
+ (param) => {
2085
+ for (const sibling of nodes) {
2086
+ if (sibling.kind !== "number") continue;
2087
+ const text = commandText(sibling);
2088
+ if (text && commandParam(text) === param)
2089
+ return sibling.value ?? sibling.start;
2090
+ }
2091
+ return undefined;
2092
+ },
2093
+ filterTypeOf(nodes),
2094
+ ) ?? ""
2095
+ );
2096
+ }
2097
+
2098
+ function filterTypeOf(nodes: readonly MenuNode[]): string | undefined {
2099
+ const row = nodes.find(
2100
+ (node) => node.kind === "choice" && node.label === "type",
2101
+ );
2102
+ return row?.kind === "choice" && typeof row.value === "string"
2103
+ ? row.value
2104
+ : undefined;
2105
+ }
2106
+
1052
2107
  function commandText(node: MenuNode): string | undefined {
1053
2108
  switch (node.kind) {
1054
2109
  case "number":
@@ -1074,10 +2129,16 @@ function nodeText(node: MenuNode): string {
1074
2129
 
1075
2130
  /** Root sections, for `/menu <section>`. */
1076
2131
  export const MENU_SECTIONS = [
1077
- "track",
1078
- "parameters",
2132
+ "sound",
1079
2133
  "effects",
1080
- "automation",
2134
+ "rhythm",
2135
+ "chords",
1081
2136
  "mix",
2137
+ "project",
2138
+ // Older names, still accepted.
2139
+ "parameters",
2140
+ "sounds",
2141
+ "track",
2142
+ "automation",
1082
2143
  "transport",
1083
2144
  ] as const;