@hraness/dawg 0.5.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/DAWG.md +592 -49
  3. package/README.md +4 -4
  4. package/core/chords.ts +492 -4
  5. package/core/diff.ts +9 -0
  6. package/core/expression.ts +143 -6
  7. package/core/fx.ts +689 -2
  8. package/core/granular.ts +619 -0
  9. package/core/instruments.ts +442 -0
  10. package/core/keys.ts +955 -0
  11. package/core/resonators.ts +862 -0
  12. package/core/score.ts +529 -8
  13. package/core/sdk/eval-child.ts +2 -0
  14. package/core/sdk/print.ts +247 -16
  15. package/core/sdk/sync-instruments.ts +58 -0
  16. package/core/sdk/v1.ts +2417 -40
  17. package/core/sections.ts +44 -10
  18. package/core/strings.ts +1080 -0
  19. package/core/winds.ts +652 -0
  20. package/guides/automation.md +1 -0
  21. package/guides/chords.md +3 -1
  22. package/guides/effects.md +5 -2
  23. package/guides/media.md +1 -1
  24. package/guides/performance.md +3 -0
  25. package/guides/resample.md +26 -0
  26. package/guides/sounds.md +7 -2
  27. package/guides/tempo.md +1 -0
  28. package/package.json +1 -1
  29. package/src/agent/agent.ts +13 -0
  30. package/src/agent/chord-tools.ts +153 -1
  31. package/src/agent/expression-tools.ts +91 -0
  32. package/src/agent/granular-tools.ts +138 -0
  33. package/src/agent/models.ts +4 -4
  34. package/src/agent/ops.ts +42 -2
  35. package/src/agent/preview-tool.ts +7 -1
  36. package/src/agent/resample-tool.ts +131 -0
  37. package/src/agent/tools.ts +726 -17
  38. package/src/agent/xcb-agent.ts +13 -0
  39. package/src/audio/arrange.ts +42 -3
  40. package/src/audio/dsp/bank.ts +233 -0
  41. package/src/audio/dsp/envelope.ts +161 -0
  42. package/src/audio/dsp/fft.ts +6 -0
  43. package/src/audio/dsp/filters.ts +57 -0
  44. package/src/audio/dsp/interp.ts +112 -0
  45. package/src/audio/dsp/modal.ts +684 -0
  46. package/src/audio/dsp/onset.ts +197 -0
  47. package/src/audio/dsp/oversample.ts +202 -0
  48. package/src/audio/dsp/rng.ts +37 -0
  49. package/src/audio/dsp/shape.ts +81 -0
  50. package/src/audio/dsp/shift.ts +256 -0
  51. package/src/audio/dsp/stft.ts +73 -0
  52. package/src/audio/dsp/window.ts +77 -0
  53. package/src/audio/effects/chain.ts +23 -3
  54. package/src/audio/effects/common.ts +14 -0
  55. package/src/audio/effects/convolution.ts +106 -1
  56. package/src/audio/effects/gaze.ts +354 -0
  57. package/src/audio/effects/rig/cab.ts +99 -0
  58. package/src/audio/effects/rig/filters.ts +152 -0
  59. package/src/audio/effects/rig/gate.ts +39 -0
  60. package/src/audio/effects/rig/head.ts +487 -0
  61. package/src/audio/effects/rig/index.ts +170 -0
  62. package/src/audio/effects/rig/section.ts +78 -0
  63. package/src/audio/effects/rig/stomp.ts +238 -0
  64. package/src/audio/engine.ts +56 -6
  65. package/src/audio/fit.ts +447 -0
  66. package/src/audio/granular.ts +995 -0
  67. package/src/audio/instrument-check.ts +137 -0
  68. package/src/audio/instruments.ts +140 -0
  69. package/src/audio/keys/dsp.ts +323 -0
  70. package/src/audio/keys/electric.ts +420 -0
  71. package/src/audio/keys/engine.ts +485 -0
  72. package/src/audio/keys/organ.ts +1335 -0
  73. package/src/audio/keys/piano.ts +476 -0
  74. package/src/audio/keys/sympathetic.ts +127 -0
  75. package/src/audio/live-worker.ts +56 -0
  76. package/src/audio/live.ts +361 -15
  77. package/src/audio/loudness.ts +65 -20
  78. package/src/audio/preview.ts +17 -1
  79. package/src/audio/resample.ts +285 -0
  80. package/src/audio/resonators.ts +295 -0
  81. package/src/audio/sampler.ts +328 -35
  82. package/src/audio/samples.ts +56 -8
  83. package/src/audio/strings/body.ts +263 -0
  84. package/src/audio/strings/bow.ts +656 -0
  85. package/src/audio/strings/engine.ts +615 -0
  86. package/src/audio/strings/loop.ts +119 -0
  87. package/src/audio/strings/measure.test-helpers.ts +200 -0
  88. package/src/audio/strings/pluck.ts +354 -0
  89. package/src/audio/warp.ts +61 -0
  90. package/src/audio/wav.ts +191 -19
  91. package/src/audio/winds/engine.ts +302 -0
  92. package/src/audio/winds/filters.ts +153 -0
  93. package/src/audio/winds/pitch.ts +104 -0
  94. package/src/audio/winds/trim.ts +80 -0
  95. package/src/audio/winds/trims.ts +917 -0
  96. package/src/audio/winds/voice.ts +479 -0
  97. package/src/commands/expression.ts +111 -23
  98. package/src/commands/fit.ts +135 -0
  99. package/src/commands/fx.ts +35 -1
  100. package/src/commands/granular.ts +432 -0
  101. package/src/commands/help.ts +159 -5
  102. package/src/commands/keys.ts +629 -0
  103. package/src/commands/modal.ts +310 -0
  104. package/src/commands/resample.ts +281 -0
  105. package/src/commands/rig.ts +264 -0
  106. package/src/commands/sample.ts +52 -2
  107. package/src/commands/shift.ts +119 -0
  108. package/src/commands/string.ts +201 -0
  109. package/src/commands/strum.ts +473 -0
  110. package/src/commands/time.ts +4 -1
  111. package/src/commands/wind.ts +244 -0
  112. package/src/main.ts +339 -15
  113. package/src/project/check.ts +17 -0
  114. package/src/render.ts +49 -4
  115. package/src/session/presence.ts +16 -3
  116. package/src/tui/audition.ts +2 -2
  117. package/src/tui/granular-menu.ts +278 -0
  118. package/src/tui/menu.ts +934 -9
  119. package/src/tui/modal-menu.ts +145 -0
  120. package/src/tui/performance-menu.ts +42 -0
  121. package/src/tui/play-chords.ts +104 -3
  122. package/src/tui/play-mode.ts +1 -0
  123. package/src/tui/play-session.ts +70 -2
  124. package/src/tui/wind-menu.ts +144 -0
package/src/tui/menu.ts CHANGED
@@ -13,6 +13,8 @@ import { HINTS } from "../../tui/grammar.ts";
13
13
  import { arrangeDetail, arrangeNodes } from "./arrange-menu.ts";
14
14
  import { auditionKey, isStageable, type AuditionKey } from "./audition.ts";
15
15
  import { performanceDetail, performanceNodes } from "./performance-menu.ts";
