@hraness/dawg 0.4.1 → 0.6.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 (166) hide show
  1. package/CHANGELOG.md +99 -0
  2. package/DAWG.md +551 -33
  3. package/README.md +4 -4
  4. package/core/chords.ts +288 -7
  5. package/core/diff.ts +41 -12
  6. package/core/expression.ts +1241 -0
  7. package/core/fx.ts +437 -2
  8. package/core/granular.ts +528 -0
  9. package/core/instruments.ts +281 -0
  10. package/core/keys.ts +386 -0
  11. package/core/loop.ts +23 -0
  12. package/core/master.ts +455 -0
  13. package/core/midi.ts +452 -0
  14. package/core/resonators.ts +574 -0
  15. package/core/rhythm.ts +7 -2
  16. package/core/score.ts +717 -62
  17. package/core/sdk/eval-child.ts +40 -3
  18. package/core/sdk/print.ts +614 -27
  19. package/core/sdk/sync-instruments.ts +58 -0
  20. package/core/sdk/v1.ts +2824 -45
  21. package/core/sections.ts +2072 -0
  22. package/core/strings.ts +845 -0
  23. package/core/synth.ts +11 -1
  24. package/core/tempo.ts +1318 -0
  25. package/core/tuning.ts +1180 -0
  26. package/guides/audition.md +26 -0
  27. package/guides/automation.md +26 -0
  28. package/guides/chords.md +28 -0
  29. package/guides/effects.md +29 -0
  30. package/guides/faders.md +29 -0
  31. package/guides/files.md +28 -0
  32. package/guides/getting-started.md +26 -0
  33. package/guides/index.ts +75 -0
  34. package/guides/keys.md +27 -0
  35. package/guides/media.md +27 -0
  36. package/guides/mix.md +23 -0
  37. package/guides/music.md +14 -0
  38. package/guides/notes.md +26 -0
  39. package/guides/performance.md +26 -0
  40. package/guides/play.md +24 -0
  41. package/guides/project.md +13 -0
  42. package/guides/providers.md +25 -0
  43. package/guides/rhythm.md +26 -0
  44. package/guides/sessions.md +21 -0
  45. package/guides/sound.md +15 -0
  46. package/guides/sounds.md +29 -0
  47. package/guides/tempo.md +27 -0
  48. package/guides/tracks.md +25 -0
  49. package/guides/web-search.md +21 -0
  50. package/package.json +3 -1
  51. package/src/agent/agent.ts +9 -0
  52. package/src/agent/brief.ts +77 -3
  53. package/src/agent/chord-tools.ts +9 -1
  54. package/src/agent/expression-tools.ts +336 -0
  55. package/src/agent/granular-tools.ts +138 -0
  56. package/src/agent/master-tools.ts +299 -0
  57. package/src/agent/models.ts +4 -4
  58. package/src/agent/ops.ts +68 -6
  59. package/src/agent/planner.ts +45 -2
  60. package/src/agent/preview-tool.ts +27 -4
  61. package/src/agent/section-tools.ts +411 -0
  62. package/src/agent/time-tools.ts +290 -0
  63. package/src/agent/tools.ts +574 -16
  64. package/src/agent/tuning-tools.ts +301 -0
  65. package/src/agent/xcb-agent.ts +9 -0
  66. package/src/audio/arrange.ts +489 -0
  67. package/src/audio/audition.ts +16 -3
  68. package/src/audio/click.ts +113 -1
  69. package/src/audio/clock.ts +71 -5
  70. package/src/audio/dsp/bank.ts +233 -0
  71. package/src/audio/dsp/fft.ts +6 -0
  72. package/src/audio/dsp/filters.ts +57 -0
  73. package/src/audio/dsp/interp.ts +112 -0
  74. package/src/audio/dsp/modal.ts +684 -0
  75. package/src/audio/dsp/onset.ts +197 -0
  76. package/src/audio/dsp/oversample.ts +202 -0
  77. package/src/audio/dsp/rng.ts +37 -0
  78. package/src/audio/dsp/shape.ts +81 -0
  79. package/src/audio/dsp/stft.ts +73 -0
  80. package/src/audio/dsp/window.ts +77 -0
  81. package/src/audio/effects/bus.ts +5 -4
  82. package/src/audio/effects/chain.ts +6 -1
  83. package/src/audio/effects/common.ts +30 -1
  84. package/src/audio/effects/dynamics.ts +2 -1
  85. package/src/audio/effects/filter.ts +5 -3
  86. package/src/audio/effects/modulation.ts +5 -1
  87. package/src/audio/effects/rig/cab.ts +99 -0
  88. package/src/audio/effects/rig/filters.ts +152 -0
  89. package/src/audio/effects/rig/gate.ts +39 -0
  90. package/src/audio/effects/rig/head.ts +487 -0
  91. package/src/audio/effects/rig/index.ts +170 -0
  92. package/src/audio/effects/rig/section.ts +78 -0
  93. package/src/audio/effects/rig/stomp.ts +238 -0
  94. package/src/audio/effects/space.ts +92 -2
  95. package/src/audio/engine.ts +100 -21
  96. package/src/audio/fit.ts +402 -0
  97. package/src/audio/granular.ts +664 -0
  98. package/src/audio/instrument-check.ts +133 -0
  99. package/src/audio/instruments.ts +111 -0
  100. package/src/audio/keys/dsp.ts +323 -0
  101. package/src/audio/keys/engine.ts +361 -0
  102. package/src/audio/keys/piano.ts +432 -0
  103. package/src/audio/live-worker.ts +54 -0
  104. package/src/audio/live.ts +263 -17
  105. package/src/audio/loudness.ts +596 -0
  106. package/src/audio/master.ts +660 -0
  107. package/src/audio/measure-worker.ts +45 -0
  108. package/src/audio/measure.ts +110 -0
  109. package/src/audio/player.ts +11 -6
  110. package/src/audio/preview.ts +76 -14
  111. package/src/audio/render-worker.ts +6 -1
  112. package/src/audio/renderer.ts +7 -1
  113. package/src/audio/resonators.ts +287 -0
  114. package/src/audio/sampler.ts +269 -34
  115. package/src/audio/samples.ts +30 -7
  116. package/src/audio/strings/body.ts +250 -0
  117. package/src/audio/strings/engine.ts +369 -0
  118. package/src/audio/strings/loop.ts +119 -0
  119. package/src/audio/strings/measure.test-helpers.ts +198 -0
  120. package/src/audio/strings/pluck.ts +354 -0
  121. package/src/audio/synth/voice.ts +60 -13
  122. package/src/audio/synth/zzfx.ts +10 -4
  123. package/src/audio/warp.ts +147 -0
  124. package/src/audio/wav.ts +377 -30
  125. package/src/commands/arrange.ts +949 -0
  126. package/src/commands/expression.ts +941 -0
  127. package/src/commands/fit.ts +135 -0
  128. package/src/commands/fx.ts +31 -0
  129. package/src/commands/granular.ts +427 -0
  130. package/src/commands/help.ts +355 -27
  131. package/src/commands/keys.ts +353 -0
  132. package/src/commands/master.ts +361 -0
  133. package/src/commands/modal.ts +247 -0
  134. package/src/commands/music.ts +6 -1
  135. package/src/commands/rig.ts +260 -0
  136. package/src/commands/sample.ts +3 -0
  137. package/src/commands/string.ts +175 -0
  138. package/src/commands/synth.ts +11 -1
  139. package/src/commands/time.ts +967 -0
  140. package/src/commands/tuning.ts +490 -0
  141. package/src/main.ts +919 -41
  142. package/src/project/check.ts +13 -0
  143. package/src/render.ts +139 -8
  144. package/src/session/daemon.ts +18 -8
  145. package/src/session/naming.ts +8 -1
  146. package/src/session/rebase.ts +21 -0
  147. package/src/tui/arrange-menu.ts +390 -0
  148. package/src/tui/audition.ts +38 -5
  149. package/src/tui/fader.ts +409 -0
  150. package/src/tui/granular-menu.ts +278 -0
  151. package/src/tui/menu-time.ts +401 -0
  152. package/src/tui/menu.ts +967 -23
  153. package/src/tui/modal-menu.ts +118 -0
  154. package/src/tui/performance-menu.ts +235 -0
  155. package/src/tui/play-chords.ts +3 -1
  156. package/src/tui/play-mode.ts +150 -6
  157. package/src/tui/play-session.ts +459 -43
  158. package/tui/app.ts +262 -13
  159. package/tui/arrange-strip.ts +174 -0
  160. package/tui/drawer.ts +478 -0
  161. package/tui/grammar.ts +63 -1
  162. package/tui/guide.ts +351 -0
  163. package/tui/highway.ts +87 -4
  164. package/tui/hits.ts +68 -0
  165. package/tui/input.ts +7 -0
  166. package/tui/keys.ts +72 -0
