@hraness/dawg 0.6.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 (93) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/DAWG.md +338 -51
  3. package/README.md +4 -4
  4. package/core/chords.ts +492 -4
  5. package/core/diff.ts +5 -0
  6. package/core/expression.ts +143 -6
  7. package/core/fx.ts +258 -6
  8. package/core/granular.ts +91 -0
  9. package/core/instruments.ts +161 -0
  10. package/core/keys.ts +573 -4
  11. package/core/resonators.ts +291 -3
  12. package/core/score.ts +349 -3
  13. package/core/sdk/print.ts +78 -3
  14. package/core/sdk/v1.ts +1320 -36
  15. package/core/sections.ts +12 -4
  16. package/core/strings.ts +237 -2
  17. package/core/winds.ts +652 -0
  18. package/guides/automation.md +1 -0
  19. package/guides/chords.md +3 -1
  20. package/guides/effects.md +3 -2
  21. package/guides/media.md +1 -1
  22. package/guides/performance.md +3 -0
  23. package/guides/resample.md +26 -0
  24. package/guides/sounds.md +7 -6
  25. package/package.json +1 -1
  26. package/src/agent/agent.ts +8 -2
  27. package/src/agent/chord-tools.ts +153 -1
  28. package/src/agent/expression-tools.ts +91 -0
  29. package/src/agent/ops.ts +4 -1
  30. package/src/agent/preview-tool.ts +2 -1
  31. package/src/agent/resample-tool.ts +131 -0
  32. package/src/agent/tools.ts +200 -15
  33. package/src/agent/xcb-agent.ts +8 -2
  34. package/src/audio/arrange.ts +21 -0
  35. package/src/audio/dsp/envelope.ts +161 -0
  36. package/src/audio/dsp/shift.ts +256 -0
  37. package/src/audio/effects/chain.ts +18 -3
  38. package/src/audio/effects/common.ts +14 -0
  39. package/src/audio/effects/convolution.ts +106 -1
  40. package/src/audio/effects/gaze.ts +354 -0
  41. package/src/audio/engine.ts +46 -5
  42. package/src/audio/fit.ts +45 -0
  43. package/src/audio/granular.ts +352 -21
  44. package/src/audio/instrument-check.ts +9 -5
  45. package/src/audio/instruments.ts +32 -3
  46. package/src/audio/keys/electric.ts +420 -0
  47. package/src/audio/keys/engine.ts +143 -19
  48. package/src/audio/keys/organ.ts +1335 -0
  49. package/src/audio/keys/piano.ts +50 -6
  50. package/src/audio/keys/sympathetic.ts +127 -0
  51. package/src/audio/live-worker.ts +3 -1
  52. package/src/audio/live.ts +136 -9
  53. package/src/audio/preview.ts +15 -1
  54. package/src/audio/resample.ts +285 -0
  55. package/src/audio/resonators.ts +10 -2
  56. package/src/audio/sampler.ts +168 -21
  57. package/src/audio/samples.ts +26 -1
  58. package/src/audio/strings/body.ts +29 -16
  59. package/src/audio/strings/bow.ts +656 -0
  60. package/src/audio/strings/engine.ts +266 -20
  61. package/src/audio/strings/measure.test-helpers.ts +2 -0
  62. package/src/audio/wav.ts +119 -19
  63. package/src/audio/winds/engine.ts +302 -0
  64. package/src/audio/winds/filters.ts +153 -0
  65. package/src/audio/winds/pitch.ts +104 -0
  66. package/src/audio/winds/trim.ts +80 -0
  67. package/src/audio/winds/trims.ts +917 -0
  68. package/src/audio/winds/voice.ts +479 -0
  69. package/src/commands/expression.ts +103 -22
  70. package/src/commands/fx.ts +4 -1
  71. package/src/commands/granular.ts +7 -2
  72. package/src/commands/help.ts +91 -8
  73. package/src/commands/keys.ts +287 -11
  74. package/src/commands/modal.ts +67 -4
  75. package/src/commands/resample.ts +281 -0
  76. package/src/commands/rig.ts +5 -1
  77. package/src/commands/sample.ts +49 -2
  78. package/src/commands/shift.ts +119 -0
  79. package/src/commands/string.ts +28 -2
  80. package/src/commands/strum.ts +473 -0
  81. package/src/commands/wind.ts +244 -0
  82. package/src/main.ts +129 -7
  83. package/src/project/check.ts +5 -1
  84. package/src/render.ts +16 -0
  85. package/src/session/presence.ts +16 -3
  86. package/src/tui/audition.ts +1 -1
  87. package/src/tui/menu.ts +637 -35
  88. package/src/tui/modal-menu.ts +34 -7
  89. package/src/tui/performance-menu.ts +42 -0
  90. package/src/tui/play-chords.ts +104 -3
  91. package/src/tui/play-mode.ts +1 -0
  92. package/src/tui/play-session.ts +34 -9
  93. package/src/tui/wind-menu.ts +144 -0