16
+ import { malletsMenu, modalParameterNodes } from "./modal-menu.ts";
17
+ import { windParameterNodes, windsMenu } from "./wind-menu.ts";
16
18
  import type { FaderSpec } from "./fader.ts";
17
19
  import {
18
20
  openingMeterCommand,
@@ -25,6 +27,7 @@ import {
25
27
  automationPoints,
26
28
  automationRange,
27
29
  REVERB_IR_BUILTINS,
30
+ SAMPLE_FIT_MODES,
28
31
  SAMPLE_UNITS,
29
32
  SCORE_LIMITS,
30
33
  isTrackAutomationParameter,
@@ -42,6 +45,10 @@ import {
42
45
  CORE_EFFECTS,
43
46
  EFFECT_NAMES,
44
47
  FX_LANES,
48
+ RIG_PRESETS,
49
+ RIG_STAGES,
50
+ rigPresetOf,
51
+ SHOEGAZE_EFFECTS,
45
52
  effectPresetNames,
46
53
  effectSpec,
47
54
  type EffectName,
@@ -85,6 +92,35 @@ import {
85
92
  SYNTH_SIMPLE,
86
93
  normalizeSynth,
87
94
  } from "../../core/synth.ts";
95
+ import {
96
+ isStringTrack,
97
+ STRING_PARAMS,
98
+ STRING_PRESET_NAMES,
99
+ STRING_PRESETS,
100
+ BOWED_PRESET_NAMES,
101
+ BOW_SIMPLE_PARAMS,
102
+ resolveString,
103
+ STRING_SIMPLE_PARAMS,
104
+ stringPresetOf,
105
+ } from "../../core/strings.ts";
106
+ import {
107
+ KEYS_PARAMS,
108
+ KEYS_PRESETS,
109
+ KEYS_FAMILIES,
110
+ ORGAN_ROWS,
111
+ ORGAN_TEXT,
112
+ PIPE_REGISTRATIONS,
113
+ PIPE_STOPS,
114
+ isElectricFamily,
115
+ isKeysFamily,
116
+ isOrganFamily,
117
+ isPianoFamily,
118
+ keysParamsFor,
119
+ keysSimpleFor,
120
+ pipeStops,
121
+ resolvedKeys,
122
+ type OrganFamily,
123
+ } from "../../core/keys.ts";
88
124
  import type { PickerItem } from "../../tui/app.ts";
89
125
  import { BUILTIN_TABLES, BUILTIN_TABLE_NAMES } from "../audio/wavetable.ts";
90
126
  import {
@@ -103,6 +139,8 @@ import {
103
139
  PROGRESSION_STYLES,
104
140
  SCALE_NAMES,
105
141
  SPREADS,
142
+ STROKE_PATTERN_NAMES,
143
+ GUITAR_TUNING_NAMES,
106
144
  keyName,
107
145
  parseKey,
108
146
  } from "../../core/chords.ts";
@@ -114,9 +152,17 @@ import {
114
152
  type Tuning,
115
153
  } from "../../core/tuning.ts";
116
154
  import { rootName, tuningStepsText } from "../commands/tuning.ts";
155
+ import { describeGuitar } from "../commands/strum.ts";
156
+ import {
157
+ granularBrowseNodes,
158
+ granularMenuDetail,
159
+ granularMenuLabel,
160
+ granularMenuNodes,
161
+ } from "./granular-menu.ts";
117
162
  import {
118
163
  ARP_RATES,
119
164
  CHORD_MODES,
165
+ STROKE_SPEED_MS,
120
166
  defaultChordSettings,
121
167
  type ChordSettings,
122
168
  } from "./play-chords.ts";
@@ -321,7 +367,7 @@ const TRACK_LANE_LABEL: Readonly<Record<TrackAutomationParameter, string>> = {
321
367
  const FX_LANE_INFO = new Map(FX_LANES.map((entry) => [entry.lane, entry]));
322
368
 
323
369
  /** Nudge for a number spec: linear by its step, or a sixth of an octave. */
324
- function specStep(
370
+ export function specStep(
325
371
  spec: NumberParam,
326
372
  ): (value: number, direction: 1 | -1) => number {
327
373
  if (spec.step === "log")
@@ -354,7 +400,14 @@ function laneLabel(lane: AutomationParameter): string {
354
400
  if (!info) return lane;
355
401
  const unit = info.spec.unit ? ` (${info.spec.unit})` : "";
356
402
  const owner =
357
- info.effect === "synth" ? "synth" : effectSpec(info.effect).label;
403
+ info.effect === "synth" ||
404
+ info.effect === "string" ||
405
+ info.effect === "keys" ||
406
+ info.effect === "modal" ||
407
+ info.effect === "grain" ||
408
+ info.effect === "wind"
409
+ ? info.effect
410
+ : effectSpec(info.effect).label;
358
411
  return `${owner} ${info.param}${unit}`;
359
412
  }
360
413
 
@@ -486,8 +539,77 @@ function soundSectionNodes(context: MenuContext): MenuNode[] {
486
539
  },
487
540
  ]
488
541
  : [];
542
+ const grainLabel = granularMenuLabel(track);
543
+ const granular: MenuNode[] =
544
+ track && grainLabel
545
+ ? [
546
+ {
547
+ kind: "menu",
548
+ id: "granular",
549
+ label: grainLabel,
550
+ detail: granularMenuDetail(track),
551
+ help: "grain clouds: preset, source, position, scan, grain size, pitch, shimmer",
552
+ build: (inner) => granularMenuNodes(focused(inner)),
553
+ },
554
+ ]
555
+ : [];
556
+ const keys: MenuNode[] =
557
+ track && isOrganFamily(track.instrument) && track.keys
558
+ ? [
559
+ {
560
+ kind: "menu",
561
+ id: "keys",
562
+ label: "keys",
563
+ detail: `${track.instrument}${track.keys.preset ? ` · ${track.keys.preset}` : ""}`,
564
+ help: "the modelled organ: preset, drawbars, registers or stops, rotary",
565
+ build: (inner) => {
566
+ const current = focused(inner);
567
+ return current ? organNodes(current) : [];
568
+ },
569
+ },
570
+ ]
571
+ : track && isKeysFamily(track.instrument) && track.keys
572
+ ? [
573
+ {
574
+ kind: "menu",
575
+ id: "keys",
576
+ label: "keys",
577
+ detail: `${track.instrument}${track.keys.preset ? ` · ${track.keys.preset}` : ""}`,
578
+ help: isElectricFamily(track.instrument)
579
+ ? "electric keys: preset, bark, bell, tone, vibe/trem, pickup and mute"
580
+ : "the modelled piano: preset, touch, hammers, dampers, stretch",
581
+ build: (inner) => {
582
+ const current = focused(inner);
583
+ return current
584
+ ? keysNodes(current, keysParamsFor(current.instrument))
585
+ : [];
586
+ },
587
+ },
588
+ ]
589
+ : [];
590
+ // 0.6.1: fretting for strum and the guitar perform mode, on guitar-like
591
+ // tracks (a string voice, a rig, or a guitar setup already stored).
592
+ const guitar: MenuNode[] =
593
+ track &&
594
+ (track.guitar ||
595
+ track.string ||
596
+ RIG_STAGES.some((stage) => track.fx?.[stage] !== undefined))
597
+ ? [
598
+ {
599
+ kind: "menu",
600
+ id: "guitar",
601
+ label: "guitar",
602
+ detail: describeGuitar(track.guitar),
603
+ help: "tuning, capo, hand stretch, ringing open strings and position for strum and chords perform guitar",
604
+ build: (inner) => guitarNodes(focused(inner)),
605
+ },
606
+ ]
607
+ : [];
489
608
  return [
490
609
  ...parameterNodes(context),
610
+ ...granular,
611
+ ...keys,
612
+ ...guitar,
491
613
  ...tuning,
492
614
  {
493
615
  kind: "menu",
@@ -508,6 +630,84 @@ function soundSectionNodes(context: MenuContext): MenuNode[] {
508
630
  ];
509
631
  }
510
632
 
633
+ /** Sound > guitar: Track.guitar rows plus a strum action. */
634
+ function guitarNodes(track: Track | undefined): MenuNode[] {
635
+ if (!track) return [];
636
+ const g = track.guitar;
637
+ const tune = g?.tune ?? "standard";
638
+ const tuneName = typeof tune === "string" ? tune : tune.join(" ");
639
+ const int = (
640
+ label: string,
641
+ field: "capo" | "hand" | "position",
642
+ min: number,
643
+ max: number,
644
+ fallback: number,
645
+ help: string,
646
+ ): MenuNode => ({
647
+ kind: "number",
648
+ label,
649
+ help,
650
+ value: g?.[field] ?? fallback,
651
+ min,
652
+ max,
653
+ step: linear(1, min, max),
654
+ format: (value) => num(Math.round(value)),
655
+ command: (value) => `guitar ${field} ${Math.round(value)}`,
656
+ });
657
+ return [
658
+ {
659
+ kind: "choice",
660
+ label: "tune",
661
+ help: "open strings; type guitar tune D A D G A D for your own",
662
+ value: tuneName,
663
+ options: (GUITAR_TUNING_NAMES as readonly string[]).includes(tuneName)
664
+ ? GUITAR_TUNING_NAMES
665
+ : [tuneName, ...GUITAR_TUNING_NAMES],
666
+ command: (option) => `guitar tune ${option}`,
667
+ },
668
+ int(
669
+ "capo",
670
+ "capo",
671
+ 0,
672
+ 12,
673
+ 0,
674
+ "capo fret; chord shapes sound this many semitones up",
675
+ ),
676
+ int("hand", "hand", 3, 6, 4, "how many frets one hand shape may span"),
677
+ {
678
+ kind: "number",
679
+ label: "ring",
680
+ help: "0 closed shapes .. 1 lets open strings ring",
681
+ value: g?.ring ?? 0.5,
682
+ min: 0,
683
+ max: 1,
684
+ step: linear(0.1, 0, 1),
685
+ format: (value) => num(value),
686
+ command: (value) => `guitar ring ${Math.round(value * 10) / 10}`,
687
+ },
688
+ int(
689
+ "position",
690
+ "position",
691
+ 0,
692
+ 12,
693
+ 0,
694
+ "preferred fret position for voicings",
695
+ ),
696
+ {
697
+ kind: "action",
698
+ label: "strum the chords on this track",
699
+ command: "strum",
700
+ help: "replaces block chords with strummed guitar notes",
701
+ },
702
+ {
703
+ kind: "action",
704
+ label: "reset",
705
+ command: "guitar reset",
706
+ help: "standard tuning, no capo",
707
+ },
708
+ ];
709
+ }
710
+
511
711
  /** Rhythm: the euclid editor, then grooves and kits. */
512
712
  function rhythmNodes(): MenuNode[] {
513
713
  return [
@@ -815,6 +1015,34 @@ function chordNodes(context: MenuContext): MenuNode[] {
815
1015
  options: CHORD_PATTERNS.map((pattern) => pattern.name),
816
1016
  command: (option) => `/chords pattern ${option}`,
817
1017
  },
1018
+ // Strum grid and speed only act when perform is guitar.
1019
+ ...(chords.perform !== "guitar"
1020
+ ? []
1021
+ : ([
1022
+ {
1023
+ kind: "choice",
1024
+ label: "strokes",
1025
+ help: "guitar strum grid (D down, U up, d u light, x chuck, - rest); type /chords strokes D-DU-UDU for your own",
1026
+ value: chords.strokes,
1027
+ options: (STROKE_PATTERN_NAMES as readonly string[]).includes(
1028
+ chords.strokes,
1029
+ )
1030
+ ? STROKE_PATTERN_NAMES
1031
+ : [chords.strokes, ...STROKE_PATTERN_NAMES],
1032
+ command: (option) => `/chords strokes ${option}`,
1033
+ },
1034
+ {
1035
+ kind: "number",
1036
+ label: "speed",
1037
+ help: "ms a full down stroke takes (0 hits every string at once); type /chords speed 1/32b to set it in beats",
1038
+ value: chords.speed,
1039
+ min: STROKE_SPEED_MS.min,
1040
+ max: STROKE_SPEED_MS.max,
1041
+ step: linear(1, STROKE_SPEED_MS.min, STROKE_SPEED_MS.max),
1042
+ format: (value) => `${num(value)} ms`,
1043
+ command: (value) => `/chords speed ${Math.round(value)}ms`,
1044
+ },
1045
+ ] satisfies MenuNode[])),
818
1046
  {
819
1047
  kind: "choice",
820
1048
  label: "arp rate",
@@ -903,7 +1131,7 @@ function trackNodes(context: MenuContext): MenuNode[] {
903
1131
  }
904
1132
 
905
1133
  function instrumentNode(track: Track): MenuNode {
906
- const options: string[] = [...AVAILABLE_INSTRUMENTS];
1134
+ const options: string[] = [...AVAILABLE_INSTRUMENTS, ...KEYS_FAMILIES];
907
1135
  if (!options.includes(track.instrument)) options.push(track.instrument);
908
1136
  return {
909
1137
  kind: "choice",
@@ -956,6 +1184,37 @@ function parameterNodes(context: MenuContext): MenuNode[] {
956
1184
  // A wavetable track also has the synth voice's envelope, filters and FM.
957
1185
  if (isWavetableInstrument(track.instrument))
958
1186
  nodes.push(...wavetableNodes(track, context.projectRoot));
1187
+ if (isStringTrack(track)) {
1188
+ nodes.push(
1189
+ ...stringNodes(
1190
+ track,
1191
+ resolveString(track.string).exciter === "bow"
1192
+ ? BOW_SIMPLE_PARAMS
1193
+ : STRING_SIMPLE_PARAMS,
1194
+ ),
1195
+ );
1196
+ nodes.push({
1197
+ kind: "menu",
1198
+ id: "string:advanced",
1199
+ label: "advanced",
1200
+ detail: `all ${Object.keys(STRING_PARAMS).length} params`,
1201
+ help: "every string parameter: exciter, loss, body, buzz, sympathetics, bow (pressure, speed, attack, vib, vibdelay, tremhz, sord, dyn)",
1202
+ build: (inner) => {
1203
+ const current = focused(inner);
1204
+ return current
1205
+ ? stringNodes(current, Object.keys(STRING_PARAMS), false)
1206
+ : [];
1207
+ },
1208
+ });
1209
+ if (Object.keys(track.string ?? {}).some((key) => key !== "preset"))
1210
+ nodes.push({
1211
+ kind: "action",
1212
+ label: "reset to preset",
1213
+ command: "string reset",
1214
+ help: "drop this track's string overrides, keep the preset",
1215
+ });
1216
+ return nodes;
1217
+ }
959
1218
  if (track.sampler) {
960
1219
  nodes.push({
961
1220
  kind: "info",
@@ -982,6 +1241,35 @@ function parameterNodes(context: MenuContext): MenuNode[] {
982
1241
  label: "add a voice",
983
1242
  value: "/sample <path> [as <voice>]",
984
1243
  });
1244
+ } else if (isOrganFamily(track.instrument) && track.keys) {
1245
+ // f061-organ: preset, Drawbars/Registers/Stops sub-menus, organ rows.
1246
+ nodes.push(...organNodes(track));
1247
+ } else if (isKeysFamily(track.instrument) && track.keys) {
1248
+ nodes.push(...keysNodes(track, keysSimpleFor(track.instrument)));
1249
+ const all = keysParamsFor(track.instrument);
1250
+ nodes.push({
1251
+ kind: "menu",
1252
+ id: "keys:all",
1253
+ label: isElectricFamily(track.instrument)
1254
+ ? `all ${track.instrument} params`
1255
+ : "all piano params",
1256
+ detail: `all ${all.length} params`,
1257
+ help: isElectricFamily(track.instrument)
1258
+ ? "every electric keys parameter for this family"
1259
+ : "every modelled piano parameter",
1260
+ build: (inner) => {
1261
+ const current = focused(inner);
1262
+ return current
1263
+ ? keysNodes(current, keysParamsFor(current.instrument))
1264
+ : [];
1265
+ },
1266
+ });
1267
+ } else if (track.instrument === "modal") {
1268
+ // 0.6 modal percussion: preset, mallet and MODAL_PARAMS rows.
1269
+ nodes.push(...modalParameterNodes(track));
1270
+ } else if (track.instrument === "wind" && track.wind) {
1271
+ // 0.6.1 wind engine: preset and WIND_PARAMS rows.
1272
+ nodes.push(...windParameterNodes(track));
985
1273
  } else if (!isDrumInstrument(track.instrument)) {
986
1274
  nodes.push(...synthNodes(track, SYNTH_SIMPLE));
987
1275
  nodes.push({
@@ -1089,10 +1377,141 @@ function sampleVoiceNodes(context: MenuContext, voice: string): MenuNode[] {
1089
1377
  label: "cut / loopAt",
1090
1378
  value: `/sample set ${voice} cut hats · loopAt 2`,
1091
1379
  },
1380
+ // 0.6 fit: the sample's own tempo, its length in beats, and how it fits.
1381
+ {
1382
+ kind: "number",
1383
+ label: "bpm",
1384
+ value: ref.bpm,
1385
+ start: Math.round(context.score.tempoBpm),
1386
+ off: "off",
1387
+ min: SCORE_LIMITS.minSampleBpm,
1388
+ max: SCORE_LIMITS.maxSampleBpm,
1389
+ step: linear(1, SCORE_LIMITS.minSampleBpm, SCORE_LIMITS.maxSampleBpm),
1390
+ format: num,
1391
+ command: (value) => `/bpm ${num(value)} ${voice}`,
1392
+ reset: `/bpm off ${voice}`,
1393
+ help: SAMPLE_CONTROLS.bpm,
1394
+ },
1395
+ {
1396
+ kind: "choice",
1397
+ label: "fitmode",
1398
+ // off: unset (plays as repitch); auto suggests one from the sound.
1399
+ value: ref.fitmode ?? "off",
1400
+ options: ["off", ...SAMPLE_FIT_MODES, "auto"],
1401
+ command: (option) => `/fitmode ${option} ${voice}`,
1402
+ help: `${SAMPLE_CONTROLS.fitmode} · beats for drums, tones for pads · set bpm or len first`,
1403
+ },
1404
+ {
1405
+ kind: "number",
1406
+ label: "len",
1407
+ value: ref.len,
1408
+ start: 4,
1409
+ off: "off",
1410
+ min: 0.25,
1411
+ max: SCORE_LIMITS.maxSampleLenBeats,
1412
+ step: linear(0.25, 0.25, SCORE_LIMITS.maxSampleLenBeats),
1413
+ format: num,
1414
+ command: (value) => `/len ${num(value)} ${voice}`,
1415
+ reset: `/len off ${voice}`,
1416
+ help: SAMPLE_CONTROLS.len,
1417
+ },
1418
+ // 0.6.1 shift: pitch without changing length, formants kept or moved,
1419
+ // and the voice's fade in and out.
1420
+ {
1421
+ kind: "number",
1422
+ label: "shift",
1423
+ value: ref.shift,
1424
+ start: 0,
1425
+ off: "off",
1426
+ min: -SCORE_LIMITS.maxSampleShift,
1427
+ max: SCORE_LIMITS.maxSampleShift,
1428
+ step: linear(
1429
+ 1,
1430
+ -SCORE_LIMITS.maxSampleShift,
1431
+ SCORE_LIMITS.maxSampleShift,
1432
+ ),
1433
+ format: num,
1434
+ command: (value) => `/shift ${num(value)} ${voice}`,
1435
+ reset: `/shift off ${voice}`,
1436
+ help: SAMPLE_CONTROLS.shift,
1437
+ },
1438
+ {
1439
+ kind: "number",
1440
+ label: "formant",
1441
+ value: ref.formant,
1442
+ start: 0,
1443
+ off: "follow",
1444
+ min: -SCORE_LIMITS.maxSampleShift,
1445
+ max: SCORE_LIMITS.maxSampleShift,
1446
+ step: linear(
1447
+ 1,
1448
+ -SCORE_LIMITS.maxSampleShift,
1449
+ SCORE_LIMITS.maxSampleShift,
1450
+ ),
1451
+ format: (value) => (value === 0 ? "keep" : num(value)),
1452
+ command: (value) => set(`formant ${num(value)}`),
1453
+ reset: set("formant off"),
1454
+ help: `${SAMPLE_CONTROLS.formant} · needs shift`,
1455
+ },
1456
+ {
1457
+ kind: "number",
1458
+ label: "fade in",
1459
+ value: ref.fadeInTime,
1460
+ start: 0.01,
1461
+ off: "off",
1462
+ min: 0,
1463
+ max: SCORE_LIMITS.maxSampleFadeSeconds,
1464
+ step: linear(0.01, 0, SCORE_LIMITS.maxSampleFadeSeconds),
1465
+ format: num,
1466
+ command: (value) => `/fade in ${num(value)} ${voice}`,
1467
+ reset: `/fade in off ${voice}`,
1468
+ help: SAMPLE_CONTROLS.fadeInTime,
1469
+ },
1470
+ {
1471
+ kind: "number",
1472
+ label: "fade out",
1473
+ value: ref.fadeTime,
1474
+ start: 0.1,
1475
+ off: "off",
1476
+ min: 0,
1477
+ max: SCORE_LIMITS.maxSampleFadeSeconds,
1478
+ step: linear(0.05, 0, SCORE_LIMITS.maxSampleFadeSeconds),
1479
+ format: num,
1480
+ command: (value) => `/fade out ${num(value)} ${voice}`,
1481
+ reset: `/fade out off ${voice}`,
1482
+ help: SAMPLE_CONTROLS.fadeTime,
1483
+ },
1484
+ // 0.6.1 layers: velocity layer (SFZ lovel/hivel) and round-robin group.
1485
+ {
1486
+ kind: "choice",
1487
+ label: "Velocity layer",
1488
+ value: ref.vel ? `${ref.vel[0]}-${ref.vel[1]}` : "off",
1489
+ options: velocityLayerOptions(ref.vel),
1490
+ command: (option) => set(`vel ${option}`),
1491
+ help: `${SAMPLE_CONTROLS.vel} · two layers: 0-63 and 64-127 · three: 0-42 43-84 85-127`,
1492
+ },
1493
+ {
1494
+ kind: "entry",
1495
+ label: "Round robin",
1496
+ value: ref.rr ?? "",
1497
+ placeholder: "group name, e.g. sn",
1498
+ command: (text) => set(`rr ${text.trim() || "off"}`),
1499
+ example: `/sample set ${voice} rr sn`,
1500
+ help: SAMPLE_CONTROLS.rr,
1501
+ },
1092
1502
  );
1093
1503
  return nodes;
1094
1504
  }
1095
1505
 
1506
+ /** Common velocity splits, plus the voice's own range when it is another. */
1507
+ function velocityLayerOptions(
1508
+ vel: readonly [number, number] | undefined,
1509
+ ): string[] {
1510
+ const options = ["off", "0-63", "64-127", "0-42", "43-84", "85-127"];
1511
+ const own = vel ? `${vel[0]}-${vel[1]}` : undefined;
1512
+ return own && !options.includes(own) ? [...options, own] : options;
1513
+ }
1514
+
1096
1515
  /** Synth preset first, then one row per parameter. */
1097
1516
  /** The preset this track's voice still matches exactly, if any. */
1098
1517
  function matchingSynthPreset(track: Track): string | undefined {
@@ -1105,6 +1524,233 @@ function matchingSynthPreset(track: Track): string | undefined {
1105
1524
  )?.[0];
1106
1525
  }
1107
1526
 
1527
+ /** Modelled piano rows: the preset, then each parameter (`keys <p> <v>`). */
1528
+ function keysNodes(
1529
+ track: Track,
1530
+ params: readonly string[],
1531
+ presets: readonly string[] = keysPresetNames(track.instrument),
1532
+ ): MenuNode[] {
1533
+ const effective = resolvedKeys(track.instrument, track.keys);
1534
+ const nodes: MenuNode[] = [
1535
+ {
1536
+ kind: "choice",
1537
+ label: "preset",
1538
+ value: track.keys?.preset ?? "—",
1539
+ options: presets,
1540
+ command: (preset) => `keys preset ${preset}`,
1541
+ help: isOrganFamily(track.instrument)
1542
+ ? "a starting organ; every value stays editable"
1543
+ : isElectricFamily(track.instrument)
1544
+ ? "a starting electric piano or clav; every value stays editable"
1545
+ : "a starting piano; every value stays editable",
1546
+ },
1547
+ ];
1548
+ for (const key of params) {
1549
+ const param = KEYS_PARAMS[key]!;
1550
+ const stored = track.keys?.[key];
1551
+ if (param.kind === "number") {
1552
+ const base = effective[key] as number;
1553
+ nodes.push({
1554
+ kind: "number",
1555
+ label: key,
1556
+ value: typeof stored === "number" ? stored : undefined,
1557
+ start: base,
1558
+ off: withUnit(formatParam(param, base), param.unit),
1559
+ min: param.min,
1560
+ max: param.max,
1561
+ step: specStep(param),
1562
+ format: (value) => withUnit(formatParam(param, value), param.unit),
1563
+ command: (value) => `keys ${key} ${formatParam(param, value)}`,
1564
+ reset: `keys ${key} off`,
1565
+ help: param.doc,
1566
+ });
1567
+ } else if (param.kind === "enum")
1568
+ nodes.push({
1569
+ kind: "choice",
1570
+ label: key,
1571
+ value: typeof stored === "string" ? stored : String(effective[key]),
1572
+ options: param.values,
1573
+ command: (option) => `keys ${key} ${option}`,
1574
+ help: param.doc,
1575
+ });
1576
+ }
1577
+ nodes.push({
1578
+ kind: "action",
1579
+ label: "reset to the family",
1580
+ command: "keys reset",
1581
+ help: "clear every override and the preset, and the effects the preset added (keeps the family's own sound)",
1582
+ });
1583
+ return nodes;
1584
+ }
1585
+
1586
+ // ---- f061-organ menu rows -------------------------------------------------
1587
+
1588
+ const PIANO_PRESET_NAMES = Object.keys(KEYS_PRESETS).filter((name) =>
1589
+ isPianoFamily(KEYS_PRESETS[name]!.instrument),
1590
+ );
1591
+ const ORGAN_PRESET_NAMES = Object.keys(KEYS_PRESETS).filter((name) =>
1592
+ isOrganFamily(KEYS_PRESETS[name]!.instrument),
1593
+ );
1594
+ const ELECTRIC_PRESET_NAMES = Object.keys(KEYS_PRESETS).filter((name) =>
1595
+ isElectricFamily(KEYS_PRESETS[name]!.instrument),
1596
+ );
1597
+
1598
+ /** The presets of the track's kind of keys (pianos, electric or organs). */
1599
+ function keysPresetNames(instrument: string): readonly string[] {
1600
+ if (isOrganFamily(instrument)) return ORGAN_PRESET_NAMES;
1601
+ if (isElectricFamily(instrument)) return ELECTRIC_PRESET_NAMES;
1602
+ return PIANO_PRESET_NAMES;
1603
+ }
1604
+
1605
+ const DRAWBAR_FEET = [
1606
+ "16'",
1607
+ "5⅓'",
1608
+ "8'",
1609
+ "4'",
1610
+ "2⅔'",
1611
+ "2'",
1612
+ "1⅗'",
1613
+ "1⅓'",
1614
+ "1'",
1615
+ ];
1616
+ const REGISTER_FEET = ["16'", "8'", "4'", "2⅔'", "2'"];
1617
+
1618
+ /**
1619
+ * One 0-8 row per footage of a digit string (`keys drawbars 888000000`):
1620
+ * left/right pull a bar in or out, x puts the whole set back.
1621
+ */
1622
+ function digitNodes(
1623
+ name: "drawbars" | "registers",
1624
+ text: string,
1625
+ feet: readonly string[],
1626
+ stored: boolean,
1627
+ ): MenuNode[] {
1628
+ const digits = text.split("").map(Number);
1629
+ const nodes: MenuNode[] = feet.map((foot, index): MenuNode => {
1630
+ const at = (value: number) =>
1631
+ digits.map((d, i) => (i === index ? value : d)).join("");
1632
+ return {
1633
+ kind: "number",
1634
+ label: foot,
1635
+ value: digits[index]!,
1636
+ min: 0,
1637
+ max: 8,
1638
+ step: (value, direction) => Math.max(0, Math.min(8, value + direction)),
1639
+ format: (value) =>
1640
+ `${value} ${"█".repeat(value)}${"·".repeat(8 - value)}`,
1641
+ command: (value) => `keys ${name} ${at(value)}`,
1642
+ // x resets this bar only, to its default digit.
1643
+ reset: `keys ${name} ${at(Number(ORGAN_TEXT[name].default[index] ?? 0))}`,
1644
+ help: `${foot} ${name === "drawbars" ? "drawbar" : "register"} 0-8`,
1645
+ };
1646
+ });
1647
+ nodes.push({
1648
+ kind: "entry",
1649
+ label: `${name} (type)`,
1650
+ value: stored ? text : `${text} (default)`,
1651
+ placeholder: `${feet.length} digits 0-8`,
1652
+ command: (typed) =>
1653
+ new RegExp(`^[0-8]{${feet.length}}$`).test(typed.trim())
1654
+ ? `keys ${name} ${typed.trim()}`
1655
+ : undefined,
1656
+ example: `keys ${name} ${text}`,
1657
+ });
1658
+ return nodes;
1659
+ }
1660
+
1661
+ /** Pipe stops: a registration, then one toggle per stop. */
1662
+ function stopNodes(text: string): MenuNode[] {
1663
+ const on = pipeStops(text);
1664
+ const nodes: MenuNode[] = [
1665
+ {
1666
+ kind: "choice",
1667
+ label: "registration",
1668
+ value: Object.keys(PIPE_REGISTRATIONS).includes(text) ? text : "—",
1669
+ options: Object.keys(PIPE_REGISTRATIONS),
1670
+ command: (option) => `keys stops ${option}`,
1671
+ help: "a named registration (stops drawn together)",
1672
+ },
1673
+ ];
1674
+ for (const stop of PIPE_STOPS)
1675
+ nodes.push({
1676
+ kind: "toggle",
1677
+ label: stop,
1678
+ value: on.includes(stop),
1679
+ command: (value) => {
1680
+ const next = value
1681
+ ? PIPE_STOPS.filter((name) => name === stop || on.includes(name))
1682
+ : on.filter((name) => name !== stop);
1683
+ return next.length > 0
1684
+ ? `keys stops ${next.join(" ")}`
1685
+ : "keys stops off";
1686
+ },
1687
+ help: `draw or retire the ${stop} stop`,
1688
+ });
1689
+ return nodes;
1690
+ }
1691
+
1692
+ /** The organ's rows: preset, its family's sub-menu, then its parameters. */
1693
+ function organNodes(track: Track): MenuNode[] {
1694
+ const family = track.instrument as OrganFamily;
1695
+ const effective = resolvedKeys(track.instrument, track.keys);
1696
+ const rows = ORGAN_ROWS[family].filter((row) => row in KEYS_PARAMS);
1697
+ const nodes = keysNodes(track, rows, ORGAN_PRESET_NAMES);
1698
+ const text = (name: keyof typeof ORGAN_TEXT) =>
1699
+ String(effective[name] ?? ORGAN_TEXT[name].default);
1700
+ const sub: MenuNode =
1701
+ family === "tonewheel"
1702
+ ? {
1703
+ kind: "menu",
1704
+ id: "keys:drawbars",
1705
+ label: "Drawbars",
1706
+ detail: text("drawbars"),
1707
+ help: "nine drawbars 16' to 1' (0 in, 8 full out)",
1708
+ build: (inner) => {
1709
+ const current = focused(inner) ?? track;
1710
+ const value = resolvedKeys(current.instrument, current.keys);
1711
+ return digitNodes(
1712
+ "drawbars",
1713
+ String(value.drawbars ?? ORGAN_TEXT.drawbars.default),
1714
+ DRAWBAR_FEET,
1715
+ current.keys?.drawbars !== undefined,
1716
+ );
1717
+ },
1718
+ }
1719
+ : family === "combo"
1720
+ ? {
1721
+ kind: "menu",
1722
+ id: "keys:registers",
1723
+ label: "Registers",
1724
+ detail: text("registers"),
1725
+ help: "five combo-organ registers 16' to 2' (0 off, 8 full)",
1726
+ build: (inner) => {
1727
+ const current = focused(inner) ?? track;
1728
+ const value = resolvedKeys(current.instrument, current.keys);
1729
+ return digitNodes(
1730
+ "registers",
1731
+ String(value.registers ?? ORGAN_TEXT.registers.default),
1732
+ REGISTER_FEET,
1733
+ current.keys?.registers !== undefined,
1734
+ );
1735
+ },
1736
+ }
1737
+ : {
1738
+ kind: "menu",
1739
+ id: "keys:stops",
1740
+ label: "Stops",
1741
+ detail: text("stops"),
1742
+ help: "pipe stops and registrations",
1743
+ build: (inner) => {
1744
+ const current = focused(inner) ?? track;
1745
+ const value = resolvedKeys(current.instrument, current.keys);
1746
+ return stopNodes(String(value.stops ?? ORGAN_TEXT.stops.default));
1747
+ },
1748
+ };
1749
+ // After the preset row, before the parameters.
1750
+ nodes.splice(1, 0, sub);
1751
+ return nodes;
1752
+ }
1753
+
1108
1754
  function synthNodes(track: Track, keys: readonly string[]): MenuNode[] {
1109
1755
  const nodes: MenuNode[] = [];
1110
1756
  if (keys === SYNTH_SIMPLE)
@@ -1175,6 +1821,57 @@ const SYNTH_LABEL: Readonly<Record<string, string>> = {
1175
1821
  fm: "FM amount",
1176
1822
  };
1177
1823
 
1824
+ /** String preset first (simple view), then one row per parameter. */
1825
+ function stringNodes(
1826
+ track: Track,
1827
+ keys: readonly string[],
1828
+ withPreset = true,
1829
+ ): MenuNode[] {
1830
+ const nodes: MenuNode[] = [];
1831
+ const preset = STRING_PRESETS[stringPresetOf(track.string)]!;
1832
+ if (withPreset)
1833
+ nodes.push({
1834
+ kind: "choice",
1835
+ label: "preset",
1836
+ help: preset.doc,
1837
+ value: stringPresetOf(track.string),
1838
+ options: STRING_PRESET_NAMES,
1839
+ command: (name) => `string preset ${name}`,
1840
+ });
1841
+ for (const key of keys) {
1842
+ const param = STRING_PARAMS[key]!;
1843
+ const own = track.string?.[key];
1844
+ const base = preset.values[key] ?? param.default;
1845
+ if (param.kind === "number")
1846
+ nodes.push({
1847
+ kind: "number",
1848
+ label: param.unit ? `${key} ${param.unit}` : key,
1849
+ help: param.doc,
1850
+ value: typeof own === "number" ? own : undefined,
1851
+ start: typeof base === "number" ? base : param.default,
1852
+ off: formatParam(
1853
+ param,
1854
+ typeof base === "number" ? base : param.default,
1855
+ ),
1856
+ min: param.min,
1857
+ max: param.max,
1858
+ step: specStep(param),
1859
+ format: (value) => formatParam(param, value),
1860
+ command: (value) => `string ${key} ${formatParam(param, value)}`,
1861
+ });
1862
+ else if (param.kind === "enum")
1863
+ nodes.push({
1864
+ kind: "choice",
1865
+ label: key,
1866
+ help: param.doc,
1867
+ value: typeof own === "string" ? own : String(base),
1868
+ options: param.values,
1869
+ command: (option) => `string ${key} ${option}`,
1870
+ });
1871
+ }
1872
+ return nodes;
1873
+ }
1874
+
1178
1875
  /** Every synth parameter, grouped (amplitude, oscillator, FM 1..8, …). */
1179
1876
  function synthAdvancedNodes(context: MenuContext): MenuNode[] {
1180
1877
  const track = focused(context);
@@ -1324,10 +2021,58 @@ function effectNodes(context: MenuContext): MenuNode[] {
1324
2021
  build: (inner) => effectParamNodes(inner, effect, false),
1325
2022
  };
1326
2023
  };
1327
- const more = EFFECT_NAMES.filter((effect) => !CORE_EFFECTS.includes(effect));
2024
+ const rigStages = RIG_STAGES as readonly string[];
2025
+ // 0.6.1 shoegaze stages get their own sub-menu.
2026
+ const gaze = SHOEGAZE_EFFECTS as readonly string[];
2027
+ const more = EFFECT_NAMES.filter(
2028
+ (effect) =>
2029
+ !CORE_EFFECTS.includes(effect) &&
2030
+ !rigStages.includes(effect) &&
2031
+ !gaze.includes(effect),
2032
+ );
2033
+ const gazeOn = SHOEGAZE_EFFECTS.filter((effect) =>
2034
+ effectValues(track, effect),
2035
+ );
2036
+ const shoegaze: MenuNode = {
2037
+ kind: "menu",
2038
+ id: "shoegaze",
2039
+ label: "Shoegaze",
2040
+ help: "wobble (tremolo-arm bend), bloom (feedback), swell (volume swell), double (two takes); `rig shoegaze` loads them all",
2041
+ detail:
2042
+ gazeOn.length === 0
2043
+ ? "off"
2044
+ : gazeOn.map((effect) => effectSpec(effect).label).join(", "),
2045
+ build: () => SHOEGAZE_EFFECTS.map(node),
2046
+ };
1328
2047
  const moreOn = more.filter((effect) => effectValues(track, effect));
2048
+ // 0.6 guitar rig: stomp → head (with its gate) → cab, plus whole rigs.
2049
+ const rigOn = RIG_STAGES.filter((stage) => effectValues(track, stage));
2050
+ const rig: MenuNode = {
2051
+ kind: "menu",
2052
+ id: "guitar rig",
2053
+ label: "Guitar rig",
2054
+ help: "stomp box, amp head with noise gate and speaker cabinet; `rig <name>` loads a whole rig",
2055
+ detail:
2056
+ rigOn.length === 0 ? "off" : (rigPresetOf(track.fx) ?? rigOn.join(" → ")),
2057
+ build: (inner) => {
2058
+ const current = focused(inner);
2059
+ return [
2060
+ {
2061
+ kind: "choice",
2062
+ label: "rig",
2063
+ value: rigPresetOf(current?.fx) ?? "—",
2064
+ options: [...Object.keys(RIG_PRESETS), "reset"],
2065
+ command: (name) => `rig ${name}`,
2066
+ help: "a whole rig (stomp, head, cab); every stage stays editable",
2067
+ },
2068
+ ...RIG_STAGES.map(node),
2069
+ ];
2070
+ },
2071
+ };
1329
2072
  return [
1330
2073
  ...CORE_EFFECTS.map(node),
2074
+ rig,
2075
+ shoegaze,
1331
2076
  {
1332
2077
  kind: "menu",
1333
2078
  id: "more effects",
@@ -1495,6 +2240,17 @@ function automationNodes(context: MenuContext): MenuNode[] {
1495
2240
  const info = FX_LANE_INFO.get(lane as FxLane);
1496
2241
  if (info?.effect === "synth")
1497
2242
  return track.synth?.[info.param] !== undefined;
2243
+ if (info?.effect === "string") return track.string !== undefined;
2244
+ // Only the lanes this family's engine reads (keys-rotary on organs,
2245
+ // keys-hardness on pianos); the rest wait under "all lanes".
2246
+ if (info?.effect === "keys")
2247
+ return (
2248
+ track.keys !== undefined &&
2249
+ keysParamsFor(track.instrument).includes(info.param)
2250
+ );
2251
+ if (info?.effect === "modal") return track.modal !== undefined;
2252
+ if (info?.effect === "grain") return track.granular !== undefined;
2253
+ if (info?.effect === "wind") return track.wind !== undefined;
1498
2254
  return info !== undefined && effectValues(track, info.effect) !== undefined;
1499
2255
  });
1500
2256
  const hidden = AUTOMATION_PARAMETERS.filter((lane) => !shown.includes(lane));
@@ -1676,14 +2432,111 @@ function patternsMenu(): MenuNode {
1676
2432
  };
1677
2433
  }
1678
2434
 
2435
+ const GRANULAR_PRESETS_COUNT = granularBrowseNodes().length;
2436
+
1679
2437
  function soundNodes(): MenuNode[] {
1680
2438
  return [
2439
+ {
2440
+ kind: "menu",
2441
+ id: "group:keys",
2442
+ label: "Keys",
2443
+ help: "modelled pianos, electric keys and organs, built in (no download)",
2444
+ detail: `${PIANO_PRESET_NAMES.join(" ")} · electric: ${ELECTRIC_PRESET_NAMES.join(" ")} · organs: ${ORGAN_PRESET_NAMES.join(" ")}`,
2445
+ build: () => [
2446
+ ...PIANO_PRESET_NAMES.map((name): MenuNode => {
2447
+ const preset = KEYS_PRESETS[name]!;
2448
+ return {
2449
+ kind: "action",
2450
+ label: `${name.padEnd(10)} ${preset.doc}`,
2451
+ command: `piano ${name}`,
2452
+ help: preset.styles,
2453
+ };
2454
+ }),
2455
+ // keys-electric (0.6.1): tine and reed pianos and the clavinet.
2456
+ {
2457
+ kind: "menu",
2458
+ id: "group:keys:electric",
2459
+ label: "Electric",
2460
+ help: "electric pianos (tine, reed) and the clavinet, modelled",
2461
+ detail: ELECTRIC_PRESET_NAMES.join(" "),
2462
+ build: () =>
2463
+ Object.entries(KEYS_PRESETS)
2464
+ .filter(([, preset]) => isElectricFamily(preset.instrument))
2465
+ .map(([name, preset]): MenuNode => ({
2466
+ kind: "action",
2467
+ label: `${name.padEnd(10)} ${preset.doc}`,
2468
+ command: `keys preset ${name}`,
2469
+ help: preset.styles,
2470
+ })),
2471
+ },
2472
+ // f061-organ
2473
+ {
2474
+ kind: "menu",
2475
+ id: "group:organs",
2476
+ label: "Organs",
2477
+ help: "tonewheel, combo and pipe organs on the keys engine",
2478
+ detail: ORGAN_PRESET_NAMES.join(" "),
2479
+ build: () =>
2480
+ ORGAN_PRESET_NAMES.map((name): MenuNode => {
2481
+ const preset = KEYS_PRESETS[name]!;
2482
+ return {
2483
+ kind: "action",
2484
+ label: `${name.padEnd(10)} ${preset.doc}`,
2485
+ command: name,
2486
+ help: preset.styles,
2487
+ };
2488
+ }),
2489
+ },
2490
+ ],
2491
+ },
1681
2492
  {
1682
2493
  kind: "action",
1683
2494
  label: "wavetable synth basic shapes morph · built-in",
1684
2495
  command: "wt basic",
1685
2496
  help: "switch this track to the morphing wavetable synth",
1686
2497
  },
2498
+ // 0.6 instrument groups, one per lane.
2499
+ malletsMenu(),
2500
+ windsMenu(),
2501
+ {
2502
+ kind: "menu",
2503
+ id: "strings",
2504
+ label: "Strings",
2505
+ help: "plucked strings: guitars, basses, sitar, harpsichord, oud, koto…; Bowed: violin to contrabass, sections",
2506
+ detail: `${STRING_PRESET_NAMES.length - BOWED_PRESET_NAMES.length} plucked · ${BOWED_PRESET_NAMES.length} bowed · built-in`,
2507
+ build: () => [
2508
+ ...STRING_PRESET_NAMES.filter(
2509
+ (name) => !BOWED_PRESET_NAMES.includes(name),
2510
+ ).map((name): MenuNode => ({
2511
+ kind: "action",
2512
+ label: `${name.padEnd(12)} ${STRING_PRESETS[name]!.doc}`,
2513
+ command: `string ${name}`,
2514
+ help: STRING_PRESETS[name]!.styles,
2515
+ })),
2516
+ {
2517
+ kind: "menu",
2518
+ id: "strings:bowed",
2519
+ label: "Bowed",
2520
+ help: "bowed strings: violin, viola, cello, bass, fiddle, erhu, sections",
2521
+ detail: BOWED_PRESET_NAMES.join(" "),
2522
+ build: () =>
2523
+ BOWED_PRESET_NAMES.map((name): MenuNode => ({
2524
+ kind: "action",
2525
+ label: `${name.padEnd(12)} ${STRING_PRESETS[name]!.doc}`,
2526
+ command: `bowed ${name}`,
2527
+ help: STRING_PRESETS[name]!.styles,
2528
+ })),
2529
+ },
2530
+ ],
2531
+ },
2532
+ {
2533
+ kind: "menu",
2534
+ id: "granular-browse",
2535
+ label: "Granular",
2536
+ help: "grain clouds from a built-in synth or this track's own sound",
2537
+ detail: `${GRANULAR_PRESETS_COUNT} presets · built-in`,
2538
+ build: granularBrowseNodes,
2539
+ },
1687
2540
  {
1688
2541
  kind: "menu",
1689
2542
  id: "instruments",
@@ -1812,9 +2665,75 @@ function transportNodes(context: MenuContext): MenuNode[] {
1812
2665
  help: "the song tuning (12-TET, EDOs, just, gamelan, Scala) and scale",
1813
2666
  build: songTuningNodes,
1814
2667
  },
2668
+ {
2669
+ kind: "menu",
2670
+ id: "resample",
2671
+ label: "resample",
2672
+ detail: "track · section · bars → new sample track",
2673
+ help: "render a track, an orbit or the mix to a pinned WAV on a new sampler or granular track",
2674
+ build: resampleNodes,
2675
+ },
1815
2676
  ];
1816
2677
  }
1817
2678
 
2679
+ /** Project > Resample: one action per useful source and range. */
2680
+ function resampleNodes(context: MenuContext): MenuNode[] {
2681
+ const track = focused(context);
2682
+ const score = context.score;
2683
+ const nodes: MenuNode[] = [];
2684
+ if (track) {
2685
+ nodes.push(
2686
+ {
2687
+ kind: "action",
2688
+ label: `${track.id} → sampler`,
2689
+ command: `resample ${track.id}`,
2690
+ help: "render this track (pre-master) to a one-shot sampler track",
2691
+ },
2692
+ {
2693
+ kind: "action",
2694
+ label: `${track.id} → granular`,
2695
+ command: `resample ${track.id} grain`,
2696
+ help: "render this track to a granular track (cloud preset) holding one note",
2697
+ },
2698
+ );
2699
+ for (const section of score.sections)
2700
+ nodes.push({
2701
+ kind: "action",
2702
+ label: `${track.id} · section ${section.name}`,
2703
+ command: `resample ${track.id} section ${section.name}`,
2704
+ help: `render bars ${section.startBar + 1}-${section.startBar + section.bars} of this track`,
2705
+ });
2706
+ }
2707
+ nodes.push(
2708
+ {
2709
+ kind: "entry",
2710
+ label: "bars",
2711
+ value: "",
2712
+ placeholder: "a-b [grain]",
2713
+ example: "/resample <track> bars 1-2",
2714
+ command: (text) => {
2715
+ const match = /^\s*(\d{1,4})(?:-(\d{1,4}))?(\s+grain)?\s*$/.exec(text);
2716
+ if (!match || !track) return undefined;
2717
+ return `resample ${track.id} bars ${match[1]}-${match[2] ?? match[1]}${match[3] ? " grain" : ""}`;
2718
+ },
2719
+ help: "render a bar range of the focused track",
2720
+ },
2721
+ {
2722
+ kind: "action",
2723
+ label: "mix → sampler (pre-master)",
2724
+ command: "resample orbit 1",
2725
+ help: "render every track on orbit 1 (the default bus) without the song master",
2726
+ },
2727
+ {
2728
+ kind: "action",
2729
+ label: "master → sampler",
2730
+ command: "resample master",
2731
+ help: "render the whole song through the master chain",
2732
+ },
2733
+ );
2734
+ return nodes;
2735
+ }
2736
+
1818
2737
  // ── tuning ────────────────────────────────────────────────────────────
1819
2738
 
1820
2739
  /** Short name of a tuning for a choice row: its library name or table. */
@@ -2372,11 +3291,17 @@ export class EditMenu {
2372
3291
  candidate.kind === "choice" &&
2373
3292
  candidate.label === node.label,
2374
3293
  ) ?? node;
2375
- return live.options.map((option) => ({
2376
- kind: "action",
2377
- label: option === live.value ? `${option} ✓` : option,
2378
- command: live.command(option),
2379
- }));
3294
+ // The current value is shown, not re-run: re-confirming a
3295
+ // legacy `piano` would otherwise re-voice it as the grand.
3296
+ return live.options.map((option): MenuNode =>
3297
+ option === live.value
3298
+ ? { kind: "info", label: `${option} ✓`, value: "current" }
3299
+ : {
3300
+ kind: "action",
3301
+ label: option,
3302
+ command: live.command(option),
3303
+ },
3304
+ );
2380
3305
  },
2381
3306
  });
2382
3307
  return { type: "handled" };