package/src/tui/menu.ts CHANGED
@@ -10,12 +10,23 @@
10
10
  */
11
11
  import { commandParam, sketchFor } from "./sketch.ts";
12
12
  import { HINTS } from "../../tui/grammar.ts";
13
+ import { arrangeDetail, arrangeNodes } from "./arrange-menu.ts";
13
14
  import { auditionKey, isStageable, type AuditionKey } from "./audition.ts";
15
+ import { performanceDetail, performanceNodes } from "./performance-menu.ts";
16
+ import { malletsMenu, modalParameterNodes } from "./modal-menu.ts";
17
+ import type { FaderSpec } from "./fader.ts";
18
+ import {
19
+ openingMeterCommand,
20
+ openingUnit,
21
+ tempoDetail,
22
+ tempoMenuNode,
23
+ } from "./menu-time.ts";
14
24
  import {
15
25
  AUTOMATION_PARAMETERS,
16
26
  automationPoints,
17
27
  automationRange,
18
28
  REVERB_IR_BUILTINS,
29
+ SAMPLE_FIT_MODES,
19
30
  SAMPLE_UNITS,
20
31
  SCORE_LIMITS,
21
32
  isTrackAutomationParameter,
@@ -33,6 +44,9 @@ import {
33
44
  CORE_EFFECTS,
34
45
  EFFECT_NAMES,
35
46
  FX_LANES,
47
+ RIG_PRESETS,
48
+ RIG_STAGES,
49
+ rigPresetOf,
36
50
  effectPresetNames,
37
51
  effectSpec,
38
52
  type EffectName,
@@ -40,7 +54,22 @@ import {
40
54
  type NumberParam,
41
55
  type ParamSpec,
42
56
  } from "../../core/fx.ts";
43
- import { effectValues } from "../commands/fx.ts";
57
+ import {
58
+ effectValues,
59
+ parseEffectName,
60
+ parseParamName,
61
+ } from "../commands/fx.ts";
62
+ import {
63
+ LOUDNESS_TARGET_NAMES,
64
+ LOUDNESS_TARGETS,
65
+ MASTER_LIMITS,
66
+ MASTER_PRESETS,
67
+ MASTER_SPECS,
68
+ MASTER_UNITS,
69
+ describeMaster,
70
+ describeUnit,
71
+ type MasterUnit,
72
+ } from "../../core/master.ts";
44
73
  import { SAMPLE_CONTROLS, type SampleControl } from "../commands/sample.ts";
45
74
  import { AVAILABLE_INSTRUMENTS } from "../audio/wav.ts";
46
75
  import {
@@ -53,6 +82,7 @@ import { kitCatalog } from "../audio/kits.ts";
53
82
  import { SYNTH_KIT_NAMES } from "../../core/kits.ts";
54
83
  import { DRUM_PATTERNS } from "../../core/sdk/v1.ts";
55
84
  import { isDrumInstrument } from "../../core/drums.ts";
85
+ import { loopTicksOf } from "../../core/tempo.ts";
56
86
  import {
57
87
  SYNTH_GROUPS,
58
88
  SYNTH_PARAMS,
@@ -60,6 +90,22 @@ import {
60
90
  SYNTH_SIMPLE,
61
91
  normalizeSynth,
62
92
  } from "../../core/synth.ts";
93
+ import {
94
+ isStringTrack,
95
+ STRING_PARAMS,
96
+ STRING_PRESET_NAMES,
97
+ STRING_PRESETS,
98
+ STRING_SIMPLE_PARAMS,
99
+ stringPresetOf,
100
+ } from "../../core/strings.ts";
101
+ import {
102
+ KEYS_PARAMS,
103
+ KEYS_PRESETS,
104
+ KEYS_SIMPLE,
105
+ PIANO_FAMILIES,
106
+ isPianoFamily,
107
+ resolvedKeys,
108
+ } from "../../core/keys.ts";
63
109
  import type { PickerItem } from "../../tui/app.ts";
64
110
  import { BUILTIN_TABLES, BUILTIN_TABLE_NAMES } from "../audio/wavetable.ts";
65
111
  import {
@@ -76,10 +122,25 @@ import {
76
122
  PERFORM_MODES,
77
123
  PROGRESSION_PRESETS,
78
124
  PROGRESSION_STYLES,
125
+ SCALE_NAMES,
79
126
  SPREADS,
80
127
  keyName,
81
128
  parseKey,
82
129
  } from "../../core/chords.ts";
130
+ import {
131
+ TUNING_LIMITS,
132
+ TUNING_MAPS,
133
+ TUNING_NAMES,
134
+ describeTuning,
135
+ type Tuning,
136
+ } from "../../core/tuning.ts";
137
+ import { rootName, tuningStepsText } from "../commands/tuning.ts";
138
+ import {
139
+ granularBrowseNodes,
140
+ granularMenuDetail,
141
+ granularMenuLabel,
142
+ granularMenuNodes,
143
+ } from "./granular-menu.ts";
83
144
  import {
84
145
  ARP_RATES,
85
146
  CHORD_MODES,
@@ -215,6 +276,8 @@ export type MenuResult =
215
276
  | { type: "unhover"; key: string }
216
277
  /** Chose a list item while the loop plays: stage it for good. */
217
278
  | { type: "choose"; command: string; key: string }
279
+ /** Enter on a number row: open the fader drawer on this level's values. */
280
+ | { type: "fader"; label: string }
218
281
  | { type: "pass" };
219
282
 
220
283
  export type MenuView = Readonly<{
@@ -285,7 +348,7 @@ const TRACK_LANE_LABEL: Readonly<Record<TrackAutomationParameter, string>> = {
285
348
  const FX_LANE_INFO = new Map(FX_LANES.map((entry) => [entry.lane, entry]));
286
349
 
287
350
  /** Nudge for a number spec: linear by its step, or a sixth of an octave. */
288
- function specStep(
351
+ export function specStep(
289
352
  spec: NumberParam,
290
353
  ): (value: number, direction: 1 | -1) => number {
291
354
  if (spec.step === "log")
@@ -318,7 +381,12 @@ function laneLabel(lane: AutomationParameter): string {
318
381
  if (!info) return lane;
319
382
  const unit = info.spec.unit ? ` (${info.spec.unit})` : "";
320
383
  const owner =
321
- info.effect === "synth" ? "synth" : effectSpec(info.effect).label;
384
+ info.effect === "synth" ||
385
+ info.effect === "string" ||
386
+ info.effect === "keys" ||
387
+ info.effect === "modal"
388
+ ? info.effect
389
+ : effectSpec(info.effect).label;
322
390
  return `${owner} ${info.param}${unit}`;
323
391
  }
324
392
 
@@ -389,10 +457,20 @@ export function rootNodes(context: MenuContext): MenuNode[] {
389
457
  kind: "menu",
390
458
  id: "project",
391
459
  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",
460
+ detail: context.score.time
461
+ ? `${tempoDetail(context.score)} · ${context.score.bars} bars`
462
+ : `${num(context.score.tempoBpm)} BPM · ${context.score.beatsPerBar}/4 · ${context.score.bars} bars`,
463
+ help: "tempo, meter, tempo map, loop length, play, click and count-in",
394
464
  build: transportNodes,
395
465
  },
466
+ {
467
+ kind: "menu",
468
+ id: "arrange",
469
+ label: "Arrange",
470
+ detail: arrangeDetail(context),
471
+ help: "song sections, the form, builds, drops and fills",
472
+ build: arrangeNodes,
473
+ },
396
474
  ];
397
475
  }
398
476
 
@@ -401,20 +479,90 @@ const SECTION_ALIASES: Readonly<Record<string, readonly string[]>> = {
401
479
  sound: ["sound"],
402
480
  parameters: ["sound"],
403
481
  sounds: ["sound", "browse"],
482
+ performance: ["sound", "performance"],
483
+ expression: ["sound", "performance"],
404
484
  effects: ["effects"],
405
485
  rhythm: ["rhythm"],
406
486
  chords: ["chords"],
407
487
  mix: ["mix"],
408
488
  track: ["mix"],
409
489
  automation: ["mix", "automation"],
490
+ master: ["mix", "master"],
410
491
  project: ["project"],
411
492
  transport: ["project"],
493
+ tempo: ["project", "tempo"],
494
+ meter: ["project", "tempo"],
495
+ time: ["project", "tempo"],
496
+ tuning: ["project", "tuning"],
497
+ scale: ["project", "tuning"],
498
+ arrange: ["arrange"],
499
+ sections: ["arrange"],
500
+ form: ["arrange"],
412
501
  };
413
502
 
414
503
  /** Sound: instrument and voice first, then the sound browser. */
415
504
  function soundSectionNodes(context: MenuContext): MenuNode[] {
505
+ const track = focused(context);
506
+ const tuning: MenuNode[] =
507
+ track && !isDrumInstrument(track.instrument)
508
+ ? [
509
+ {
510
+ kind: "menu",
511
+ id: "track-tuning",
512
+ label: "tuning",
513
+ detail: track.tuning
514
+ ? tuningLabel(track.tuning)
515
+ : `song · ${tuningLabel(context.score.tuning)}`,
516
+ help: "this track's tuning, or follow the song",
517
+ build: trackTuningNodes,
518
+ },
519
+ ]
520
+ : [];
521
+ const grainLabel = granularMenuLabel(track);
522
+ const granular: MenuNode[] =
523
+ track && grainLabel
524
+ ? [
525
+ {
526
+ kind: "menu",
527
+ id: "granular",
528
+ label: grainLabel,
529
+ detail: granularMenuDetail(track),
530
+ help: "grain clouds: preset, source, position, scan, grain size, pitch, shimmer",
531
+ build: (inner) => granularMenuNodes(focused(inner)),
532
+ },
533
+ ]
534
+ : [];
535
+ const keys: MenuNode[] =
536
+ track && isPianoFamily(track.instrument) && track.keys
537
+ ? [
538
+ {
539
+ kind: "menu",
540
+ id: "keys",
541
+ label: "keys",
542
+ detail: `${track.instrument}${track.keys.preset ? ` · ${track.keys.preset}` : ""}`,
543
+ help: "the modelled piano: preset, touch, hammers, dampers, stretch",
544
+ build: (inner) => {
545
+ const current = focused(inner);
546
+ return current
547
+ ? keysNodes(current, Object.keys(KEYS_PARAMS))
548
+ : [];
549
+ },
550
+ },
551
+ ]
552
+ : [];
416
553
  return [
417
554
  ...parameterNodes(context),
555
+ ...granular,
556
+ ...keys,
557
+ ...tuning,
558
+ {
559
+ kind: "menu",
560
+ id: "performance",
561
+ label: "performance",
562
+ detail: performanceDetail(focused(context)),
563
+ help: "articulation, glide, bend, vibrato, pedal, velocity curve, humanize",
564
+ build: performanceNodes,
565
+ },
418
566
  {
419
567
  kind: "menu",
420
568
  id: "browse",
@@ -464,7 +612,184 @@ function mixSectionNodes(context: MenuContext): MenuNode[] {
464
612
  help: "points that move a value over the loop",
465
613
  build: automationNodes,
466
614
  },
615
+ {
616
+ kind: "menu",
617
+ id: "master",
618
+ label: "master",
619
+ detail: context.score.master
620
+ ? describeMaster(context.score.master)
621
+ : "off · bypass",
622
+ help: "the song master on the summed mix: EQ, glue, tape, width, limiter",
623
+ build: masterNodes,
624
+ },
625
+ ];
626
+ }
627
+
628
+ /** Mix & automation > Master: the loudness target, then each unit in order. */
629
+ function masterNodes(context: MenuContext): MenuNode[] {
630
+ const master = context.score.master;
631
+ // A name only when the LUFS and the limiter ceiling both match it, and no
632
+ // other name shares them (apple and podcast are both -16 at -1 dBTP).
633
+ const matches = LOUDNESS_TARGET_NAMES.filter(
634
+ (name) =>
635
+ LOUDNESS_TARGETS[name].lufs === master?.target &&
636
+ LOUDNESS_TARGETS[name].ceiling ===
637
+ (master?.limiter?.ceiling ?? MASTER_LIMITS.safeCeiling),
638
+ );
639
+ const named =
640
+ matches.length === 1
641
+ ? matches[0]
642
+ : matches.length > 1
643
+ ? `${num(master!.target!)} LUFS`
644
+ : undefined;
645
+ const nodes: MenuNode[] = [
646
+ {
647
+ kind: "choice",
648
+ label: "target",
649
+ value: master?.target === undefined ? "off" : (named ?? "custom"),
650
+ options: ["off", ...LOUDNESS_TARGET_NAMES],
651
+ command: (name) => `master target ${name}`,
652
+ help: "named loudness target; loud ones also load a fast limiter",
653
+ },
654
+ {
655
+ kind: "number",
656
+ label: "target LUFS",
657
+ value: master?.target,
658
+ start: -14,
659
+ off: "off",
660
+ min: MASTER_LIMITS.minTarget,
661
+ max: MASTER_LIMITS.maxTarget,
662
+ step: (value, direction) =>
663
+ clamp(
664
+ Math.round((value + direction * 0.5) * 2) / 2,
665
+ MASTER_LIMITS.minTarget,
666
+ MASTER_LIMITS.maxTarget,
667
+ ),
668
+ format: (value) => `${num(value)} LUFS`,
669
+ command: (value) => `master target ${num(value)}`,
670
+ reset: "master target off",
671
+ help: "integrated loudness renders normalize to (EBU R 128 / BS.1770)",
672
+ },
467
673
  ];
674
+ for (const unit of MASTER_UNITS) {
675
+ const spec = MASTER_SPECS[unit];
676
+ const values = master?.[unit];
677
+ nodes.push({
678
+ kind: "menu",
679
+ id: `master:${unit}`,
680
+ label: unit,
681
+ detail: values ? describeUnit(unit, values) : "off",
682
+ help: spec.doc,
683
+ build: (inner) => masterUnitNodes(inner, unit, false),
684
+ });
685
+ }
686
+ nodes.push({
687
+ kind: "action",
688
+ label: "measure the mix",
689
+ command: "master measure",
690
+ help: "render and report LUFS, true peak, LRA, balance and correlation",
691
+ });
692
+ if (master)
693
+ nodes.push({
694
+ kind: "action",
695
+ label: "remove master",
696
+ command: "master off",
697
+ help: "bypass: the mix renders exactly as without a master",
698
+ });
699
+ return nodes;
700
+ }
701
+
702
+ /** One master unit: on/off, preset, its simple params, then `advanced`. */
703
+ function masterUnitNodes(
704
+ context: MenuContext,
705
+ unit: MasterUnit,
706
+ advanced: boolean,
707
+ ): MenuNode[] {
708
+ const spec = MASTER_SPECS[unit];
709
+ const values = context.score.master?.[unit];
710
+ const nodes: MenuNode[] = [];
711
+ if (!advanced) {
712
+ nodes.push({
713
+ kind: "toggle",
714
+ label: "on",
715
+ value: values !== undefined,
716
+ command: (on) => `master ${unit} ${on ? "on" : "off"}`,
717
+ help: `switch the master ${spec.label} on or off`,
718
+ });
719
+ const presets = Object.keys(MASTER_PRESETS[unit]);
720
+ if (presets.length > 0)
721
+ nodes.push({
722
+ kind: "choice",
723
+ label: "preset",
724
+ value: "—",
725
+ options: presets,
726
+ command: (preset) => `master ${unit} preset ${preset}`,
727
+ help: "a starting point; every value stays editable",
728
+ });
729
+ }
730
+ const keys = advanced ? Object.keys(spec.params) : spec.simple;
731
+ for (const key of keys) {
732
+ const param = spec.params[key]!;
733
+ const current = values?.[key];
734
+ // With a target the search sets the limiter's drive; its gain is unused.
735
+ if (
736
+ unit === "limiter" &&
737
+ key === "gain" &&
738
+ context.score.master?.target !== undefined
739
+ ) {
740
+ nodes.push({ kind: "info", label: key, value: "set by target" });
741
+ continue;
742
+ }
743
+ if (param.kind === "number") {
744
+ nodes.push({
745
+ kind: "number",
746
+ label: key,
747
+ value: typeof current === "number" ? current : undefined,
748
+ start: param.default,
749
+ off: values
750
+ ? withUnit(formatParam(param, param.default), param.unit)
751
+ : "off",
752
+ min: param.min,
753
+ max: param.max,
754
+ step: specStep(param),
755
+ format: (value) => withUnit(formatParam(param, value), param.unit),
756
+ command: (value) =>
757
+ `master ${unit} ${key} ${formatParam(param, value)}`,
758
+ help: param.doc,
759
+ ...(values
760
+ ? {
761
+ reset: `master ${unit} ${key} ${formatParam(param, param.default)}`,
762
+ }
763
+ : {}),
764
+ });
765
+ } else if (param.kind === "enum") {
766
+ nodes.push({
767
+ kind: "choice",
768
+ label: key,
769
+ value: typeof current === "string" ? current : param.default,
770
+ options: param.values,
771
+ command: (option) => `master ${unit} ${key} ${option}`,
772
+ help: param.doc,
773
+ });
774
+ } else {
775
+ nodes.push({
776
+ kind: "toggle",
777
+ label: key,
778
+ value: typeof current === "boolean" ? current : param.default,
779
+ command: (on) => `master ${unit} ${key} ${on ? "on" : "off"}`,
780
+ help: param.doc,
781
+ });
782
+ }
783
+ }
784
+ if (!advanced && Object.keys(spec.params).length > spec.simple.length)
785
+ nodes.push({
786
+ kind: "menu",
787
+ id: `master:${unit}:advanced`,
788
+ label: "advanced",
789
+ detail: `all ${Object.keys(spec.params).length} params`,
790
+ build: (inner) => masterUnitNodes(inner, unit, true),
791
+ });
792
+ return nodes;
468
793
  }
469
794
 
470
795
  function chordsDetail(context: MenuContext): string {
@@ -478,7 +803,8 @@ function chordNodes(context: MenuContext): MenuNode[] {
478
803
  const chords = context.chords ?? defaultChordSettings();
479
804
  const key = parseKey(context.score.key ?? undefined);
480
805
  const tonic = key ? keyName(key).split(" ")[0]! : "C";
481
- const mode = key?.mode ?? "major";
806
+ // A library scale (D hijaz, C yaman) survives a tonic change.
807
+ const mode: string = key?.scale ?? key?.mode ?? "major";
482
808
  const presets = PROGRESSION_PRESETS.map((preset) => preset.name);
483
809
  return [
484
810
  {
@@ -500,9 +826,9 @@ function chordNodes(context: MenuContext): MenuNode[] {
500
826
  {
501
827
  kind: "choice",
502
828
  label: "key mode",
503
- help: "major, minor or a church mode",
829
+ help: "major, minor, a church mode or any library scale",
504
830
  value: mode,
505
- options: MODE_NAMES,
831
+ options: [...MODE_NAMES, ...SCALE_NAMES],
506
832
  command: (option) => `key ${tonic} ${option}`,
507
833
  },
508
834
  {
@@ -643,7 +969,7 @@ function trackNodes(context: MenuContext): MenuNode[] {
643
969
  }
644
970
 
645
971
  function instrumentNode(track: Track): MenuNode {
646
- const options: string[] = [...AVAILABLE_INSTRUMENTS];
972
+ const options: string[] = [...AVAILABLE_INSTRUMENTS, ...PIANO_FAMILIES];
647
973
  if (!options.includes(track.instrument)) options.push(track.instrument);
648
974
  return {
649
975
  kind: "choice",
@@ -696,6 +1022,30 @@ function parameterNodes(context: MenuContext): MenuNode[] {
696
1022
  // A wavetable track also has the synth voice's envelope, filters and FM.
697
1023
  if (isWavetableInstrument(track.instrument))
698
1024
  nodes.push(...wavetableNodes(track, context.projectRoot));
1025
+ if (isStringTrack(track)) {
1026
+ nodes.push(...stringNodes(track, STRING_SIMPLE_PARAMS));
1027
+ nodes.push({
1028
+ kind: "menu",
1029
+ id: "string:advanced",
1030
+ label: "advanced",
1031
+ detail: `all ${Object.keys(STRING_PARAMS).length} params`,
1032
+ help: "every string parameter: exciter, loss, body, buzz, sympathetics",
1033
+ build: (inner) => {
1034
+ const current = focused(inner);
1035
+ return current
1036
+ ? stringNodes(current, Object.keys(STRING_PARAMS), false)
1037
+ : [];
1038
+ },
1039
+ });
1040
+ if (Object.keys(track.string ?? {}).some((key) => key !== "preset"))
1041
+ nodes.push({
1042
+ kind: "action",
1043
+ label: "reset to preset",
1044
+ command: "string reset",
1045
+ help: "drop this track's string overrides, keep the preset",
1046
+ });
1047
+ return nodes;
1048
+ }
699
1049
  if (track.sampler) {
700
1050
  nodes.push({
701
1051
  kind: "info",
@@ -722,6 +1072,22 @@ function parameterNodes(context: MenuContext): MenuNode[] {
722
1072
  label: "add a voice",
723
1073
  value: "/sample <path> [as <voice>]",
724
1074
  });
1075
+ } else if (isPianoFamily(track.instrument) && track.keys) {
1076
+ nodes.push(...keysNodes(track, KEYS_SIMPLE));
1077
+ nodes.push({
1078
+ kind: "menu",
1079
+ id: "keys:all",
1080
+ label: "all piano params",
1081
+ detail: `all ${Object.keys(KEYS_PARAMS).length} params`,
1082
+ help: "every modelled piano parameter",
1083
+ build: (inner) => {
1084
+ const current = focused(inner);
1085
+ return current ? keysNodes(current, Object.keys(KEYS_PARAMS)) : [];
1086
+ },
1087
+ });
1088
+ } else if (track.instrument === "modal") {
1089
+ // 0.6 modal percussion: preset, mallet and MODAL_PARAMS rows.
1090
+ nodes.push(...modalParameterNodes(track));
725
1091
  } else if (!isDrumInstrument(track.instrument)) {
726
1092
  nodes.push(...synthNodes(track, SYNTH_SIMPLE));
727
1093
  nodes.push({
@@ -829,6 +1195,44 @@ function sampleVoiceNodes(context: MenuContext, voice: string): MenuNode[] {
829
1195
  label: "cut / loopAt",
830
1196
  value: `/sample set ${voice} cut hats · loopAt 2`,
831
1197
  },
1198
+ // 0.6 fit: the sample's own tempo, its length in beats, and how it fits.
1199
+ {
1200
+ kind: "number",
1201
+ label: "bpm",
1202
+ value: ref.bpm,
1203
+ start: Math.round(context.score.tempoBpm),
1204
+ off: "off",
1205
+ min: SCORE_LIMITS.minSampleBpm,
1206
+ max: SCORE_LIMITS.maxSampleBpm,
1207
+ step: linear(1, SCORE_LIMITS.minSampleBpm, SCORE_LIMITS.maxSampleBpm),
1208
+ format: num,
1209
+ command: (value) => `/bpm ${num(value)} ${voice}`,
1210
+ reset: `/bpm off ${voice}`,
1211
+ help: SAMPLE_CONTROLS.bpm,
1212
+ },
1213
+ {
1214
+ kind: "choice",
1215
+ label: "fitmode",
1216
+ // off: unset (plays as repitch); auto suggests one from the sound.
1217
+ value: ref.fitmode ?? "off",
1218
+ options: ["off", ...SAMPLE_FIT_MODES, "auto"],
1219
+ command: (option) => `/fitmode ${option} ${voice}`,
1220
+ help: `${SAMPLE_CONTROLS.fitmode} · beats for drums, tones for pads · set bpm or len first`,
1221
+ },
1222
+ {
1223
+ kind: "number",
1224
+ label: "len",
1225
+ value: ref.len,
1226
+ start: 4,
1227
+ off: "off",
1228
+ min: 0.25,
1229
+ max: SCORE_LIMITS.maxSampleLenBeats,
1230
+ step: linear(0.25, 0.25, SCORE_LIMITS.maxSampleLenBeats),
1231
+ format: num,
1232
+ command: (value) => `/len ${num(value)} ${voice}`,
1233
+ reset: `/len off ${voice}`,
1234
+ help: SAMPLE_CONTROLS.len,
1235
+ },
832
1236
  );
833
1237
  return nodes;
834
1238
  }
@@ -845,6 +1249,57 @@ function matchingSynthPreset(track: Track): string | undefined {
845
1249
  )?.[0];
846
1250
  }
847
1251
 
1252
+ /** Modelled piano rows: the preset, then each parameter (`keys <p> <v>`). */
1253
+ function keysNodes(track: Track, params: readonly string[]): MenuNode[] {
1254
+ const effective = resolvedKeys(track.instrument, track.keys);
1255
+ const nodes: MenuNode[] = [
1256
+ {
1257
+ kind: "choice",
1258
+ label: "preset",
1259
+ value: track.keys?.preset ?? "—",
1260
+ options: Object.keys(KEYS_PRESETS),
1261
+ command: (preset) => `keys preset ${preset}`,
1262
+ help: "a starting piano; every value stays editable",
1263
+ },
1264
+ ];
1265
+ for (const key of params) {
1266
+ const param = KEYS_PARAMS[key]!;
1267
+ const stored = track.keys?.[key];
1268
+ if (param.kind === "number") {
1269
+ const base = effective[key] as number;
1270
+ nodes.push({
1271
+ kind: "number",
1272
+ label: key,
1273
+ value: typeof stored === "number" ? stored : undefined,
1274
+ start: base,
1275
+ off: withUnit(formatParam(param, base), param.unit),
1276
+ min: param.min,
1277
+ max: param.max,
1278
+ step: specStep(param),
1279
+ format: (value) => withUnit(formatParam(param, value), param.unit),
1280
+ command: (value) => `keys ${key} ${formatParam(param, value)}`,
1281
+ reset: `keys ${key} off`,
1282
+ help: param.doc,
1283
+ });
1284
+ } else if (param.kind === "enum")
1285
+ nodes.push({
1286
+ kind: "choice",
1287
+ label: key,
1288
+ value: typeof stored === "string" ? stored : String(effective[key]),
1289
+ options: param.values,
1290
+ command: (option) => `keys ${key} ${option}`,
1291
+ help: param.doc,
1292
+ });
1293
+ }
1294
+ nodes.push({
1295
+ kind: "action",
1296
+ label: "reset to the family",
1297
+ command: "keys reset",
1298
+ help: "clear every override and the preset, and the effects the preset added (keeps the family's own sound)",
1299
+ });
1300
+ return nodes;
1301
+ }
1302
+
848
1303
  function synthNodes(track: Track, keys: readonly string[]): MenuNode[] {
849
1304
  const nodes: MenuNode[] = [];
850
1305
  if (keys === SYNTH_SIMPLE)
@@ -915,6 +1370,57 @@ const SYNTH_LABEL: Readonly<Record<string, string>> = {
915
1370
  fm: "FM amount",
916
1371
  };
917
1372
 
1373
+ /** String preset first (simple view), then one row per parameter. */
1374
+ function stringNodes(
1375
+ track: Track,
1376
+ keys: readonly string[],
1377
+ withPreset = true,
1378
+ ): MenuNode[] {
1379
+ const nodes: MenuNode[] = [];
1380
+ const preset = STRING_PRESETS[stringPresetOf(track.string)]!;
1381
+ if (withPreset)
1382
+ nodes.push({
1383
+ kind: "choice",
1384
+ label: "preset",
1385
+ help: preset.doc,
1386
+ value: stringPresetOf(track.string),
1387
+ options: STRING_PRESET_NAMES,
1388
+ command: (name) => `string preset ${name}`,
1389
+ });
1390
+ for (const key of keys) {
1391
+ const param = STRING_PARAMS[key]!;
1392
+ const own = track.string?.[key];
1393
+ const base = preset.values[key] ?? param.default;
1394
+ if (param.kind === "number")
1395
+ nodes.push({
1396
+ kind: "number",
1397
+ label: param.unit ? `${key} ${param.unit}` : key,
1398
+ help: param.doc,
1399
+ value: typeof own === "number" ? own : undefined,
1400
+ start: typeof base === "number" ? base : param.default,
1401
+ off: formatParam(
1402
+ param,
1403
+ typeof base === "number" ? base : param.default,
1404
+ ),
1405
+ min: param.min,
1406
+ max: param.max,
1407
+ step: specStep(param),
1408
+ format: (value) => formatParam(param, value),
1409
+ command: (value) => `string ${key} ${formatParam(param, value)}`,
1410
+ });
1411
+ else if (param.kind === "enum")
1412
+ nodes.push({
1413
+ kind: "choice",
1414
+ label: key,
1415
+ help: param.doc,
1416
+ value: typeof own === "string" ? own : String(base),
1417
+ options: param.values,
1418
+ command: (option) => `string ${key} ${option}`,
1419
+ });
1420
+ }
1421
+ return nodes;
1422
+ }
1423
+
918
1424
  /** Every synth parameter, grouped (amplitude, oscillator, FM 1..8, …). */
919
1425
  function synthAdvancedNodes(context: MenuContext): MenuNode[] {
920
1426
  const track = focused(context);
@@ -1064,10 +1570,38 @@ function effectNodes(context: MenuContext): MenuNode[] {
1064
1570
  build: (inner) => effectParamNodes(inner, effect, false),
1065
1571
  };
1066
1572
  };
1067
- const more = EFFECT_NAMES.filter((effect) => !CORE_EFFECTS.includes(effect));
1573
+ const rigStages = RIG_STAGES as readonly string[];
1574
+ const more = EFFECT_NAMES.filter(
1575
+ (effect) => !CORE_EFFECTS.includes(effect) && !rigStages.includes(effect),
1576
+ );
1068
1577
  const moreOn = more.filter((effect) => effectValues(track, effect));
1578
+ // 0.6 guitar rig: stomp → head (with its gate) → cab, plus whole rigs.
1579
+ const rigOn = RIG_STAGES.filter((stage) => effectValues(track, stage));
1580
+ const rig: MenuNode = {
1581
+ kind: "menu",
1582
+ id: "guitar rig",
1583
+ label: "Guitar rig",
1584
+ help: "stomp box, amp head with noise gate and speaker cabinet; `rig <name>` loads a whole rig",
1585
+ detail:
1586
+ rigOn.length === 0 ? "off" : (rigPresetOf(track.fx) ?? rigOn.join(" → ")),
1587
+ build: (inner) => {
1588
+ const current = focused(inner);
1589
+ return [
1590
+ {
1591
+ kind: "choice",
1592
+ label: "rig",
1593
+ value: rigPresetOf(current?.fx) ?? "—",
1594
+ options: [...Object.keys(RIG_PRESETS), "reset"],
1595
+ command: (name) => `rig ${name}`,
1596
+ help: "a whole rig (stomp, head, cab); every stage stays editable",
1597
+ },
1598
+ ...RIG_STAGES.map(node),
1599
+ ];
1600
+ },
1601
+ };
1069
1602
  return [
1070
1603
  ...CORE_EFFECTS.map(node),
1604
+ rig,
1071
1605
  {
1072
1606
  kind: "menu",
1073
1607
  id: "more effects",
@@ -1235,6 +1769,9 @@ function automationNodes(context: MenuContext): MenuNode[] {
1235
1769
  const info = FX_LANE_INFO.get(lane as FxLane);
1236
1770
  if (info?.effect === "synth")
1237
1771
  return track.synth?.[info.param] !== undefined;
1772
+ if (info?.effect === "string") return track.string !== undefined;
1773
+ if (info?.effect === "keys") return track.keys !== undefined;
1774
+ if (info?.effect === "modal") return track.modal !== undefined;
1238
1775
  return info !== undefined && effectValues(track, info.effect) !== undefined;
1239
1776
  });
1240
1777
  const hidden = AUTOMATION_PARAMETERS.filter((lane) => !shown.includes(lane));
@@ -1310,7 +1847,7 @@ function laneNodes(
1310
1847
  value: "two points",
1311
1848
  placeholder: "from-beat:value to-beat:value",
1312
1849
  command: (text) => pointsCommand(lane, text, 2),
1313
- example: `automate ${lane} points 0:${laneFormat(lane, min)} ${context.score.beatsPerBar * context.score.bars}:${laneFormat(lane, max)}`,
1850
+ example: `automate ${lane} points 0:${laneFormat(lane, min)} ${loopTicksOf(context.score) / context.score.ticksPerBeat}:${laneFormat(lane, max)}`,
1314
1851
  },
1315
1852
  ];
1316
1853
  for (const point of points)
@@ -1416,14 +1953,54 @@ function patternsMenu(): MenuNode {
1416
1953
  };
1417
1954
  }
1418
1955
 
1956
+ const GRANULAR_PRESETS_COUNT = granularBrowseNodes().length;
1957
+
1419
1958
  function soundNodes(): MenuNode[] {
1420
1959
  return [
1960
+ {
1961
+ kind: "menu",
1962
+ id: "group:keys",
1963
+ label: "Keys",
1964
+ help: "modelled pianos, built in (no download)",
1965
+ detail: Object.keys(KEYS_PRESETS).join(" "),
1966
+ build: () =>
1967
+ Object.entries(KEYS_PRESETS).map(([name, preset]): MenuNode => ({
1968
+ kind: "action",
1969
+ label: `${name.padEnd(10)} ${preset.doc}`,
1970
+ command: `piano ${name}`,
1971
+ help: preset.styles,
1972
+ })),
1973
+ },
1421
1974
  {
1422
1975
  kind: "action",
1423
1976
  label: "wavetable synth basic shapes morph · built-in",
1424
1977
  command: "wt basic",
1425
1978
  help: "switch this track to the morphing wavetable synth",
1426
1979
  },
1980
+ // 0.6 instrument groups, one per lane.
1981
+ malletsMenu(),
1982
+ {
1983
+ kind: "menu",
1984
+ id: "strings",
1985
+ label: "Strings",
1986
+ help: "plucked strings: guitars, basses, sitar, harpsichord, oud, koto…",
1987
+ detail: `${STRING_PRESET_NAMES.length} plucked · built-in`,
1988
+ build: () =>
1989
+ STRING_PRESET_NAMES.map((name): MenuNode => ({
1990
+ kind: "action",
1991
+ label: `${name.padEnd(12)} ${STRING_PRESETS[name]!.doc}`,
1992
+ command: `string ${name}`,
1993
+ help: STRING_PRESETS[name]!.styles,
1994
+ })),
1995
+ },
1996
+ {
1997
+ kind: "menu",
1998
+ id: "granular-browse",
1999
+ label: "Granular",
2000
+ help: "grain clouds from a built-in synth or this track's own sound",
2001
+ detail: `${GRANULAR_PRESETS_COUNT} presets · built-in`,
2002
+ build: granularBrowseNodes,
2003
+ },
1427
2004
  {
1428
2005
  kind: "menu",
1429
2006
  id: "instruments",
@@ -1506,9 +2083,10 @@ function transportNodes(context: MenuContext): MenuNode[] {
1506
2083
  min: 1,
1507
2084
  max: SCORE_LIMITS.maxBeatsPerBar,
1508
2085
  step: linear(1, 1, SCORE_LIMITS.maxBeatsPerBar),
1509
- format: (value) => `${value}/4`,
1510
- command: (value) => `meter ${Math.round(value)}`,
2086
+ format: (value) => `${value}/${openingUnit(score)}`,
2087
+ command: (value) => openingMeterCommand(score, value),
1511
2088
  },
2089
+ tempoMenuNode(context),
1512
2090
  {
1513
2091
  kind: "number",
1514
2092
  label: "loop length",
@@ -1543,9 +2121,226 @@ function transportNodes(context: MenuContext): MenuNode[] {
1543
2121
  options: ["0", "1", "2"],
1544
2122
  command: (option) => `/count-in ${option}`,
1545
2123
  },
2124
+ {
2125
+ kind: "menu",
2126
+ id: "tuning",
2127
+ label: "tuning & scale",
2128
+ detail: `${tuningLabel(score.tuning)} · ${keyLabel(score.key)}`,
2129
+ help: "the song tuning (12-TET, EDOs, just, gamelan, Scala) and scale",
2130
+ build: songTuningNodes,
2131
+ },
1546
2132
  ];
1547
2133
  }
1548
2134
 
2135
+ // ── tuning ────────────────────────────────────────────────────────────
2136
+
2137
+ /** Short name of a tuning for a choice row: its library name or table. */
2138
+ function tuningLabel(tuning: Tuning | undefined): string {
2139
+ if (!tuning) return "12-tet";
2140
+ if (tuning.name) return tuning.name;
2141
+ if (tuning.edo) return `${tuning.edo}-edo`;
2142
+ if (tuning.scl) return tuning.scl.split("/").at(-1)!;
2143
+ if (tuning.ratios || tuning.cents) return "custom";
2144
+ return "12-tet";
2145
+ }
2146
+
2147
+ function keyLabel(key: string | null | undefined): string {
2148
+ const parsed = parseKey(key ?? undefined);
2149
+ return parsed ? keyName(parsed) : "no key";
2150
+ }
2151
+
2152
+ const ROOT_OPTIONS = [
2153
+ "auto",
2154
+ "C4",
2155
+ "Db4",
2156
+ "D4",
2157
+ "Eb4",
2158
+ "E4",
2159
+ "F4",
2160
+ "Gb4",
2161
+ "G4",
2162
+ "Ab4",
2163
+ "A4",
2164
+ "Bb4",
2165
+ "B4",
2166
+ ] as const;
2167
+
2168
+ /** Rows shared by the song and track tuning screens. */
2169
+ function tuningRows(
2170
+ tuning: Tuning | undefined,
2171
+ prefix: string,
2172
+ first: MenuNode,
2173
+ song?: Tuning,
2174
+ ): MenuNode[] {
2175
+ // A track screen shows the song's ref and root where the track has none.
2176
+ const ref = tuning?.ref ?? song?.ref;
2177
+ const refFrom =
2178
+ tuning?.ref === undefined && ref !== undefined ? " · song" : "";
2179
+ const root = tuning?.root ?? song?.root;
2180
+ const rootFrom =
2181
+ tuning?.root === undefined && root !== undefined ? " · song" : "";
2182
+ const nodes: MenuNode[] = [
2183
+ first,
2184
+ {
2185
+ kind: "info",
2186
+ label: "steps (cents)",
2187
+ value: tuningStepsText(tuning),
2188
+ help: describeTuning(tuning),
2189
+ },
2190
+ {
2191
+ kind: "number",
2192
+ label: "reference A4",
2193
+ help: "12-TET A4 in Hz; the root sounds at its 12-TET pitch from it · x: 440",
2194
+ value: ref,
2195
+ off: "440 Hz",
2196
+ start: 440,
2197
+ min: TUNING_LIMITS.minRefHz,
2198
+ max: TUNING_LIMITS.maxRefHz,
2199
+ step: linear(1, TUNING_LIMITS.minRefHz, TUNING_LIMITS.maxRefHz),
2200
+ format: (value) => `${num(value)} Hz${refFrom}`,
2201
+ command: (value) => `${prefix} ref ${num(value)}`,
2202
+ reset: `${prefix} ref off`,
2203
+ },
2204
+ {
2205
+ kind: "choice",
2206
+ label: "root",
2207
+ help: song
2208
+ ? `the key that plays degree 0; auto follows the song tuning's root${rootFrom ? " (now the song's)" : ""}`
2209
+ : "the key that plays degree 0; auto follows the song key",
2210
+ value: rootName(root),
2211
+ options: ROOT_OPTIONS,
2212
+ command: (option) => `${prefix} root ${option}`,
2213
+ },
2214
+ {
2215
+ kind: "choice",
2216
+ label: "keys",
2217
+ help: "linear: one key per step (Scala) · nearest: each of 12 keys to its nearest step",
2218
+ value: tuning?.map ?? "linear",
2219
+ options: TUNING_MAPS,
2220
+ command: (option) => `${prefix} map ${option}`,
2221
+ },
2222
+ {
2223
+ kind: "entry",
2224
+ label: "equal steps",
2225
+ value: tuning?.edo ? String(tuning.edo) : "",
2226
+ placeholder: "steps per octave, e.g. 22",
2227
+ command: (text) =>
2228
+ /^\d+$/.test(text.trim()) ? `${prefix} edo ${text.trim()}` : undefined,
2229
+ example: `${prefix} edo 22`,
2230
+ help: "n equal divisions of the octave",
2231
+ },
2232
+ {
2233
+ kind: "entry",
2234
+ label: "Scala file",
2235
+ value: tuning?.scl ?? "",
2236
+ placeholder: "path to a .scl file",
2237
+ command: (text) =>
2238
+ text.trim() ? `${prefix} scl ${text.trim()}` : undefined,
2239
+ example: `${prefix} scl tunings/meantone.scl`,
2240
+ help: "a Scala scale; files outside the project are copied into tunings/",
2241
+ },
2242
+ {
2243
+ kind: "entry",
2244
+ label: "ratios",
2245
+ value: tuning?.ratios?.join(" ") ?? "",
2246
+ placeholder: "just ratios, the last is the period",
2247
+ command: (text) =>
2248
+ text.trim() ? `${prefix} ratios ${text.trim()}` : undefined,
2249
+ example: `${prefix} ratios 9/8 5/4 3/2 2/1`,
2250
+ help: "a just-intonation table: ratios for degrees 1..n",
2251
+ },
2252
+ {
2253
+ kind: "entry",
2254
+ label: "cents",
2255
+ value: tuning?.cents && !tuning.scl ? tuning.cents.join(" ") : "",
2256
+ placeholder: "cents, the last is the period",
2257
+ command: (text) =>
2258
+ /^[-+\d.\s]+$/.test(text.trim())
2259
+ ? `${prefix} cents ${text.trim()}`
2260
+ : undefined,
2261
+ example: `${prefix} cents 231 474 717 955 1200`,
2262
+ help: "a table in cents for degrees 1..n",
2263
+ },
2264
+ {
2265
+ kind: "entry",
2266
+ label: "keyboard map (.kbm)",
2267
+ value: tuning?.kbm ?? "",
2268
+ placeholder: "path to a .kbm file, or off",
2269
+ command: (text) =>
2270
+ text.trim() ? `${prefix} kbm ${text.trim()}` : undefined,
2271
+ example: `${prefix} kbm tunings/white.kbm`,
2272
+ help: "a Scala keyboard map: which key plays which degree; off removes it",
2273
+ },
2274
+ ];
2275
+ return nodes;
2276
+ }
2277
+
2278
+ /** Project › tuning & scale: the song tuning and the song scale. */
2279
+ function songTuningNodes(context: MenuContext): MenuNode[] {
2280
+ const score = context.score;
2281
+ const key = parseKey(score.key ?? undefined);
2282
+ const scale = key ? (key.scale ?? key.mode) : "major";
2283
+ const tonic = key ? keyName(key).split(" ")[0]! : "C";
2284
+ return [
2285
+ ...tuningRows(score.tuning, "tuning", {
2286
+ kind: "choice",
2287
+ label: "tuning",
2288
+ help: "the song tuning; every track without its own follows it",
2289
+ value: tuningLabel(score.tuning),
2290
+ options: TUNING_NAMES,
2291
+ command: (option) =>
2292
+ option === "12-tet" ? "tuning off" : `tuning ${option}`,
2293
+ }),
2294
+ {
2295
+ kind: "choice",
2296
+ label: "scale",
2297
+ help: "the song scale: modes, minors, pentatonics, maqam, ragas, Messiaen",
2298
+ value: scale,
2299
+ options: [...MODE_NAMES, ...SCALE_NAMES],
2300
+ command: (option) => `scale ${option}`,
2301
+ },
2302
+ {
2303
+ kind: "choice",
2304
+ label: "tonic",
2305
+ help: "the song key's tonic; keeps the scale",
2306
+ value: tonic,
2307
+ options: TONICS,
2308
+ command: (option) => `scale ${option} ${scale}`,
2309
+ },
2310
+ {
2311
+ kind: "action",
2312
+ label: "list tunings",
2313
+ command: "tuning list",
2314
+ help: "every library tuning, by family",
2315
+ },
2316
+ {
2317
+ kind: "action",
2318
+ label: "list scales",
2319
+ command: "scale list",
2320
+ help: "every mode and scale, by family",
2321
+ },
2322
+ ];
2323
+ }
2324
+
2325
+ /** Sound › tuning: the focused track's own tuning, or the song's. */
2326
+ function trackTuningNodes(context: MenuContext): MenuNode[] {
2327
+ const track = focused(context);
2328
+ return tuningRows(
2329
+ track?.tuning,
2330
+ "tuning track",
2331
+ {
2332
+ kind: "choice",
2333
+ label: "tuning",
2334
+ help: "song: follow the song tuning · or a tuning for this track only",
2335
+ value: track?.tuning ? tuningLabel(track.tuning) : "song",
2336
+ options: ["song", ...TUNING_NAMES],
2337
+ command: (option) =>
2338
+ option === "song" ? "tuning track off" : `tuning track ${option}`,
2339
+ },
2340
+ context.score.tuning ?? {},
2341
+ );
2342
+ }
2343
+
1549
2344
  // ── controller ────────────────────────────────────────────────────────
1550
2345
 
1551
2346
  type Frame = {
@@ -1639,6 +2434,88 @@ export class EditMenu {
1639
2434
  }
1640
2435
  }
1641
2436
 
2437
+ /**
2438
+ * Open on the level a bare parameter command edits, for the fader drawer:
2439
+ * `volume` / `pan` (Mix), `fx filter` / `fx filter cutoff` (that effect,
2440
+ * in Effects or its "more effects" list). Returns the label to focus, or
2441
+ * undefined when `command` names no such parameter (the menu is closed).
2442
+ */
2443
+ showFader(context: MenuContext, command: string): string | undefined {
2444
+ const words = command.trim().replace(/^\//, "").toLowerCase().split(/\s+/);
2445
+ if (words.length === 1 && (words[0] === "volume" || words[0] === "pan")) {
2446
+ this.show(context, "mix");
2447
+ if (this.stack.length < 2) return this.fail();
2448
+ return this.focusLabel(context, words[0]) ?? this.fail();
2449
+ }
2450
+ if (words[0] !== "fx" || words.length < 2 || words.length > 3)
2451
+ return undefined;
2452
+ const effect = parseEffectName(words[1]!);
2453
+ if (!effect) return undefined;
2454
+ const param = words[2] ? parseParamName(effect, words[2]) : undefined;
2455
+ if (words[2] && !param) return undefined;
2456
+ this.show(context, "effects");
2457
+ if (this.stack.length < 2) return this.fail();
2458
+ if (!this.descend(context, effect)) {
2459
+ if (
2460
+ !this.descend(context, "more effects") ||
2461
+ !this.descend(context, effect)
2462
+ )
2463
+ return this.fail();
2464
+ }
2465
+ const fields = this.faderFields(context);
2466
+ const label = param
2467
+ ? fields.find((field) => field.label === param)?.label
2468
+ : fields.find((field) => field.kind === "number")?.label;
2469
+ if (!label) return this.fail();
2470
+ return this.focusLabel(context, label) ?? this.fail();
2471
+ }
2472
+
2473
+ private fail(): undefined {
2474
+ this.close();
2475
+ return undefined;
2476
+ }
2477
+
2478
+ /** Step into the child menu `id` of the current level. */
2479
+ private descend(context: MenuContext, id: string): boolean {
2480
+ const frame = this.stack.at(-1);
2481
+ if (!frame) return false;
2482
+ const nodes = frame.build(context);
2483
+ const index = nodes.findIndex(
2484
+ (node) => node.kind === "menu" && node.id === id,
2485
+ );
2486
+ const node = nodes[index];
2487
+ if (node?.kind !== "menu") return false;
2488
+ frame.index = index;
2489
+ this.stack.push(childFrame(frame, node));
2490
+ return true;
2491
+ }
2492
+
2493
+ /** Put the cursor on the row labelled `label`; returns it when found. */
2494
+ private focusLabel(context: MenuContext, label: string): string | undefined {
2495
+ const frame = this.stack.at(-1);
2496
+ if (!frame) return undefined;
2497
+ const index = frame
2498
+ .build(context)
2499
+ .findIndex((node) => node.label === label);
2500
+ if (index < 0) return undefined;
2501
+ frame.index = index;
2502
+ return label;
2503
+ }
2504
+
2505
+ /** Select row `index` of the current level (a click), clearing typing. */
2506
+ select(context: MenuContext, index: number): void {
2507
+ const frame = this.stack.at(-1);
2508
+ if (!frame) return;
2509
+ this.entry = undefined;
2510
+ this.filtering = false;
2511
+ frame.index = clamp(index, 0, Math.max(0, this.nodes(context).length - 1));
2512
+ }
2513
+
2514
+ /** The row index under the cursor (the picker's). */
2515
+ get index(): number {
2516
+ return this.stack.at(-1)?.index ?? 0;
2517
+ }
2518
+
1642
2519
  close(): void {
1643
2520
  this.stack = [];
1644
2521
  this.filtering = false;
@@ -1812,20 +2689,23 @@ export class EditMenu {
1812
2689
  candidate.kind === "choice" &&
1813
2690
  candidate.label === node.label,
1814
2691
  ) ?? node;
1815
- return live.options.map((option) => ({
1816
- kind: "action",
1817
- label: option === live.value ? `${option} ✓` : option,
1818
- command: live.command(option),
1819
- }));
2692
+ // The current value is shown, not re-run: re-confirming a
2693
+ // legacy `piano` would otherwise re-voice it as the grand.
2694
+ return live.options.map((option): MenuNode =>
2695
+ option === live.value
2696
+ ? { kind: "info", label: `${option} ✓`, value: "current" }
2697
+ : {
2698
+ kind: "action",
2699
+ label: option,
2700
+ command: live.command(option),
2701
+ },
2702
+ );
1820
2703
  },
1821
2704
  });
1822
2705
  return { type: "handled" };
1823
2706
  }
1824
- if (
1825
- node.kind === "entry" ||
1826
- node.kind === "number" ||
1827
- node.kind === "point"
1828
- ) {
2707
+ if (node.kind === "number") return { type: "fader", label: node.label };
2708
+ if (node.kind === "entry" || node.kind === "point") {
1829
2709
  this.entry = { label: node.label, buffer: "" };
1830
2710
  return { type: "handled" };
1831
2711
  }
@@ -1926,6 +2806,33 @@ export class EditMenu {
1926
2806
  return { type: "handled" };
1927
2807
  }
1928
2808
 
2809
+ /**
2810
+ * The current level's number and choice rows as fader fields (the drawer's
2811
+ * related params: every filter control, every reverb control), built
2812
+ * from `context` (the staged score while auditioning).
2813
+ */
2814
+ faderFields(context: MenuContext): FaderSpec[] {
2815
+ const frame = this.stack.at(-1);
2816
+ if (!frame) return [];
2817
+ return faderSpecs(frame.build(context));
2818
+ }
2819
+
2820
+ /** The same fields as committed, for the drawer's `staged ← committed`. */
2821
+ faderCommitted(context: MenuContext): FaderSpec[] {
2822
+ const audition = context.audition;
2823
+ if (!audition?.dirty) return this.faderFields(context);
2824
+ return this.faderFields({
2825
+ ...context,
2826
+ score: audition.committed,
2827
+ ...(audition.committedChords ? { chords: audition.committedChords } : {}),
2828
+ });
2829
+ }
2830
+
2831
+ /** The breadcrumb, for the drawer's title. */
2832
+ get crumbs(): string {
2833
+ return this.stack.map((level) => level.title).join(" › ");
2834
+ }
2835
+
1929
2836
  /** The picker the TUI draws for the current level. */
1930
2837
  view(context: MenuContext): MenuView {
1931
2838
  const frame = this.stack.at(-1);
@@ -2023,6 +2930,37 @@ function placeholderFor(node: MenuNode | undefined): string {
2023
2930
  return "";
2024
2931
  }
2025
2932
 
2933
+ /** Menu rows → fader fields: numbers and choices, in row order. */
2934
+ export function faderSpecs(nodes: readonly MenuNode[]): FaderSpec[] {
2935
+ const fields: FaderSpec[] = [];
2936
+ for (const node of nodes) {
2937
+ if (node.kind === "number")
2938
+ fields.push({
2939
+ kind: "number",
2940
+ label: node.label,
2941
+ value: node.value,
2942
+ min: node.min,
2943
+ max: node.max,
2944
+ step: node.step,
2945
+ format: node.format,
2946
+ command: node.command,
2947
+ parse: (text) => entryCommand(node, text),
2948
+ reset: node.reset,
2949
+ start: node.start,
2950
+ off: node.off,
2951
+ });
2952
+ else if (node.kind === "choice")
2953
+ fields.push({
2954
+ kind: "choice",
2955
+ label: node.label,
2956
+ value: node.value,
2957
+ options: node.options,
2958
+ command: node.command,
2959
+ });
2960
+ }
2961
+ return fields;
2962
+ }
2963
+
2026
2964
  function entryCommand(node: MenuNode, text: string): string | undefined {
2027
2965
  const trimmed = text.trim();
2028
2966
  if (node.kind === "entry") return node.command(trimmed);
@@ -2134,11 +3072,17 @@ export const MENU_SECTIONS = [
2134
3072
  "rhythm",
2135
3073
  "chords",
2136
3074
  "mix",
3075
+ "master",
2137
3076
  "project",
3077
+ "arrange",
2138
3078
  // Older names, still accepted.
2139
3079
  "parameters",
2140
3080
  "sounds",
2141
3081
  "track",
2142
3082
  "automation",
2143
3083
  "transport",
3084
+ // Subsections.
3085
+ "tempo",
3086
+ "meter",
3087
+ "time",
2144
3088
  ] as const;