package/src/tui/menu.ts CHANGED
@@ -14,6 +14,7 @@ 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
16
  import { malletsMenu, modalParameterNodes } from "./modal-menu.ts";
17
+ import { windParameterNodes, windsMenu } from "./wind-menu.ts";
17
18
  import type { FaderSpec } from "./fader.ts";
18
19
  import {
19
20
  openingMeterCommand,
@@ -47,6 +48,7 @@ import {
47
48
  RIG_PRESETS,
48
49
  RIG_STAGES,
49
50
  rigPresetOf,
51
+ SHOEGAZE_EFFECTS,
50
52
  effectPresetNames,
51
53
  effectSpec,
52
54
  type EffectName,
@@ -95,16 +97,29 @@ import {
95
97
  STRING_PARAMS,
96
98
  STRING_PRESET_NAMES,
97
99
  STRING_PRESETS,
100
+ BOWED_PRESET_NAMES,
101
+ BOW_SIMPLE_PARAMS,
102
+ resolveString,
98
103
  STRING_SIMPLE_PARAMS,
99
104
  stringPresetOf,
100
105
  } from "../../core/strings.ts";
101
106
  import {
102
107
  KEYS_PARAMS,
103
108
  KEYS_PRESETS,
104
- KEYS_SIMPLE,
105
- PIANO_FAMILIES,
109
+ KEYS_FAMILIES,
110
+ ORGAN_ROWS,
111
+ ORGAN_TEXT,
112
+ PIPE_REGISTRATIONS,
113
+ PIPE_STOPS,
114
+ isElectricFamily,
115
+ isKeysFamily,
116
+ isOrganFamily,
106
117
  isPianoFamily,
118
+ keysParamsFor,
119
+ keysSimpleFor,
120
+ pipeStops,
107
121
  resolvedKeys,
122
+ type OrganFamily,
108
123
  } from "../../core/keys.ts";
109
124
  import type { PickerItem } from "../../tui/app.ts";
110
125
  import { BUILTIN_TABLES, BUILTIN_TABLE_NAMES } from "../audio/wavetable.ts";
@@ -124,6 +139,8 @@ import {
124
139
  PROGRESSION_STYLES,
125
140
  SCALE_NAMES,
126
141
  SPREADS,
142
+ STROKE_PATTERN_NAMES,
143
+ GUITAR_TUNING_NAMES,
127
144
  keyName,
128
145
  parseKey,
129
146
  } from "../../core/chords.ts";
@@ -135,6 +152,7 @@ import {
135
152
  type Tuning,
136
153
  } from "../../core/tuning.ts";
137
154
  import { rootName, tuningStepsText } from "../commands/tuning.ts";
155
+ import { describeGuitar } from "../commands/strum.ts";
138
156
  import {
139
157
  granularBrowseNodes,
140
158
  granularMenuDetail,
@@ -144,6 +162,7 @@ import {
144
162
  import {
145
163
  ARP_RATES,
146
164
  CHORD_MODES,
165
+ STROKE_SPEED_MS,
147
166
  defaultChordSettings,
148
167
  type ChordSettings,
149
168
  } from "./play-chords.ts";
@@ -384,7 +403,9 @@ function laneLabel(lane: AutomationParameter): string {
384
403
  info.effect === "synth" ||
385
404
  info.effect === "string" ||
386
405
  info.effect === "keys" ||
387
- info.effect === "modal"
406
+ info.effect === "modal" ||
407
+ info.effect === "grain" ||
408
+ info.effect === "wind"
388
409
  ? info.effect
389
410
  : effectSpec(info.effect).label;
390
411
  return `${owner} ${info.param}${unit}`;
@@ -533,27 +554,62 @@ function soundSectionNodes(context: MenuContext): MenuNode[] {
533
554
  ]
534
555
  : [];
535
556
  const keys: MenuNode[] =
536
- track && isPianoFamily(track.instrument) && track.keys
557
+ track && isOrganFamily(track.instrument) && track.keys
537
558
  ? [
538
559
  {
539
560
  kind: "menu",
540
561
  id: "keys",
541
562
  label: "keys",
542
563
  detail: `${track.instrument}${track.keys.preset ? ` · ${track.keys.preset}` : ""}`,
543
- help: "the modelled piano: preset, touch, hammers, dampers, stretch",
564
+ help: "the modelled organ: preset, drawbars, registers or stops, rotary",
544
565
  build: (inner) => {
545
566
  const current = focused(inner);
546
- return current
547
- ? keysNodes(current, Object.keys(KEYS_PARAMS))
548
- : [];
567
+ return current ? organNodes(current) : [];
549
568
  },
550
569
  },
551
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
+ ]
552
607
  : [];
553
608
  return [
554
609
  ...parameterNodes(context),
555
610
  ...granular,
556
611
  ...keys,
612
+ ...guitar,
557
613
  ...tuning,
558
614
  {
559
615
  kind: "menu",
@@ -574,6 +630,84 @@ function soundSectionNodes(context: MenuContext): MenuNode[] {
574
630
  ];
575
631
  }
576
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
+
577
711
  /** Rhythm: the euclid editor, then grooves and kits. */
578
712
  function rhythmNodes(): MenuNode[] {
579
713
  return [
@@ -881,6 +1015,34 @@ function chordNodes(context: MenuContext): MenuNode[] {
881
1015
  options: CHORD_PATTERNS.map((pattern) => pattern.name),
882
1016
  command: (option) => `/chords pattern ${option}`,
883
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[])),
884
1046
  {
885
1047
  kind: "choice",
886
1048
  label: "arp rate",
@@ -969,7 +1131,7 @@ function trackNodes(context: MenuContext): MenuNode[] {
969
1131
  }
970
1132
 
971
1133
  function instrumentNode(track: Track): MenuNode {
972
- const options: string[] = [...AVAILABLE_INSTRUMENTS, ...PIANO_FAMILIES];
1134
+ const options: string[] = [...AVAILABLE_INSTRUMENTS, ...KEYS_FAMILIES];
973
1135
  if (!options.includes(track.instrument)) options.push(track.instrument);
974
1136
  return {
975
1137
  kind: "choice",
@@ -1023,13 +1185,20 @@ function parameterNodes(context: MenuContext): MenuNode[] {
1023
1185
  if (isWavetableInstrument(track.instrument))
1024
1186
  nodes.push(...wavetableNodes(track, context.projectRoot));
1025
1187
  if (isStringTrack(track)) {
1026
- nodes.push(...stringNodes(track, STRING_SIMPLE_PARAMS));
1188
+ nodes.push(
1189
+ ...stringNodes(
1190
+ track,
1191
+ resolveString(track.string).exciter === "bow"
1192
+ ? BOW_SIMPLE_PARAMS
1193
+ : STRING_SIMPLE_PARAMS,
1194
+ ),
1195
+ );
1027
1196
  nodes.push({
1028
1197
  kind: "menu",
1029
1198
  id: "string:advanced",
1030
1199
  label: "advanced",
1031
1200
  detail: `all ${Object.keys(STRING_PARAMS).length} params`,
1032
- help: "every string parameter: exciter, loss, body, buzz, sympathetics",
1201
+ help: "every string parameter: exciter, loss, body, buzz, sympathetics, bow (pressure, speed, attack, vib, vibdelay, tremhz, sord, dyn)",
1033
1202
  build: (inner) => {
1034
1203
  const current = focused(inner);
1035
1204
  return current
@@ -1072,22 +1241,35 @@ function parameterNodes(context: MenuContext): MenuNode[] {
1072
1241
  label: "add a voice",
1073
1242
  value: "/sample <path> [as <voice>]",
1074
1243
  });
1075
- } else if (isPianoFamily(track.instrument) && track.keys) {
1076
- nodes.push(...keysNodes(track, KEYS_SIMPLE));
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);
1077
1250
  nodes.push({
1078
1251
  kind: "menu",
1079
1252
  id: "keys:all",
1080
- label: "all piano params",
1081
- detail: `all ${Object.keys(KEYS_PARAMS).length} params`,
1082
- help: "every modelled piano parameter",
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",
1083
1260
  build: (inner) => {
1084
1261
  const current = focused(inner);
1085
- return current ? keysNodes(current, Object.keys(KEYS_PARAMS)) : [];
1262
+ return current
1263
+ ? keysNodes(current, keysParamsFor(current.instrument))
1264
+ : [];
1086
1265
  },
1087
1266
  });
1088
1267
  } else if (track.instrument === "modal") {
1089
1268
  // 0.6 modal percussion: preset, mallet and MODAL_PARAMS rows.
1090
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));
1091
1273
  } else if (!isDrumInstrument(track.instrument)) {
1092
1274
  nodes.push(...synthNodes(track, SYNTH_SIMPLE));
1093
1275
  nodes.push({
@@ -1233,10 +1415,103 @@ function sampleVoiceNodes(context: MenuContext, voice: string): MenuNode[] {
1233
1415
  reset: `/len off ${voice}`,
1234
1416
  help: SAMPLE_CONTROLS.len,
1235
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
+ },
1236
1502
  );
1237
1503
  return nodes;
1238
1504
  }
1239
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
+
1240
1515
  /** Synth preset first, then one row per parameter. */
1241
1516
  /** The preset this track's voice still matches exactly, if any. */
1242
1517
  function matchingSynthPreset(track: Track): string | undefined {
@@ -1250,16 +1525,24 @@ function matchingSynthPreset(track: Track): string | undefined {
1250
1525
  }
1251
1526
 
1252
1527
  /** Modelled piano rows: the preset, then each parameter (`keys <p> <v>`). */
1253
- function keysNodes(track: Track, params: readonly string[]): MenuNode[] {
1528
+ function keysNodes(
1529
+ track: Track,
1530
+ params: readonly string[],
1531
+ presets: readonly string[] = keysPresetNames(track.instrument),
1532
+ ): MenuNode[] {
1254
1533
  const effective = resolvedKeys(track.instrument, track.keys);
1255
1534
  const nodes: MenuNode[] = [
1256
1535
  {
1257
1536
  kind: "choice",
1258
1537
  label: "preset",
1259
1538
  value: track.keys?.preset ?? "—",
1260
- options: Object.keys(KEYS_PRESETS),
1539
+ options: presets,
1261
1540
  command: (preset) => `keys preset ${preset}`,
1262
- help: "a starting piano; every value stays editable",
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",
1263
1546
  },
1264
1547
  ];
1265
1548
  for (const key of params) {
@@ -1300,6 +1583,174 @@ function keysNodes(track: Track, params: readonly string[]): MenuNode[] {
1300
1583
  return nodes;
1301
1584
  }
1302
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
+
1303
1754
  function synthNodes(track: Track, keys: readonly string[]): MenuNode[] {
1304
1755
  const nodes: MenuNode[] = [];
1305
1756
  if (keys === SYNTH_SIMPLE)
@@ -1571,9 +2022,28 @@ function effectNodes(context: MenuContext): MenuNode[] {
1571
2022
  };
1572
2023
  };
1573
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[];
1574
2027
  const more = EFFECT_NAMES.filter(
1575
- (effect) => !CORE_EFFECTS.includes(effect) && !rigStages.includes(effect),
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),
1576
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
+ };
1577
2047
  const moreOn = more.filter((effect) => effectValues(track, effect));
1578
2048
  // 0.6 guitar rig: stomp → head (with its gate) → cab, plus whole rigs.
1579
2049
  const rigOn = RIG_STAGES.filter((stage) => effectValues(track, stage));
@@ -1602,6 +2072,7 @@ function effectNodes(context: MenuContext): MenuNode[] {
1602
2072
  return [
1603
2073
  ...CORE_EFFECTS.map(node),
1604
2074
  rig,
2075
+ shoegaze,
1605
2076
  {
1606
2077
  kind: "menu",
1607
2078
  id: "more effects",
@@ -1770,8 +2241,16 @@ function automationNodes(context: MenuContext): MenuNode[] {
1770
2241
  if (info?.effect === "synth")
1771
2242
  return track.synth?.[info.param] !== undefined;
1772
2243
  if (info?.effect === "string") return track.string !== undefined;
1773
- if (info?.effect === "keys") return track.keys !== 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
+ );
1774
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;
1775
2254
  return info !== undefined && effectValues(track, info.effect) !== undefined;
1776
2255
  });
1777
2256
  const hidden = AUTOMATION_PARAMETERS.filter((lane) => !shown.includes(lane));
@@ -1961,15 +2440,54 @@ function soundNodes(): MenuNode[] {
1961
2440
  kind: "menu",
1962
2441
  id: "group:keys",
1963
2442
  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
- })),
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
+ ],
1973
2491
  },
1974
2492
  {
1975
2493
  kind: "action",
@@ -1979,19 +2497,37 @@ function soundNodes(): MenuNode[] {
1979
2497
  },
1980
2498
  // 0.6 instrument groups, one per lane.
1981
2499
  malletsMenu(),
2500
+ windsMenu(),
1982
2501
  {
1983
2502
  kind: "menu",
1984
2503
  id: "strings",
1985
2504
  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 => ({
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 => ({
1990
2511
  kind: "action",
1991
2512
  label: `${name.padEnd(12)} ${STRING_PRESETS[name]!.doc}`,
1992
2513
  command: `string ${name}`,
1993
2514
  help: STRING_PRESETS[name]!.styles,
1994
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
+ ],
1995
2531
  },
1996
2532
  {
1997
2533
  kind: "menu",
@@ -2129,9 +2665,75 @@ function transportNodes(context: MenuContext): MenuNode[] {
2129
2665
  help: "the song tuning (12-TET, EDOs, just, gamelan, Scala) and scale",
2130
2666
  build: songTuningNodes,
2131
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
+ },
2132
2676
  ];
2133
2677
  }
2134
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
+
2135
2737
  // ── tuning ────────────────────────────────────────────────────────────
2136
2738
 
2137
2739
  /** Short name of a tuning for a choice row: its library name or table. */