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